diff --git a/gitnexus-shared/src/graph/types.ts b/gitnexus-shared/src/graph/types.ts index 49762d145..d3dc81625 100644 --- a/gitnexus-shared/src/graph/types.ts +++ b/gitnexus-shared/src/graph/types.ts @@ -131,4 +131,20 @@ export interface GraphRelationship { confidence: number; reason: string; step?: number; + /** + * Per-signal evidence trace for edges emitted by the scope-based + * resolution pipeline (RFC #909 Ring 2 PKG #925). Populated by + * `emit-references.ts` when draining `ReferenceIndex` into the graph + * so downstream query / audit tools can inspect *why* a given edge + * was emitted with its confidence value. + * + * Optional and additive — every existing edge emitter ignores this + * field, and every existing query continues to work whether or not + * an edge carries it. + */ + evidence?: readonly { + readonly kind: string; + readonly weight: number; + readonly note?: string; + }[]; } diff --git a/gitnexus/src/core/ingestion/emit-references.ts b/gitnexus/src/core/ingestion/emit-references.ts new file mode 100644 index 000000000..1d2c4db5b --- /dev/null +++ b/gitnexus/src/core/ingestion/emit-references.ts @@ -0,0 +1,299 @@ +/** + * Phase 5 of the RFC #909 ingestion lifecycle: drain `ReferenceIndex` + * into the knowledge graph as labeled edges with `confidence` and + * `evidence` properties (Ring 2 PKG #925). + * + * The resolution phase (future PR) writes `Reference` records into + * `model.scopes.referenceSites`-derived `ReferenceIndex`; this module + * materializes those records as `GraphRelationship`s via + * `graph.addRelationship`. Every emitted edge carries: + * + * - `type`: one of `'CALLS' | 'ACCESSES' | 'INHERITS' | 'USES'` + * (mapped from `Reference.kind` — `'read'` and `'write'` both route + * to `ACCESSES`; `'type-reference'` and `'import-use'` route to + * `USES`; `'call'` stays `CALLS`; `'inherits'` stays `INHERITS`). + * - `confidence`: the pre-computed confidence from the Reference record. + * - `reason`: human-readable summary (`"scope-resolution: call | confidence 0.75"`). + * - `evidence`: the full `ResolutionEvidence[]` trace — additive graph + * property (see `GraphRelationship.evidence` in gitnexus-shared), + * so queries that don't know about it are unaffected. + * - `step`: carries the reference's access-kind discriminant when + * available (`1` for read, `2` for write) so `ACCESSES` edges retain + * the read/write distinction without forcing a new edge type. + * + * ## Optional scope-tree flush + * + * When `INGESTION_EMIT_SCOPES=1` is set, this module also emits: + * + * - `Scope` nodes for every `Scope` in the tree + * - `CONTAINS` edges from parent scope to child scope + * - `DEFINES` edges from scope to its `ownedDefs` members + * - `IMPORTS` edges from scope to `targetModuleScope` of each finalized + * `ImportEdge` that carries one + * + * Off by default — existing queries that don't know about `Scope` nodes + * continue to work, and the storage cost is opt-in. + * + * ## Source-of-truth: the caller def for a reference + * + * A `Reference` says "some code inside `fromScope` references `toDef`". + * The graph wants `(callerNodeId, calleeNodeId)`. We resolve the caller + * by walking up the scope tree from `fromScope` until we find a scope + * whose `ownedDefs` contains a Function-like def. If no such ancestor + * exists, the edge is attributed to the first def owned by the innermost + * ancestor scope, and if THAT produces nothing either the edge is + * skipped (with a count returned in `EmitStats.skippedNoCaller`). + */ + +import type { + NodeLabel, + RelationshipType, + Reference, + ReferenceIndex, + ResolutionEvidence, + Scope, + ScopeId, + SymbolDefinition, +} from 'gitnexus-shared'; +import type { KnowledgeGraph } from '../graph/types.js'; +import type { ScopeResolutionIndexes } from './model/scope-resolution-indexes.js'; + +// ─── Public API ───────────────────────────────────────────────────────────── + +export interface EmitStats { + readonly edgesEmitted: number; + /** References dropped because no caller def could be resolved. */ + readonly skippedNoCaller: number; + /** References dropped because `toDef` was not found in the DefIndex. */ + readonly skippedMissingTarget: number; + /** Scope nodes emitted — `0` unless `INGESTION_EMIT_SCOPES=1`. */ + readonly scopeNodesEmitted: number; + /** Scope-tree structural edges emitted — `0` unless `INGESTION_EMIT_SCOPES=1`. */ + readonly scopeEdgesEmitted: number; +} + +export interface EmitReferencesInput { + readonly graph: KnowledgeGraph; + readonly scopes: ScopeResolutionIndexes; + readonly referenceIndex: ReferenceIndex; + /** Human-consumable label for the `reason` prefix. Defaults to `'scope-resolution'`. */ + readonly sourceLabel?: string; +} + +/** + * Drain `referenceIndex.bySourceScope` into graph edges. + * + * The scope-tree flush is controlled separately by + * `INGESTION_EMIT_SCOPES` — callers can run `emitReferencesToGraph` + * without scope-node emission or layer the two calls as needed. + */ +export function emitReferencesToGraph(input: EmitReferencesInput): EmitStats { + const { graph, scopes, referenceIndex } = input; + const sourceLabel = input.sourceLabel ?? 'scope-resolution'; + + let edgesEmitted = 0; + let skippedNoCaller = 0; + let skippedMissingTarget = 0; + + for (const [fromScope, refs] of referenceIndex.bySourceScope) { + for (const ref of refs) { + const targetDef = scopes.defs.get(ref.toDef); + if (targetDef === undefined) { + skippedMissingTarget++; + continue; + } + const callerId = resolveCallerNodeId(fromScope, scopes); + if (callerId === undefined) { + skippedNoCaller++; + continue; + } + graph.addRelationship(buildRelationship(ref, callerId, targetDef, sourceLabel)); + edgesEmitted++; + } + } + + const scopeStats = isScopeEmissionEnabled() + ? emitScopeGraph({ graph, scopes }) + : { scopeNodesEmitted: 0, scopeEdgesEmitted: 0 }; + + return { edgesEmitted, skippedNoCaller, skippedMissingTarget, ...scopeStats }; +} + +/** + * Emit `Scope` nodes + `CONTAINS`/`DEFINES`/`IMPORTS` edges representing + * the lexical scope tree itself. Skipped unless `INGESTION_EMIT_SCOPES=1` + * at the public entry point; exported here for tests that want to + * exercise the path directly. + */ +export function emitScopeGraph(input: { + readonly graph: KnowledgeGraph; + readonly scopes: ScopeResolutionIndexes; +}): { readonly scopeNodesEmitted: number; readonly scopeEdgesEmitted: number } { + const { graph, scopes } = input; + let scopeNodesEmitted = 0; + let scopeEdgesEmitted = 0; + + for (const scope of scopes.scopeTree.byId.values()) { + graph.addNode({ + id: scope.id, + label: 'CodeElement' as NodeLabel, // the generic bucket for non-symbol graph nodes + properties: { + name: scope.kind, + filePath: scope.filePath, + startLine: scope.range.startLine, + endLine: scope.range.endLine, + description: `Scope: ${scope.kind}`, + } as unknown as Parameters[0]['properties'], + }); + scopeNodesEmitted++; + + if (scope.parent !== null) { + graph.addRelationship({ + id: `rel:contains:${scope.parent}->${scope.id}`, + sourceId: scope.parent, + targetId: scope.id, + type: 'CONTAINS', + confidence: 1, + reason: 'scope-tree parent/child', + }); + scopeEdgesEmitted++; + } + + for (const def of scope.ownedDefs) { + graph.addRelationship({ + id: `rel:defines:${scope.id}->${def.nodeId}`, + sourceId: scope.id, + targetId: def.nodeId, + type: 'DEFINES', + confidence: 1, + reason: 'scope.ownedDefs', + }); + scopeEdgesEmitted++; + } + } + + for (const [scopeId, edges] of scopes.imports) { + for (const edge of edges) { + if (edge.targetModuleScope === undefined) continue; + graph.addRelationship({ + id: `rel:imports:${scopeId}->${edge.targetModuleScope}:${edge.localName}`, + sourceId: scopeId, + targetId: edge.targetModuleScope, + type: 'IMPORTS', + confidence: edge.linkStatus === 'unresolved' ? 0.5 : 1, + reason: `import ${edge.kind} ${edge.localName}`, + }); + scopeEdgesEmitted++; + } + } + + return { scopeNodesEmitted, scopeEdgesEmitted }; +} + +// ─── Internal ─────────────────────────────────────────────────────────────── + +/** Accepted truthy values for `INGESTION_EMIT_SCOPES`. */ +const TRUTHY: ReadonlySet = new Set(['true', '1', 'yes']); + +function isScopeEmissionEnabled(): boolean { + const raw = process.env['INGESTION_EMIT_SCOPES']; + if (raw === undefined) return false; + return TRUTHY.has(raw.trim().toLowerCase()); +} + +/** + * Walk up from `startScope` looking for the first ancestor scope whose + * `ownedDefs` contains a Function-like def (Function / Method / + * Constructor). Fall back to the innermost ancestor's first `ownedDef` + * if none is found; return `undefined` if all ancestors have no defs. + */ +function resolveCallerNodeId( + startScope: ScopeId, + scopes: ScopeResolutionIndexes, +): string | undefined { + const tree = scopes.scopeTree; + let current: ScopeId | null = startScope; + const visited = new Set(); + let firstOwnedFallback: string | undefined; + + while (current !== null) { + if (visited.has(current)) break; + visited.add(current); + + const scope: Scope | undefined = tree.getScope(current); + if (scope === undefined) break; + + // Prefer a Function-like owner. + const fnDef = scope.ownedDefs.find((d) => isFunctionLike(d.type)); + if (fnDef !== undefined) return fnDef.nodeId; + + // Stash the first owned def we see as a conservative fallback. + if (firstOwnedFallback === undefined && scope.ownedDefs.length > 0) { + firstOwnedFallback = scope.ownedDefs[0]!.nodeId; + } + + current = scope.parent; + } + + return firstOwnedFallback; +} + +function isFunctionLike(type: NodeLabel): boolean { + return type === 'Function' || type === 'Method' || type === 'Constructor'; +} + +function buildRelationship( + ref: Reference, + callerId: string, + targetDef: SymbolDefinition, + sourceLabel: string, +): Parameters[0] { + const type = mapKindToType(ref.kind); + const reason = `${sourceLabel}: ${ref.kind} | confidence ${ref.confidence.toFixed(3)}`; + // `step` encodes read/write discriminator for ACCESSES edges (1=read, 2=write). + // Other kinds omit `step`. + const step = ref.kind === 'read' ? 1 : ref.kind === 'write' ? 2 : undefined; + return { + id: `rel:${type}:${callerId}->${targetDef.nodeId}:${ref.atRange.startLine}:${ref.atRange.startCol}`, + sourceId: callerId, + targetId: targetDef.nodeId, + type, + confidence: ref.confidence, + reason, + evidence: ref.evidence.map(serializeEvidence), + ...(step !== undefined ? { step } : {}), + }; +} + +/** + * Map a `Reference.kind` to an existing `RelationshipType`. Read/write + * both fold into `ACCESSES`; `type-reference` + `import-use` both fold + * into `USES`. This keeps the graph schema additive — no new + * RelationshipType values are introduced by this module. + */ +function mapKindToType(kind: Reference['kind']): RelationshipType { + switch (kind) { + case 'call': + return 'CALLS'; + case 'read': + case 'write': + return 'ACCESSES'; + case 'inherits': + return 'INHERITS'; + case 'type-reference': + case 'import-use': + return 'USES'; + } +} + +function serializeEvidence(e: ResolutionEvidence): { + readonly kind: string; + readonly weight: number; + readonly note?: string; +} { + return { + kind: e.kind, + weight: e.weight, + ...(e.note !== undefined ? { note: e.note } : {}), + }; +} diff --git a/gitnexus/test/unit/scope-resolution/emit-references.test.ts b/gitnexus/test/unit/scope-resolution/emit-references.test.ts new file mode 100644 index 000000000..c88dc998e --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/emit-references.test.ts @@ -0,0 +1,495 @@ +/** + * Unit tests for `emit-references` (RFC #909 Ring 2 PKG #925). + * + * Covers kind → RelationshipType mapping, enclosing-def resolution + * through the scope tree, evidence serialization onto emitted edges, + * skip counts, and the optional `INGESTION_EMIT_SCOPES` scope-node + * flush. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + buildDefIndex, + buildMethodDispatchIndex, + buildModuleScopeIndex, + buildQualifiedNameIndex, + buildScopeTree, + type BindingRef, + type DefId, + type Range, + type Reference, + type ReferenceIndex, + type Scope, + type ScopeId, + type SymbolDefinition, +} from 'gitnexus-shared'; +import { createKnowledgeGraph } from '../../../src/core/graph/graph.js'; +import { + emitReferencesToGraph, + emitScopeGraph, +} from '../../../src/core/ingestion/emit-references.js'; +import type { ScopeResolutionIndexes } from '../../../src/core/ingestion/model/scope-resolution-indexes.js'; + +// ─── Env isolation ──────────────────────────────────────────────────────── + +let savedEnv: string | undefined; +beforeEach(() => { + savedEnv = process.env['INGESTION_EMIT_SCOPES']; + delete process.env['INGESTION_EMIT_SCOPES']; +}); +afterEach(() => { + if (savedEnv === undefined) delete process.env['INGESTION_EMIT_SCOPES']; + else process.env['INGESTION_EMIT_SCOPES'] = savedEnv; +}); + +// ─── Fixture builders ───────────────────────────────────────────────────── + +const range = (sl = 1, sc = 0, el = 100, ec = 0): Range => ({ + startLine: sl, + startCol: sc, + endLine: el, + endCol: ec, +}); + +const def = ( + nodeId: string, + type: SymbolDefinition['type'] = 'Method', + qname?: string, +): SymbolDefinition => ({ + nodeId, + filePath: 'x.ts', + type, + ...(qname !== undefined ? { qualifiedName: qname } : {}), +}); + +const scope = ( + id: ScopeId, + parent: ScopeId | null, + kind: Scope['kind'], + ownedDefs: readonly SymbolDefinition[] = [], + r: Range = range(), + filePath = 'x.ts', + bindings: Record = {}, +): Scope => ({ + id, + parent, + kind, + range: r, + filePath, + bindings: new Map(Object.entries(bindings)), + ownedDefs, + imports: [], + typeBindings: new Map(), +}); + +function makeIndexes(scopes: Scope[], allDefs: SymbolDefinition[]): ScopeResolutionIndexes { + return { + scopeTree: buildScopeTree(scopes), + defs: buildDefIndex(allDefs), + qualifiedNames: buildQualifiedNameIndex(allDefs), + moduleScopes: buildModuleScopeIndex( + scopes + .filter((s) => s.kind === 'Module') + .map((s) => ({ filePath: s.filePath, moduleScopeId: s.id })), + ), + methodDispatch: buildMethodDispatchIndex({ + owners: [], + computeMro: () => [], + implementsOf: () => [], + }), + imports: new Map(), + bindings: new Map(), + referenceSites: [], + sccs: [], + stats: { + totalFiles: 0, + totalEdges: 0, + linkedEdges: 0, + unresolvedEdges: 0, + sccCount: 0, + largestSccSize: 0, + }, + }; +} + +function buildRefIndex(sourceScope: ScopeId, refs: readonly Reference[]): ReferenceIndex { + const bySource = new Map(); + bySource.set(sourceScope, refs); + const byTarget = new Map(); + for (const ref of refs) { + const bucket = byTarget.get(ref.toDef) ?? []; + bucket.push(ref); + byTarget.set(ref.toDef, bucket); + } + return { + bySourceScope: bySource, + byTargetDef: new Map( + Array.from(byTarget.entries()).map(([k, v]) => [k, Object.freeze([...v])]), + ), + }; +} + +// ─── Kind mapping + basic emission ──────────────────────────────────────── + +describe('emitReferencesToGraph: kind mapping', () => { + it('maps call → CALLS and carries confidence + evidence onto the edge', () => { + const callerFn = def('def:saveUser', 'Function', 'saveUser'); + const targetFn = def('def:User.save', 'Method', 'User.save'); + const mod = scope('scope:m', null, 'Module', [callerFn, targetFn]); + const indexes = makeIndexes([mod], [callerFn, targetFn]); + + const ref: Reference = { + fromScope: 'scope:m', + toDef: 'def:User.save', + atRange: range(10, 4, 10, 8), + kind: 'call', + confidence: 0.75, + evidence: [ + { kind: 'local', weight: 0.55 }, + { kind: 'arity-match', weight: 0.1, note: 'compatible' }, + ], + }; + + const graph = createKnowledgeGraph(); + const stats = emitReferencesToGraph({ + graph, + scopes: indexes, + referenceIndex: buildRefIndex('scope:m', [ref]), + }); + + expect(stats.edgesEmitted).toBe(1); + expect(graph.relationships).toHaveLength(1); + const edge = graph.relationships[0]!; + expect(edge.type).toBe('CALLS'); + expect(edge.sourceId).toBe('def:saveUser'); + expect(edge.targetId).toBe('def:User.save'); + expect(edge.confidence).toBe(0.75); + expect(edge.evidence).toEqual([ + { kind: 'local', weight: 0.55 }, + { kind: 'arity-match', weight: 0.1, note: 'compatible' }, + ]); + expect(edge.reason).toContain('call'); + expect(edge.reason).toContain('0.750'); + }); + + it('maps read / write → ACCESSES and stamps step=1 / step=2 for discrimination', () => { + const fn = def('def:render', 'Function'); + const field = def('def:User.name', 'Property'); + const mod = scope('scope:m', null, 'Module', [fn, field]); + const indexes = makeIndexes([mod], [fn, field]); + + const readRef: Reference = { + fromScope: 'scope:m', + toDef: 'def:User.name', + atRange: range(5, 0, 5, 4), + kind: 'read', + confidence: 0.55, + evidence: [{ kind: 'local', weight: 0.55 }], + }; + const writeRef: Reference = { + fromScope: 'scope:m', + toDef: 'def:User.name', + atRange: range(6, 0, 6, 4), + kind: 'write', + confidence: 0.55, + evidence: [{ kind: 'local', weight: 0.55 }], + }; + + const graph = createKnowledgeGraph(); + emitReferencesToGraph({ + graph, + scopes: indexes, + referenceIndex: buildRefIndex('scope:m', [readRef, writeRef]), + }); + + const edges = graph.relationships; + expect(edges).toHaveLength(2); + expect(edges.every((e) => e.type === 'ACCESSES')).toBe(true); + const readEdge = edges.find((e) => e.step === 1)!; + const writeEdge = edges.find((e) => e.step === 2)!; + expect(readEdge).toBeDefined(); + expect(writeEdge).toBeDefined(); + }); + + it('maps inherits → INHERITS and type-reference/import-use → USES', () => { + const hostFn = def('def:host', 'Function'); + const base = def('def:Base', 'Class'); + const mixin = def('def:Mixin', 'Class'); + const module = def('def:SomeModule', 'Namespace'); + const mod = scope('scope:m', null, 'Module', [hostFn, base, mixin, module]); + const indexes = makeIndexes([mod], [hostFn, base, mixin, module]); + + const refs: Reference[] = [ + { + fromScope: 'scope:m', + toDef: 'def:Base', + atRange: range(1, 0, 1, 4), + kind: 'inherits', + confidence: 0.9, + evidence: [], + }, + { + fromScope: 'scope:m', + toDef: 'def:Mixin', + atRange: range(2, 0, 2, 4), + kind: 'type-reference', + confidence: 0.7, + evidence: [], + }, + { + fromScope: 'scope:m', + toDef: 'def:SomeModule', + atRange: range(3, 0, 3, 4), + kind: 'import-use', + confidence: 0.5, + evidence: [], + }, + ]; + + const graph = createKnowledgeGraph(); + emitReferencesToGraph({ + graph, + scopes: indexes, + referenceIndex: buildRefIndex('scope:m', refs), + }); + + const types = graph.relationships.map((r) => r.type).sort(); + expect(types).toEqual(['INHERITS', 'USES', 'USES']); + }); +}); + +// ─── Enclosing-def resolution ───────────────────────────────────────────── + +describe('enclosing-def resolution', () => { + it('uses the innermost Function/Method ancestor as the caller', () => { + const method = def('def:User.save', 'Method'); + const classScope = scope('scope:c', 'scope:m', 'Class', [], range(5, 0, 40, 0)); + const methodScope = scope('scope:f', 'scope:c', 'Function', [method], range(10, 0, 30, 0)); + const mod = scope('scope:m', null, 'Module', [], range(1, 0, 100, 0)); + const target = def('def:Logger.log', 'Method'); + const indexes = makeIndexes([mod, classScope, methodScope], [method, target]); + + // Reference fires from a block inside the method scope. + const ref: Reference = { + fromScope: 'scope:f', + toDef: 'def:Logger.log', + atRange: range(20, 4, 20, 8), + kind: 'call', + confidence: 0.75, + evidence: [], + }; + + const graph = createKnowledgeGraph(); + emitReferencesToGraph({ + graph, + scopes: indexes, + referenceIndex: buildRefIndex('scope:f', [ref]), + }); + + expect(graph.relationships[0]!.sourceId).toBe('def:User.save'); + }); + + it('walks up to the parent Class if the immediate scope has no Function def', () => { + const classDef = def('def:User', 'Class'); + const targetFn = def('def:Logger.log', 'Method'); + const mod = scope('scope:m', null, 'Module', [], range(1, 0, 100, 0)); + // Class scope owns the Class def but no Function/Method; fallback + // walks into the Class's owned defs. + const classScope = scope('scope:c', 'scope:m', 'Class', [classDef], range(5, 0, 50, 0)); + const indexes = makeIndexes([mod, classScope], [classDef, targetFn]); + + const ref: Reference = { + fromScope: 'scope:c', + toDef: 'def:Logger.log', + atRange: range(7, 0, 7, 4), + kind: 'call', + confidence: 0.55, + evidence: [], + }; + + const graph = createKnowledgeGraph(); + emitReferencesToGraph({ + graph, + scopes: indexes, + referenceIndex: buildRefIndex('scope:c', [ref]), + }); + + // No Function ancestor — falls back to the first owned def: the class itself. + expect(graph.relationships[0]!.sourceId).toBe('def:User'); + }); + + it('increments skippedNoCaller when no ancestor has any owned defs', () => { + // Module scope is empty; the lone child scope references something + // but neither it nor its ancestors own anything. + const target = def('def:someClass', 'Class'); + const mod = scope('scope:m', null, 'Module', [], range(1, 0, 100, 0)); + const child = scope('scope:c', 'scope:m', 'Function', [], range(5, 0, 10, 0)); + const indexes = makeIndexes([mod, child], [target]); + + const ref: Reference = { + fromScope: 'scope:c', + toDef: 'def:someClass', + atRange: range(7, 0, 7, 4), + kind: 'type-reference', + confidence: 0.3, + evidence: [], + }; + + const graph = createKnowledgeGraph(); + const stats = emitReferencesToGraph({ + graph, + scopes: indexes, + referenceIndex: buildRefIndex('scope:c', [ref]), + }); + + expect(stats.edgesEmitted).toBe(0); + expect(stats.skippedNoCaller).toBe(1); + expect(graph.relationships).toHaveLength(0); + }); +}); + +// ─── Missing target ────────────────────────────────────────────────────── + +describe('missing target', () => { + it('skips references whose toDef is not in the DefIndex', () => { + const callerFn = def('def:caller', 'Function'); + const mod = scope('scope:m', null, 'Module', [callerFn]); + const indexes = makeIndexes([mod], [callerFn]); // target def missing + + const ref: Reference = { + fromScope: 'scope:m', + toDef: 'def:ghost', + atRange: range(5, 0, 5, 4), + kind: 'call', + confidence: 0.3, + evidence: [], + }; + const graph = createKnowledgeGraph(); + const stats = emitReferencesToGraph({ + graph, + scopes: indexes, + referenceIndex: buildRefIndex('scope:m', [ref]), + }); + expect(stats.edgesEmitted).toBe(0); + expect(stats.skippedMissingTarget).toBe(1); + expect(graph.relationships).toHaveLength(0); + }); +}); + +// ─── Scope-graph emission (INGESTION_EMIT_SCOPES) ───────────────────────── + +describe('scope-graph emission', () => { + it('stays off by default — no scope nodes emitted', () => { + const callerFn = def('def:caller', 'Function'); + const targetFn = def('def:target', 'Method'); + const mod = scope('scope:m', null, 'Module', [callerFn, targetFn]); + const indexes = makeIndexes([mod], [callerFn, targetFn]); + + const ref: Reference = { + fromScope: 'scope:m', + toDef: 'def:target', + atRange: range(5, 0, 5, 4), + kind: 'call', + confidence: 0.5, + evidence: [], + }; + const graph = createKnowledgeGraph(); + const stats = emitReferencesToGraph({ + graph, + scopes: indexes, + referenceIndex: buildRefIndex('scope:m', [ref]), + }); + expect(stats.scopeNodesEmitted).toBe(0); + expect(stats.scopeEdgesEmitted).toBe(0); + // No scope nodes in the graph either. + expect(graph.nodes.filter((n) => n.id.startsWith('scope:')).length).toBe(0); + }); + + it('emits Scope nodes + CONTAINS + DEFINES when INGESTION_EMIT_SCOPES=1', () => { + process.env['INGESTION_EMIT_SCOPES'] = '1'; + const fn = def('def:fn', 'Function'); + const childScope = scope('scope:f', 'scope:m', 'Function', [fn], range(5, 0, 10, 0)); + const mod = scope('scope:m', null, 'Module', [], range(1, 0, 100, 0)); + const indexes = makeIndexes([mod, childScope], [fn]); + + const graph = createKnowledgeGraph(); + const stats = emitReferencesToGraph({ + graph, + scopes: indexes, + referenceIndex: buildRefIndex('scope:f', []), + }); + + expect(stats.scopeNodesEmitted).toBe(2); // module + function scope + // 1 CONTAINS (module→function) + 1 DEFINES (function→fn def) = 2 + expect(stats.scopeEdgesEmitted).toBe(2); + const containsEdge = graph.relationships.find((e) => e.type === 'CONTAINS'); + const definesEdge = graph.relationships.find((e) => e.type === 'DEFINES'); + expect(containsEdge).toBeDefined(); + expect(containsEdge!.sourceId).toBe('scope:m'); + expect(containsEdge!.targetId).toBe('scope:f'); + expect(definesEdge).toBeDefined(); + expect(definesEdge!.targetId).toBe('def:fn'); + }); + + it("treats 'true', 'yes' (case-insensitive) as enabled; anything else as disabled", () => { + const fn = def('def:fn', 'Function'); + const mod = scope('scope:m', null, 'Module', [fn]); + const indexes = makeIndexes([mod], [fn]); + + for (const value of ['true', 'TRUE', 'yes', '1']) { + process.env['INGESTION_EMIT_SCOPES'] = value; + const g = createKnowledgeGraph(); + const stats = emitReferencesToGraph({ + graph: g, + scopes: indexes, + referenceIndex: buildRefIndex('scope:m', []), + }); + expect(stats.scopeNodesEmitted).toBeGreaterThan(0); + } + for (const value of ['false', '0', '', 'off', 'tru']) { + process.env['INGESTION_EMIT_SCOPES'] = value; + const g = createKnowledgeGraph(); + const stats = emitReferencesToGraph({ + graph: g, + scopes: indexes, + referenceIndex: buildRefIndex('scope:m', []), + }); + expect(stats.scopeNodesEmitted).toBe(0); + } + }); + + it('emitScopeGraph can be called directly (bypasses env flag)', () => { + const fn = def('def:fn', 'Function'); + const mod = scope('scope:m', null, 'Module', [fn]); + const indexes = makeIndexes([mod], [fn]); + + const graph = createKnowledgeGraph(); + const stats = emitScopeGraph({ graph, scopes: indexes }); + expect(stats.scopeNodesEmitted).toBe(1); + expect(stats.scopeEdgesEmitted).toBe(1); // only the DEFINES edge; no parent scope + }); +}); + +// ─── Empty input ────────────────────────────────────────────────────────── + +describe('empty input', () => { + it('returns zeroed stats and mutates nothing when ReferenceIndex is empty', () => { + const mod = scope('scope:m', null, 'Module', []); + const indexes = makeIndexes([mod], []); + const graph = createKnowledgeGraph(); + const stats = emitReferencesToGraph({ + graph, + scopes: indexes, + referenceIndex: { bySourceScope: new Map(), byTargetDef: new Map() }, + }); + expect(stats).toEqual({ + edgesEmitted: 0, + skippedNoCaller: 0, + skippedMissingTarget: 0, + scopeNodesEmitted: 0, + scopeEdgesEmitted: 0, + }); + expect(graph.nodes).toHaveLength(0); + expect(graph.relationships).toHaveLength(0); + }); +});