diff --git a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts index 9ab1649a1..680b030dd 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts @@ -18,7 +18,7 @@ * format that downstream consumers (queries, edges, MCP) expect. */ -import type { NodeLabel, ParameterTypeClass } from 'gitnexus-shared'; +import type { GraphNode, NodeLabel, ParameterTypeClass } from 'gitnexus-shared'; import type { KnowledgeGraph } from '../../../graph/types.js'; import { isOverloadableCallable } from '../../utils/callable-labels.js'; import { templateConstraintsIdTag } from '../../utils/template-arguments.js'; @@ -67,9 +67,34 @@ export function simpleKey(filePath: string, name: string): string { return `${filePath}::${name}`; } +function compareText(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +function compareSourceOrder(left: GraphNode, right: GraphNode): number { + const fileOrder = compareText(left.properties.filePath, right.properties.filePath); + if (fileOrder !== 0) return fileOrder; + + const leftLine = Number.isFinite(left.properties.startLine) + ? (left.properties.startLine ?? Number.MAX_SAFE_INTEGER) + : Number.MAX_SAFE_INTEGER; + const rightLine = Number.isFinite(right.properties.startLine) + ? (right.properties.startLine ?? Number.MAX_SAFE_INTEGER) + : Number.MAX_SAFE_INTEGER; + if (leftLine !== rightLine) return leftLine - rightLine; + + return compareText(left.id, right.id); +} + export function buildGraphNodeLookup(graph: KnowledgeGraph): GraphNodeLookup { const lookup = new Map(); - for (const node of graph.iterNodes()) { + const linkableNodes = Array.from(graph.iterNodes()).filter((node) => { + const props = node.properties as { filePath?: string; name?: string }; + return props.filePath !== undefined && props.name !== undefined && isLinkableLabel(node.label); + }); + linkableNodes.sort(compareSourceOrder); + + for (const node of linkableNodes) { const props = node.properties as { filePath?: string; name?: string; @@ -77,7 +102,6 @@ export function buildGraphNodeLookup(graph: KnowledgeGraph): GraphNodeLookup { templateArguments?: readonly string[]; }; if (props.filePath === undefined || props.name === undefined) continue; - if (!isLinkableLabel(node.label)) continue; // Primary key: fully-qualified name + label, in a separate // keyspace from simple names. Class nodes carry `qualifiedName` @@ -163,7 +187,7 @@ export function buildGraphNodeLookup(graph: KnowledgeGraph): GraphNodeLookup { } } - // Fallback key: simple name. First-wins within a file — used when + // Fallback key: simple name. Source-order first-wins within a file — used when // the caller doesn't know the qualifier (unqualified free-call // fallback, cross-file resolution where MethodRegistry already // disambiguated the owner). diff --git a/gitnexus/test/unit/scope-resolution/node-lookup-determinism.test.ts b/gitnexus/test/unit/scope-resolution/node-lookup-determinism.test.ts new file mode 100644 index 000000000..06cc8442d --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/node-lookup-determinism.test.ts @@ -0,0 +1,59 @@ +import type { NodeLabel } from 'gitnexus-shared'; +import { describe, expect, it } from 'vitest'; + +import { createKnowledgeGraph } from '../../../src/core/graph/graph.js'; +import { + buildGraphNodeLookup, + qualifiedKey, + simpleKey, +} from '../../../src/core/ingestion/scope-resolution/graph-bridge/node-lookup.js'; + +const FILE = 'src/service.ts'; + +interface Candidate { + id: string; + startLine: number; +} + +function buildLookup(candidates: readonly Candidate[]) { + const graph = createKnowledgeGraph(); + for (const candidate of candidates) { + graph.addNode({ + id: candidate.id, + label: 'Method' as NodeLabel, + properties: { + name: 'save', + qualifiedName: 'Service.save', + filePath: FILE, + startLine: candidate.startLine, + }, + }); + } + return buildGraphNodeLookup(graph); +} + +describe('buildGraphNodeLookup determinism', () => { + it('selects the earliest source definition regardless of graph insertion order', () => { + const early = { id: `Method:${FILE}:Service.save#1`, startLine: 10 }; + const late = { id: `Method:${FILE}:Service.save#2`, startLine: 20 }; + + const lateFirst = buildLookup([late, early]); + const earlyFirst = buildLookup([early, late]); + + for (const key of [simpleKey(FILE, 'save'), qualifiedKey(FILE, 'Method', 'Service.save')]) { + expect(lateFirst.get(key)).toBe(early.id); + expect(earlyFirst.get(key)).toBe(early.id); + } + }); + + it('uses the stable node id when source positions are identical', () => { + const first = { id: `Method:${FILE}:Service.save#1`, startLine: 10 }; + const second = { id: `Method:${FILE}:Service.save#2`, startLine: 10 }; + + const firstLookup = buildLookup([second, first]); + const secondLookup = buildLookup([first, second]); + + expect(firstLookup.get(simpleKey(FILE, 'save'))).toBe(first.id); + expect(secondLookup.get(simpleKey(FILE, 'save'))).toBe(first.id); + }); +});