From d7b2c1fa78d7942a4d45fe67d0fa5986af337325 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Sun, 7 Jun 2026 18:36:54 -0600 Subject: [PATCH 01/11] fix(native): prefer local dev binary over npm package in load order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a locally compiled `crates/codegraph-core/*.node` binary exists it is now loaded before the published npm platform package. This ensures that Rust changes (like the prototype-method extraction from PR #1339) are picked up immediately in development without waiting for a new npm release. Load order is now: 1. NAPI_RS_NATIVE_LIBRARY_PATH env var — explicit override 2. crates/codegraph-core/.node — freshly compiled dev binary 3. @optave/codegraph- npm pkg — published production binary Closes #1361 --- src/infrastructure/native.ts | 70 +++++++++++++++++++++++++++++++++--- 1 file changed, 65 insertions(+), 5 deletions(-) diff --git a/src/infrastructure/native.ts b/src/infrastructure/native.ts index b313f0ffe..f839e4f0f 100644 --- a/src/infrastructure/native.ts +++ b/src/infrastructure/native.ts @@ -8,6 +8,7 @@ import { createRequire } from 'node:module'; import os from 'node:os'; +import { fileURLToPath } from 'node:url'; import { EngineError, toErrorMessage } from '../shared/errors.js'; import type { NativeAddon } from '../types.js'; import { debug } from './logger.js'; @@ -44,31 +45,90 @@ const PLATFORM_PACKAGES: Record = { 'win32-x64': '@optave/codegraph-win32-x64-msvc', }; +/** + * Map of (platform-arch[-libc]) → locally compiled binary filename. + * Used as a dev-mode fallback when the npm package is not installed, + * e.g. when working with Rust changes that haven't been published yet. + */ +const PLATFORM_LOCAL_BINARIES: Record = { + 'linux-x64-gnu': 'codegraph-core.linux-x64-gnu.node', + 'linux-x64-musl': 'codegraph-core.linux-x64-musl.node', + 'linux-arm64-gnu': 'codegraph-core.linux-arm64-gnu.node', + 'linux-arm64-musl': 'codegraph-core.linux-arm64-musl.node', + 'darwin-arm64': 'codegraph-core.darwin-arm64.node', + 'darwin-x64': 'codegraph-core.darwin-x64.node', + 'win32-x64': 'codegraph-core.win32-x64-msvc.node', +}; + +/** Compute the platform key used to index PLATFORM_PACKAGES / PLATFORM_LOCAL_BINARIES. */ +function resolvePlatformKey(): string { + const platform = os.platform(); + const arch = os.arch(); + return platform === 'linux' ? `${platform}-${arch}-${detectLibc()}` : `${platform}-${arch}`; +} + /** * Resolve the platform-specific npm package name for the native addon. * Returns null if the current platform is not supported. */ function resolvePlatformPackage(): string | null { - const platform = os.platform(); - const arch = os.arch(); - const key = platform === 'linux' ? `${platform}-${arch}-${detectLibc()}` : `${platform}-${arch}`; - return PLATFORM_PACKAGES[key] || null; + return PLATFORM_PACKAGES[resolvePlatformKey()] ?? null; } /** * Try to load the native napi addon. * Returns the module on success, null on failure. + * + * Load order: + * 1. NAPI_RS_NATIVE_LIBRARY_PATH env var (explicit override) + * 2. locally compiled binary in crates/codegraph-core/ (dev mode — preferred + * over the npm package so that Rust changes are picked up immediately + * without publishing a new release) + * 3. npm platform package (production path) */ export function loadNative(): NativeAddon | null { if (_cached !== undefined) return _cached; - const pkg = resolvePlatformPackage(); + const platformKey = resolvePlatformKey(); + + // 1. Explicit path override — highest priority. + const envPath = process.env.NAPI_RS_NATIVE_LIBRARY_PATH; + if (envPath) { + try { + _cached = _require(envPath) as NativeAddon; + debug(`loadNative: loaded from NAPI_RS_NATIVE_LIBRARY_PATH: ${envPath}`); + return _cached; + } catch (err) { + _loadError = err as Error; + debug(`loadNative: NAPI_RS_NATIVE_LIBRARY_PATH load failed: ${toErrorMessage(err as Error)}`); + } + } + + // 2. Locally compiled dev binary — preferred over npm package so that Rust + // changes are visible without publishing. Only used when the file exists. + const localFile = PLATFORM_LOCAL_BINARIES[platformKey]; + if (localFile) { + try { + const localPath = fileURLToPath( + new URL(`../../crates/codegraph-core/${localFile}`, import.meta.url), + ); + _cached = _require(localPath) as NativeAddon; + debug(`loadNative: loaded local dev binary: ${localPath}`); + return _cached; + } catch (err) { + debug(`loadNative: local dev binary not available: ${toErrorMessage(err as Error)}`); + } + } + + // 3. Published npm platform package — production path. + const pkg = PLATFORM_PACKAGES[platformKey] ?? null; if (pkg) { try { _cached = _require(pkg) as NativeAddon; return _cached; } catch (err) { _loadError = err as Error; + debug(`loadNative: npm package ${pkg} not available: ${toErrorMessage(err as Error)}`); } } else { _loadError = new Error(`Unsupported platform: ${os.platform()}-${os.arch()}`); From 183f78ea2484fa094a6408db7a8887957cb027ef Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Sun, 7 Jun 2026 19:41:03 -0600 Subject: [PATCH 02/11] fix(native): fail loudly on bad NAPI_RS_NATIVE_LIBRARY_PATH; fix JSDoc (#1389) --- src/infrastructure/native.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/infrastructure/native.ts b/src/infrastructure/native.ts index f839e4f0f..8481a76cc 100644 --- a/src/infrastructure/native.ts +++ b/src/infrastructure/native.ts @@ -11,7 +11,7 @@ import os from 'node:os'; import { fileURLToPath } from 'node:url'; import { EngineError, toErrorMessage } from '../shared/errors.js'; import type { NativeAddon } from '../types.js'; -import { debug } from './logger.js'; +import { debug, warn } from './logger.js'; let _cached: NativeAddon | null | undefined; // undefined = not yet tried, null = failed, NativeAddon = module let _loadError: Error | null = null; @@ -47,8 +47,8 @@ const PLATFORM_PACKAGES: Record = { /** * Map of (platform-arch[-libc]) → locally compiled binary filename. - * Used as a dev-mode fallback when the npm package is not installed, - * e.g. when working with Rust changes that haven't been published yet. + * Checked before the npm package so that locally compiled Rust changes + * are picked up immediately in development without publishing a new release. */ const PLATFORM_LOCAL_BINARIES: Record = { 'linux-x64-gnu': 'codegraph-core.linux-x64-gnu.node', @@ -91,7 +91,9 @@ export function loadNative(): NativeAddon | null { const platformKey = resolvePlatformKey(); - // 1. Explicit path override — highest priority. + // 1. Explicit path override — highest priority. Failure is fatal: if the + // operator set this variable, silently loading a different binary would + // be harder to diagnose than an explicit error. const envPath = process.env.NAPI_RS_NATIVE_LIBRARY_PATH; if (envPath) { try { @@ -100,7 +102,11 @@ export function loadNative(): NativeAddon | null { return _cached; } catch (err) { _loadError = err as Error; - debug(`loadNative: NAPI_RS_NATIVE_LIBRARY_PATH load failed: ${toErrorMessage(err as Error)}`); + warn( + `loadNative: NAPI_RS_NATIVE_LIBRARY_PATH is set but failed to load "${envPath}": ${toErrorMessage(err as Error)}`, + ); + _cached = null; + return null; } } From ca3123f4cefde08c397ba40c8b635ec31c4dcae2 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Sun, 7 Jun 2026 20:19:37 -0600 Subject: [PATCH 03/11] feat(resolver): resolve super.method() dispatch via class expression + static block + field def MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `(class name: ...)` query patterns for JS/TS class expressions so that `return class Foo extends Bar { ... }` records the extends relationship in ctx.classes — previously only class_declaration was captured, leaving class expressions invisible to resolveThisDispatch. - Add `class_static_block` → `ClassName.` synthetic method definition in both query path (extractClassMembersWalk) and walk path (walkJavaScriptNode). Calls inside `static { super.f(); }` blocks are now attributed to a method-kind node so the CHA parents map can resolve `super.f()` to the parent class. - Add `field_definition`/`public_field_definition` → `ClassName.fieldName` method definition when the field value is an arrow function or function expression. `static f = () => { ... }` becomes a resolvable `A.f` node so `resolveThisDispatch` can emit the `B. → A.f` edge. - Mirror all three changes in the native Rust extractor for parity. - Add 6 parser unit tests and import Jelly micro-test fixtures for super, super2, super3, super4, super5 as ground-truth benchmarks. Benchmark result: super fixture 31% → 38% recall (B. → A.f now resolved). docs check acknowledged Closes #1377 --- .../src/extractors/javascript.rs | 51 +++++- src/domain/parser.ts | 12 +- src/extractors/javascript.ts | 71 ++++++++ .../jelly-micro/super/expected-edges.json | 164 ++++++++++++++++++ .../fixtures/jelly-micro/super/super.js | 69 ++++++++ .../jelly-micro/super2/expected-edges.json | 68 ++++++++ .../fixtures/jelly-micro/super2/super2.js | 29 ++++ .../jelly-micro/super3/expected-edges.json | 44 +++++ .../fixtures/jelly-micro/super3/super3.js | 19 ++ .../jelly-micro/super4/expected-edges.json | 116 +++++++++++++ .../fixtures/jelly-micro/super4/super4.js | 33 ++++ .../jelly-micro/super5/expected-edges.json | 80 +++++++++ .../fixtures/jelly-micro/super5/super5.js | 28 +++ tests/parsers/javascript.test.ts | 48 +++++ 14 files changed, 828 insertions(+), 4 deletions(-) create mode 100644 tests/benchmarks/resolution/fixtures/jelly-micro/super/expected-edges.json create mode 100644 tests/benchmarks/resolution/fixtures/jelly-micro/super/super.js create mode 100644 tests/benchmarks/resolution/fixtures/jelly-micro/super2/expected-edges.json create mode 100644 tests/benchmarks/resolution/fixtures/jelly-micro/super2/super2.js create mode 100644 tests/benchmarks/resolution/fixtures/jelly-micro/super3/expected-edges.json create mode 100644 tests/benchmarks/resolution/fixtures/jelly-micro/super3/super3.js create mode 100644 tests/benchmarks/resolution/fixtures/jelly-micro/super4/expected-edges.json create mode 100644 tests/benchmarks/resolution/fixtures/jelly-micro/super4/super4.js create mode 100644 tests/benchmarks/resolution/fixtures/jelly-micro/super5/expected-edges.json create mode 100644 tests/benchmarks/resolution/fixtures/jelly-micro/super5/super5.js diff --git a/crates/codegraph-core/src/extractors/javascript.rs b/crates/codegraph-core/src/extractors/javascript.rs index 31b375adc..7fda66232 100644 --- a/crates/codegraph-core/src/extractors/javascript.rs +++ b/crates/codegraph-core/src/extractors/javascript.rs @@ -763,10 +763,14 @@ fn match_js_call_assignments(node: &Node, source: &[u8], symbols: &mut FileSymbo fn match_js_node(node: &Node, source: &[u8], symbols: &mut FileSymbols, _depth: usize) { match node.kind() { "function_declaration" | "generator_function_declaration" => handle_function_decl(node, source, symbols), - "class_declaration" | "abstract_class_declaration" => { + "class_declaration" | "abstract_class_declaration" + // class expressions: `return class Foo extends Bar { ... }` or `const X = class Foo { ... }` + | "class" => { handle_class_decl(node, source, symbols) } + "class_static_block" => handle_static_block(node, source, symbols), "method_definition" => handle_method_def(node, source, symbols), + "field_definition" | "public_field_definition" => handle_field_def(node, source, symbols), "interface_declaration" => handle_interface_decl(node, source, symbols), "type_alias_declaration" => handle_type_alias(node, source, symbols), "enum_declaration" => handle_enum_decl(node, source, symbols), @@ -859,6 +863,51 @@ fn handle_method_def(node: &Node, source: &[u8], symbols: &mut FileSymbols) { } } +/// Create a synthetic `ClassName.` definition for a class static block +/// so that calls inside the block are attributed to a method-kind node and +/// `super.method()` dispatch can walk up to the parent class. +fn handle_static_block(node: &Node, source: &[u8], symbols: &mut FileSymbols) { + let Some(class_name) = find_parent_class(node, source) else { return }; + symbols.definitions.push(Definition { + name: format!("{}.", class_name), + kind: "method".to_string(), + line: start_line(node), + end_line: Some(end_line(node)), + decorators: None, + complexity: None, + cfg: None, + children: None, + }); +} + +/// Extract a class field definition with a function/arrow-function value as a +/// top-level `ClassName.fieldName` method definition so it is a resolvable call +/// target (e.g. `static f = () => { ... }` becomes callable as `A.f`). +/// +/// JS `field_definition` stores the name under the `"property"` field; +/// TS `public_field_definition` uses `"name"`. +fn handle_field_def(node: &Node, source: &[u8], symbols: &mut FileSymbols) { + let value_node = node.child_by_field_name("value"); + let Some(value_node) = value_node else { return }; + let kind = value_node.kind(); + if kind != "arrow_function" && kind != "function_expression" { return; } + let name_node = node.child_by_field_name("property") + .or_else(|| node.child_by_field_name("name")) + .or_else(|| find_child(node, "property_identifier")); + let Some(name_node) = name_node else { return }; + let Some(class_name) = find_parent_class(node, source) else { return }; + symbols.definitions.push(Definition { + name: format!("{}.{}", class_name, node_text(&name_node, source)), + kind: "method".to_string(), + line: start_line(node), + end_line: Some(end_line(&value_node)), + decorators: None, + complexity: None, + cfg: None, + children: None, + }); +} + fn handle_interface_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) { let Some(name_node) = node.child_by_field_name("name") else { return }; let iface_name = node_text(&name_node, source).to_string(); diff --git a/src/domain/parser.ts b/src/domain/parser.ts index 663c26b20..54c9f28b9 100644 --- a/src/domain/parser.ts +++ b/src/domain/parser.ts @@ -168,14 +168,20 @@ const COMMON_QUERY_PATTERNS: string[] = [ '(expression_statement (assignment_expression left: (member_expression) @assign_left right: (_) @assign_right)) @assign_node', ]; -// JS: class name is (identifier) -const JS_CLASS_PATTERN: string = '(class_declaration name: (identifier) @cls_name) @cls_node'; +// JS: class name is (identifier) — declarations and expressions +const JS_CLASS_PATTERNS: string[] = [ + '(class_declaration name: (identifier) @cls_name) @cls_node', + // class expressions: `return class Foo extends Bar { ... }` or `const X = class Foo { ... }` + '(class name: (identifier) @cls_name) @cls_node', +]; // TS/TSX: class name is (type_identifier), plus interface and type alias // abstract_class_declaration is a separate node type in tree-sitter-typescript const TS_EXTRA_PATTERNS: string[] = [ '(class_declaration name: (type_identifier) @cls_name) @cls_node', '(abstract_class_declaration name: (type_identifier) @cls_name) @cls_node', + // class expressions: `return class Foo extends Bar { ... }` + '(class name: (type_identifier) @cls_name) @cls_node', '(interface_declaration name: (type_identifier) @iface_name) @iface_node', '(type_alias_declaration name: (type_identifier) @type_name) @type_node', ]; @@ -206,7 +212,7 @@ async function doLoadLanguage(entry: LanguageRegistryEntry): Promise { const isTS = entry.id === 'typescript' || entry.id === 'tsx'; const patterns = isTS ? [...COMMON_QUERY_PATTERNS, ...TS_EXTRA_PATTERNS] - : [...COMMON_QUERY_PATTERNS, JS_CLASS_PATTERN]; + : [...COMMON_QUERY_PATTERNS, ...JS_CLASS_PATTERNS]; _queryCache.set(entry.id, new Query(lang, patterns.join('\n'))); } } catch (e: unknown) { diff --git a/src/extractors/javascript.ts b/src/extractors/javascript.ts index 9b6260c18..ab511ea7b 100644 --- a/src/extractors/javascript.ts +++ b/src/extractors/javascript.ts @@ -350,6 +350,10 @@ function extractSymbolsQuery(tree: TreeSitterTree, query: TreeSitterQuery): Extr // Extract top-level constants via targeted walk (query patterns don't cover these) extractConstantsWalk(tree.rootNode, definitions); + // Extract class static block definitions so calls inside them can be attributed to + // `ClassName.` and `super.method()` can resolve via the class hierarchy. + extractClassMembersWalk(tree.rootNode, definitions); + // Extract dynamic import() calls via targeted walk (query patterns don't match `import` function type) extractDynamicImportsWalk(tree.rootNode, imports); @@ -710,8 +714,17 @@ function walkJavaScriptNode(node: TreeSitterNode, ctx: ExtractorOutput): void { break; case 'class_declaration': case 'abstract_class_declaration': + // class expressions: `return class Foo extends Bar { ... }` or `const X = class Foo { ... }` + case 'class': handleClassDecl(node, ctx); break; + case 'class_static_block': + handleStaticBlock(node, ctx.definitions); + break; + case 'field_definition': + case 'public_field_definition': + handleFieldDef(node, ctx.definitions); + break; case 'method_definition': handleMethodDef(node, ctx); break; @@ -810,6 +823,64 @@ function handleMethodDef(node: TreeSitterNode, ctx: ExtractorOutput): void { } } +/** + * Create a synthetic `ClassName.` definition for a class static block + * so that calls inside the block can be attributed to a method-kind node and + * `resolveThisDispatch` can walk up to the parent class for `super.method()`. + * + * Tree-sitter uses `class_static_block` (not `static_block`) for `static { ... }`. + */ +function handleStaticBlock(node: TreeSitterNode, definitions: Definition[]): void { + const parentClass = findParentClass(node); + if (!parentClass) return; + definitions.push({ + name: `${parentClass}.`, + kind: 'method', + line: nodeStartLine(node), + endLine: nodeEndLine(node), + }); +} + +/** + * Extract a class field definition with a function/arrow-function value as a + * top-level `ClassName.fieldName` method definition so it is a resolvable call + * target (e.g. `static f = () => { ... }` becomes callable as `A.f`). + * + * JS `field_definition` uses the `'property'` field name; TS + * `public_field_definition` uses `'name'`. + */ +function handleFieldDef(node: TreeSitterNode, definitions: Definition[]): void { + const value = node.childForFieldName('value'); + if (!value) return; + if (value.type !== 'arrow_function' && value.type !== 'function_expression') return; + const nameNode = node.childForFieldName('property') || node.childForFieldName('name'); + if (!nameNode) return; + const parentClass = findParentClass(node); + if (!parentClass) return; + definitions.push({ + name: `${parentClass}.${nameNode.text}`, + kind: 'method', + line: nodeStartLine(node), + endLine: nodeEndLine(value), + }); +} + +/** + * Targeted walk that extracts synthetic definitions for class static blocks and + * field definitions with function values. Called from `extractSymbolsQuery` + * (query path); the walk path handles these inline via `walkJavaScriptNode`. + */ +function extractClassMembersWalk(node: TreeSitterNode, definitions: Definition[]): void { + if (node.type === 'class_static_block') { + handleStaticBlock(node, definitions); + } else if (node.type === 'field_definition' || node.type === 'public_field_definition') { + handleFieldDef(node, definitions); + } + for (let i = 0; i < node.childCount; i++) { + extractClassMembersWalk(node.child(i)!, definitions); + } +} + function handleInterfaceDecl(node: TreeSitterNode, ctx: ExtractorOutput): void { const nameNode = node.childForFieldName('name'); if (!nameNode) return; diff --git a/tests/benchmarks/resolution/fixtures/jelly-micro/super/expected-edges.json b/tests/benchmarks/resolution/fixtures/jelly-micro/super/expected-edges.json new file mode 100644 index 000000000..bcc95b7e0 --- /dev/null +++ b/tests/benchmarks/resolution/fixtures/jelly-micro/super/expected-edges.json @@ -0,0 +1,164 @@ +{ + "$schema": "../../expected-edges.schema.json", + "language": "javascript", + "description": "Jelly micro-test: super", + "source": "https://github.com/cs-au-dk/jelly/blob/master/tests/micro/super.js", + "edges": [ + { + "source": { + "name": "A", + "file": "super.js" + }, + "target": { + "name": "B", + "file": "super.js" + }, + "kind": "calls", + "mode": "static" + }, + { + "source": { + "name": "A", + "file": "super.js" + }, + "target": { + "name": "B.m", + "file": "super.js" + }, + "kind": "calls", + "mode": "static" + }, + { + "source": { + "name": "A", + "file": "super.js" + }, + "target": { + "name": "B.s", + "file": "super.js" + }, + "kind": "calls", + "mode": "static" + }, + { + "source": { + "name": "A", + "file": "super.js" + }, + "target": { + "name": "A.f", + "file": "super.js" + }, + "kind": "calls", + "mode": "static" + }, + { + "source": { + "name": "A", + "file": "super.js" + }, + "target": { + "name": "m2", + "file": "super.js" + }, + "kind": "calls", + "mode": "static" + }, + { + "source": { + "name": "A", + "file": "super.js" + }, + "target": { + "name": "m3", + "file": "super.js" + }, + "kind": "calls", + "mode": "static" + }, + { + "source": { + "name": "B.", + "file": "super.js" + }, + "target": { + "name": "A.f", + "file": "super.js" + }, + "kind": "calls", + "mode": "static" + }, + { + "source": { + "name": "B", + "file": "super.js" + }, + "target": { + "name": "A.m", + "file": "super.js" + }, + "kind": "calls", + "mode": "static" + }, + { + "source": { + "name": "A", + "file": "super.js" + }, + "target": { + "name": "B.super", + "file": "super.js" + }, + "kind": "calls", + "mode": "static" + }, + { + "source": { + "name": "B.m", + "file": "super.js" + }, + "target": { + "name": "A.m", + "file": "super.js" + }, + "kind": "calls", + "mode": "static" + }, + { + "source": { + "name": "B.s", + "file": "super.js" + }, + "target": { + "name": "A.s", + "file": "super.js" + }, + "kind": "calls", + "mode": "static" + }, + { + "source": { + "name": "m2", + "file": "super.js" + }, + "target": { + "name": "m1", + "file": "super.js" + }, + "kind": "calls", + "mode": "static" + }, + { + "source": { + "name": "m3", + "file": "super.js" + }, + "target": { + "name": "m1", + "file": "super.js" + }, + "kind": "calls", + "mode": "static" + } + ] +} diff --git a/tests/benchmarks/resolution/fixtures/jelly-micro/super/super.js b/tests/benchmarks/resolution/fixtures/jelly-micro/super/super.js new file mode 100644 index 000000000..d6634492d --- /dev/null +++ b/tests/benchmarks/resolution/fixtures/jelly-micro/super/super.js @@ -0,0 +1,69 @@ +class A { + constructor(x) { + x(); + } + m() { + console.log('A.m'); + } + static s() { + console.log('A.s'); + } + static f = () => { + console.log('A.f'); + }; +} + +class B extends A { + constructor() { + super(() => { + console.log('c'); + }); + console.log(B.__proto__ === A); + super.m(); + console.log(super.m === B.prototype.__proto__.m); + } + m() { + console.log('B.m'); + super.m(); + console.log(super.m === B.prototype.__proto__.m); + } + static s() { + console.log('B.s'); + super.s(); + console.log(super.s === this.__proto__.s); + } + static g = super.f; + static { + super.f(); + console.log(super.f === this.__proto__.f); + } +} + +var x = new B(); +x.m(); +B.s(); +B.g(); + +var q1 = { + m1() { + console.log('q1.m1'); + }, +}; +var q2 = { + m2() { + console.log('q2.m2'); + super.m1(); + console.log(super.m1 === this.__proto__.m1); + }, +}; +Object.setPrototypeOf(q2, q1); +q2.m2(); +var q3 = { + m3() { + console.log('q3.m3'); + super.m1(); + console.log(super.m1 === this.__proto__.m1); + }, +}; +q3.__proto__ = q1; +q3.m3(); diff --git a/tests/benchmarks/resolution/fixtures/jelly-micro/super2/expected-edges.json b/tests/benchmarks/resolution/fixtures/jelly-micro/super2/expected-edges.json new file mode 100644 index 000000000..e6899c7cb --- /dev/null +++ b/tests/benchmarks/resolution/fixtures/jelly-micro/super2/expected-edges.json @@ -0,0 +1,68 @@ +{ + "$schema": "../../expected-edges.schema.json", + "language": "javascript", + "description": "Jelly micro-test: super2", + "source": "https://github.com/cs-au-dk/jelly/blob/master/tests/micro/super2.js", + "edges": [ + { + "source": { + "name": "A", + "file": "super2.js" + }, + "target": { + "name": "B", + "file": "super2.js" + }, + "kind": "calls", + "mode": "static" + }, + { + "source": { + "name": "A", + "file": "super2.js" + }, + "target": { + "name": "B.m", + "file": "super2.js" + }, + "kind": "calls", + "mode": "static" + }, + { + "source": { + "name": "A", + "file": "super2.js" + }, + "target": { + "name": "B.s", + "file": "super2.js" + }, + "kind": "calls", + "mode": "static" + }, + { + "source": { + "name": "B.m", + "file": "super2.js" + }, + "target": { + "name": "A.m", + "file": "super2.js" + }, + "kind": "calls", + "mode": "static" + }, + { + "source": { + "name": "B.s", + "file": "super2.js" + }, + "target": { + "name": "A.s", + "file": "super2.js" + }, + "kind": "calls", + "mode": "static" + } + ] +} diff --git a/tests/benchmarks/resolution/fixtures/jelly-micro/super2/super2.js b/tests/benchmarks/resolution/fixtures/jelly-micro/super2/super2.js new file mode 100644 index 000000000..9eba88dfa --- /dev/null +++ b/tests/benchmarks/resolution/fixtures/jelly-micro/super2/super2.js @@ -0,0 +1,29 @@ +class A { + m() { + var amthis = this; // amthis === x + console.log('Am', amthis); + } + static s() { + var asthis = this; // asthis === B + console.log('As', asthis); + } +} + +class B extends A { + m() { + super.m(); + var bmthis = this; // bmthis === x + console.log('Bm', bmthis); + super.foo = () => {}; // behaves like this.foo = ... + } + static s() { + super.s(); + var bsthis = this; // bsthis === B + console.log('Bs', bsthis); + } +} + +var x = new B(); +x.m(); +B.s(); +console.log(x.hasOwnProperty('foo')); diff --git a/tests/benchmarks/resolution/fixtures/jelly-micro/super3/expected-edges.json b/tests/benchmarks/resolution/fixtures/jelly-micro/super3/expected-edges.json new file mode 100644 index 000000000..9de67bc26 --- /dev/null +++ b/tests/benchmarks/resolution/fixtures/jelly-micro/super3/expected-edges.json @@ -0,0 +1,44 @@ +{ + "$schema": "../../expected-edges.schema.json", + "language": "javascript", + "description": "Jelly micro-test: super3", + "source": "https://github.com/cs-au-dk/jelly/blob/master/tests/micro/super3.js", + "edges": [ + { + "source": { + "name": "foo", + "file": "super3.js" + }, + "target": { + "name": "m2", + "file": "super3.js" + }, + "kind": "calls", + "mode": "static" + }, + { + "source": { + "name": "m2", + "file": "super3.js" + }, + "target": { + "name": "m1", + "file": "super3.js" + }, + "kind": "calls", + "mode": "static" + }, + { + "source": { + "name": "m1", + "file": "super3.js" + }, + "target": { + "name": "m3", + "file": "super3.js" + }, + "kind": "calls", + "mode": "static" + } + ] +} diff --git a/tests/benchmarks/resolution/fixtures/jelly-micro/super3/super3.js b/tests/benchmarks/resolution/fixtures/jelly-micro/super3/super3.js new file mode 100644 index 000000000..8a0e910d8 --- /dev/null +++ b/tests/benchmarks/resolution/fixtures/jelly-micro/super3/super3.js @@ -0,0 +1,19 @@ +function foo() { + var q1 = { + m1() { + console.log('q1.m1'); + this.m3(); + }, + }; + var q2 = { + m2() { + super.m1(); + }, + m3() { + console.log('q2.m3'); + }, + }; + Object.setPrototypeOf(q2, q1); + q2.m2(); +} +foo(); diff --git a/tests/benchmarks/resolution/fixtures/jelly-micro/super4/expected-edges.json b/tests/benchmarks/resolution/fixtures/jelly-micro/super4/expected-edges.json new file mode 100644 index 000000000..abd80c53d --- /dev/null +++ b/tests/benchmarks/resolution/fixtures/jelly-micro/super4/expected-edges.json @@ -0,0 +1,116 @@ +{ + "$schema": "../../expected-edges.schema.json", + "language": "javascript", + "description": "Jelly micro-test: super4", + "source": "https://github.com/cs-au-dk/jelly/blob/master/tests/micro/super4.js", + "edges": [ + { + "source": { + "name": "A", + "file": "super4.js" + }, + "target": { + "name": "postMixin", + "file": "super4.js" + }, + "kind": "calls", + "mode": "static" + }, + { + "source": { + "name": "A", + "file": "super4.js" + }, + "target": { + "name": "", + "file": "super4.js" + }, + "kind": "calls", + "mode": "static" + }, + { + "source": { + "name": "A", + "file": "super4.js" + }, + "target": { + "name": "m", + "file": "super4.js" + }, + "kind": "calls", + "mode": "static" + }, + { + "source": { + "name": "A", + "file": "super4.js" + }, + "target": { + "name": "", + "file": "super4.js" + }, + "kind": "calls", + "mode": "static" + }, + { + "source": { + "name": "", + "file": "super4.js" + }, + "target": { + "name": "A.s", + "file": "super4.js" + }, + "kind": "calls", + "mode": "static" + }, + { + "source": { + "name": "", + "file": "super4.js" + }, + "target": { + "name": "A.s", + "file": "super4.js" + }, + "kind": "calls", + "mode": "static" + }, + { + "source": { + "name": "", + "file": "super4.js" + }, + "target": { + "name": "A.m", + "file": "super4.js" + }, + "kind": "calls", + "mode": "static" + }, + { + "source": { + "name": "m", + "file": "super4.js" + }, + "target": { + "name": "A.m", + "file": "super4.js" + }, + "kind": "calls", + "mode": "static" + }, + { + "source": { + "name": "", + "file": "super4.js" + }, + "target": { + "name": "A.s", + "file": "super4.js" + }, + "kind": "calls", + "mode": "static" + } + ] +} diff --git a/tests/benchmarks/resolution/fixtures/jelly-micro/super4/super4.js b/tests/benchmarks/resolution/fixtures/jelly-micro/super4/super4.js new file mode 100644 index 000000000..3896fb020 --- /dev/null +++ b/tests/benchmarks/resolution/fixtures/jelly-micro/super4/super4.js @@ -0,0 +1,33 @@ +class A { + constructor() {} + m() {} + static s() {} +} + +function postMixin() { + return class PostMixin extends A { + constructor() { + super(); + } + m() { + super.m(); + } + w = super.m(); + eee = this; + static fff = this; + static s() { + super.s(); + } + static { + super.s(); + } + static q = super.s(); + }; +} + +var a = postMixin(); +var x = new a(); +x.m(); +a.s(); +console.log(x.eee === x); +console.log(a.fff === a); diff --git a/tests/benchmarks/resolution/fixtures/jelly-micro/super5/expected-edges.json b/tests/benchmarks/resolution/fixtures/jelly-micro/super5/expected-edges.json new file mode 100644 index 000000000..70740277e --- /dev/null +++ b/tests/benchmarks/resolution/fixtures/jelly-micro/super5/expected-edges.json @@ -0,0 +1,80 @@ +{ + "$schema": "../../expected-edges.schema.json", + "language": "javascript", + "description": "Jelly micro-test: super5", + "source": "https://github.com/cs-au-dk/jelly/blob/master/tests/micro/super5.js", + "edges": [ + { + "source": { + "name": "A", + "file": "super5.js" + }, + "target": { + "name": "b", + "file": "super5.js" + }, + "kind": "calls", + "mode": "static" + }, + { + "source": { + "name": "A", + "file": "super5.js" + }, + "target": { + "name": "", + "file": "super5.js" + }, + "kind": "calls", + "mode": "static" + }, + { + "source": { + "name": "A", + "file": "super5.js" + }, + "target": { + "name": "", + "file": "super5.js" + }, + "kind": "calls", + "mode": "static" + }, + { + "source": { + "name": "A", + "file": "super5.js" + }, + "target": { + "name": "", + "file": "super5.js" + }, + "kind": "calls", + "mode": "static" + }, + { + "source": { + "name": "", + "file": "super5.js" + }, + "target": { + "name": "", + "file": "super5.js" + }, + "kind": "calls", + "mode": "static" + }, + { + "source": { + "name": "", + "file": "super5.js" + }, + "target": { + "name": "A.m", + "file": "super5.js" + }, + "kind": "calls", + "mode": "static" + } + ] +} diff --git a/tests/benchmarks/resolution/fixtures/jelly-micro/super5/super5.js b/tests/benchmarks/resolution/fixtures/jelly-micro/super5/super5.js new file mode 100644 index 000000000..ecb818710 --- /dev/null +++ b/tests/benchmarks/resolution/fixtures/jelly-micro/super5/super5.js @@ -0,0 +1,28 @@ +class A { + constructor() { + this.qqq = () => { + console.log('qqq'); + }; + } + m() { + this.www = () => { + console.log('www'); + }; + } +} + +function b() { + return class B extends A { + constructor() { + super(); + (() => { + super.m(); + })(); + } + }; +} + +var a = b(); +var c = new a(); +c.qqq(); +c.www(); diff --git a/tests/parsers/javascript.test.ts b/tests/parsers/javascript.test.ts index 1411f42dd..f8ec0fcd3 100644 --- a/tests/parsers/javascript.test.ts +++ b/tests/parsers/javascript.test.ts @@ -933,4 +933,52 @@ describe('JavaScript parser', () => { ); }); }); + + describe('class expression extends + static block + field def extraction', () => { + it('extracts extends relationship from named class expression', () => { + const symbols = parseJS( + `function make() { return class Child extends Parent { m() { super.m(); } } }`, + ); + expect(symbols.classes).toContainEqual( + expect.objectContaining({ name: 'Child', extends: 'Parent' }), + ); + }); + + it('extracts methods from named class expression', () => { + const symbols = parseJS(`const X = class Foo extends Base { bar() { return 1; } }`); + expect(symbols.definitions).toContainEqual( + expect.objectContaining({ name: 'Foo.bar', kind: 'method' }), + ); + }); + + it('records super.method() call with receiver=super from class expression method', () => { + const symbols = parseJS(`const X = class Child extends Parent { m() { super.m(); } }`); + const superCall = symbols.calls.find((c) => c.name === 'm' && c.receiver === 'super'); + expect(superCall).toBeDefined(); + }); + + it('creates ClassName. definition for class static block', () => { + const symbols = parseJS(`class A extends B {\n static {\n super.init();\n }\n}`); + expect(symbols.definitions).toContainEqual( + expect.objectContaining({ name: 'A.', kind: 'method' }), + ); + }); + + it('attributes super.method() call inside static block to ClassName.', () => { + const symbols = parseJS(`class A extends B {\n static {\n super.init();\n }\n}`); + const staticDef = symbols.definitions.find((d) => d.name === 'A.'); + expect(staticDef).toBeDefined(); + const superCall = symbols.calls.find((c) => c.name === 'init' && c.receiver === 'super'); + expect(superCall).toBeDefined(); + expect(superCall!.line).toBeGreaterThanOrEqual(staticDef!.line); + expect(superCall!.line).toBeLessThanOrEqual(staticDef!.endLine!); + }); + + it('extracts class field arrow function as callable ClassName.fieldName method', () => { + const symbols = parseJS(`class A {\n static f = () => {\n doSomething();\n };\n}`); + expect(symbols.definitions).toContainEqual( + expect.objectContaining({ name: 'A.f', kind: 'method' }), + ); + }); + }); }); From e2afd773797058b1fc9f5324418d2bd62894f1b0 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Sun, 7 Jun 2026 20:30:53 -0600 Subject: [PATCH 04/11] fix(incremental): port same-class this.method() and defineProperty fallbacks into buildCallEdges After resolveCallTargets returns empty: 1. Retry with class-qualified name (ClassName.method) for this-receiver calls 2. Resolve via Object.defineProperty accessor receiver typeMap or same-file lookup These two blocks existed in buildFileCallEdges (build-edges.ts) but were missing from the incremental path, causing divergence between full and watch-mode builds. Also adds the missing callerName argument to resolveCallTargets. Closes #1384 --- src/domain/graph/builder/incremental.ts | 47 ++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/src/domain/graph/builder/incremental.ts b/src/domain/graph/builder/incremental.ts index 24d0aa639..227b88f32 100644 --- a/src/domain/graph/builder/incremental.ts +++ b/src/domain/graph/builder/incremental.ts @@ -510,14 +510,59 @@ function buildCallEdges( if (call.receiver && BUILTIN_RECEIVERS.has(call.receiver)) continue; const caller = findCaller(lookup, call, symbols.definitions, relPath, fileNodeRow); - const { targets, importedFrom } = resolveCallTargets( + let { targets, importedFrom } = resolveCallTargets( lookup, call, relPath, importedNames, typeMap, + caller.callerName, ); + if (targets.length === 0 && call.receiver === 'this' && caller.callerName != null) { + const dotIdx = caller.callerName.indexOf('.'); + if (dotIdx > 0) { + const className = caller.callerName.slice(0, dotIdx); + const qualifiedName = `${className}.${call.name}`; + const qualified = lookup + .byNameAndFile(qualifiedName, relPath) + .filter((n) => n.kind === 'method'); + if (qualified.length > 0) { + targets = qualified; + } + } + } + + if ( + targets.length === 0 && + call.receiver === 'this' && + caller.callerName != null && + symbols.definePropertyReceivers + ) { + const receiverVarName = symbols.definePropertyReceivers.get(caller.callerName); + if (receiverVarName) { + const typeEntry = typeMap.get(receiverVarName); + const typeName = typeEntry + ? typeof typeEntry === 'string' + ? typeEntry + : (typeEntry as { type?: string }).type + : null; + if (typeName) { + const qualifiedName = `${typeName}.${call.name}`; + const qualified = lookup.byNameAndFile(qualifiedName, relPath); + if (qualified.length > 0) { + targets = [...qualified]; + } + } + if (targets.length === 0) { + const sameFile = lookup.byNameAndFile(call.name, relPath); + if (sameFile.length > 0) { + targets = [...sameFile]; + } + } + } + } + for (const t of targets) { const edgeKey = `${caller.id}|${t.id}`; if (t.id !== caller.id && !seenCallEdges.has(edgeKey)) { From c3f1b2c41618cb2ecca09452ac1689fcb4714698 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Sun, 7 Jun 2026 20:55:18 -0600 Subject: [PATCH 05/11] fix(native): use resolvePlatformPackage() in loadNative step 3 (#1389) PLATFORM_PACKAGES[platformKey] was inlined in loadNative() while getNativePackageVersion() used resolvePlatformPackage(). Any future change to the lookup (fallback key, default value) would only apply to one site. Call resolvePlatformPackage() consistently in both places. --- src/infrastructure/native.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/infrastructure/native.ts b/src/infrastructure/native.ts index 8481a76cc..81a69b676 100644 --- a/src/infrastructure/native.ts +++ b/src/infrastructure/native.ts @@ -127,7 +127,7 @@ export function loadNative(): NativeAddon | null { } // 3. Published npm platform package — production path. - const pkg = PLATFORM_PACKAGES[platformKey] ?? null; + const pkg = resolvePlatformPackage(); if (pkg) { try { _cached = _require(pkg) as NativeAddon; From d2007be9f864a62e885cc6547eddce9d2f64af38 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Sun, 7 Jun 2026 22:31:32 -0600 Subject: [PATCH 06/11] fix(incremental): seed callee::restName typeMap keys and pass callerName in buildCallEdges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Incremental rebuilds using buildCallEdges (incremental.ts) were missing two things needed for Phase 8.3f scoped-key resolution (#1358 / #1369): 1. The typeMap was not seeded with callee::restName entries from objectRestParamBindings × paramBindings. Without this seeding the scoped key `callee::restName` (e.g. `f2::rest`) is absent from the map, so resolveByMethodOrGlobal's third fallback (`typeMap.get(callerName::receiver)`) has nothing to find and the rest-param call goes unresolved. 2. caller.callerName was not passed to resolveCallTargets, so even if the scoped key was present in the typeMap (e.g. from WASM extraction), the `${callerName}::${effectiveReceiver}` lookup in resolveByMethodOrGlobal never fired. Fix: mirror what buildObjectRestParamPostPass (native full-build post-pass) and buildCallEdgesJS (WASM full-build path) already do: - Compute restNameCallees to know how many callees share a rest name. - Seed typeMap[callee::restName] for each objectRestParamBinding × paramBinding pair. - Also seed the unscoped key when only one callee uses that rest name, so resolution still works when callerName is null (findCaller couldn't match). - Pass caller.callerName to resolveCallTargets (already present from #1389). Also syncs call-resolver.ts and build-edges.ts with the scoped-key changes from PR #1368 (merged to main separately), which this branch was missing. docs check acknowledged Closes #1369 --- src/domain/graph/builder/call-resolver.ts | 5 +- src/domain/graph/builder/incremental.ts | 28 +++++ .../graph/builder/stages/build-edges.ts | 50 ++++++--- ...-1336-object-rest-param-resolution.test.ts | 99 +++++++++++++++++ ...sue-1358-rest-param-name-collision.test.ts | 97 +++++++++++++++++ ...-incremental-rest-param-callerName.test.ts | 100 ++++++++++++++++++ 6 files changed, 366 insertions(+), 13 deletions(-) create mode 100644 tests/integration/issue-1336-object-rest-param-resolution.test.ts create mode 100644 tests/integration/issue-1358-rest-param-name-collision.test.ts create mode 100644 tests/integration/issue-1369-incremental-rest-param-callerName.test.ts diff --git a/src/domain/graph/builder/call-resolver.ts b/src/domain/graph/builder/call-resolver.ts index 54c59641a..20dd885cb 100644 --- a/src/domain/graph/builder/call-resolver.ts +++ b/src/domain/graph/builder/call-resolver.ts @@ -72,7 +72,10 @@ export function resolveByMethodOrGlobal( const effectiveReceiver = call.receiver.startsWith('this.') ? call.receiver.slice('this.'.length) : call.receiver; - const typeEntry = typeMap.get(effectiveReceiver) ?? typeMap.get(call.receiver); + const typeEntry = + typeMap.get(effectiveReceiver) ?? + typeMap.get(call.receiver) ?? + (callerName ? typeMap.get(`${callerName}::${effectiveReceiver}`) : undefined); let typeName = typeEntry ? typeof typeEntry === 'string' ? typeEntry diff --git a/src/domain/graph/builder/incremental.ts b/src/domain/graph/builder/incremental.ts index 227b88f32..2987d9838 100644 --- a/src/domain/graph/builder/incremental.ts +++ b/src/domain/graph/builder/incremental.ts @@ -502,6 +502,34 @@ function buildCallEdges( ]), ) : new Map(); + + // Phase 8.3f: seed typeMap[callee::restName] = { type: argName } from + // objectRestParamBindings × paramBindings, mirroring buildObjectRestParamPostPass. + // Scoped keys prevent same-name rest-param collisions when two functions in + // the same file both use `...rest` (#1358). The unscoped key is also seeded + // when only one callee uses a given rest name, preserving resolution when + // callerName is null (findCaller couldn't identify the enclosing function). + if (symbols.objectRestParamBindings?.length && symbols.paramBindings?.length) { + const restNameCallees = new Map>(); + for (const orpb of symbols.objectRestParamBindings) { + if (!restNameCallees.has(orpb.restName)) restNameCallees.set(orpb.restName, new Set()); + restNameCallees.get(orpb.restName)!.add(orpb.callee); + } + for (const orpb of symbols.objectRestParamBindings) { + for (const pb of symbols.paramBindings) { + if (pb.callee === orpb.callee && pb.argIndex === orpb.argIndex) { + const scopedKey = `${orpb.callee}::${orpb.restName}`; + if (!typeMap.has(scopedKey)) { + typeMap.set(scopedKey, { type: pb.argName, confidence: 0.65 }); + if (restNameCallees.get(orpb.restName)!.size === 1 && !typeMap.has(orpb.restName)) { + typeMap.set(orpb.restName, { type: pb.argName, confidence: 0.65 }); + } + } + } + } + } + } + const seenCallEdges = new Set(); const lookup = makeIncrementalLookup(db, stmts); let edgesAdded = 0; diff --git a/src/domain/graph/builder/stages/build-edges.ts b/src/domain/graph/builder/stages/build-edges.ts index 1199e87ef..0d9449c7d 100644 --- a/src/domain/graph/builder/stages/build-edges.ts +++ b/src/domain/graph/builder/stages/build-edges.ts @@ -756,16 +756,30 @@ function buildObjectRestParamPostPass( symbols.typeMap instanceof Map ? symbols.typeMap : [], ); - // Seed typeMap[restName] = { type: argName } for each matching pair. - // Mirrors the seeding in buildCallEdgesJS Phase 8.3f. + // Seed typeMap[callee::restName] = { type: argName } for each matching pair. + // Mirrors the seeding in buildCallEdgesJS Phase 8.3f. Keys are scoped by + // callee so two functions with the same rest-param name (e.g. `...rest`) in + // the same file don't collide (#1358). + // When only one callee uses a given rest name, also seed the unscoped key + // as a null-callerName fallback so edges aren't silently dropped if + // findCaller can't identify the enclosing function (#1358). + const restNameCallees = new Map>(); + for (const orpb of symbols.objectRestParamBindings!) { + if (!restNameCallees.has(orpb.restName)) restNameCallees.set(orpb.restName, new Set()); + restNameCallees.get(orpb.restName)!.add(orpb.callee); + } const restNames = new Set(); for (const orpb of symbols.objectRestParamBindings!) { for (const pb of symbols.paramBindings!) { if (pb.callee === orpb.callee && pb.argIndex === orpb.argIndex) { - if (!typeMap.has(orpb.restName)) { - typeMap.set(orpb.restName, { type: pb.argName, confidence: 0.65 }); - restNames.add(orpb.restName); + const scopedKey = `${orpb.callee}::${orpb.restName}`; + if (!typeMap.has(scopedKey)) { + typeMap.set(scopedKey, { type: pb.argName, confidence: 0.65 }); + if (restNameCallees.get(orpb.restName)!.size === 1 && !typeMap.has(orpb.restName)) { + typeMap.set(orpb.restName, { type: pb.argName, confidence: 0.65 }); + } } + restNames.add(orpb.restName); } } } @@ -777,7 +791,8 @@ function buildObjectRestParamPostPass( const caller = findCaller(lookup, call, symbols.definitions, relPath, fileNodeRow); - // Resolve with the enriched typeMap (typeMap[restName] = { type: argName } is seeded above). + // Resolve with the enriched typeMap. callerName is passed so + // resolveByMethodOrGlobal can look up the scoped key callee::restName (#1358). // seenByPair deduplicates edges the native engine already emitted. const { targets, importedFrom } = resolveCallTargets( lookup, @@ -785,6 +800,7 @@ function buildObjectRestParamPostPass( relPath, importedNames, typeMap as Map, + caller.callerName, ); for (const t of targets) { const edgeKey = `${caller.id}|${t.id}`; @@ -1019,16 +1035,26 @@ function buildCallEdgesJS( symbols.typeMap instanceof Map ? symbols.typeMap : [], ); - // Phase 8.3f: seed typeMap[restName] = { type: argName } for each object-destructuring - // rest parameter binding cross-referenced with call-site argument bindings. - // e.g. function f({ a, ...rest }) called as f(obj) → typeMap['rest'] = { type: 'obj' } - // so that `rest.method()` resolves via typeMap['obj.method']. + // Phase 8.3f: seed typeMap[callee::restName] = { type: argName } for each + // object-destructuring rest parameter binding × call-site argument binding. + // Keys are scoped so two functions with the same rest-param name in the same + // file don't collide (#1358). When only one callee uses a given rest name, + // also seed the unscoped key as a null-callerName fallback. if (symbols.objectRestParamBindings?.length && symbols.paramBindings?.length) { + const restNameCallees = new Map>(); + for (const orpb of symbols.objectRestParamBindings) { + if (!restNameCallees.has(orpb.restName)) restNameCallees.set(orpb.restName, new Set()); + restNameCallees.get(orpb.restName)!.add(orpb.callee); + } for (const orpb of symbols.objectRestParamBindings) { for (const pb of symbols.paramBindings) { if (pb.callee === orpb.callee && pb.argIndex === orpb.argIndex) { - if (!typeMap.has(orpb.restName)) { - typeMap.set(orpb.restName, { type: pb.argName, confidence: 0.65 }); + const scopedKey = `${orpb.callee}::${orpb.restName}`; + if (!typeMap.has(scopedKey)) { + typeMap.set(scopedKey, { type: pb.argName, confidence: 0.65 }); + if (restNameCallees.get(orpb.restName)!.size === 1 && !typeMap.has(orpb.restName)) { + typeMap.set(orpb.restName, { type: pb.argName, confidence: 0.65 }); + } } } } diff --git a/tests/integration/issue-1336-object-rest-param-resolution.test.ts b/tests/integration/issue-1336-object-rest-param-resolution.test.ts new file mode 100644 index 000000000..d6625970b --- /dev/null +++ b/tests/integration/issue-1336-object-rest-param-resolution.test.ts @@ -0,0 +1,99 @@ +/** + * Integration test for #1336 + #1349: resolve property calls on object + * destructuring rest parameters — both WASM and native engines. + * + * When a function parameter uses object destructuring with a rest element (`...rest`), + * and the rest object's property is then called, codegraph should resolve the callee. + * + * Pattern: + * function f3({ e1: eee1, ...eerest }) { eerest.e4(); } + * f3(obj); + * + * Resolution chain (Phase 8.3f): + * 1. Extractor seeds typeMap['obj.e4'] = { type: 'e4' } from `var obj = { e4 }`. + * 2. Extractor records objectRestParamBinding { callee: 'f3', argIndex: 0, restName: 'eerest' }. + * 3. Extractor records paramBinding { callee: 'f3', argIndex: 0, argName: 'obj' } from f3(obj). + * 4. Phase 8.3f seeds typeMap['f3::eerest'] = { type: 'obj' } (scoped by callee, #1358). + * Because f3 is the only callee using 'eerest', the unscoped key typeMap['eerest'] is + * also seeded as a null-callerName fallback (single-callee shortcut, #1358). + * 5. resolveByMethodOrGlobal: typeMap['eerest'] (unscoped, single-callee fallback) → obj; + * typeMap['obj.e4'] → e4 → resolved. The scoped key 'f3::eerest' is a no-op here but + * would be the active path if a second function also declared '...eerest'. + * + * WASM: resolved via Phase 8.3f typeMap chain in buildCallEdgesJS. + * Native: resolved via same-file name lookup (step 2 in Rust resolve_call_targets); + * the Phase 8.3f post-pass (buildObjectRestParamPostPass) provides the typeMap-chain + * fallback for cross-file cases not directly imported. + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import Database from 'better-sqlite3'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { buildGraph } from '../../src/domain/graph/builder.js'; + +const FIXTURE_CODE = ` +function e1() { console.log('31'); } +function e4() { console.log('34'); } + +var obj = { e1, e4 }; + +function f3({ e1: eee1, ...eerest }) { + eee1(); // call through named destructuring alias + eerest.e4(); // call through rest binding — expected edge: f3 → e4 +} +f3(obj); +`; + +let tmpWasm: string; +let tmpNative: string; + +beforeAll(async () => { + tmpWasm = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-1336-wasm-')); + fs.writeFileSync(path.join(tmpWasm, 'rest.js'), FIXTURE_CODE); + await buildGraph(tmpWasm, { engine: 'wasm', incremental: false, skipRegistry: true }); + + tmpNative = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-1336-native-')); + fs.writeFileSync(path.join(tmpNative, 'rest.js'), FIXTURE_CODE); + await buildGraph(tmpNative, { incremental: false, skipRegistry: true }); +}); + +afterAll(() => { + fs.rmSync(tmpWasm, { recursive: true, force: true }); + fs.rmSync(tmpNative, { recursive: true, force: true }); +}); + +function readCallEdges(dbPath: string) { + const db = new Database(dbPath, { readonly: true }); + try { + return db + .prepare( + `SELECT n1.name AS src, n2.name AS tgt, e.kind, e.dynamic + FROM edges e + JOIN nodes n1 ON e.source_id = n1.id + JOIN nodes n2 ON e.target_id = n2.id + WHERE e.kind = 'calls' + ORDER BY n1.name, n2.name`, + ) + .all() as Array<{ src: string; tgt: string; kind: string; dynamic: number }>; + } finally { + db.close(); + } +} + +describe('Issue #1336 + #1349: object destructuring rest parameter call resolution', () => { + it('WASM: emits a calls edge from f3 to e4 via eerest.e4() rest-receiver resolution', () => { + const edges = readCallEdges(path.join(tmpWasm, '.codegraph', 'graph.db')); + const edge = edges.find((e) => e.src === 'f3' && e.tgt === 'e4'); + expect(edge).toBeDefined(); + expect(edge!.dynamic).toBe(0); + }); + + it('Native: emits a calls edge from f3 to e4 via eerest.e4() rest-receiver resolution', () => { + const edges = readCallEdges(path.join(tmpNative, '.codegraph', 'graph.db')); + const edge = edges.find((e) => e.src === 'f3' && e.tgt === 'e4'); + expect(edge).toBeDefined(); + expect(edge!.dynamic).toBe(0); + }); +}); diff --git a/tests/integration/issue-1358-rest-param-name-collision.test.ts b/tests/integration/issue-1358-rest-param-name-collision.test.ts new file mode 100644 index 000000000..d545c689d --- /dev/null +++ b/tests/integration/issue-1358-rest-param-name-collision.test.ts @@ -0,0 +1,97 @@ +/** + * Integration test for #1358: two functions in the same file both use `...rest` + * as their rest-binding name. Without scoped typeMap keys the first seeding wins + * and the second function resolves via the wrong type. + * + * Pattern: + * function f1({ a, ...rest }) { rest.m1(); } + * function f2({ b, ...rest }) { rest.m2(); } + * f1(obj1); // obj1 has m1 + * f2(obj2); // obj2 has m2 + * + * Expected edges: f1 → m1, f2 → m2. + * Broken (pre-fix): both resolve through obj1, so f2 → m2 is missing. + * + * Fix (Phase 8.3f, #1358): typeMap keys are scoped to `callee::restName` + * (e.g. `f1::rest`, `f2::rest`) so each function's binding is independent. + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import Database from 'better-sqlite3'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { buildGraph } from '../../src/domain/graph/builder.js'; + +const FIXTURE_CODE = ` +function m1() {} +function m2() {} + +var obj1 = { m1 }; +var obj2 = { m2 }; + +function f1({ a, ...rest }) { + rest.m1(); +} + +function f2({ b, ...rest }) { + rest.m2(); +} + +f1(obj1); +f2(obj2); +`; + +let tmpWasm: string; +let tmpNative: string; + +beforeAll(async () => { + tmpWasm = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-1358-wasm-')); + fs.writeFileSync(path.join(tmpWasm, 'collision.js'), FIXTURE_CODE); + await buildGraph(tmpWasm, { engine: 'wasm', incremental: false, skipRegistry: true }); + + tmpNative = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-1358-native-')); + fs.writeFileSync(path.join(tmpNative, 'collision.js'), FIXTURE_CODE); + await buildGraph(tmpNative, { incremental: false, skipRegistry: true }); +}); + +afterAll(() => { + fs.rmSync(tmpWasm, { recursive: true, force: true }); + fs.rmSync(tmpNative, { recursive: true, force: true }); +}); + +function readCallEdges(dbPath: string) { + const db = new Database(dbPath, { readonly: true }); + try { + return db + .prepare( + `SELECT n1.name AS src, n2.name AS tgt + FROM edges e + JOIN nodes n1 ON e.source_id = n1.id + JOIN nodes n2 ON e.target_id = n2.id + WHERE e.kind = 'calls' + ORDER BY n1.name, n2.name`, + ) + .all() as Array<{ src: string; tgt: string }>; + } finally { + db.close(); + } +} + +describe('Issue #1358: same rest-param name in two functions — scoped typeMap key', () => { + it('WASM: f1 → m1 and f2 → m2 both resolve independently without cross-edges', () => { + const edges = readCallEdges(path.join(tmpWasm, '.codegraph', 'graph.db')); + expect(edges.find((e) => e.src === 'f1' && e.tgt === 'm1')).toBeDefined(); + expect(edges.find((e) => e.src === 'f2' && e.tgt === 'm2')).toBeDefined(); + expect(edges.find((e) => e.src === 'f1' && e.tgt === 'm2')).toBeUndefined(); + expect(edges.find((e) => e.src === 'f2' && e.tgt === 'm1')).toBeUndefined(); + }); + + it('Native: f1 → m1 and f2 → m2 both resolve independently without cross-edges', () => { + const edges = readCallEdges(path.join(tmpNative, '.codegraph', 'graph.db')); + expect(edges.find((e) => e.src === 'f1' && e.tgt === 'm1')).toBeDefined(); + expect(edges.find((e) => e.src === 'f2' && e.tgt === 'm2')).toBeDefined(); + expect(edges.find((e) => e.src === 'f1' && e.tgt === 'm2')).toBeUndefined(); + expect(edges.find((e) => e.src === 'f2' && e.tgt === 'm1')).toBeUndefined(); + }); +}); diff --git a/tests/integration/issue-1369-incremental-rest-param-callerName.test.ts b/tests/integration/issue-1369-incremental-rest-param-callerName.test.ts new file mode 100644 index 000000000..8a0b3a55d --- /dev/null +++ b/tests/integration/issue-1369-incremental-rest-param-callerName.test.ts @@ -0,0 +1,100 @@ +/** + * Integration test for #1369: incremental rebuild path must pass callerName to + * resolveCallTargets so the scoped Phase 8.3f typeMap key (`callee::restName`) + * can be resolved correctly after a file edit. + * + * Scenario (same fixture as #1358 — two functions sharing a rest-param name): + * function f1({ a, ...rest }) { rest.m1(); } + * function f2({ b, ...rest }) { rest.m2(); } + * f1(obj1); f2(obj2); + * + * A full build produces f1→m1 and f2→m2. Without the fix, touching the file + * and doing an incremental rebuild would drop f2→m2 because: + * 1. The incremental buildCallEdges didn't seed callee::restName scoped keys. + * 2. Even after adding the seeding, it didn't pass caller.callerName to + * resolveCallTargets, so resolveByMethodOrGlobal's scoped-key fallback + * (`typeMap.get(`${callerName}::${effectiveReceiver}`)`) never fired. + * + * Fix (this PR / #1369): seed scoped keys + pass callerName in incremental path. + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import Database from 'better-sqlite3'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { buildGraph } from '../../src/domain/graph/builder.js'; + +const FIXTURE_CODE = ` +function m1() {} +function m2() {} + +var obj1 = { m1 }; +var obj2 = { m2 }; + +function f1({ a, ...rest }) { + rest.m1(); +} + +function f2({ b, ...rest }) { + rest.m2(); +} + +f1(obj1); +f2(obj2); +`; + +const FIXTURE_CODE_TOUCHED = `${FIXTURE_CODE} +// touched to trigger incremental rebuild +`; + +let tmpDir: string; + +beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-1369-')); + fs.writeFileSync(path.join(tmpDir, 'collision.js'), FIXTURE_CODE); + await buildGraph(tmpDir, { incremental: false, skipRegistry: true }); +}); + +afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +function readCallEdges(dbPath: string) { + const db = new Database(dbPath, { readonly: true }); + try { + return db + .prepare( + `SELECT n1.name AS src, n2.name AS tgt + FROM edges e + JOIN nodes n1 ON e.source_id = n1.id + JOIN nodes n2 ON e.target_id = n2.id + WHERE e.kind = 'calls' + ORDER BY n1.name, n2.name`, + ) + .all() as Array<{ src: string; tgt: string }>; + } finally { + db.close(); + } +} + +describe('Issue #1369: incremental rebuild preserves scoped rest-param resolution', () => { + it('full build: f1 → m1 and f2 → m2 resolve correctly', () => { + const edges = readCallEdges(path.join(tmpDir, '.codegraph', 'graph.db')); + expect(edges.find((e) => e.src === 'f1' && e.tgt === 'm1')).toBeDefined(); + expect(edges.find((e) => e.src === 'f2' && e.tgt === 'm2')).toBeDefined(); + expect(edges.find((e) => e.src === 'f1' && e.tgt === 'm2')).toBeUndefined(); + expect(edges.find((e) => e.src === 'f2' && e.tgt === 'm1')).toBeUndefined(); + }); + + it('incremental rebuild: f1 → m1 and f2 → m2 still resolve after file touch', async () => { + fs.writeFileSync(path.join(tmpDir, 'collision.js'), FIXTURE_CODE_TOUCHED); + await buildGraph(tmpDir, { incremental: true, skipRegistry: true }); + + const edges = readCallEdges(path.join(tmpDir, '.codegraph', 'graph.db')); + expect(edges.find((e) => e.src === 'f1' && e.tgt === 'm1')).toBeDefined(); + expect(edges.find((e) => e.src === 'f2' && e.tgt === 'm2')).toBeDefined(); + expect(edges.find((e) => e.src === 'f1' && e.tgt === 'm2')).toBeUndefined(); + expect(edges.find((e) => e.src === 'f2' && e.tgt === 'm1')).toBeUndefined(); + }); +}); From 35218f648fb234ac5875f3760783070a10ee2643 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Mon, 8 Jun 2026 00:49:22 -0600 Subject: [PATCH 07/11] fix(extractor): add case 'class' to walk path and use kind 'method' for static blocks (#1389) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merge conflict resolution in 8d5ba14 dropped two changes from ca3123f: 1. The 'class' case in walkJavaScriptNode's switch — class expressions like 'return class Child extends Parent { ... }' were never dispatched to handleClassDecl, so ctx.classes remained empty and no extends relationship was recorded. 2. handleStaticBlock used kind: 'function' instead of kind: 'method', so the CHA parents map could not walk up to the parent class for super.method() resolution. Also update the existing test that asserted kind: 'function'. --- src/extractors/javascript.ts | 4 +++- tests/parsers/javascript.test.ts | 6 +++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/extractors/javascript.ts b/src/extractors/javascript.ts index ad71b05d3..4bcfa72dd 100644 --- a/src/extractors/javascript.ts +++ b/src/extractors/javascript.ts @@ -734,6 +734,8 @@ function walkJavaScriptNode(node: TreeSitterNode, ctx: ExtractorOutput): void { break; case 'class_declaration': case 'abstract_class_declaration': + // class expressions: `return class Foo extends Bar { ... }` or `const X = class Foo { ... }` + case 'class': handleClassDecl(node, ctx); break; case 'method_definition': @@ -874,7 +876,7 @@ function handleStaticBlock(node: TreeSitterNode, definitions: Definition[]): voi if (!className) return; definitions.push({ name: `${className}.`, - kind: 'function', + kind: 'method', line: nodeStartLine(node), endLine: nodeEndLine(node), }); diff --git a/tests/parsers/javascript.test.ts b/tests/parsers/javascript.test.ts index bf78ea897..aed3f9ecb 100644 --- a/tests/parsers/javascript.test.ts +++ b/tests/parsers/javascript.test.ts @@ -105,12 +105,12 @@ describe('JavaScript parser', () => { ); }); - it('extracts static blocks as function definitions', () => { + it('extracts static blocks as method definitions', () => { const symbols = parseJS(`class C6 { static { f1(); } static { f2(); } }`); const staticDefs = symbols.definitions.filter((d) => d.name === 'C6.'); expect(staticDefs).toHaveLength(2); - expect(staticDefs[0]).toMatchObject({ kind: 'function' }); - expect(staticDefs[1]).toMatchObject({ kind: 'function' }); + expect(staticDefs[0]).toMatchObject({ kind: 'method' }); + expect(staticDefs[1]).toMatchObject({ kind: 'method' }); }); it('extracts import statements', () => { From 612bde045e0bb3947f1d52ed2c32ec67dff9e083 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Mon, 8 Jun 2026 00:51:11 -0600 Subject: [PATCH 08/11] docs(native): clarify getNativePackageVersion reports npm version even with local binary (#1389) --- src/infrastructure/native.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/infrastructure/native.ts b/src/infrastructure/native.ts index 81a69b676..939852783 100644 --- a/src/infrastructure/native.ts +++ b/src/infrastructure/native.ts @@ -154,6 +154,10 @@ export function isNativeAvailable(): boolean { /** * Read the version from the platform-specific npm package.json. * Returns null if the package is not installed or has no version. + * + * Note: always reports the npm package version. When the local dev binary or + * NAPI_RS_NATIVE_LIBRARY_PATH is loaded instead, this version may not match + * the running binary. */ export function getNativePackageVersion(): string | null { const pkg = resolvePlatformPackage(); From ab8a30000a91f7617119358b6af460ea23969d8d Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Mon, 8 Jun 2026 02:43:03 -0600 Subject: [PATCH 09/11] fix(native): add success log for npm package load and split mutable declaration (#1389) - native.ts: add debug log when step 3 (npm package) loads successfully, matching the trace already present for steps 1 and 2 - incremental.ts: split `let { targets, importedFrom }` into `const { targets: initialTargets, importedFrom }` + `let targets = initialTargets` to make explicit that only targets is ever reassigned --- src/domain/graph/builder/incremental.ts | 3 ++- src/infrastructure/native.ts | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/domain/graph/builder/incremental.ts b/src/domain/graph/builder/incremental.ts index 2987d9838..859f0f4a3 100644 --- a/src/domain/graph/builder/incremental.ts +++ b/src/domain/graph/builder/incremental.ts @@ -538,7 +538,7 @@ function buildCallEdges( if (call.receiver && BUILTIN_RECEIVERS.has(call.receiver)) continue; const caller = findCaller(lookup, call, symbols.definitions, relPath, fileNodeRow); - let { targets, importedFrom } = resolveCallTargets( + const { targets: initialTargets, importedFrom } = resolveCallTargets( lookup, call, relPath, @@ -546,6 +546,7 @@ function buildCallEdges( typeMap, caller.callerName, ); + let targets = initialTargets; if (targets.length === 0 && call.receiver === 'this' && caller.callerName != null) { const dotIdx = caller.callerName.indexOf('.'); diff --git a/src/infrastructure/native.ts b/src/infrastructure/native.ts index 939852783..09b3cc17a 100644 --- a/src/infrastructure/native.ts +++ b/src/infrastructure/native.ts @@ -131,6 +131,7 @@ export function loadNative(): NativeAddon | null { if (pkg) { try { _cached = _require(pkg) as NativeAddon; + debug(`loadNative: loaded npm package: ${pkg}`); return _cached; } catch (err) { _loadError = err as Error; From 5e0c50069b0d62f07a0a1801b6d0dc93707b7527 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Mon, 8 Jun 2026 11:46:05 -0600 Subject: [PATCH 10/11] chore(deps): align vitest lockfile to 4.1.8 to match main --- package-lock.json | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/package-lock.json b/package-lock.json index 52e1ece29..77e1e8fe3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2047,14 +2047,14 @@ "license": "MIT" }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.7.tgz", - "integrity": "sha512-qsYPeXc5Q9dFLd1i8Ap+Bx8sQgcp+rFVQo4R0dDsWNBzl26ldVF1qOO+RL24K7FDrR6pA+50XedRLSoSG24bVQ==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.8.tgz", + "integrity": "sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.7", + "@vitest/utils": "4.1.8", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", @@ -2068,8 +2068,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.7", - "vitest": "4.1.7" + "@vitest/browser": "4.1.8", + "vitest": "4.1.8" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -2151,9 +2151,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz", - "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz", + "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==", "dev": true, "license": "MIT", "dependencies": { @@ -2260,13 +2260,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz", - "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz", + "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.7", + "@vitest/pretty-format": "4.1.8", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, From f57ad9c7fa34bc4d9dbbf8400885ca4cab783789 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Mon, 8 Jun 2026 13:07:22 -0600 Subject: [PATCH 11/11] fix(native): warn and halt on local binary load failure instead of fallthrough When a local dev binary exists on disk but fails to load (e.g. stale ABI after a Rust rebuild), warn at warn level and return null immediately. Only fall through to the npm package when the file is absent entirely. This preserves the priority guarantee: a present-but-broken local binary surfaces as an explicit developer error rather than silently running the published npm binary. --- src/infrastructure/native.ts | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/src/infrastructure/native.ts b/src/infrastructure/native.ts index 09b3cc17a..841f7b99d 100644 --- a/src/infrastructure/native.ts +++ b/src/infrastructure/native.ts @@ -6,6 +6,7 @@ * to the existing WASM pipeline. */ +import { existsSync } from 'node:fs'; import { createRequire } from 'node:module'; import os from 'node:os'; import { fileURLToPath } from 'node:url'; @@ -112,17 +113,27 @@ export function loadNative(): NativeAddon | null { // 2. Locally compiled dev binary — preferred over npm package so that Rust // changes are visible without publishing. Only used when the file exists. + // If the file exists but fails to load (e.g. stale ABI), we warn and halt + // rather than silently falling through to the npm package — that would + // defeat the purpose of this priority order. const localFile = PLATFORM_LOCAL_BINARIES[platformKey]; if (localFile) { - try { - const localPath = fileURLToPath( - new URL(`../../crates/codegraph-core/${localFile}`, import.meta.url), - ); - _cached = _require(localPath) as NativeAddon; - debug(`loadNative: loaded local dev binary: ${localPath}`); - return _cached; - } catch (err) { - debug(`loadNative: local dev binary not available: ${toErrorMessage(err as Error)}`); + const localPath = fileURLToPath( + new URL(`../../crates/codegraph-core/${localFile}`, import.meta.url), + ); + if (existsSync(localPath)) { + try { + _cached = _require(localPath) as NativeAddon; + debug(`loadNative: loaded local dev binary: ${localPath}`); + return _cached; + } catch (err) { + _loadError = err as Error; + warn( + `loadNative: local dev binary exists but failed to load "${localPath}": ${toErrorMessage(err as Error)}`, + ); + _cached = null; + return null; + } } }