From 1f7036dbb03ed7f41dc3ecdf2470052ee7f8bb26 Mon Sep 17 00:00:00 2001 From: Eva Date: Thu, 16 Jul 2026 09:21:38 +0700 Subject: [PATCH] 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 88d8cd121..c91df2953 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 680b030dd..0d5abe510 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 06cc8442d..852ab09f7 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); + }); });