feat(ingestion): emit-references drains ReferenceIndex to graph edges (#925, RFC #909 Ring 2 PKG) (#973)
Some checks are pending
CI / quality (push) Waiting to run
CI / tests (push) Waiting to run
CI / e2e (push) Waiting to run
CI / Save PR Metadata (push) Blocked by required conditions
CI / CI Gate (push) Blocked by required conditions
Release Candidate / Check if release candidate should run (push) Waiting to run
Release Candidate / ci (push) Blocked by required conditions
Release Candidate / Publish release candidate to npm (push) Blocked by required conditions

This commit is contained in:
Gergő Magyar 2026-04-18 23:36:10 +01:00 committed by GitHub
parent e2ba4a04c9
commit 6222b5be9b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 810 additions and 0 deletions

View file

@ -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;
}[];
}

View file

@ -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<KnowledgeGraph['addNode']>[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<string> = 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<ScopeId>();
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<KnowledgeGraph['addRelationship']>[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 } : {}),
};
}

View file

@ -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<string, readonly BindingRef[]> = {},
): 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<ScopeId, readonly Reference[]>();
bySource.set(sourceScope, refs);
const byTarget = new Map<DefId, Reference[]>();
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);
});
});