fix(ingestion): canonicalize parse node insertion

This commit is contained in:
Eva 2026-07-16 09:21:38 +07:00
parent ed3f58d292
commit 1f7036dbb0
3 changed files with 91 additions and 43 deletions

View file

@ -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,

View file

@ -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<string, string>();
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`

View file

@ -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);
});
});