mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
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
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:
parent
e2ba4a04c9
commit
6222b5be9b
3 changed files with 810 additions and 0 deletions
|
|
@ -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;
|
||||
}[];
|
||||
}
|
||||
|
|
|
|||
299
gitnexus/src/core/ingestion/emit-references.ts
Normal file
299
gitnexus/src/core/ingestion/emit-references.ts
Normal 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 } : {}),
|
||||
};
|
||||
}
|
||||
495
gitnexus/test/unit/scope-resolution/emit-references.test.ts
Normal file
495
gitnexus/test/unit/scope-resolution/emit-references.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue