diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts
index 29d48477f..c88a7b0c7 100644
--- a/__tests__/extraction.test.ts
+++ b/__tests__/extraction.test.ts
@@ -14,6 +14,7 @@ import { extractFromSource, scanDirectory, scanDirectoryAsync, buildDefaultIgnor
import { detectLanguage, isLanguageSupported, getSupportedLanguages, initGrammars, loadAllGrammars, isSourceFile } from '../src/extraction/grammars';
import { stripCppTemplateArgs, blankCppExportMacros, blankCppInlineMacros, blankMetalAttributes, blankCudaConstructs, blankCppAnnotationMacroCalls, blankCppApiPrefixMacros, blankCppInlineAnnotationMacros, blankCLeadingAttrMacros, recoverMangledCppName } from '../src/extraction/languages/c-cpp';
import { normalizePath } from '../src/utils';
+import { generateNodeId } from '../src/extraction/tree-sitter-helpers';
beforeAll(async () => {
await initGrammars();
@@ -32,6 +33,76 @@ function cleanupTempDir(dir: string): void {
}
}
+describe('same-line node identity (#1349)', () => {
+ let dir: string;
+ let cg: CodeGraph | undefined;
+ let kernel: string | undefined;
+
+ beforeEach(() => {
+ dir = createTempDir();
+ kernel = process.env.CODEGRAPH_KERNEL;
+ });
+
+ afterEach(() => {
+ cg?.destroy();
+ cg = undefined;
+ cleanupTempDir(dir);
+ if (kernel === undefined) delete process.env.CODEGRAPH_KERNEL;
+ else process.env.CODEGRAPH_KERNEL = kernel;
+ });
+
+ it.each(['default', 'wasm'])('persists both accessors and their separate call edges (%s)', async (backend) => {
+ if (backend === 'wasm') process.env.CODEGRAPH_KERNEL = '0';
+ else delete process.env.CODEGRAPH_KERNEL;
+ fs.writeFileSync(path.join(dir, 'point.ts'), [
+ 'function read() { return 1; } function write(v: number) {}',
+ 'export class Point { /* é😀 */ get x() { return read(); } set x(v: number) { write(v); }',
+ ' get y() { return read(); }',
+ ' set y(v: number) { write(v); }',
+ '}',
+ ].join('\n'));
+ cg = CodeGraph.initSync(dir, { config: { include: ['**/*.ts'], exclude: [] } });
+ await cg.indexAll();
+ cg.resolveReferences();
+ const nodes = cg.getNodesInFile('point.ts');
+ const x = nodes.filter((n) => n.name === 'x').sort((a, b) => a.startColumn - b.startColumn);
+ expect(x).toHaveLength(2);
+ expect(new Set(nodes.map((n) => n.id)).size).toBe(nodes.length);
+ expect(nodes.filter((n) => n.name === 'y')).toHaveLength(2);
+ expect(x[0]!.id).toBe(generateNodeId('point.ts', 'method', 'x', 2));
+ expect(x[1]!.id).toBe(`${x[0]!.id}:${x[1]!.startColumn}`);
+ const cls = nodes.find((n) => n.name === 'Point')!;
+ for (const accessor of x) {
+ expect(cg.getIncomingEdges(accessor.id)).toContainEqual(expect.objectContaining({ source: cls.id, kind: 'contains' }));
+ }
+ expect(cg.getCallees(x[0]!.id).map((c) => c.node.name)).toEqual(['read']);
+ expect(cg.getCallees(x[1]!.id).map((c) => c.node.name)).toEqual(['write']);
+ for (const node of nodes.filter((n) => n.name !== 'x' && n.kind !== 'file')) {
+ expect(node.id).toBe(generateNodeId(node.filePath, node.kind, node.name, node.startLine));
+ }
+ });
+
+ it.each([
+ ['template.liquid', 'é😀 {% render "x" %}{% render "x" %}{% assign v = 1 %}{% assign v = 2 %}', 'component'],
+ ['Service.cfc', '', 'method'],
+ ] as const)('persists repeated same-line declarations in %s', async (file, source, kind) => {
+ fs.writeFileSync(path.join(dir, file), source);
+ cg = CodeGraph.initSync(dir, { config: { include: [file], exclude: [] } });
+ await cg.indexAll();
+ const nodes = cg.getNodesInFile(file);
+ const xs = nodes.filter((n) => n.name === 'x' && n.kind === kind);
+ expect(xs).toHaveLength(2);
+ expect(new Set(xs.map((n) => n.id)).size).toBe(2);
+ for (const node of xs) {
+ expect(cg.getIncomingEdges(node.id).some((e) => e.kind === 'contains')).toBe(true);
+ }
+ if (file.endsWith('.liquid')) {
+ expect(nodes.filter((n) => n.kind === 'import' && n.name === 'x')).toHaveLength(2);
+ expect(nodes.filter((n) => n.name === 'v')).toHaveLength(2);
+ }
+ });
+});
+
describe('Language Detection', () => {
it('should detect TypeScript files', () => {
expect(detectLanguage('src/index.ts')).toBe('typescript');
diff --git a/__tests__/kernel-lua-parity.test.ts b/__tests__/kernel-lua-parity.test.ts
index e87b007e3..36bae7d4c 100644
--- a/__tests__/kernel-lua-parity.test.ts
+++ b/__tests__/kernel-lua-parity.test.ts
@@ -13,7 +13,7 @@
* `calls "require"`, table fn-ref registries with dedupe and the
* `M.cb = cb` param-storage skip, the raw-text callee zoo with colon/
* bracket/call-result callees and the `(handler)` conversion, LuaDoc
- * `- `-keeping docstrings, `` attributes, one-line duplicate-id
+ * `- `-keeping docstrings, `` attributes, one-line disambiguated
* declarations; torture.luau: `--!strict` docstring joining, `export type`
* isExported, verbatim `Generic` alias names, the typeof(require(...))
* alias+import pair, typed signatures with return suffixes, interpolation/
@@ -194,12 +194,12 @@ describe.skipIf(!kernelBuilt)('kernel Lua/Luau extraction parity', () => {
expect(names).toContain('obj:foo');
});
- it('one-line duplicate declarations emit duplicate-id rows verbatim', () => {
+ it('one-line duplicate declarations retain distinct IDs (#1349)', () => {
const src = 'local x = 1; local x = 2\n';
const result = assertParity('fixtures/dup.lua', src, 'lua', 2);
const xs = result.nodes.filter((n) => n.kind === 'variable' && n.name === 'x');
expect(xs).toHaveLength(2);
- expect(xs[0]!.id).toBe(xs[1]!.id);
+ expect(xs[1]!.id).toBe(`${xs[0]!.id}:${xs[1]!.startColumn}`);
});
it('cross-dialect syntax defers to the wasm extractor', () => {
diff --git a/__tests__/kernel-node-id-parity.test.ts b/__tests__/kernel-node-id-parity.test.ts
new file mode 100644
index 000000000..d6a5b19ad
--- /dev/null
+++ b/__tests__/kernel-node-id-parity.test.ts
@@ -0,0 +1,81 @@
+import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest';
+import * as fs from 'fs';
+import * as path from 'path';
+import { extractFromSource } from '../src/extraction';
+import { initGrammars, loadGrammarsForLanguages } from '../src/extraction/grammars';
+import { tryKernelExtract, resetKernelForTests } from '../src/extraction/kernel';
+import { generateNodeId } from '../src/extraction/tree-sitter-helpers';
+import type { ExtractionResult, Language } from '../src/types';
+
+// Exercise every sibling walker with a same-line collision after BMP + astral Unicode.
+const fixtures: Array<[Language, string]> = [
+ ['java', '/* é😀 */ class A { void x() {} void x(int n) {} }'],
+ ['python', 'label = "é😀"; x = 1; x = 2'],
+ ['go', 'package p\n/* é😀 */ type A struct{}; type B struct{}; func (a A) x(){}; func (b B) x(){}'],
+ ['c', '/* é😀 */ enum A { x }; void f() { enum B { x }; }'],
+ ['cpp', '/* é😀 */ int x() { return 1; } int x(int n) { return n; }'],
+ ['rust', '/* é😀 */ struct A; struct B; impl A { fn x() {} } impl B { fn x() {} }'],
+ ['csharp', '/* é😀 */ class A { void x() {} void x(int n) {} }'],
+ ['ruby', 'label = "é😀"; class A; def x; end; end; class B; def x; end; end'],
+ ['php', ' JSON.stringify(n, Object.keys(n).sort())).sort(),
+ edges: result.edges.map((e) => JSON.stringify(e, Object.keys(e).sort())).sort(),
+ refs: result.unresolvedReferences.map((r) => JSON.stringify(r, Object.keys(r).sort())).sort(),
+ };
+}
+
+const kernelBuilt = fs.existsSync(path.join(__dirname, '..', 'codegraph-kernel', 'prebuilds',
+ `${process.platform}-${process.arch}`, 'codegraph-kernel.node'));
+const ENV_KEYS = ['CODEGRAPH_KERNEL', 'CODEGRAPH_KERNEL_LANGS'] as const;
+let savedEnv: Record;
+
+describe.skipIf(!kernelBuilt)('collision-only node identity parity (#1349)', () => {
+ beforeAll(async () => {
+ await initGrammars();
+ await loadGrammarsForLanguages(fixtures.map(([language]) => language));
+ });
+ beforeEach(() => {
+ savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]]));
+ resetKernelForTests();
+ });
+ afterEach(() => {
+ for (const k of ENV_KEYS) {
+ if (savedEnv[k] === undefined) delete process.env[k];
+ else process.env[k] = savedEnv[k];
+ }
+ resetKernelForTests();
+ });
+
+ it.each(fixtures)('%s keeps distinct same-line symbols and UTF-16 identity suffixes', (language, source) => {
+ const file = `fixture.${language}`;
+ process.env.CODEGRAPH_KERNEL_LANGS = 'all';
+ delete process.env.CODEGRAPH_KERNEL;
+ const native = tryKernelExtract(file, source, language);
+ expect(native, 'must exercise the native walker, not fallback').not.toBeNull();
+ process.env.CODEGRAPH_KERNEL = '0';
+ const wasm = extractFromSource(file, source, language);
+ expect(canon(native!)).toEqual(canon(wasm));
+ const xs = wasm.nodes.filter((n) => n.name === 'x');
+ expect(xs).toHaveLength(2);
+ expect(xs[0]!.startLine).toBe(xs[1]!.startLine);
+ expect(xs[0]!.id).toBe(generateNodeId(file, xs[0]!.kind, 'x', xs[0]!.startLine));
+ expect(xs[1]!.id).toBe(`${xs[0]!.id}:${xs[1]!.startColumn}`);
+ expect(new Set(wasm.nodes.map((n) => n.id)).size).toBe(wasm.nodes.length);
+ const ids = new Set(wasm.nodes.map((n) => n.id));
+ for (const edge of wasm.edges) {
+ expect(ids.has(edge.source)).toBe(true);
+ expect(ids.has(edge.target)).toBe(true);
+ }
+ });
+});
diff --git a/__tests__/kernel-scaffold.test.ts b/__tests__/kernel-scaffold.test.ts
index f74bb897a..71650267e 100644
--- a/__tests__/kernel-scaffold.test.ts
+++ b/__tests__/kernel-scaffold.test.ts
@@ -15,7 +15,7 @@ import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import { NODE_KINDS, EDGE_KINDS } from '../src/types';
-import { generateNodeId } from '../src/extraction/tree-sitter-helpers';
+import { generateNodeId, NodeIdAllocator } from '../src/extraction/tree-sitter-helpers';
import { getKernel, tryKernelExtract, kernelRoutes, resetKernelForTests } from '../src/extraction/kernel';
import { extractFromSource } from '../src/extraction';
import { initGrammars, loadGrammarsForLanguages } from '../src/extraction/grammars';
@@ -29,6 +29,18 @@ const KERNEL_PATH = path.join(
'codegraph-kernel.node'
);
const kernelBuilt = fs.existsSync(KERNEL_PATH);
+
+it('collision-only identity vectors preserve legacy and repeated-position IDs (#1349)', () => {
+ const ids = new NodeIdAllocator();
+ const base = 'function:bfb15544fed707794274a5c61006ea7b';
+ expect(ids.generate('src/a.ts', 'function', 'foo', 3, 2)).toBe(base);
+ expect(ids.generate('src/a.ts', 'function', 'foo', 3, 24)).toBe(`${base}:24`);
+ expect(ids.generate('src/a.ts', 'function', 'foo', 3, 48)).toBe(`${base}:48`);
+ expect(ids.generate('src/a.ts', 'function', 'foo', 3, 24)).toBe(`${base}:24`);
+ expect(ids.generate('src/a.ts', 'function', 'foo', 3, 2)).toBe(base);
+ expect(ids.generate('src/b.ts', 'function', 'foo', 3, 24)).toBe(generateNodeId('src/b.ts', 'function', 'foo', 3));
+ expect(new NodeIdAllocator().generate('src/a.ts', 'function', 'foo', 3, 24)).toBe(base);
+});
const expectKernel = process.env.CODEGRAPH_KERNEL_EXPECT === '1';
const FIXTURE = [
diff --git a/__tests__/kernel-tsjs-parity.test.ts b/__tests__/kernel-tsjs-parity.test.ts
index 7bfd3caa9..7e16df56b 100644
--- a/__tests__/kernel-tsjs-parity.test.ts
+++ b/__tests__/kernel-tsjs-parity.test.ts
@@ -96,6 +96,20 @@ describe.skipIf(!kernelBuilt)('kernel TS/JS extraction parity', () => {
return viaWasm;
}
+ it.each([
+ ['ts', 'typescript'], ['tsx', 'tsx'], ['js', 'javascript'], ['jsx', 'jsx'],
+ ] as const)('same-line accessors retain distinct identities after Unicode: %s (#1349)', (ext, language) => {
+ const source = 'class Point { /* é😀 */ get x() { return read(); } set x(v) { write(v); } }';
+ const result = assertParity(`point.${ext}`, source, language);
+ const x = result.nodes.filter((n) => n.name === 'x');
+ expect(x).toHaveLength(2);
+ expect(x[1]!.id).toBe(`${x[0]!.id}:${source.indexOf('set x')}`);
+ expect(result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => [r.fromNodeId, r.referenceName]))
+ .toEqual([[x[0]!.id, 'read'], [x[1]!.id, 'write']]);
+ // No state leaks between files or repeated extractions.
+ expect(canon(assertParity(`point.${ext}`, source, language))).toEqual(canon(result));
+ });
+
it.each([
['ts', 'typescript'], ['tsx', 'tsx'], ['js', 'javascript'], ['jsx', 'jsx'],
] as const)('leaves nested identifier receivers unresolved and keeps argument calls: %s (#1566)', (ext, language) => {
diff --git a/codegraph-kernel/src/ccpp/mod.rs b/codegraph-kernel/src/ccpp/mod.rs
index 197f163ca..26952d100 100644
--- a/codegraph-kernel/src/ccpp/mod.rs
+++ b/codegraph-kernel/src/ccpp/mod.rs
@@ -343,6 +343,7 @@ pub struct Walker<'t> {
variant: Variant,
line_starts: Vec,
arena: Arena,
+ node_id_allocator: ids::NodeIdAllocator,
tables: Tables,
stack: Vec,
nodes_meta: Vec,
@@ -388,6 +389,7 @@ pub fn extract(file_path: &str, source: &str, language: &str) -> Result Walker<'t> {
return None;
}
let start_line = self.line_of(node);
- let id = ids::node_id(self.file_path, kind, name, start_line);
+ let column = self.col_of(node);
+ let id = self.node_id_allocator.generate(self.file_path, kind, name, start_line, column);
// (c/cpp define no resolveBody hook, so createNode's endLine extension
// for sibling-body grammars never fires — endLine is the node's own.)
let end_line = node.end_position().row as u32 + 1;
diff --git a/codegraph-kernel/src/csharp.rs b/codegraph-kernel/src/csharp.rs
index f370a0e54..75f9e0f8e 100644
--- a/codegraph-kernel/src/csharp.rs
+++ b/codegraph-kernel/src/csharp.rs
@@ -130,6 +130,7 @@ pub struct Walker<'t> {
file_path: &'t str,
line_starts: Vec,
arena: Arena,
+ node_id_allocator: ids::NodeIdAllocator,
tables: Tables,
stack: Vec,
/// Node id string per row — ids COLLIDE for same-(kind, name, line) nodes
@@ -162,6 +163,7 @@ pub fn extract(file_path: &str, source: &str) -> Result {
file_path,
line_starts: util::line_starts(source),
arena: Arena::default(),
+ node_id_allocator: ids::NodeIdAllocator::default(),
tables: Tables::default(),
stack: Vec::new(),
node_ids: Vec::new(),
@@ -317,7 +319,8 @@ impl<'t> Walker<'t> {
return None;
}
let start_line = self.line_of(node);
- let id = ids::node_id(self.file_path, kind, name, start_line);
+ let column = self.col_of(node);
+ let id = self.node_id_allocator.generate(self.file_path, kind, name, start_line, column);
let end_line = node.end_position().row as u32 + 1; // no resolveBody for csharp
let qualified = {
diff --git a/codegraph-kernel/src/dart.rs b/codegraph-kernel/src/dart.rs
index e229a3295..8182069b0 100644
--- a/codegraph-kernel/src/dart.rs
+++ b/codegraph-kernel/src/dart.rs
@@ -126,6 +126,7 @@ pub struct Walker<'t> {
file_path: &'t str,
line_starts: Vec,
arena: Arena,
+ node_id_allocator: ids::NodeIdAllocator,
tables: Tables,
stack: Vec,
node_ids: Vec,
@@ -156,6 +157,7 @@ pub fn extract(file_path: &str, source: &str) -> Result {
file_path,
line_starts: util::line_starts(source),
arena: Arena::default(),
+ node_id_allocator: ids::NodeIdAllocator::default(),
tables: Tables::default(),
stack: Vec::new(),
node_ids: Vec::new(),
@@ -265,7 +267,8 @@ impl<'t> Walker<'t> {
return None;
}
let start_line = self.line_of(node);
- let id = ids::node_id(self.file_path, kind, name, start_line);
+ let column = self.col_of(node);
+ let id = self.node_id_allocator.generate(self.file_path, kind, name, start_line, column);
let qualified = {
let mut parts: Vec<&str> = Vec::new();
diff --git a/codegraph-kernel/src/go.rs b/codegraph-kernel/src/go.rs
index 66bb352f1..ecdbcadc8 100644
--- a/codegraph-kernel/src/go.rs
+++ b/codegraph-kernel/src/go.rs
@@ -88,6 +88,7 @@ pub struct Walker<'t> {
file_path: &'t str,
line_starts: Vec,
arena: Arena,
+ node_id_allocator: ids::NodeIdAllocator,
tables: Tables,
stack: Vec,
nodes_meta: Vec,
@@ -119,6 +120,7 @@ pub fn extract(file_path: &str, source: &str) -> Result {
file_path,
line_starts: util::line_starts(source),
arena: Arena::default(),
+ node_id_allocator: ids::NodeIdAllocator::default(),
tables: Tables::default(),
stack: Vec::new(),
nodes_meta: Vec::new(),
@@ -224,7 +226,8 @@ impl<'t> Walker<'t> {
return None;
}
let start_line = self.line_of(node);
- let id = ids::node_id(self.file_path, kind, name, start_line);
+ let column = self.col_of(node);
+ let id = self.node_id_allocator.generate(self.file_path, kind, name, start_line, column);
let end_line = node.end_position().row as u32 + 1;
let qualified = extra.qualified_name.unwrap_or_else(|| {
diff --git a/codegraph-kernel/src/ids.rs b/codegraph-kernel/src/ids.rs
index 23b3aeb1c..1f7cf84aa 100644
--- a/codegraph-kernel/src/ids.rs
+++ b/codegraph-kernel/src/ids.rs
@@ -2,6 +2,8 @@
//! `generateNodeId` in `src/extraction/tree-sitter-helpers.ts`:
//!
//! `${kind}:${sha256(`${filePath}:${kind}:${name}:${line}`).hex[0..32]}`
+//! NodeIdAllocator appends `:` only for later, distinct source
+//! positions sharing that legacy ID within an extraction.
//!
//! and the file-node special case in `TreeSitterExtractor.extract()`:
//!
@@ -12,6 +14,33 @@
//! in `__tests__/kernel-scaffold.test.ts`.
use sha2::{Digest, Sha256};
+use std::collections::HashMap;
+
+/// Per-extraction collision handling, matching NodeIdAllocator in the wasm path.
+#[derive(Default)]
+pub struct NodeIdAllocator {
+ first_columns: HashMap,
+}
+
+impl NodeIdAllocator {
+ pub fn generate(
+ &mut self,
+ file_path: &str,
+ kind: &str,
+ name: &str,
+ line: u32,
+ column: u32,
+ ) -> String {
+ let id = node_id(file_path, kind, name, line);
+ // Zero-based UTF-16 column (util::col16), never tree-sitter's byte column.
+ let first_column = self.first_columns.entry(id.clone()).or_insert(column);
+ if *first_column == column {
+ id
+ } else {
+ format!("{id}:{column}")
+ }
+ }
+}
pub fn node_id(file_path: &str, kind: &str, name: &str, line: u32) -> String {
let mut hasher = Sha256::new();
@@ -41,6 +70,32 @@ pub fn file_node_id(file_path: &str) -> String {
mod tests {
use super::*;
+ #[test]
+ fn collision_only_identity_vectors() {
+ let mut ids = NodeIdAllocator::default();
+ let base = "function:bfb15544fed707794274a5c61006ea7b";
+ for (column, expected) in [
+ (2, base.to_string()),
+ (24, format!("{base}:24")),
+ (48, format!("{base}:48")),
+ (24, format!("{base}:24")),
+ (2, base.to_string()),
+ ] {
+ assert_eq!(
+ ids.generate("src/a.ts", "function", "foo", 3, column),
+ expected
+ );
+ }
+ assert_eq!(
+ ids.generate("src/b.ts", "function", "foo", 3, 24),
+ node_id("src/b.ts", "function", "foo", 3)
+ );
+ assert_eq!(
+ NodeIdAllocator::default().generate("src/a.ts", "function", "foo", 3, 24),
+ base
+ );
+ }
+
#[test]
fn matches_known_ts_output() {
// Pinned vector: node -e "crypto.createHash('sha256')
diff --git a/codegraph-kernel/src/java.rs b/codegraph-kernel/src/java.rs
index ed8369cc7..d5947ac52 100644
--- a/codegraph-kernel/src/java.rs
+++ b/codegraph-kernel/src/java.rs
@@ -124,6 +124,7 @@ pub struct Walker<'t> {
file_path: &'t str,
line_starts: Vec,
arena: Arena,
+ node_id_allocator: ids::NodeIdAllocator,
tables: Tables,
stack: Vec,
nodes_meta: Vec,
@@ -157,6 +158,7 @@ pub fn extract(file_path: &str, source: &str) -> Result {
file_path,
line_starts: util::line_starts(source),
arena: Arena::default(),
+ node_id_allocator: ids::NodeIdAllocator::default(),
tables: Tables::default(),
stack: Vec::new(),
nodes_meta: Vec::new(),
@@ -300,7 +302,8 @@ impl<'t> Walker<'t> {
return None;
}
let start_line = self.line_of(node);
- let id = ids::node_id(self.file_path, kind, name, start_line);
+ let column = self.col_of(node);
+ let id = self.node_id_allocator.generate(self.file_path, kind, name, start_line, column);
let end_line = node.end_position().row as u32 + 1; // no resolveBody for java
let qualified = {
diff --git a/codegraph-kernel/src/kotlin.rs b/codegraph-kernel/src/kotlin.rs
index f632d768f..9d4a0afec 100644
--- a/codegraph-kernel/src/kotlin.rs
+++ b/codegraph-kernel/src/kotlin.rs
@@ -191,6 +191,7 @@ pub struct Walker<'t> {
file_path: &'t str,
line_starts: Vec,
arena: Arena,
+ node_id_allocator: ids::NodeIdAllocator,
tables: Tables,
stack: Vec,
node_ids: Vec,
@@ -224,6 +225,7 @@ pub fn extract(file_path: &str, source: &str) -> Result {
file_path,
line_starts: util::line_starts(source),
arena: Arena::default(),
+ node_id_allocator: ids::NodeIdAllocator::default(),
tables: Tables::default(),
stack: Vec::new(),
node_ids: Vec::new(),
@@ -383,7 +385,8 @@ impl<'t> Walker<'t> {
return None;
}
let start_line = self.line_of(node);
- let id = ids::node_id(self.file_path, kind, name, start_line);
+ let column = self.col_of(node);
+ let id = self.node_id_allocator.generate(self.file_path, kind, name, start_line, column);
// endLine extension via resolveBody — LIVE for kotlin function/method
// kinds (in-range for this grammar, so practically a no-op — but the
// hook is part of the contract).
diff --git a/codegraph-kernel/src/lua.rs b/codegraph-kernel/src/lua.rs
index af1245034..543666557 100644
--- a/codegraph-kernel/src/lua.rs
+++ b/codegraph-kernel/src/lua.rs
@@ -75,6 +75,7 @@ pub struct Walker<'t> {
is_luau: bool,
line_starts: Vec,
arena: Arena,
+ node_id_allocator: ids::NodeIdAllocator,
tables: Tables,
stack: Vec,
node_ids: Vec,
@@ -103,6 +104,7 @@ pub fn extract(file_path: &str, source: &str, language: &str) -> Result Walker<'t> {
return None;
}
let start_line = self.line_of(node);
- let id = ids::node_id(self.file_path, kind, name, start_line);
+ let column = self.col_of(node);
+ let id = self.node_id_allocator.generate(self.file_path, kind, name, start_line, column);
// buildQualifiedName (1447-1460) — non-file stack NAMES joined `::`;
// the receiver override (extractMethod:1790-1792) replaces it whole.
diff --git a/codegraph-kernel/src/php.rs b/codegraph-kernel/src/php.rs
index 13a8626b6..fef28c9c5 100644
--- a/codegraph-kernel/src/php.rs
+++ b/codegraph-kernel/src/php.rs
@@ -142,6 +142,7 @@ pub struct Walker<'t> {
file_path: &'t str,
line_starts: Vec,
arena: Arena,
+ node_id_allocator: ids::NodeIdAllocator,
tables: Tables,
stack: Vec,
node_ids: Vec,
@@ -172,6 +173,7 @@ pub fn extract(file_path: &str, source: &str) -> Result {
file_path,
line_starts: util::line_starts(source),
arena: Arena::default(),
+ node_id_allocator: ids::NodeIdAllocator::default(),
tables: Tables::default(),
stack: Vec::new(),
node_ids: Vec::new(),
@@ -316,7 +318,8 @@ impl<'t> Walker<'t> {
return None;
}
let start_line = self.line_of(node);
- let id = ids::node_id(self.file_path, kind, name, start_line);
+ let column = self.col_of(node);
+ let id = self.node_id_allocator.generate(self.file_path, kind, name, start_line, column);
let end_line = node.end_position().row as u32 + 1; // no resolveBody for php
let qualified = {
diff --git a/codegraph-kernel/src/python.rs b/codegraph-kernel/src/python.rs
index 66f61f885..7c628b99b 100644
--- a/codegraph-kernel/src/python.rs
+++ b/codegraph-kernel/src/python.rs
@@ -141,6 +141,7 @@ pub struct Walker<'t> {
file_path: &'t str,
line_starts: Vec,
arena: Arena,
+ node_id_allocator: ids::NodeIdAllocator,
tables: Tables,
stack: Vec,
node_ids: Vec,
@@ -171,6 +172,7 @@ pub fn extract(file_path: &str, source: &str) -> Result {
file_path,
line_starts: util::line_starts(source),
arena: Arena::default(),
+ node_id_allocator: ids::NodeIdAllocator::default(),
tables: Tables::default(),
stack: Vec::new(),
node_ids: Vec::new(),
@@ -276,7 +278,8 @@ impl<'t> Walker<'t> {
return None;
}
let start_line = self.line_of(node);
- let id = ids::node_id(self.file_path, kind, name, start_line);
+ let column = self.col_of(node);
+ let id = self.node_id_allocator.generate(self.file_path, kind, name, start_line, column);
let end_line = node.end_position().row as u32 + 1;
let qualified = {
diff --git a/codegraph-kernel/src/rlang.rs b/codegraph-kernel/src/rlang.rs
index 9c85f0cf2..e3170cb78 100644
--- a/codegraph-kernel/src/rlang.rs
+++ b/codegraph-kernel/src/rlang.rs
@@ -69,6 +69,7 @@ pub struct Walker<'t> {
file_path: &'t str,
line_starts: Vec,
arena: Arena,
+ node_id_allocator: ids::NodeIdAllocator,
tables: Tables,
stack: Vec,
}
@@ -92,6 +93,7 @@ pub fn extract(file_path: &str, source: &str) -> Result {
file_path,
line_starts: util::line_starts(source),
arena: Arena::default(),
+ node_id_allocator: ids::NodeIdAllocator::default(),
tables: Tables::default(),
stack: Vec::new(),
};
@@ -180,7 +182,8 @@ impl<'t> Walker<'t> {
return None;
}
let start_line = self.line_of(node);
- let id = ids::node_id(self.file_path, kind, name, start_line);
+ let column = self.col_of(node);
+ let id = self.node_id_allocator.generate(self.file_path, kind, name, start_line, column);
// buildQualifiedName (tree-sitter.ts:1447-1460): non-file stack names
// joined `::` (namespacePrefix is always empty for R).
diff --git a/codegraph-kernel/src/ruby.rs b/codegraph-kernel/src/ruby.rs
index 35cd2a713..3e4e33cb0 100644
--- a/codegraph-kernel/src/ruby.rs
+++ b/codegraph-kernel/src/ruby.rs
@@ -118,6 +118,7 @@ pub struct Walker<'t> {
file_path: &'t str,
line_starts: Vec,
arena: Arena,
+ node_id_allocator: ids::NodeIdAllocator,
tables: Tables,
stack: Vec,
node_ids: Vec,
@@ -148,6 +149,7 @@ pub fn extract(file_path: &str, source: &str) -> Result {
file_path,
line_starts: util::line_starts(source),
arena: Arena::default(),
+ node_id_allocator: ids::NodeIdAllocator::default(),
tables: Tables::default(),
stack: Vec::new(),
node_ids: Vec::new(),
@@ -257,7 +259,8 @@ impl<'t> Walker<'t> {
return None;
}
let start_line = self.line_of(node);
- let id = ids::node_id(self.file_path, kind, name, start_line);
+ let column = self.col_of(node);
+ let id = self.node_id_allocator.generate(self.file_path, kind, name, start_line, column);
let end_line = node.end_position().row as u32 + 1; // no resolveBody for ruby
let qualified = {
diff --git a/codegraph-kernel/src/rustlang.rs b/codegraph-kernel/src/rustlang.rs
index 4515d3469..1dbc689b4 100644
--- a/codegraph-kernel/src/rustlang.rs
+++ b/codegraph-kernel/src/rustlang.rs
@@ -112,6 +112,7 @@ pub struct Walker<'t> {
file_path: &'t str,
line_starts: Vec,
arena: Arena,
+ node_id_allocator: ids::NodeIdAllocator,
tables: Tables,
stack: Vec,
nodes_meta: Vec,
@@ -143,6 +144,7 @@ pub fn extract(file_path: &str, source: &str) -> Result {
file_path,
line_starts: util::line_starts(source),
arena: Arena::default(),
+ node_id_allocator: ids::NodeIdAllocator::default(),
tables: Tables::default(),
stack: Vec::new(),
nodes_meta: Vec::new(),
@@ -252,7 +254,8 @@ impl<'t> Walker<'t> {
return None;
}
let start_line = self.line_of(node);
- let id = ids::node_id(self.file_path, kind, name, start_line);
+ let column = self.col_of(node);
+ let id = self.node_id_allocator.generate(self.file_path, kind, name, start_line, column);
let end_line = node.end_position().row as u32 + 1;
let qualified = extra.qualified_name.unwrap_or_else(|| {
diff --git a/codegraph-kernel/src/scala.rs b/codegraph-kernel/src/scala.rs
index 27968f0eb..9b7aeb7c2 100644
--- a/codegraph-kernel/src/scala.rs
+++ b/codegraph-kernel/src/scala.rs
@@ -155,6 +155,7 @@ pub struct Walker<'t> {
file_path: &'t str,
line_starts: Vec,
arena: Arena,
+ node_id_allocator: ids::NodeIdAllocator,
tables: Tables,
stack: Vec,
node_ids: Vec,
@@ -187,6 +188,7 @@ pub fn extract(file_path: &str, source: &str) -> Result {
file_path,
line_starts: util::line_starts(source),
arena: Arena::default(),
+ node_id_allocator: ids::NodeIdAllocator::default(),
tables: Tables::default(),
stack: Vec::new(),
node_ids: Vec::new(),
@@ -297,7 +299,8 @@ impl<'t> Walker<'t> {
return None;
}
let start_line = self.line_of(node);
- let id = ids::node_id(self.file_path, kind, name, start_line);
+ let column = self.col_of(node);
+ let id = self.node_id_allocator.generate(self.file_path, kind, name, start_line, column);
// buildQualifiedName (:1447-1460) — non-file stack names, `::`-joined;
// namespacePrefix always empty (no C++ namespaces, no scala namespace).
diff --git a/codegraph-kernel/src/swift.rs b/codegraph-kernel/src/swift.rs
index 7d46c4922..1613888c3 100644
--- a/codegraph-kernel/src/swift.rs
+++ b/codegraph-kernel/src/swift.rs
@@ -152,6 +152,7 @@ pub struct Walker<'t> {
file_path: &'t str,
line_starts: Vec,
arena: Arena,
+ node_id_allocator: ids::NodeIdAllocator,
tables: Tables,
stack: Vec,
node_ids: Vec,
@@ -182,6 +183,7 @@ pub fn extract(file_path: &str, source: &str) -> Result {
file_path,
line_starts: util::line_starts(source),
arena: Arena::default(),
+ node_id_allocator: ids::NodeIdAllocator::default(),
tables: Tables::default(),
stack: Vec::new(),
node_ids: Vec::new(),
@@ -332,7 +334,8 @@ impl<'t> Walker<'t> {
return None;
}
let start_line = self.line_of(node);
- let id = ids::node_id(self.file_path, kind, name, start_line);
+ let column = self.col_of(node);
+ let id = self.node_id_allocator.generate(self.file_path, kind, name, start_line, column);
let end_line = node.end_position().row as u32 + 1; // no resolveBody for swift
let qualified = {
diff --git a/codegraph-kernel/src/tsjs/mod.rs b/codegraph-kernel/src/tsjs/mod.rs
index 28f299f5e..bdc88e8ab 100644
--- a/codegraph-kernel/src/tsjs/mod.rs
+++ b/codegraph-kernel/src/tsjs/mod.rs
@@ -167,6 +167,7 @@ pub struct Walker<'t> {
variant: Variant,
line_starts: Vec,
arena: Arena,
+ node_id_allocator: ids::NodeIdAllocator,
tables: Tables,
stack: Vec,
/// Node id string per row. Rows are unique but IDS COLLIDE for same
@@ -222,6 +223,7 @@ pub fn extract(file_path: &str, source: &str, language: &str) -> Result Walker<'t> {
return None;
}
let start_line = self.line_of(node);
- let id = ids::node_id(self.file_path, kind, name, start_line);
+ let column = self.col_of(node);
+ let id = self.node_id_allocator.generate(self.file_path, kind, name, start_line, column);
// endLine body extension: resolveBody only (TS/JS: function-valued
// class fields whose body nests in the arrow / HOF-wrapped arrow).
diff --git a/src/extraction/cfml-extractor.ts b/src/extraction/cfml-extractor.ts
index edb4a4955..8395940c1 100644
--- a/src/extraction/cfml-extractor.ts
+++ b/src/extraction/cfml-extractor.ts
@@ -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'
diff --git a/src/extraction/liquid-extractor.ts b/src/extraction/liquid-extractor.ts
index 895101df7..f6d9aee60 100644
--- a/src/extraction/liquid-extractor.ts
+++ b/src/extraction/liquid-extractor.ts
@@ -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,
diff --git a/src/extraction/tree-sitter-helpers.ts b/src/extraction/tree-sitter-helpers.ts
index a6438e1b4..da880cca5 100644
--- a/src/extraction/tree-sitter-helpers.ts
+++ b/src/extraction/tree-sitter-helpers.ts
@@ -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();
+
+ 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
*/
diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts
index f3c654350..62a194db7 100644
--- a/src/extraction/tree-sitter.ts
+++ b/src/extraction/tree-sitter.ts
@@ -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