fix(extraction): preserve symbols with colliding same-line IDs (#1349) (#2030)

Same-kind, same-name declarations on one line shared an ID and silently overwrote each other in SQLite.
Use per-extraction collision tracking to append a UTF-16 column only to later distinct declarations.
Apply the same rule across wasm, Rust language walkers, Liquid, and CFML while preserving file IDs and edge endpoints.
Add persistence, call-edge, identity-vector, and Unicode parity regressions.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-09-27 13:35:19 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent e27d6da6b2
commit 86708bf519
25 changed files with 329 additions and 31 deletions
+4 -3
View File
@@ -1,6 +1,6 @@
import type { Node as SyntaxNode } from 'web-tree-sitter';
import { Node, Edge, ExtractionResult, ExtractionError, UnresolvedReference, Language } from '../types';
import { generateNodeId } from './tree-sitter-helpers';
import { generateNodeId, NodeIdAllocator } from './tree-sitter-helpers';
import { TreeSitterExtractor } from './tree-sitter';
import { getParser } from './grammars';
@@ -22,6 +22,7 @@ export class CfmlExtractor {
private source: string;
private language: Language;
private nodes: Node[] = [];
private nodeIds = new NodeIdAllocator();
private edges: Edge[] = [];
private unresolvedReferences: UnresolvedReference[] = [];
private errors: ExtractionError[] = [];
@@ -174,7 +175,7 @@ export class CfmlExtractor {
*/
private extractComponent(openTag: SyntaxNode, containerId: string | undefined): SyntaxNode {
const name = this.tagAttr(openTag, 'name') ?? this.componentNameFromPath();
const id = generateNodeId(this.filePath, 'class', name, openTag.startPosition.row + 1);
const id = this.nodeIds.generate(this.filePath, 'class', name, openTag.startPosition.row + 1, openTag.startPosition.column);
const classNode: Node = {
id,
@@ -261,7 +262,7 @@ export class CfmlExtractor {
if (!name) return;
const kind = parentClassId ? 'method' : 'function';
const id = generateNodeId(this.filePath, kind, name, tag.startPosition.row + 1);
const id = this.nodeIds.generate(this.filePath, kind, name, tag.startPosition.row + 1, tag.startPosition.column);
const access = this.tagAttr(tag, 'access');
const visibility = access === 'private' ? 'private'
: access === 'package' ? 'internal'
+8 -7
View File
@@ -1,5 +1,5 @@
import { Node, Edge, ExtractionResult, ExtractionError, UnresolvedReference } from '../types';
import { generateNodeId } from './tree-sitter-helpers';
import { generateNodeId, NodeIdAllocator } from './tree-sitter-helpers';
/**
* LiquidExtractor - Extracts relationships from Liquid template files
@@ -14,6 +14,7 @@ export class LiquidExtractor {
private filePath: string;
private source: string;
private nodes: Node[] = [];
private nodeIds = new NodeIdAllocator();
private edges: Edge[] = [];
private unresolvedReferences: UnresolvedReference[] = [];
private errors: ExtractionError[] = [];
@@ -217,7 +218,7 @@ export class LiquidExtractor {
const line = this.getLineNumber(match.index);
// Create an import node for searchability
const importNodeId = generateNodeId(this.filePath, 'import', snippetName!, line);
const importNodeId = this.nodeIds.generate(this.filePath, 'import', snippetName!, line, match.index - this.getLineStart(line));
const importNode: Node = {
id: importNodeId,
kind: 'import',
@@ -242,7 +243,7 @@ export class LiquidExtractor {
});
// Create a component node for the snippet reference
const nodeId = generateNodeId(this.filePath, 'component', `${tagType}:${snippetName}`, line);
const nodeId = this.nodeIds.generate(this.filePath, 'component', `${tagType}:${snippetName}`, line, match.index - this.getLineStart(line));
const node: Node = {
id: nodeId,
@@ -289,7 +290,7 @@ export class LiquidExtractor {
const line = this.getLineNumber(match.index);
// Create an import node for searchability
const importNodeId = generateNodeId(this.filePath, 'import', sectionName!, line);
const importNodeId = this.nodeIds.generate(this.filePath, 'import', sectionName!, line, match.index - this.getLineStart(line));
const importNode: Node = {
id: importNodeId,
kind: 'import',
@@ -314,7 +315,7 @@ export class LiquidExtractor {
});
// Create a component node for the section reference
const nodeId = generateNodeId(this.filePath, 'component', `section:${sectionName}`, line);
const nodeId = this.nodeIds.generate(this.filePath, 'component', `section:${sectionName}`, line, match.index - this.getLineStart(line));
const node: Node = {
id: nodeId,
@@ -378,7 +379,7 @@ export class LiquidExtractor {
}
// Create a node for the schema
const nodeId = generateNodeId(this.filePath, 'constant', `schema:${schemaName}`, startLine);
const nodeId = this.nodeIds.generate(this.filePath, 'constant', `schema:${schemaName}`, startLine, match.index - this.getLineStart(startLine));
const node: Node = {
id: nodeId,
@@ -419,7 +420,7 @@ export class LiquidExtractor {
const line = this.getLineNumber(match.index);
// Create a variable node
const nodeId = generateNodeId(this.filePath, 'variable', variableName!, line);
const nodeId = this.nodeIds.generate(this.filePath, 'variable', variableName!, line, match.index - this.getLineStart(line));
const node: Node = {
id: nodeId,
+17
View File
@@ -29,6 +29,23 @@ export function generateNodeId(
return `${kind}:${hash}`;
}
/** Per-extraction identities: preserve legacy IDs unless distinct source positions collide. */
export class NodeIdAllocator {
private firstColumns = new Map<string, number>();
generate(filePath: string, kind: NodeKind, name: string, line: number, column: number): string {
const id = generateNodeId(filePath, kind, name, line);
const firstColumn = this.firstColumns.get(id);
if (firstColumn === undefined) {
this.firstColumns.set(id, column);
return id;
}
// Columns are zero-based UTF-16 code units in both wasm and the kernel.
// Revisiting the same declaration must still produce the same identity.
return firstColumn === column ? id : `${id}:${column}`;
}
}
/**
* Extract text from a syntax node
*/
+3 -2
View File
@@ -16,7 +16,7 @@ import {
UnresolvedReference,
} from '../types';
import { getParser, detectLanguage, isLanguageSupported, isFileLevelOnlyLanguage } from './grammars';
import { generateNodeId, getNodeText, getChildByField, getPrecedingDocstring } from './tree-sitter-helpers';
import { NodeIdAllocator, getNodeText, getChildByField, getPrecedingDocstring } from './tree-sitter-helpers';
import { FN_REF_SPECS, captureFnRefCandidates, type FnRefSpec, type FnRefCandidate } from './function-ref';
import { isGeneratedFile } from './generated-detection';
import type { LanguageExtractor, ExtractorContext } from './tree-sitter-types';
@@ -454,6 +454,7 @@ export class TreeSitterExtractor {
private source: string;
private tree: Tree | null = null;
private nodes: Node[] = [];
private nodeIds = new NodeIdAllocator();
private edges: Edge[] = [];
private unresolvedReferences: UnresolvedReference[] = [];
// Value-reference edges (default ON; set CODEGRAPH_VALUE_REFS=0 to disable; see flushValueRefs).
@@ -1412,7 +1413,7 @@ export class TreeSitterExtractor {
return null;
}
const id = generateNodeId(this.filePath, kind, name, node.startPosition.row + 1);
const id = this.nodeIds.generate(this.filePath, kind, name, node.startPosition.row + 1, node.startPosition.column);
// Some grammars (e.g. Dart) model a function/method body as a *sibling* of
// the signature node, so the declaration node's own range is just the