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