From 3eb54fa6502f257ff5418cd224111f77a83ecc82 Mon Sep 17 00:00:00 2001 From: Eva Date: Tue, 14 Jul 2026 04:02:22 +0700 Subject: [PATCH 1/2] fix(scope): stabilize graph lookup collisions --- .../graph-bridge/node-lookup.ts | 32 ++++++++-- .../node-lookup-determinism.test.ts | 59 +++++++++++++++++++ 2 files changed, 87 insertions(+), 4 deletions(-) create mode 100644 gitnexus/test/unit/scope-resolution/node-lookup-determinism.test.ts 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 9ab1649a18..680b030dd0 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 0000000000..06cc8442d0 --- /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); + }); +}); From 4c11e594e6d37cf29845c327a43c10634fa75c39 Mon Sep 17 00:00:00 2001 From: Eva Date: Thu, 16 Jul 2026 09:21:38 +0700 Subject: [PATCH 2/2] fix(ingestion): canonicalize parse node insertion --- .../src/core/ingestion/parsing-processor.ts | 39 ++++++++++- .../graph-bridge/node-lookup.ts | 30 +-------- .../node-lookup-determinism.test.ts | 65 ++++++++++++++----- 3 files changed, 91 insertions(+), 43 deletions(-) diff --git a/gitnexus/src/core/ingestion/parsing-processor.ts b/gitnexus/src/core/ingestion/parsing-processor.ts index 88d8cd1210..c91df29536 100644 --- a/gitnexus/src/core/ingestion/parsing-processor.ts +++ b/gitnexus/src/core/ingestion/parsing-processor.ts @@ -58,6 +58,39 @@ export interface WorkerExtractedData { parsedFiles: ParsedFile[]; } +type ParsedGraphNode = ParseWorkerResult['nodes'][number]; + +function compareText(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +function sourceLine(node: ParsedGraphNode): number { + const value = node.properties.startLine; + return typeof value === 'number' && Number.isFinite(value) ? value : Number.MAX_SAFE_INTEGER; +} + +function compareParsedNodeSourceOrder(left: ParsedGraphNode, right: ParsedGraphNode): number { + const leftPath = typeof left.properties.filePath === 'string' ? left.properties.filePath : ''; + const rightPath = typeof right.properties.filePath === 'string' ? right.properties.filePath : ''; + const fileOrder = compareText(leftPath, rightPath); + if (fileOrder !== 0) return fileOrder; + + const leftLine = sourceLine(left); + const rightLine = sourceLine(right); + if (leftLine !== rightLine) return leftLine < rightLine ? -1 : 1; + + return compareText(left.id, right.id); +} + +function nodesInSourceOrder(nodes: readonly ParsedGraphNode[]): readonly ParsedGraphNode[] { + for (let index = 1; index < nodes.length; index++) { + if (compareParsedNodeSourceOrder(nodes[index - 1], nodes[index]) > 0) { + return [...nodes].sort(compareParsedNodeSourceOrder); + } + } + return nodes; +} + // ============================================================================ // Worker-based parallel parsing // ============================================================================ @@ -95,7 +128,11 @@ export const mergeChunkResults = ( const allParsedFiles: ParsedFile[] = []; for (const result of chunkResults) { - for (const node of result.nodes) { + // Worker jobs and input files are already merged in stable start-index/path + // order. Canonicalize the final per-result node boundary once so graph + // insertion, cache replay, and first-wins graph indexes share source order. + // The common already-ordered path stays allocation-free and linear. + for (const node of nodesInSourceOrder(result.nodes)) { graph.addNode({ id: node.id, label: node.label as NodeLabel, 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 680b030dd0..0d5abe5103 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 { GraphNode, NodeLabel, ParameterTypeClass } from 'gitnexus-shared'; +import type { 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,34 +67,9 @@ 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(); - 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) { + for (const node of graph.iterNodes()) { const props = node.properties as { filePath?: string; name?: string; @@ -102,6 +77,7 @@ 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` diff --git a/gitnexus/test/unit/scope-resolution/node-lookup-determinism.test.ts b/gitnexus/test/unit/scope-resolution/node-lookup-determinism.test.ts index 06cc8442d0..852ab09f75 100644 --- a/gitnexus/test/unit/scope-resolution/node-lookup-determinism.test.ts +++ b/gitnexus/test/unit/scope-resolution/node-lookup-determinism.test.ts @@ -2,38 +2,64 @@ import type { NodeLabel } from 'gitnexus-shared'; import { describe, expect, it } from 'vitest'; import { createKnowledgeGraph } from '../../../src/core/graph/graph.js'; +import { createSemanticModel } from '../../../src/core/ingestion/model/semantic-model.js'; +import { mergeChunkResults } from '../../../src/core/ingestion/parsing-processor.js'; import { buildGraphNodeLookup, qualifiedKey, simpleKey, } from '../../../src/core/ingestion/scope-resolution/graph-bridge/node-lookup.js'; +import type { ParseWorkerResult } from '../../../src/core/ingestion/workers/parse-worker.js'; const FILE = 'src/service.ts'; interface Candidate { id: string; - startLine: number; + 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, - }, - }); - } + const nodes = candidates.map( + (candidate) => + ({ + id: candidate.id, + label: 'Method' as NodeLabel, + properties: { + name: 'save', + qualifiedName: 'Service.save', + filePath: FILE, + ...(candidate.startLine !== undefined ? { startLine: candidate.startLine } : {}), + }, + }) satisfies ParseWorkerResult['nodes'][number], + ); + const result: ParseWorkerResult = { + nodes, + relationships: [], + symbols: [], + calls: [], + assignments: [], + routes: [], + fetchCalls: [], + fetchWrapperDefs: [], + decoratorRoutes: [], + routerIncludes: [], + routerImports: [], + toolDefs: [], + ormQueries: [], + constructorBindings: [], + fileScopeBindings: [], + parsedFiles: [], + skippedLanguages: {}, + fileCount: 1, + }; + + mergeChunkResults(graph, createSemanticModel().symbols, [result]); return buildGraphNodeLookup(graph); } -describe('buildGraphNodeLookup determinism', () => { - it('selects the earliest source definition regardless of graph insertion order', () => { +describe('parse-result graph insertion determinism', () => { + it('selects the earliest source definition regardless of worker result order', () => { const early = { id: `Method:${FILE}:Service.save#1`, startLine: 10 }; const late = { id: `Method:${FILE}:Service.save#2`, startLine: 20 }; @@ -56,4 +82,13 @@ describe('buildGraphNodeLookup determinism', () => { expect(firstLookup.get(simpleKey(FILE, 'save'))).toBe(first.id); expect(secondLookup.get(simpleKey(FILE, 'save'))).toBe(first.id); }); + + it('uses the stable node id when source positions are unavailable', () => { + const first = { id: `Method:${FILE}:Service.save#1` }; + const second = { id: `Method:${FILE}:Service.save#2` }; + + const lookup = buildLookup([second, first]); + + expect(lookup.get(simpleKey(FILE, 'save'))).toBe(first.id); + }); });