From 2b6a00d2a80d4f03e3080dd55c4f3fb7b8a086e3 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Sun, 5 Jul 2026 11:24:15 -0600 Subject: [PATCH 01/67] fix: scope codegraph batch complexity targets to file paths BATCH_COMMANDS entries with sig: 'dbOnly' (currently only complexity) always wrote their target into opts.target. complexityData treats opts.target as a symbol-name filter and opts.file as the file-path filter, so batch complexity never matched anything and the function list silently fell back to empty with a whole-repo summary regardless of which file was requested. Add an optional targetKey on BatchCommandEntry so each dbOnly command can declare which opts key its targets map to, and set it to 'file' for complexity. batchData and multiBatchData now route the target through the declared key instead of assuming opts.target. Fixes #1721 Impact: 4 functions changed, 6 affected --- src/features/batch.ts | 28 ++++++++++++++-- tests/integration/batch.test.ts | 57 ++++++++++++++++++++++++++++++--- 2 files changed, 77 insertions(+), 8 deletions(-) diff --git a/src/features/batch.ts b/src/features/batch.ts index 12a6aefb0..d8f343e34 100644 --- a/src/features/batch.ts +++ b/src/features/batch.ts @@ -18,6 +18,15 @@ type BatchSig = 'name' | 'target' | 'file' | 'dbOnly'; interface BatchCommandEntry { fn: (...args: unknown[]) => unknown; sig: BatchSig; + /** + * For `sig: 'dbOnly'` commands only: the `opts` key each target should be + * written to before calling `fn(customDbPath, opts)`. Defaults to `'target'` + * (a symbol/name filter). Commands whose underlying query function expects a + * *file path* target (rather than a symbol name) — e.g. `complexityData`, + * which treats `opts.target` as a name filter and `opts.file` as the file + * scope — must override this so the batch target lands in the right filter. + */ + targetKey?: string; } export const BATCH_COMMANDS: Record = { @@ -31,9 +40,22 @@ export const BATCH_COMMANDS: Record = { exports: { fn: exportsData as (...args: unknown[]) => unknown, sig: 'file' }, flow: { fn: flowData as (...args: unknown[]) => unknown, sig: 'name' }, dataflow: { fn: dataflowData as (...args: unknown[]) => unknown, sig: 'name' }, - complexity: { fn: complexityData as (...args: unknown[]) => unknown, sig: 'dbOnly' }, + complexity: { + fn: complexityData as (...args: unknown[]) => unknown, + sig: 'dbOnly', + targetKey: 'file', + }, }; +/** Resolve the opts key a `dbOnly` command's target should be written to. */ +function dbOnlyTargetOpts( + entry: BatchCommandEntry, + opts: Record, + target: string, +): Record { + return { ...opts, [entry.targetKey ?? 'target']: target }; +} + interface BatchResultOk { target: string; ok: true; @@ -77,7 +99,7 @@ export function batchData( try { let data: unknown; if (entry.sig === 'dbOnly') { - data = entry.fn(customDbPath, { ...opts, target }); + data = entry.fn(customDbPath, dbOnlyTargetOpts(entry, opts, target)); } else { data = entry.fn(target, customDbPath, opts); } @@ -166,7 +188,7 @@ export function multiBatchData( try { let data: unknown; if (entry.sig === 'dbOnly') { - data = entry.fn(customDbPath, { ...merged, target }); + data = entry.fn(customDbPath, dbOnlyTargetOpts(entry, merged, target)); } else { data = entry.fn(target, customDbPath, merged); } diff --git a/tests/integration/batch.test.ts b/tests/integration/batch.test.ts index 0dc44e3b0..8d84ea344 100644 --- a/tests/integration/batch.test.ts +++ b/tests/integration/batch.test.ts @@ -39,6 +39,12 @@ function insertEdge(db, sourceId, targetId, kind = 'calls', confidence = 1.0) { ).run(sourceId, targetId, kind, confidence); } +function insertComplexity(db, nodeId, cognitive, cyclomatic, maxNesting = 0) { + db.prepare( + 'INSERT INTO function_complexity (node_id, cognitive, cyclomatic, max_nesting) VALUES (?, ?, ?, ?)', + ).run(nodeId, cognitive, cyclomatic, maxNesting); +} + // ─── Fixture DB ──────────────────────────────────────────────────────── let tmpDir: string, dbPath: string; @@ -71,6 +77,13 @@ beforeAll(() => { insertEdge(db, fnRoute, fnAuth); insertEdge(db, fnRoute, fnFormat); + // Complexity data, distinct per file, so batch complexity can be asserted + // to scope results to the requested file rather than the whole repo. + insertComplexity(db, fnAuth, 5, 2); + insertComplexity(db, fnValidate, 3, 1); + insertComplexity(db, fnRoute, 10, 4); + insertComplexity(db, fnFormat, 1, 1); + db.close(); }); @@ -197,12 +210,46 @@ describe('batchData — edge cases', () => { // ─── complexity (dbOnly sig) ───────────────────────────────────────── describe('batchData — complexity (dbOnly signature)', () => { - test('complexity command uses target as opts.target', () => { - const data = batchData('complexity', ['authenticate'], dbPath); - expect(data.total).toBe(1); - // complexityData returns functions array — it won't error for unknown targets + test('complexity command scopes each target to a file, not a symbol name (#1721)', () => { + // Regression test for #1721: batch previously forwarded the file-path + // target as opts.target (a symbol-name filter), so complexityData never + // matched anything and silently fell back to identical, unfiltered + // whole-repo results for every target. It must land in opts.file instead. + const data = batchData('complexity', ['src/auth.js', 'src/routes.js'], dbPath); + expect(data.total).toBe(2); + expect(data.succeeded).toBe(2); + + const auth = data.results.find((r) => r.target === 'src/auth.js'); + const routes = data.results.find((r) => r.target === 'src/routes.js'); + expect(auth.ok).toBe(true); + expect(routes.ok).toBe(true); + + // src/auth.js has authenticate (cognitive 5) + validateToken (cognitive 3) + const authNames = auth.data.functions.map((f) => f.name).sort(); + expect(authNames).toEqual(['authenticate', 'validateToken']); + + // src/routes.js has only handleRoute (cognitive 10) + const routeNames = routes.data.functions.map((f) => f.name); + expect(routeNames).toEqual(['handleRoute']); + + // The two targets must not collapse into identical, unfiltered results. + // (Note: `summary` is a whole-repo health metric independent of the file + // filter, by design of complexityData — see #1807 for a separate, + // pre-existing gap where it should but doesn't reflect the file scope. + // The bug this test guards against is that `functions` came back empty + // for every target because the file path was routed into a symbol-name + // filter instead of the file filter.) + expect(auth.data.functions).not.toEqual(routes.data.functions); + }); + + test('complexity command still accepts a bare symbol name via opts.target passthrough', () => { + // BATCH_COMMANDS commands other than complexity keep symbol-name targets; + // this guards that complexity's own --target-style filtering (via shared + // opts, not the batch target) continues to work for direct callers. + const data = batchData('complexity', ['src/auth.js'], dbPath, { target: 'valid' }); expect(data.results[0].ok).toBe(true); - expect(data.results[0].data).toHaveProperty('functions'); + const names = data.results[0].data.functions.map((f) => f.name); + expect(names).toEqual(['validateToken']); }); }); From b28051ab3b220a745a6bf7b012b68fbe948716be Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Sun, 5 Jul 2026 13:06:28 -0600 Subject: [PATCH 02/67] fix: exclude parameters and interface/type members from dead-role classification codegraph roles --role dead flagged every function parameter as dead-leaf and every interface/type member as dead-unresolved, regardless of actual usage. Root cause: the role classifier's fan-in-based "no callers = dead" heuristic is meaningless for these two symbol categories, since neither can ever have inbound call edges by construction: - Parameters: `kind = 'parameter'` nodes were unconditionally force-assigned dead-leaf via a fast-path bypass in classifyNodeRolesFull/Incremental (TS) and do_classify_full/do_classify_incremental (native), before any fan-in was even computed. A parameter's liveness is a local dataflow question (is it referenced within its own function body), not a call-graph reachability question, so this produced ~90% noise in --role dead output. - Interface/type members: every language extractor qualifies interface/type members as `Owner.member` top-level definitions (mirroring class method qualification), and they never receive inbound call edges (they're consumed via type annotations, not calls). Lacking any recognition of this, they fell through the normal fan-in/fan-out path and landed in dead-unresolved. Fix: - Parameters are now fully excluded from role classification (role stays NULL), the same treatment already given to file/directory nodes. - Interface/type members are now recognized in the classifier by resolving the Owner.member prefix against same-file TYPE_DEF_KINDS declarations (interface/type/struct/enum/trait/record) and classified `leaf` unconditionally. Class methods use the identical Owner.member naming convention but are unaffected since `class` is not in TYPE_DEF_KINDS, so real dead methods/functions remain detected. Applied to both the TS/WASM classifier (graph/classifiers/roles.ts, features/structure.ts) and the native Rust classifier (graph/classifiers/roles.rs) per the dual-engine parity requirement; verified both engines produce identical results end-to-end against this repo's own graph. Fixes #1723 Impact: 6 functions changed, 5 affected --- .../src/graph/classifiers/roles.rs | 88 ++++++++++++-- src/features/structure.ts | 17 ++- src/graph/classifiers/roles.ts | 85 ++++++++++++- tests/graph/classifiers/roles.test.ts | 113 ++++++++++++++++++ tests/unit/roles.test.ts | 69 +++++++++++ 5 files changed, 356 insertions(+), 16 deletions(-) diff --git a/crates/codegraph-core/src/graph/classifiers/roles.rs b/crates/codegraph-core/src/graph/classifiers/roles.rs index c45184650..1b45f03bc 100644 --- a/crates/codegraph-core/src/graph/classifiers/roles.rs +++ b/crates/codegraph-core/src/graph/classifiers/roles.rs @@ -106,6 +106,55 @@ fn median(sorted: &[u32]) -> f64 { } } +/// Compute, per file, the set of symbol names that are `TYPE_DEF_KINDS`-kind +/// declarations (interface/type/struct/enum/trait/record). Mirrors JS +/// `computeTypeDefNamesByFile` in `graph/classifiers/roles.ts`. +fn compute_type_def_names_by_file( + rows: &[(i64, String, String, String, u32, u32)], +) -> HashMap> { + let mut by_file: HashMap> = HashMap::new(); + for (_id, name, kind, file, _fan_in, _fan_out) in rows { + if TYPE_DEF_KINDS.iter().any(|k| *k == kind.as_str()) { + by_file + .entry(file.clone()) + .or_default() + .insert(name.clone()); + } + } + by_file +} + +/// True when `name`/`kind` is a `method`/`property` member of an interface/type +/// declared in the same file — e.g. TS `interface Foo { bar: string }` extracts +/// `bar` as a top-level `method`-kind definition named `Foo.bar` (#1723). Every +/// language extractor qualifies interface/type members as `Owner.member` +/// (mirroring class method qualification), so the owner name is recovered from +/// the prefix before the first `.` and looked up against same-file +/// `TYPE_DEF_KINDS` declarations. Class methods use the identical `Owner.member` +/// convention but are unaffected here because `class` is not in `TYPE_DEF_KINDS` +/// — they remain subject to normal dead-code detection. +/// +/// These members can never gain inbound call edges by construction, so a +/// `fan_in == 0` reading carries zero dead-code signal for them, unlike a real +/// function/method where it does. Mirrors JS `isTypeDeclarationMember`. +fn is_type_declaration_member( + name: &str, + kind: &str, + file: &str, + type_def_names_by_file: &HashMap>, +) -> bool { + if kind != "method" && kind != "property" { + return false; + } + let Some(dot_idx) = name.find('.') else { + return false; + }; + let owner_name = &name[..dot_idx]; + type_def_names_by_file + .get(file) + .is_some_and(|names| names.contains(owner_name)) +} + /// Dead sub-role classification matching JS `classifyDeadSubRole`. fn classify_dead_sub_role(_name: &str, kind: &str, file: &str) -> &'static str { // Leaf kinds @@ -126,6 +175,7 @@ fn classify_dead_sub_role(_name: &str, kind: &str, file: &str) -> &'static str { } /// Classify a single node into a role. +#[allow(clippy::too_many_arguments)] fn classify_node( name: &str, kind: &str, @@ -135,9 +185,16 @@ fn classify_node( is_exported: bool, production_fan_in: u32, has_active_file_siblings: bool, + is_type_member: bool, median_fan_in: f64, median_fan_out: f64, ) -> &'static str { + // Interface/type members (#1723) — never subject to call-graph dead-code + // detection, regardless of fan-in/fan-out/export status. + if is_type_member { + return "leaf"; + } + // Framework entry if FRAMEWORK_ENTRY_PREFIXES.iter().any(|p| name.starts_with(p)) { return "entry"; @@ -267,10 +324,14 @@ pub(crate) fn do_classify_full(conn: &Connection) -> rusqlite::Result dead-leaf + // 1. Property kind (class/struct fields) -> dead-leaf. + // `parameter` is deliberately NOT included here (#1723): a parameter's liveness + // is a local dataflow question, not a call-graph reachability question, so + // "no incoming call edges" carries zero dead-code signal for it. Parameters are + // also excluded from the main rows query below, so they never receive a role + // at all — the same treatment as `file`/`directory` nodes. let leaf_ids: Vec = { - let mut stmt = - tx.prepare("SELECT id FROM nodes WHERE kind IN ('parameter', 'property')")?; + let mut stmt = tx.prepare("SELECT id FROM nodes WHERE kind = 'property'")?; let rows = stmt.query_map([], |row| row.get::<_, i64>(0))?; rows.filter_map(|r| r.ok()).collect() }; @@ -408,6 +469,11 @@ pub(crate) fn do_classify_full(conn: &Connection) -> rusqlite::Result> = HashMap::new(); @@ -423,6 +489,7 @@ pub(crate) fn do_classify_full(conn: &Connection) -> rusqlite::Result, prod_fan_in: &HashMap, active_files: &std::collections::HashSet, called_active_files: &std::collections::HashSet, + type_def_names_by_file: &HashMap>, median_fan_in: f64, median_fan_out: f64, ids_by_role: &mut HashMap<&'static str, Vec>, @@ -573,6 +642,7 @@ fn classify_rows( } else { false }; + let is_type_member = is_type_declaration_member(name, kind, file, type_def_names_by_file); let role = classify_node( name, kind, @@ -582,6 +652,7 @@ fn classify_rows( is_exported, prod_fi, has_active_siblings, + is_type_member, median_fan_in, median_fan_out, ); @@ -629,16 +700,15 @@ fn find_neighbour_files( } /// Query leaf kind node IDs and callable node rows for a set of files. +/// `parameter` is intentionally excluded from the leaf query (#1723) — see +/// `do_classify_full`'s leaf_ids comment for the rationale. fn query_nodes_for_files( tx: &rusqlite::Transaction, files: &[&str], ) -> rusqlite::Result<(Vec, Vec<(i64, String, String, String, u32, u32)>)> { let ph: String = files.iter().map(|_| "?").collect::>().join(","); - let leaf_sql = format!( - "SELECT id FROM nodes WHERE kind IN ('parameter', 'property') AND file IN ({})", - ph - ); + let leaf_sql = format!("SELECT id FROM nodes WHERE kind = 'property' AND file IN ({})", ph); let leaf_ids: Vec = { let mut stmt = tx.prepare(&leaf_sql)?; for (i, f) in files.iter().enumerate() { @@ -804,6 +874,9 @@ pub(crate) fn do_classify_incremental( let (active_files, called_active_files) = compute_active_files(&rows); + // Compute interface/type owner names per file (#1723) — see do_classify_full. + let type_def_names_by_file = compute_type_def_names_by_file(&rows); + let mut ids_by_role: HashMap<&str, Vec> = HashMap::new(); if !leaf_ids.is_empty() { @@ -818,6 +891,7 @@ pub(crate) fn do_classify_incremental( &prod_fan_in, &active_files, &called_active_files, + &type_def_names_by_file, median_fan_in, median_fan_out, &mut ids_by_role, diff --git a/src/features/structure.ts b/src/features/structure.ts index 74b88ef4a..85f3afff9 100644 --- a/src/features/structure.ts +++ b/src/features/structure.ts @@ -777,13 +777,20 @@ function writeMedianCache( } function classifyNodeRolesFull(db: BetterSqlite3Database, emptySummary: RoleSummary): RoleSummary { - // Leaf kinds (parameter, property) can never have callers/callees. + // Property kind (class/struct fields) can never have callers/callees. // Classify them directly as dead-leaf without the expensive fan-in/fan-out JOINs. + // + // `parameter` is deliberately NOT included here (#1723): a parameter's liveness + // is a local dataflow question (is it referenced within its own function body), + // not a call-graph reachability question, so "no incoming call edges" carries + // zero dead-code signal for it. Parameters are also excluded from the main + // query below, so they never receive a role at all — the same treatment as + // `file`/`directory` nodes. const leafRows = db .prepare( `SELECT n.id FROM nodes n - WHERE n.kind IN ('parameter', 'property')`, + WHERE n.kind = 'property'`, ) .all() as { id: number }[]; @@ -967,11 +974,13 @@ function classifyNodeRolesIncremental( globalMedians = computed; } - // 2a. Leaf kinds (parameter, property) in affected files — always dead-leaf + // 2a. Property kind (class/struct fields) in affected files — always dead-leaf. + // `parameter` is intentionally excluded (#1723) — see classifyNodeRolesFull for + // the rationale. Parameters stay unclassified (role = NULL) after the reset below. const leafRows = db .prepare( `SELECT n.id FROM nodes n - WHERE n.kind IN ('parameter', 'property') + WHERE n.kind = 'property' AND n.file IN (${placeholders})`, ) .all(...allAffectedFiles) as { id: number }[]; diff --git a/src/graph/classifiers/roles.ts b/src/graph/classifiers/roles.ts index 2b17c33ba..fe278ff31 100644 --- a/src/graph/classifiers/roles.ts +++ b/src/graph/classifiers/roles.ts @@ -4,10 +4,23 @@ * Roles: entry, core, utility, adapter, leaf, dead-*, test-only * * Dead sub-categories refine the coarse "dead" bucket: - * dead-leaf — parameters, properties, constants (leaf nodes by definition) + * dead-leaf — properties, constants (leaf nodes by definition) * dead-entry — framework dispatch: CLI commands, MCP tools, event handlers * dead-ffi — cross-language FFI boundaries (e.g. Rust napi-rs bindings) * dead-unresolved — genuinely unreferenced callables (the real dead code) + * + * `parameter`-kind nodes never reach this module in production — callers + * (`features/structure.ts`, native `graph/classifiers/roles.rs`) exclude them + * entirely, leaving `role` unset, the same treatment as `file`/`directory` + * nodes. A parameter's liveness is a local dataflow question (is it referenced + * within its own function body), not a call-graph reachability question, so + * "no incoming call edges" carries zero dead-code signal for it (#1723). + * + * `method`/`property`-kind members of an interface/type declaration (e.g. + * `interface Foo { bar: string }`) DO reach this module, but are recognized by + * `isTypeDeclarationMember` and classified `leaf` unconditionally — they can + * never gain inbound call edges by construction, so call-graph reachability + * doesn't apply to them either (#1723). */ import type { DeadSubRole, Role } from '../../types.js'; @@ -53,6 +66,56 @@ export interface ClassifiableNode { file?: string; } +/** + * Compute, per file, the set of symbol names that are `TYPE_DEF_KINDS`-kind + * declarations (interface/type/struct/enum/trait/record). Used by + * `isTypeDeclarationMember` to recognize `Owner.member`-qualified nodes whose + * owner is a type-level declaration rather than a class. + */ +function computeTypeDefNamesByFile(nodes: RoleClassificationNode[]): Map> { + const byFile = new Map>(); + for (const n of nodes) { + if (n.file && n.kind && TYPE_DEF_KINDS.has(n.kind)) { + let names = byFile.get(n.file); + if (!names) { + names = new Set(); + byFile.set(n.file, names); + } + names.add(n.name); + } + } + return byFile; +} + +/** + * True when `node` is a `method`/`property`-kind member of an interface/type + * declared in the same file — e.g. TS `interface Foo { bar: string }` extracts + * `bar` as a top-level `method`-kind definition named `Foo.bar` (#1723). Every + * language extractor qualifies interface/type members as `Owner.member` + * (mirroring class method qualification), so the owner name is recovered from + * the prefix before the first `.` and looked up against same-file + * `TYPE_DEF_KINDS` declarations. Class methods use the identical `Owner.member` + * convention but are unaffected here because `class` is not in `TYPE_DEF_KINDS` + * — they remain subject to normal dead-code detection. + * + * These members can never gain inbound call edges by construction — they are + * consumed via type annotations and structural typing, never calls — so a + * `fanIn === 0` reading carries zero dead-code signal for them, unlike a real + * function/method where it does. Call-edge-based reachability just doesn't + * apply to type-level declarations, so they must never be judged dead by it. + */ +function isTypeDeclarationMember( + node: RoleClassificationNode, + typeDefNamesByFile: Map>, +): boolean { + if (node.kind !== 'method' && node.kind !== 'property') return false; + if (!node.file) return false; + const dotIdx = node.name.indexOf('.'); + if (dotIdx === -1) return false; + const ownerName = node.name.slice(0, dotIdx); + return typeDefNamesByFile.get(node.file)?.has(ownerName) ?? false; +} + /** * Refine a "dead" classification into a sub-category. */ @@ -176,10 +239,21 @@ function classifyByFanShape(highIn: boolean, highOut: boolean): Role { /** * Apply role-classification rules to a single node. - * Order matters — framework entries are tagged first, then dead/test cases, - * then the fan-in/fan-out shape decides among the structural roles. + * Order matters — type-level members are ruled out first (they can never be + * judged by call-graph reachability at all), then framework entries, then + * dead/test cases, then the fan-in/fan-out shape decides among the structural + * roles. */ -function classifyNodeRole(node: RoleClassificationNode, medFanIn: number, medFanOut: number): Role { +function classifyNodeRole( + node: RoleClassificationNode, + medFanIn: number, + medFanOut: number, + typeDefNamesByFile: Map>, +): Role { + // Interface/type members (#1723) — never subject to call-graph dead-code + // detection, regardless of fan-in/fan-out/export status. + if (isTypeDeclarationMember(node, typeDefNamesByFile)) return 'leaf'; + if (FRAMEWORK_ENTRY_PREFIXES.some((p) => node.name.startsWith(p))) return 'entry'; if (node.fanIn === 0) { @@ -217,10 +291,11 @@ export function classifyRoles( if (nodes.length === 0) return new Map(); const { fanIn: medFanIn, fanOut: medFanOut } = medianOverrides ?? computeFanMedians(nodes); + const typeDefNamesByFile = computeTypeDefNamesByFile(nodes); const result = new Map(); for (const node of nodes) { - result.set(node.id, classifyNodeRole(node, medFanIn, medFanOut)); + result.set(node.id, classifyNodeRole(node, medFanIn, medFanOut, typeDefNamesByFile)); } return result; } diff --git a/tests/graph/classifiers/roles.test.ts b/tests/graph/classifiers/roles.test.ts index 03ae894c7..b1de630ee 100644 --- a/tests/graph/classifiers/roles.test.ts +++ b/tests/graph/classifiers/roles.test.ts @@ -515,4 +515,117 @@ describe('classifyRoles', () => { const roles = classifyRoles(nodes); expect(roles.get('1')).toBe('dead-unresolved'); }); + + // ── Interface/type member exemption (#1723) ───────────────────────── + + it('classifies interface method-signature member as leaf, not dead', () => { + // TS `interface Foo { bar(): void }` extracts `bar` as a top-level + // `method`-kind definition named `Foo.bar`. It can never gain an inbound + // call edge (nothing "calls" a type-level declaration), so fanIn === 0 + // carries zero dead-code signal here — unlike a real function/method. + const nodes = [ + { + id: '1', + name: 'Foo', + kind: 'interface', + file: 'src/a.ts', + fanIn: 0, + fanOut: 0, + isExported: false, + }, + { + id: '2', + name: 'Foo.bar', + kind: 'method', + file: 'src/a.ts', + fanIn: 0, + fanOut: 0, + isExported: false, + }, + ]; + const roles = classifyRoles(nodes); + expect(roles.get('2')).toBe('leaf'); + }); + + it('classifies type-alias property-signature member as leaf, not dead', () => { + // TS `type Foo = { bar: string }` — a property-kind member of a `type` owner. + const nodes = [ + { + id: '1', + name: 'Foo', + kind: 'type', + file: 'src/a.ts', + fanIn: 0, + fanOut: 0, + isExported: false, + }, + { + id: '2', + name: 'Foo.bar', + kind: 'property', + file: 'src/a.ts', + fanIn: 0, + fanOut: 0, + isExported: false, + }, + ]; + const roles = classifyRoles(nodes); + expect(roles.get('2')).toBe('leaf'); + }); + + it('classifies interface member as leaf even when the interface is isolated (no active siblings)', () => { + // Unlike bare TYPE_DEF_KINDS nodes (which need hasActiveFileSiblings to + // avoid being marked dead), members are exempt unconditionally — "no call + // edges" is a structural certainty for them, not merely a likelihood. + const nodes = [ + { + id: '1', + name: 'Isolated', + kind: 'interface', + file: 'src/isolated.ts', + fanIn: 0, + fanOut: 0, + isExported: false, + }, + { + id: '2', + name: 'Isolated.onlyMember', + kind: 'method', + file: 'src/isolated.ts', + fanIn: 0, + fanOut: 0, + isExported: false, + }, + ]; + const roles = classifyRoles(nodes); + expect(roles.get('2')).toBe('leaf'); + }); + + it('does not exempt class methods sharing the Owner.member naming convention', () => { + // Class methods are qualified identically to interface members + // (`ClassName.method`), but `class` is not in TYPE_DEF_KINDS — they must + // remain subject to normal dead-code detection. + const nodes = [ + { + id: '1', + name: 'Foo', + kind: 'class', + file: 'src/a.ts', + fanIn: 5, + fanOut: 0, + isExported: true, + }, + { + id: '2', + name: 'Foo.deadMethod', + kind: 'method', + file: 'src/a.ts', + fanIn: 0, + fanOut: 0, + isExported: false, + }, + ]; + const roles = classifyRoles(nodes); + expect(roles.get('2')).toBe('dead-unresolved'); + }); }); diff --git a/tests/unit/roles.test.ts b/tests/unit/roles.test.ts index 110e1c7bd..88d5ee1e0 100644 --- a/tests/unit/roles.test.ts +++ b/tests/unit/roles.test.ts @@ -380,4 +380,73 @@ describe('classifyNodeRoles', () => { // Should be entry (exported, fan-in 0), not dead-unresolved expect(role.role).toBe('entry'); }); + + // ── Parameters and interface members are not dead-code targets (#1723) ── + + it('excludes parameter-kind nodes from role classification entirely', () => { + // A parameter's liveness is a local dataflow question (is it referenced + // within its own function body), not a call-graph reachability question. + // "No incoming call edges" is guaranteed for every parameter regardless of + // usage, so parameters must never receive a `dead-*` role — or any role at + // all, the same treatment as file/directory nodes. + insertNode('src/helpers.ts', 'file', 'src/helpers.ts', 0); + const fn = insertNode('findChild', 'function', 'src/helpers.ts', 26); + insertNode('node', 'parameter', 'src/helpers.ts', 26); + insertNode('type', 'parameter', 'src/helpers.ts', 26); + // An external caller so findChild itself is not incidentally dead too — + // isolates the assertion to parameter handling specifically. + const caller = insertNode('caller', 'function', 'other.ts', 1); + insertEdge(caller, fn, 'calls'); + + classifyNodeRoles(db); + + const paramRoles = db.prepare("SELECT role FROM nodes WHERE kind = 'parameter'").all() as { + role: string | null; + }[]; + expect(paramRoles).toHaveLength(2); + for (const row of paramRoles) { + expect(row.role).toBeNull(); + } + }); + + it('does not classify interface members as dead', () => { + // TS `interface ExtractParametersOptions { typeMap?: Map<...> }` extracts + // `typeMap` as a top-level `method`-kind definition named + // `ExtractParametersOptions.typeMap` (property-signature members are + // currently mislabeled `method` by the extractor — tracked separately). + // It has no callers by construction; it must not be flagged dead. + insertNode('src/helpers.ts', 'file', 'src/helpers.ts', 0); + insertNode('ExtractParametersOptions', 'interface', 'src/helpers.ts', 359); + insertNode('ExtractParametersOptions.typeMap', 'method', 'src/helpers.ts', 361); + + classifyNodeRoles(db); + + const role = db + .prepare("SELECT role FROM nodes WHERE name = 'ExtractParametersOptions.typeMap'") + .get() as { role: string | null }; + expect(role.role).toBe('leaf'); + }); + + it('regression: used parameters, interface members, and a genuinely dead function are classified correctly together', () => { + // Mirrors the exact issue #1723 repro: `findChild(node, type)` in + // src/extractors/helpers.ts, alongside an interface and a truly dead function. + insertNode('src/helpers.ts', 'file', 'src/helpers.ts', 0); + const findChildFn = insertNode('findChild', 'function', 'src/helpers.ts', 26); + insertNode('node', 'parameter', 'src/helpers.ts', 26); + insertNode('type', 'parameter', 'src/helpers.ts', 26); + insertNode('ChaContext', 'interface', 'src/helpers.ts', 18); + insertNode('ChaContext.implementors', 'method', 'src/helpers.ts', 20); + insertNode('trulyDeadHelper', 'function', 'src/helpers.ts', 400); + + const caller = insertNode('caller', 'function', 'other.ts', 1); + insertEdge(caller, findChildFn, 'calls'); + + classifyNodeRoles(db); + const getRole = (name) => db.prepare('SELECT role FROM nodes WHERE name = ?').get(name)?.role; + + expect(getRole('node')).toBeNull(); + expect(getRole('type')).toBeNull(); + expect(getRole('ChaContext.implementors')).toBe('leaf'); + expect(getRole('trulyDeadHelper')).toBe('dead-unresolved'); + }); }); From 17fbcba97423e96693bfc2b689b090abd4ea45ff Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Sun, 5 Jul 2026 13:25:38 -0600 Subject: [PATCH 03/67] fix: credit import-type usages as exports consumers codegraph exports --json (and the --unused dead-export filter it feeds) reported zero consumers for interfaces/types that are demonstrably imported and used elsewhere via `import type { X }`, even though `codegraph deps ` correctly showed the importing file in `importedBy`. Example: ChaContext in src/domain/graph/builder/cha.ts is imported (type-only) by build-edges.ts and native-orchestrator.ts, but `codegraph exports` showed consumerCount: 0. Root cause: exportsFileImpl's per-symbol consumers query (domain/analysis/exports.ts) only looked at kind = 'calls' edges. The builder already emits a symbol-level `imports-type` edge for `import type { X }` statements (source = importing file node, target = the specific imported symbol -- see emitTypeOnlySymbolEdges in build-edges.ts/incremental.ts), which `codegraph deps` reads from, but the exports consumer query never looked at this edge kind. Role classification (features/structure.ts) already includes 'imports-type' in its fan-in formula, so `codegraph roles --role dead` was unaffected -- this was purely a gap in exports's independent consumer list. Fix: widen the consumers query to `kind IN ('calls', 'imports-type')`, matching the edge-kind set already used everywhere else in the codebase for cross-file usage credit (structure.ts, graph-enrichment.ts, boundaries.ts, dependencies.ts). No native Rust changes needed -- domain/analysis/exports.ts is pure query-layer code that reads the already-built edges table and has no engine-specific mirror. Deliberately does NOT add `extends`/`implements`: investigation found those edges are resolved by symbol name only, with no file/import scoping (buildClassHierarchyEdges in both build-edges.ts/incremental.ts and the native emit_hierarchy_edges), so they link same-named declarations across unrelated files (verified: this repo's own graph has false `implements`/`extends` edges between unrelated fixture classes across languages and a `Repository` interface in src/types.ts). Filed as #1812. Also filed #1813 for a related but distinct gap: `import { type X }` inline per-specifier modifiers aren't tracked as type-only in either engine's extractor, so such X still gets no credit even after this fix. Added tests/integration/exports.test.ts coverage: an interface consumed only via a symbol-level `imports-type` edge gets consumerCount >= 1 and is excluded from --unused, while a genuinely unreferenced interface still shows 0 consumers. Verified against this repo's own graph: `codegraph exports src/domain/graph/builder/cha.ts -T --json` now credits ChaContext with 2 consumers (build-edges.ts, native-orchestrator.ts). Full test suite (201 files, 3359 tests) and lint pass clean. docs check acknowledged: internal bug fix to exports consumer computation, no new feature/language/CLI surface/architecture change -- README.md, CLAUDE.md, and ROADMAP.md are unaffected. Fixes #1724 Impact: 1 functions changed, 3 affected --- src/domain/analysis/exports.ts | 15 ++++++- tests/integration/exports.test.ts | 73 +++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/src/domain/analysis/exports.ts b/src/domain/analysis/exports.ts index 62f97c434..3c9710df6 100644 --- a/src/domain/analysis/exports.ts +++ b/src/domain/analysis/exports.ts @@ -164,11 +164,24 @@ function exportsFileImpl( "SELECT * FROM nodes WHERE file = ? AND kind != 'file' AND exported = 1 ORDER BY line", ) : null; + // Consumers include real call/construct edges plus `imports-type` edges — + // the symbol-level edge emitted for `import type { X }` statements (source + // is the importing *file* node, since the import statement references the + // type rather than a specific function). Without this, interfaces/types + // that are only ever used as type annotations are misclassified as dead + // exports even though `codegraph deps` already reports the importing file + // via this same edge (#1724). + // + // `extends`/`implements` edges are deliberately NOT included here: they are + // resolved by symbol name only, with no file/import scoping (see + // buildClassHierarchyEdges), so they can link same-named declarations + // across unrelated files — crediting them as consumers would surface false + // positives instead of fixing false negatives. const consumersStmt = cachedStmt( _consumersStmtCache, db, `SELECT n.name, n.file, n.line FROM edges e JOIN nodes n ON e.source_id = n.id - WHERE e.target_id = ? AND e.kind = 'calls'`, + WHERE e.target_id = ? AND e.kind IN ('calls', 'imports-type')`, ); const reexportsFromStmt = cachedStmt( _reexportsFromStmtCache, diff --git a/tests/integration/exports.test.ts b/tests/integration/exports.test.ts index 467621280..f869ec115 100644 --- a/tests/integration/exports.test.ts +++ b/tests/integration/exports.test.ts @@ -240,3 +240,76 @@ describe('exportsData', () => { expect(data.totalReexportedUnused).toBe(0); }); }); + +// ─── import type / type-only consumer crediting (#1724) ────────────────── +// +// Regression coverage for: interfaces/types that are only ever consumed via +// `import type { X }` (never called/constructed) were reported as zero- +// consumer dead exports, even though the builder already emits a +// symbol-level `imports-type` edge (source = importing file node, target = +// the specific imported symbol) for exactly this case. `codegraph deps` +// already surfaced the file-level import correctly; `exportsData`'s +// per-symbol consumer query only looked at `kind = 'calls'` and missed it. + +describe('exportsData — import type consumer crediting (#1724)', () => { + let tmpDir2: string, dbPath2: string; + + beforeAll(() => { + tmpDir2 = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-exports-typeonly-')); + fs.mkdirSync(path.join(tmpDir2, '.codegraph')); + dbPath2 = path.join(tmpDir2, '.codegraph', 'graph.db'); + + const db = new Database(dbPath2); + db.pragma('journal_mode = WAL'); + initSchema(db); + + // File nodes + insertNode(db, 'types.ts', 'file', 'types.ts', 0); + const fConsumer = insertNode(db, 'consumer.ts', 'file', 'consumer.ts', 0); + + // Interface exported from types.ts, referenced elsewhere only via a type + // annotation (never called/constructed). + const configIface = insertNode(db, 'Config', 'interface', 'types.ts', 1); + // Interface exported from types.ts, genuinely never referenced anywhere. + const unusedIface = insertNode(db, 'Unused', 'interface', 'types.ts', 10); + + const markExported = db.prepare('UPDATE nodes SET exported = 1 WHERE id = ?'); + markExported.run(configIface); + markExported.run(unusedIface); + + // consumer.ts does `import type { Config } from './types'`. The builder + // emits the symbol-level edge with the importing *file* as source (see + // emitTypeOnlySymbolEdges in domain/graph/builder/stages/build-edges.ts + // and incremental.ts) since the import statement — not a specific + // function — is what references the type. + insertEdge(db, fConsumer, configIface, 'imports-type'); + + db.close(); + }); + + afterAll(() => { + if (tmpDir2) fs.rmSync(tmpDir2, { recursive: true, force: true }); + }); + + test('interface consumed only via `import type` is credited with a consumer', () => { + const data = exportsData('types.ts', dbPath2); + const config = data.results.find((r) => r.name === 'Config'); + expect(config).toBeDefined(); + expect(config.consumerCount).toBe(1); + expect(config.consumers.length).toBe(1); + expect(config.consumers[0].file).toBe('consumer.ts'); + }); + + test('interface consumed only via `import type` is excluded from --unused', () => { + const data = exportsData('types.ts', dbPath2, { unused: true }); + expect(data.results.find((r) => r.name === 'Config')).toBeUndefined(); + }); + + test('interface with no references anywhere is still classified unused', () => { + const data = exportsData('types.ts', dbPath2, { unused: true }); + const unused = data.results.find((r) => r.name === 'Unused'); + expect(unused).toBeDefined(); + expect(unused.consumerCount).toBe(0); + expect(unused.consumers).toEqual([]); + }); +}); From 26918bb90ebad99480933cf54adeb5a1cf7e336c Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Sun, 5 Jul 2026 13:56:17 -0600 Subject: [PATCH 04/67] fix: prevent fn-impact/query crash when -f/--file is passed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI's `-f/--file` option is a repeatable Commander accumulator (collectFile) that always produces a string[], even on first use. The native composite bindings backing fn-impact (findNodesWithFanIn) and query (fnDeps) forwarded that array straight into napi-rs functions whose Rust signatures only accepted a single String, so any use of -f/--file crashed with "Failed to convert JavaScript value ... into rust type `String`" regardless of engine defaults. context worked only because its code path never touches the native repository for symbol lookup. Widen the native Rust signatures (find_nodes_with_fan_in, fn_deps) to accept Vec and build an OR-of-LIKE clause for multiple files, mirroring buildFileConditionSQL/NodeQuery.fileFilter on the JS side. Normalize the file option before calling into the native binding on the TS side, and thread the widened string | string[] type through QueryOpts, fnDeps's opts, and the call chain down to findMatchingNodes. This also makes repeated -f/--file genuinely multi-file end to end for fn-impact/query, matching context's existing behavior, instead of crashing on any use. findNodesByScope shares QueryOpts but has no CLI caller and its native binding still only accepts one file; it now takes the first value defensively rather than crash or fail to type-check. Fixes #1726 docs check acknowledged: pure bug fix, no new CLI options/languages/ architecture — the -f/--file "repeatable" docs are now accurate rather than needing correction. Impact: 14 functions changed, 34 affected --- .../src/db/repository/graph_read.rs | 47 +++++++++++-- src/db/repository/base.ts | 2 +- src/db/repository/native-repository.ts | 17 +++-- src/domain/analysis/dependencies.ts | 2 +- src/domain/analysis/fn-impact.ts | 2 +- src/domain/analysis/symbol-lookup.ts | 7 +- src/presentation/queries-cli/impact.ts | 2 +- src/types.ts | 9 +-- tests/integration/cli.test.ts | 66 +++++++++++++++++++ tests/integration/queries.test.ts | 47 +++++++++++++ 10 files changed, 179 insertions(+), 22 deletions(-) diff --git a/crates/codegraph-core/src/db/repository/graph_read.rs b/crates/codegraph-core/src/db/repository/graph_read.rs index b55bec051..5f36382e8 100644 --- a/crates/codegraph-core/src/db/repository/graph_read.rs +++ b/crates/codegraph-core/src/db/repository/graph_read.rs @@ -28,6 +28,41 @@ fn escape_like(s: &str) -> String { out } +/// Append an `AND (col LIKE ? [OR col LIKE ? ...])` fragment for a multi-value +/// file filter and push the corresponding escaped LIKE params. Mirrors +/// `buildFileConditionSQL()` / `NodeQuery.fileFilter()` in +/// `src/db/query-builder.ts`, which is why callers must accept `Vec` +/// (repeatable `-f/--file`) rather than a single `String`. No-op when `files` +/// is empty. Advances `*idx` past the placeholders it consumes. +fn push_file_filter( + sql: &mut String, + params_v: &mut Vec>, + idx: &mut usize, + column: &str, + files: &[String], +) { + if files.is_empty() { + return; + } + let start = *idx; + if files.len() == 1 { + sql.push_str(&format!(" AND {column} LIKE ?{start} ESCAPE '\\'")); + params_v.push(Box::new(format!("%{}%", escape_like(&files[0])))); + *idx += 1; + return; + } + let clauses: Vec = files + .iter() + .enumerate() + .map(|(i, _)| format!("{column} LIKE ?{} ESCAPE '\\'", start + i)) + .collect(); + sql.push_str(&format!(" AND ({})", clauses.join(" OR "))); + for f in files { + params_v.push(Box::new(format!("%{}%", escape_like(f)))); + } + *idx += files.len(); +} + /// Check if a file path looks like a test file (mirrors `isTestFile` in JS). fn is_test_file(file: &str) -> bool { file.contains(".test.") @@ -143,7 +178,7 @@ struct FnDepsCallerWithId { fn build_fn_deps_match_query( name: &str, kind: Option<&str>, - file: Option<&str>, + file: Option<&[String]>, ) -> (String, Vec>) { let default_kinds: Vec = vec![ "function".to_string(), @@ -180,8 +215,7 @@ fn build_fn_deps_match_query( idx += kinds.len(); } if let Some(f) = file { - sql.push_str(&format!(" AND n.file LIKE ?{idx} ESCAPE '\\'")); - params_v.push(Box::new(format!("%{}%", escape_like(f)))); + push_file_filter(&mut sql, &mut params_v, &mut idx, "n.file", f); } (sql, params_v) @@ -1086,7 +1120,7 @@ impl NativeDatabase { &self, name_pattern: String, kinds: Option>, - file: Option, + file: Option>, ) -> napi::Result> { let conn = self.conn()?; @@ -1112,8 +1146,7 @@ impl NativeDatabase { } } if let Some(ref f) = file { - sql.push_str(&format!(" AND n.file LIKE ?{idx} ESCAPE '\\'")); - param_values.push(Box::new(format!("%{}%", escape_like(f)))); + push_file_filter(&mut sql, &mut param_values, &mut idx, "n.file", f); } let mut stmt = conn @@ -2114,7 +2147,7 @@ impl NativeDatabase { name: String, depth: Option, no_tests: Option, - file: Option, + file: Option>, kind: Option, ) -> napi::Result { let conn = self.conn()?; diff --git a/src/db/repository/base.ts b/src/db/repository/base.ts index 4c1546c2e..9ab137a86 100644 --- a/src/db/repository/base.ts +++ b/src/db/repository/base.ts @@ -231,7 +231,7 @@ export class Repository implements IRepository { */ fnDeps( _name: string, - _opts?: { depth?: number; noTests?: boolean; file?: string; kind?: string }, + _opts?: { depth?: number; noTests?: boolean; file?: string | string[]; kind?: string }, ): FnDepsResult | null { return null; } diff --git a/src/db/repository/native-repository.ts b/src/db/repository/native-repository.ts index a822c6912..202b3bb74 100644 --- a/src/db/repository/native-repository.ts +++ b/src/db/repository/native-repository.ts @@ -45,6 +45,7 @@ import type { TriageNodeRow, TriageQueryOpts, } from '../../types.js'; +import { normalizeFileFilter } from '../query-builder.js'; import { type FnDepsCallerNode, type FnDepsEntry, @@ -267,8 +268,9 @@ export class NativeRepository extends Repository { } findNodesWithFanIn(namePattern: string, opts: QueryOpts = {}): NodeRowWithFanIn[] { + const files = normalizeFileFilter(opts.file); return this.#ndb - .findNodesWithFanIn(namePattern, opts.kinds ?? null, opts.file ?? null) + .findNodesWithFanIn(namePattern, opts.kinds ?? null, files.length > 0 ? files : null) .map(toNodeRowWithFanIn); } @@ -301,9 +303,11 @@ export class NativeRepository extends Repository { } findNodesByScope(scopeName: string, opts: QueryOpts = {}): NodeRow[] { - return this.#ndb - .findNodesByScope(scopeName, opts.kind ?? null, opts.file ?? null) - .map(toNodeRow); + // Unlike findNodesWithFanIn/fnDeps, this native binding only accepts a single + // file (no CLI command currently wires -f/--file to it); take the first value + // rather than crash if a caller ever passes the repeatable-flag array form. + const [file] = normalizeFileFilter(opts.file); + return this.#ndb.findNodesByScope(scopeName, opts.kind ?? null, file ?? null).map(toNodeRow); } findNodeByQualifiedName(qualifiedName: string, opts: { file?: string } = {}): NodeRow[] { @@ -526,14 +530,15 @@ export class NativeRepository extends Repository { // ── Composite queries ────────────────────────────────────────────── fnDeps( name: string, - opts?: { depth?: number; noTests?: boolean; file?: string; kind?: string }, + opts?: { depth?: number; noTests?: boolean; file?: string | string[]; kind?: string }, ): FnDepsResult | null { if (typeof this.#ndb.fnDeps !== 'function') return null; + const files = normalizeFileFilter(opts?.file); const raw = this.#ndb.fnDeps( name, opts?.depth ?? undefined, opts?.noTests ?? undefined, - opts?.file ?? undefined, + files.length > 0 ? files : undefined, opts?.kind ?? undefined, ); // Convert from native format (transitive_callers as array of groups) diff --git a/src/domain/analysis/dependencies.ts b/src/domain/analysis/dependencies.ts index 4a759e6e1..e602f0ab6 100644 --- a/src/domain/analysis/dependencies.ts +++ b/src/domain/analysis/dependencies.ts @@ -175,7 +175,7 @@ export function fnDepsData( opts: { depth?: number; noTests?: boolean; - file?: string; + file?: string | string[]; kind?: string; limit?: number; offset?: number; diff --git a/src/domain/analysis/fn-impact.ts b/src/domain/analysis/fn-impact.ts index 148cf5b59..d7cfc2901 100644 --- a/src/domain/analysis/fn-impact.ts +++ b/src/domain/analysis/fn-impact.ts @@ -268,7 +268,7 @@ export function fnImpactData( opts: { depth?: number; noTests?: boolean; - file?: string; + file?: string | string[]; kind?: string; includeImplementors?: boolean; limit?: number; diff --git a/src/domain/analysis/symbol-lookup.ts b/src/domain/analysis/symbol-lookup.ts index 5009d043a..065c11f4e 100644 --- a/src/domain/analysis/symbol-lookup.ts +++ b/src/domain/analysis/symbol-lookup.ts @@ -38,7 +38,12 @@ const FUNCTION_KINDS: SymbolKind[] = ['function', 'method', 'class', 'constant'] export function findMatchingNodes( dbOrRepo: BetterSqlite3Database | InstanceType, name: string, - opts: { noTests?: boolean; file?: string; kind?: string; kinds?: readonly string[] } = {}, + opts: { + noTests?: boolean; + file?: string | string[]; + kind?: string; + kinds?: readonly string[]; + } = {}, ): Array { const kinds = ( opts.kind ? [opts.kind] : opts.kinds?.length ? [...opts.kinds] : FUNCTION_KINDS diff --git a/src/presentation/queries-cli/impact.ts b/src/presentation/queries-cli/impact.ts index e9eafe052..94b1fa64d 100644 --- a/src/presentation/queries-cli/impact.ts +++ b/src/presentation/queries-cli/impact.ts @@ -118,7 +118,7 @@ interface OutputOpts { format?: string; staged?: boolean; ref?: string; - file?: string; + file?: string | string[]; kind?: string; [key: string]: unknown; } diff --git a/src/types.ts b/src/types.ts index 980713528..8374a2a0c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -265,7 +265,8 @@ export interface ComplexityMetrics { export interface QueryOpts { kind?: SymbolKind; kinds?: SymbolKind[]; - file?: string; + /** Partial-match file filter. Repeatable on the CLI (`-f/--file`), so a single value arrives as a one-element array. */ + file?: string | string[]; noTests?: boolean; } @@ -346,7 +347,7 @@ export interface Repository { // ── Composite queries ────────────────────────────────────────────── fnDeps( name: string, - opts?: { depth?: number; noTests?: boolean; file?: string; kind?: string }, + opts?: { depth?: number; noTests?: boolean; file?: string | string[]; kind?: string }, ): { name: string; results: Array<{ @@ -2496,7 +2497,7 @@ export interface NativeDatabase { findNodesWithFanIn( namePattern: string, kinds: string[] | null | undefined, - file: string | null | undefined, + file: string[] | null | undefined, ): NativeNodeRowWithFanIn[]; findNodesForTriage( kind: string | null | undefined, @@ -2553,7 +2554,7 @@ export interface NativeDatabase { name: string, depth: number | null | undefined, noTests: boolean | null | undefined, - file: string | null | undefined, + file: string[] | null | undefined, kind: string | null | undefined, ): NativeFnDepsResult; diff --git a/tests/integration/cli.test.ts b/tests/integration/cli.test.ts index f61b69153..28c3a0a76 100644 --- a/tests/integration/cli.test.ts +++ b/tests/integration/cli.test.ts @@ -110,6 +110,40 @@ describe('CLI smoke tests', () => { expect(data).toHaveProperty('results'); }); + // Regression test for #1726: `-f/--file` is a repeatable Commander option + // (collectFile) that always produces a string[], even for a single use. + // query's native composite path used to forward that array straight into + // a napi binding typed for a single String and crash. + test('query -f scopes results to a single file without crashing', () => { + const out = run('query', 'add', '-f', 'math.js', '--db', dbPath, '--json'); + const data = JSON.parse(out); + expect(data.results.length).toBeGreaterThan(0); + for (const r of data.results) expect(r.file).toContain('math.js'); + }); + + test('query --file (long form) scopes results to a single file without crashing', () => { + const out = run('query', 'add', '--file', 'math.js', '--db', dbPath, '--json'); + const data = JSON.parse(out); + expect(data.results.length).toBeGreaterThan(0); + for (const r of data.results) expect(r.file).toContain('math.js'); + }); + + test('query with a single -f excludes non-matching files', () => { + // `add` is only defined in math.js, so scoping to utils.js must be empty. + const out = run('query', 'add', '-f', 'utils.js', '--db', dbPath, '--json'); + const data = JSON.parse(out); + expect(data.results).toHaveLength(0); + }); + + test('query supports repeated -f (multi-file scoping)', () => { + // "square" substring-matches square() in math.js and sumOfSquares() in utils.js. + const out = run('query', 'square', '-f', 'math.js', '-f', 'utils.js', '--db', dbPath, '--json'); + const data = JSON.parse(out); + const names = data.results.map((r) => r.name); + expect(names).toContain('square'); + expect(names).toContain('sumOfSquares'); + }); + // ─── Fn-Impact ─────────────────────────────────────────────────────── test('fn-impact --json returns valid JSON with results', () => { const out = run('fn-impact', 'add', '--db', dbPath, '--json'); @@ -117,6 +151,38 @@ describe('CLI smoke tests', () => { expect(data).toHaveProperty('results'); }); + // Regression test for #1726 (see query -f test above for full context). + test('fn-impact -f scopes results to a single file without crashing', () => { + const out = run('fn-impact', 'add', '-f', 'math.js', '--db', dbPath, '--json'); + const data = JSON.parse(out); + expect(data.results.length).toBeGreaterThan(0); + for (const r of data.results) expect(r.file).toContain('math.js'); + }); + + test('fn-impact with a single -f excludes non-matching files', () => { + const out = run('fn-impact', 'add', '-f', 'utils.js', '--db', dbPath, '--json'); + const data = JSON.parse(out); + expect(data.results).toHaveLength(0); + }); + + test('fn-impact supports repeated -f (multi-file scoping)', () => { + const out = run( + 'fn-impact', + 'square', + '-f', + 'math.js', + '-f', + 'utils.js', + '--db', + dbPath, + '--json', + ); + const data = JSON.parse(out); + const names = data.results.map((r) => r.name); + expect(names).toContain('square'); + expect(names).toContain('sumOfSquares'); + }); + // ─── Query (path mode, formerly path) ──────────────────────────────── test('query --path --json returns valid JSON with path info', () => { const out = run('query', 'sumOfSquares', '--path', 'add', '--db', dbPath, '--json'); diff --git a/tests/integration/queries.test.ts b/tests/integration/queries.test.ts index 11fe0190c..c76d4ac86 100644 --- a/tests/integration/queries.test.ts +++ b/tests/integration/queries.test.ts @@ -361,6 +361,31 @@ describe('fnDepsData', () => { expect(names).not.toContain('authMiddleware'); }); + // Regression test for #1726: the CLI's `-f/--file` option is a repeatable + // Commander accumulator (collectFile) that always produces a string[], even + // for a single use. The native composite fnDeps() path used to forward that + // array straight into a napi binding typed for a single String and crash + // with "Failed to convert JavaScript value ... into rust type `String`". + test('--file as a single-element array (real CLI shape) scopes to a single file', () => { + const data = fnDepsData('auth', dbPath, { file: ['auth.js'] }); + for (const r of data.results) { + expect(r.file).toContain('auth.js'); + } + const names = data.results.map((r) => r.name); + expect(names).not.toContain('authMiddleware'); + }); + + test('--file with multiple values (repeated -f) scopes across all of them', () => { + const data = fnDepsData('auth', dbPath, { file: ['auth.js', 'middleware.js'] }); + const names = data.results.map((r) => r.name); + expect(names).toContain('authenticate'); // auth.js + expect(names).toContain('authMiddleware'); // middleware.js + // routes.js/utils.js should still be excluded + for (const r of data.results) { + expect(['auth.js', 'middleware.js']).toContain(r.file); + } + }); + test('--kind method filters to methods only', () => { // All fixtures are functions, so filtering by method should return empty const data = fnDepsData('auth', dbPath, { kind: 'method' }); @@ -414,6 +439,28 @@ describe('fnImpactData', () => { expect(r.direct + r.transitive).toBe(r.totalDependents); }); + // Regression test for #1726 — see the equivalent fnDepsData test above for + // full context. fnImpactData's native path (findNodesWithFanIn) forwards + // the file filter used to resolve the *matched root symbol(s)* (not their + // callers) into rusqlite, and used to crash on the array shape the CLI + // always sends. + test('--file as a single-element array scopes matched root symbols to that file', () => { + const data = fnImpactData('auth', dbPath, { file: ['auth.js'] }); + const names = data.results.map((r) => r.name); + expect(names).toContain('authenticate'); + expect(names).not.toContain('authMiddleware'); // middleware.js + for (const r of data.results) expect(r.file).toBe('auth.js'); + }); + + test('--file with multiple values scopes matched root symbols across all of them', () => { + const data = fnImpactData('auth', dbPath, { file: ['auth.js', 'middleware.js'] }); + const names = data.results.map((r) => r.name); + expect(names).toContain('authenticate'); // auth.js + expect(names).toContain('authMiddleware'); // middleware.js + // preAuthenticate (utils.js) also substring-matches "auth" — must stay excluded + for (const r of data.results) expect(['auth.js', 'middleware.js']).toContain(r.file); + }); + test('direct and transitive are 0 for a function with no callers', () => { // handleRoute is the root of the call graph — nothing calls it const data = fnImpactData('handleRoute', dbPath); From be6432cda8580230d9ed0401ceb7bc5720abc281 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Sun, 5 Jul 2026 14:29:26 -0600 Subject: [PATCH 05/67] fix: correct exported-symbol detection for literal and object-literal exports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two compounding root causes made `where --file`'s `exported` array (and `codegraph exports`) unreliable for entire classes of exports: 1. The JS/TS export-statement handler (WASM query path, WASM walk path, and the mirrored native Rust extractor) only recognized `export function`, `export class`, `export interface`, and `export type` declarations. It never matched `lexical_declaration`/`variable_declaration`, so `export const/let/var …` was never added to the extractor's export list — regardless of the initializer's shape — leaving the `exported=1` DB column permanently unset for every exported constant in the codebase (including ones that happened to look "correct", like a `new Set(...)`-initialized constant referenced as a call argument elsewhere). 2. `where --file`'s `exported` list ignored the `exported` DB column entirely and computed membership from `findCrossFileCallTargets` (symbols targeted by a cross-file `calls` edge) — a heuristic that only "worked" by coincidence for symbols that happened to be passed as call arguments elsewhere. `codegraph exports` already had the right idea (prefer the `exported` column, fall back to the heuristic only for pre-migration DBs) but duplicated that logic locally instead of sharing it. Fixes: - src/extractors/javascript.ts: extract the function/class/interface/type kind map plus a new lexical/variable-declaration branch into a shared `collectExportedDeclarations`, used by both `handleExportCapture` (query path) and `handleExportStmt` (walk path) so they can't drift apart again. Declarator values are classified with the same predicate already used to build the matching Definition (function-valued -> kind 'function', literal/array/object/new-expression-valued `const` -> kind 'constant'). - crates/codegraph-core/src/extractors/javascript.rs: mirror the same fix in `handle_export_declaration` via a new `collect_exported_var_declarations`. - src/db/repository/nodes.ts: add `findExportedNodesByFile`, the single shared implementation of "prefer exported=1, fall back to cross-file calls for pre-migration DBs" — re-exported through db/repository/index.ts and db/index.ts. - src/domain/analysis/exports.ts: use the shared helper instead of a locally duplicated hasExportedCol/exportedNodesStmt implementation. - src/domain/analysis/symbol-lookup.ts: `whereFileImpl` now uses the shared helper instead of `findCrossFileCallTargets` directly, so `where --file` and `exports` agree on what "exported" means. Tests: - tests/parsers/javascript.test.ts: unit coverage for bare-literal, new Set(...), object-literal-with-methods, and arrow-function exported consts, plus a non-exported-const negative case and a function/class regression guard for the shared-helper refactor. - tests/engines/query-walk-parity.test.ts, tests/engines/parity.test.ts: cross-path/cross-engine regression cases for the same shapes. - tests/integration/queries.test.ts: new fixture node with exported=1 but zero incoming edges of any kind, proving `where --file`'s exported list no longer depends on cross-file call presence. No user-facing command/language/architecture changes, so README/CLAUDE.md/ ROADMAP.md are unaffected — docs check acknowledged. Fixes #1728 Impact: 8 functions changed, 17 affected --- .../src/extractors/javascript.rs | 53 +++++++++++ src/db/index.ts | 1 + src/db/repository/index.ts | 1 + src/db/repository/nodes.ts | 52 +++++++++++ src/domain/analysis/exports.ts | 52 +---------- src/domain/analysis/symbol-lookup.ts | 6 +- src/extractors/javascript.ts | 92 ++++++++++++------- tests/engines/parity.test.ts | 22 +++++ tests/engines/query-walk-parity.test.ts | 18 ++++ tests/integration/queries.test.ts | 25 ++++- tests/parsers/javascript.test.ts | 63 +++++++++++++ 11 files changed, 298 insertions(+), 87 deletions(-) diff --git a/crates/codegraph-core/src/extractors/javascript.rs b/crates/codegraph-core/src/extractors/javascript.rs index 5a9292c7e..21d91a8c6 100644 --- a/crates/codegraph-core/src/extractors/javascript.rs +++ b/crates/codegraph-core/src/extractors/javascript.rs @@ -1700,6 +1700,10 @@ fn handle_export_declaration(node: &Node, decl: &Node, source: &[u8], symbols: & "class_declaration" | "abstract_class_declaration" => ("class", "name"), "interface_declaration" => ("interface", "name"), "type_alias_declaration" => ("type", "name"), + "lexical_declaration" | "variable_declaration" => { + collect_exported_var_declarations(node, decl, source, symbols); + return; + } _ => return, }; if let Some(n) = decl.child_by_field_name(field) { @@ -1711,6 +1715,55 @@ fn handle_export_declaration(node: &Node, decl: &Node, source: &[u8], symbols: & } } +/// Push `ExportInfo` entries for `export const/let/var …`, one per declarator. +/// +/// Named function/class/interface/type declarations carry their own `name` +/// field (handled above); a lexical/variable declaration doesn't, so each +/// declarator's value is classified the same way `handle_var_decl` classifies +/// it when creating the matching `Definition`: function-valued declarators +/// become kind "function", `const` declarators with a literal/array/object/ +/// new-expression value (per `is_js_literal`) become kind "constant". +/// Mirrors the WASM/TS extractor's `collectExportedDeclarations`. +/// +/// This predicate must stay identical to `handle_var_decl`'s: `insert_nodes.rs` +/// marks `exported = 1` by matching (name, kind, file, line) against +/// already-inserted definition rows, so a mismatched kind here silently +/// no-ops the UPDATE instead of marking the symbol exported (#1728). +fn collect_exported_var_declarations( + node: &Node, + decl: &Node, + source: &[u8], + symbols: &mut FileSymbols, +) { + let is_const = decl + .child(0) + .map(|c| node_text(&c, source) == "const") + .unwrap_or(false); + let line = start_line(node); + for i in 0..decl.child_count() { + let Some(declarator) = decl.child(i) else { continue }; + if declarator.kind() != "variable_declarator" { continue; } + let name_n = declarator.child_by_field_name("name"); + let value_n = declarator.child_by_field_name("value"); + let (Some(name_n), Some(value_n)) = (name_n, value_n) else { continue }; + if name_n.kind() != "identifier" { continue; } + let vt = value_n.kind(); + if vt == "arrow_function" || vt == "function_expression" || vt == "function" || vt == "generator_function" { + symbols.exports.push(ExportInfo { + name: node_text(&name_n, source).to_string(), + kind: "function".to_string(), + line, + }); + } else if is_const && is_js_literal(&value_n) { + symbols.exports.push(ExportInfo { + name: node_text(&name_n, source).to_string(), + kind: "constant".to_string(), + line, + }); + } + } +} + fn handle_reexport(node: &Node, source_node: &Node, source: &[u8], symbols: &mut FileSymbols) { let mod_path = node_text(source_node, source) .replace(&['\'', '"'][..], ""); diff --git a/src/db/index.ts b/src/db/index.ts index 65b81de9c..f08a37677 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -39,6 +39,7 @@ export { findCallers, findCrossFileCallTargets, findDistinctCallers, + findExportedNodesByFile, findFileNodes, findImplementors, findImportDependents, diff --git a/src/db/repository/index.ts b/src/db/repository/index.ts index a7011806a..5fd5d1ff9 100644 --- a/src/db/repository/index.ts +++ b/src/db/repository/index.ts @@ -35,6 +35,7 @@ export { countEdges, countFiles, countNodes, + findExportedNodesByFile, findFileNodes, findNodeById, findNodeByQualifiedName, diff --git a/src/db/repository/nodes.ts b/src/db/repository/nodes.ts index b9465383e..cab617a5d 100644 --- a/src/db/repository/nodes.ts +++ b/src/db/repository/nodes.ts @@ -15,6 +15,7 @@ import type { } from '../../types.js'; import { buildFileConditionSQL, NodeQuery } from '../query-builder.js'; import { cachedStmt } from './cached-stmt.js'; +import { findCrossFileCallTargets } from './edges.js'; // ─── Query-builder based lookups (moved from src/db/repository.js) ───── @@ -179,6 +180,57 @@ export function findNodesByFile(db: BetterSqlite3Database, file: string): NodeRo ).all(file); } +/** Cache the schema probe for the `exported` column per db handle. */ +const _hasExportedColCache: WeakMap = new WeakMap(); + +const _findExportedNodesByFileStmt: StmtCache = new WeakMap(); + +/** + * Detect whether this DB's `nodes` table has the `exported` column populated + * (older DBs built before it existed fall back to a cross-file-calls + * heuristic). Cached per db handle to avoid re-probing on every call. + */ +function hasExportedColumn(db: BetterSqlite3Database): boolean { + if (_hasExportedColCache.has(db)) return _hasExportedColCache.get(db)!; + let has = false; + try { + db.prepare('SELECT exported FROM nodes LIMIT 0').raw(true); + has = true; + } catch { + /* older DB predates the exported column */ + } + _hasExportedColCache.set(db, has); + return has; +} + +/** + * Find nodes in `file` that are part of its exported surface. + * + * Prefers the `exported = 1` column — set by the extractor at build time from + * the file's actual `export` statements, the authoritative "was this + * declared as exported" signal — and only falls back to + * `findCrossFileCallTargets` (symbols targeted by a cross-file `calls` edge) + * for DBs built before the column existed. + * + * Shared by `codegraph exports` and `codegraph where --file` so both commands + * agree on what "exported" means; before this was extracted, `where --file` + * used the cross-file-calls heuristic unconditionally, which misses any + * exported symbol that's only ever read as a value (not called) from another + * file — e.g. an exported constant used in a comparison (#1728). + */ +export function findExportedNodesByFile(db: BetterSqlite3Database, file: string): NodeRow[] { + if (hasExportedColumn(db)) { + return cachedStmt( + _findExportedNodesByFileStmt, + db, + "SELECT * FROM nodes WHERE file = ? AND kind != 'file' AND exported = 1 ORDER BY line", + ).all(file); + } + const symbols = findNodesByFile(db, file); + const exportedIds = findCrossFileCallTargets(db, file); + return symbols.filter((s) => exportedIds.has(s.id)); +} + /** * Find file-kind nodes matching a LIKE pattern. */ diff --git a/src/domain/analysis/exports.ts b/src/domain/analysis/exports.ts index 3c9710df6..f4780f8a3 100644 --- a/src/domain/analysis/exports.ts +++ b/src/domain/analysis/exports.ts @@ -1,12 +1,11 @@ import path from 'node:path'; import { - findCrossFileCallTargets, findDbPath, + findExportedNodesByFile, findFileNodes, findNodesByFile, } from '../../db/index.js'; import { cachedStmt } from '../../db/repository/cached-stmt.js'; -import { debug } from '../../infrastructure/logger.js'; import { isTestFile } from '../../infrastructure/test-filter.js'; import { createFileLinesReader, @@ -17,10 +16,6 @@ import { paginateResult } from '../../shared/paginate.js'; import type { BetterSqlite3Database, NodeRow, StmtCache } from '../../types.js'; import { resolveAnalysisOpts, withReadonlyDb } from './query-helpers.js'; -/** Cache the schema probe for the `exported` column per db handle. */ -const _hasExportedColCache: WeakMap = new WeakMap(); - -const _exportedNodesStmtCache: StmtCache = new WeakMap(); const _consumersStmtCache: StmtCache<{ name: string; file: string; line: number }> = new WeakMap(); const _reexportsFromStmtCache: StmtCache<{ file: string }> = new WeakMap(); const _reexportsToStmtCache: StmtCache<{ file: string }> = new WeakMap(); @@ -104,8 +99,6 @@ function collectReexportedSymbols( db: BetterSqlite3Database, fileNodeId: number, reexportsToStmt: ReturnType, - exportedNodesStmt: ReturnType | null, - hasExportedCol: boolean, getFileLines: (file: string) => string[] | null, buildSymbolResult: (s: NodeRow, fileLines: string[] | null) => any, ) { @@ -113,14 +106,7 @@ function collectReexportedSymbols( const reexportedSymbols: Array & { originFile: string }> = []; for (const reexTarget of reexportTargets) { - let targetExported: NodeRow[]; - if (hasExportedCol) { - targetExported = exportedNodesStmt!.all(reexTarget.file) as NodeRow[]; - } else { - const targetSymbols = findNodesByFile(db, reexTarget.file) as NodeRow[]; - const exportedIds = findCrossFileCallTargets(db, reexTarget.file) as Set; - targetExported = targetSymbols.filter((s) => exportedIds.has(s.id)); - } + const targetExported = findExportedNodesByFile(db, reexTarget.file); for (const s of targetExported) { reexportedSymbols.push({ ...buildSymbolResult(s, getFileLines(reexTarget.file)), @@ -142,28 +128,6 @@ function exportsFileImpl( const fileNodes = findFileNodes(db, `%${target}%`) as NodeRow[]; if (fileNodes.length === 0) return []; - // Detect whether exported column exists (cached per db handle) - let hasExportedCol: boolean; - if (_hasExportedColCache.has(db)) { - hasExportedCol = _hasExportedColCache.get(db)!; - } else { - hasExportedCol = false; - try { - db.prepare('SELECT exported FROM nodes LIMIT 0').raw(true); - hasExportedCol = true; - } catch (e: unknown) { - debug(`exported column not available, using fallback: ${(e as Error).message}`); - } - _hasExportedColCache.set(db, hasExportedCol); - } - - const exportedNodesStmt = hasExportedCol - ? cachedStmt( - _exportedNodesStmtCache, - db, - "SELECT * FROM nodes WHERE file = ? AND kind != 'file' AND exported = 1 ORDER BY line", - ) - : null; // Consumers include real call/construct edges plus `imports-type` edges — // the symbol-level edge emitted for `import type { X }` statements (source // is the importing *file* node, since the import statement references the @@ -199,15 +163,7 @@ function exportsFileImpl( return fileNodes.map((fn) => { const symbols = findNodesByFile(db, fn.file) as NodeRow[]; - let exported: NodeRow[]; - if (hasExportedCol) { - // Use the exported column populated during build - exported = exportedNodesStmt!.all(fn.file) as NodeRow[]; - } else { - // Fallback: symbols that have incoming calls from other files - const exportedIds = findCrossFileCallTargets(db, fn.file) as Set; - exported = symbols.filter((s) => exportedIds.has(s.id)); - } + const exported = findExportedNodesByFile(db, fn.file); const internalCount = symbols.length - exported.length; const buildSymbolResult = (s: NodeRow, fileLines: string[] | null) => { @@ -244,8 +200,6 @@ function exportsFileImpl( db, fn.id, reexportsToStmt, - exportedNodesStmt, - hasExportedCol, getFileLines, buildSymbolResult, ); diff --git a/src/domain/analysis/symbol-lookup.ts b/src/domain/analysis/symbol-lookup.ts index 065c11f4e..90a39934e 100644 --- a/src/domain/analysis/symbol-lookup.ts +++ b/src/domain/analysis/symbol-lookup.ts @@ -3,7 +3,7 @@ import { findAllIncomingEdges, findAllOutgoingEdges, findCallers, - findCrossFileCallTargets, + findExportedNodesByFile, findFileNodes, findImportSources, findImportTargets, @@ -177,9 +177,7 @@ function whereFileImpl(db: BetterSqlite3Database, target: string) { const importedBy = (findImportSources(db, fn.id) as ImportEdgeRow[]).map((r) => r.file); - const exportedIds = findCrossFileCallTargets(db, fn.file) as Set; - - const exported = symbols.filter((s) => exportedIds.has(s.id)).map((s) => s.name); + const exported = findExportedNodesByFile(db, fn.file).map((s) => s.name); return { file: fn.file, diff --git a/src/extractors/javascript.ts b/src/extractors/javascript.ts index 4d189fa4c..7c4b1ea9e 100644 --- a/src/extractors/javascript.ts +++ b/src/extractors/javascript.ts @@ -195,6 +195,64 @@ function handleMethodCapture(c: Record, definitions: Def }); } +/** Node types whose own `name` field is the exported symbol's name. */ +const EXPORT_DECL_KIND: Record = { + function_declaration: 'function', + generator_function_declaration: 'function', + class_declaration: 'class', + abstract_class_declaration: 'class', + interface_declaration: 'interface', + type_alias_declaration: 'type', +}; + +/** + * Push Export entries for the declaration wrapped by an `export` statement. + * Shared by both extraction paths (query-based `handleExportCapture` and + * walk-based `handleExportStmt`) so they can't drift apart on what counts as + * an export — see the "two code paths" gotcha for this extractor. + * + * Named function/class/interface/type declarations carry their own `name` + * field. `export const/let/var …` has no such field — each declarator's value + * is classified the same way `handleVariableDeclarator` classifies it when + * building the matching Definition (function-valued → kind 'function', + * literal/array/object/new-expression-valued `const` → kind 'constant'). + * This predicate must stay identical to the Definition-building one: the + * exported=1 UPDATE it feeds matches DB rows by (name, kind, file, line), so + * a mismatched kind silently no-ops instead of marking the symbol exported (#1728). + */ +function collectExportedDeclarations( + decl: TreeSitterNode, + exportLine: number, + exps: Export[], +): void { + const kind = EXPORT_DECL_KIND[decl.type]; + if (kind) { + const n = decl.childForFieldName('name'); + if (n) exps.push({ name: n.text, kind: kind as Export['kind'], line: exportLine }); + return; + } + if (decl.type !== 'lexical_declaration' && decl.type !== 'variable_declaration') return; + const isConst = decl.text.startsWith('const '); + for (let i = 0; i < decl.childCount; i++) { + const declarator = decl.child(i); + if (declarator?.type !== 'variable_declarator') continue; + const nameN = declarator.childForFieldName('name'); + const valueN = declarator.childForFieldName('value'); + if (nameN?.type !== 'identifier' || !valueN) continue; + const valType = valueN.type; + if ( + valType === 'arrow_function' || + valType === 'function_expression' || + valType === 'function' || + valType === 'generator_function' + ) { + exps.push({ name: nameN.text, kind: 'function', line: exportLine }); + } else if (isConst && isConstantValue(valueN)) { + exps.push({ name: nameN.text, kind: 'constant', line: exportLine }); + } + } +} + /** Handle export_statement capture. */ function handleExportCapture( c: Record, @@ -203,22 +261,7 @@ function handleExportCapture( ): void { const exportLine = nodeStartLine(c.exp_node!); const decl = c.exp_node!.childForFieldName('declaration'); - if (decl) { - const declType = decl.type; - const kindMap: Record = { - function_declaration: 'function', - generator_function_declaration: 'function', - class_declaration: 'class', - abstract_class_declaration: 'class', - interface_declaration: 'interface', - type_alias_declaration: 'type', - }; - const kind = kindMap[declType]; - if (kind) { - const n = decl.childForFieldName('name'); - if (n) exps.push({ name: n.text, kind: kind as Export['kind'], line: exportLine }); - } - } + if (decl) collectExportedDeclarations(decl, exportLine, exps); const source = c.exp_node!.childForFieldName('source') || findChild(c.exp_node!, 'string'); if (source && !decl) { const modPath = source.text.replace(/['"]/g, ''); @@ -1448,22 +1491,7 @@ function handleImportStmt(node: TreeSitterNode, ctx: ExtractorOutput): void { function handleExportStmt(node: TreeSitterNode, ctx: ExtractorOutput): void { const exportLine = nodeStartLine(node); const decl = node.childForFieldName('declaration'); - if (decl) { - const declType = decl.type; - const kindMap: Record = { - function_declaration: 'function', - generator_function_declaration: 'function', - class_declaration: 'class', - abstract_class_declaration: 'class', - interface_declaration: 'interface', - type_alias_declaration: 'type', - }; - const kind = kindMap[declType]; - if (kind) { - const n = decl.childForFieldName('name'); - if (n) ctx.exports.push({ name: n.text, kind: kind as Export['kind'], line: exportLine }); - } - } + if (decl) collectExportedDeclarations(decl, exportLine, ctx.exports); const source = node.childForFieldName('source') || findChild(node, 'string'); if (source && !decl) { const modPath = source.text.replace(/['"]/g, ''); diff --git a/tests/engines/parity.test.ts b/tests/engines/parity.test.ts index 679207e79..f9c5b5c4d 100644 --- a/tests/engines/parity.test.ts +++ b/tests/engines/parity.test.ts @@ -144,6 +144,28 @@ add(1, 2); const MAX_RETRIES = 3; const APP_NAME = "codegraph"; const add = (a, b) => a + b; +`, + }, + { + // Regression guard for #1728: `export const …` must be recognized as an + // export in both engines regardless of the initializer's shape — a bare + // literal, a `new Expr(...)` call, and an arrow function must all + // produce a matching exports entry, and a non-exported const must not. + // + // The object-literal-with-methods shape (e.g. `export const command = { + // execute() {} }`, issue #1728's other repro) is deliberately not + // included here: both engines already agree it's exported (see + // tests/parsers/javascript.test.ts and tests/engines/query-walk-parity.test.ts), + // but they emit its qualified/unqualified method definitions in different + // relative array order — a separate, pre-existing, content-neutral gap + // (see #1818) that this test's unsorted `normalize()` would otherwise trip on. + name: 'JavaScript — exported constants of varying initializer shapes', + file: 'exported-const.js', + code: ` +export const MAX_WALK_DEPTH = 200; +export const PUNCTUATION_TOKENS = new Set([',', ';']); +export const add = (a, b) => a + b; +const INTERNAL = 'not exported'; `, }, { diff --git a/tests/engines/query-walk-parity.test.ts b/tests/engines/query-walk-parity.test.ts index 2b1f3e787..37f660264 100644 --- a/tests/engines/query-walk-parity.test.ts +++ b/tests/engines/query-walk-parity.test.ts @@ -170,6 +170,24 @@ export function serve(port) { listen(port); } export class Server { start() { this.init(); } } +`, + }, + { + // Regression guard for #1728: `export const …` must be recognized as an + // export regardless of the initializer's shape (bare literal, new-expression, + // object-literal-with-methods, arrow function) — both extraction paths must + // agree on the resulting (name, kind, line) exports entries. + name: 'exported const declarations of varying initializer shapes', + file: 'test.js', + code: ` +export const MAX_WALK_DEPTH = 200; +export const PUNCTUATION_TOKENS = new Set([',', ';']); +export const add = (a, b) => a + b; +export const command = { + name: 'info', + execute(args, opts, ctx) {}, +}; +const INTERNAL = 'not exported'; `, }, { diff --git a/tests/integration/queries.test.ts b/tests/integration/queries.test.ts index c76d4ac86..38672ced0 100644 --- a/tests/integration/queries.test.ts +++ b/tests/integration/queries.test.ts @@ -84,6 +84,14 @@ beforeAll(() => { const formatResponse = insertNode(db, 'formatResponse', 'function', 'utils.js', 1); const preAuthenticate = insertNode(db, 'preAuthenticate', 'function', 'utils.js', 10); + // Exported constant with zero incoming edges of any kind — regression fixture + // for #1728: "exported" must reflect the DB's exported=1 column (set by the + // extractor from the source's `export` keyword), not "has an incoming + // cross-file `calls` edge." A value-only export like this is never the + // target of a calls edge, so a cross-file-calls heuristic incorrectly + // omitted it from `where --file`'s exported list. + const apiVersion = insertNode(db, 'API_VERSION', 'constant', 'middleware.js', 1); + // Test file + function const fTest = insertNode(db, 'auth.test.js', 'file', 'auth.test.js', 0); const testAuth = insertNode(db, 'testAuthenticate', 'function', 'auth.test.js', 5); @@ -125,7 +133,14 @@ beforeAll(() => { // Mark exported symbols (those declared as exports in source) const markExported = db.prepare('UPDATE nodes SET exported = 1 WHERE id = ?'); - for (const id of [authenticate, validateToken, authMiddleware, formatResponse, preAuthenticate]) { + for (const id of [ + authenticate, + validateToken, + authMiddleware, + formatResponse, + preAuthenticate, + apiVersion, + ]) { markExported.run(id); } @@ -767,8 +782,14 @@ describe('whereData', () => { test('file: exported list', () => { const data = whereData('middleware.js', dbPath, { file: true }); const r = data.results[0]; - // authMiddleware is called from routes.js (cross-file) + // authMiddleware has exported=1 in the DB (and, incidentally, is also + // called from routes.js — but presence in `exported` must not depend on + // that; see the next assertion). expect(r.exported).toContain('authMiddleware'); + // API_VERSION has exported=1 but no incoming edges of any kind (#1728): + // "exported" reflects the DB's exported=1 column, not "has an incoming + // cross-file `calls` edge" — a value-only export is never call-targeted. + expect(r.exported).toContain('API_VERSION'); }); test('file: empty for unknown', () => { diff --git a/tests/parsers/javascript.test.ts b/tests/parsers/javascript.test.ts index b31040e2a..c825df2a9 100644 --- a/tests/parsers/javascript.test.ts +++ b/tests/parsers/javascript.test.ts @@ -1482,4 +1482,67 @@ describe('JavaScript parser', () => { ); }); }); + + describe('export-list detection for `export const/let/var …` (#1728)', () => { + it('lists named exported function/class declarations (refactor regression guard)', () => { + const symbols = parseJS(`export function greet() {}\nexport class Widget {}`); + expect(symbols.exports).toContainEqual( + expect.objectContaining({ name: 'greet', kind: 'function' }), + ); + expect(symbols.exports).toContainEqual( + expect.objectContaining({ name: 'Widget', kind: 'class' }), + ); + }); + + it('lists an exported const with a bare numeric-literal initializer (repro 1)', () => { + const symbols = parseJS(`export const MAX_WALK_DEPTH = 200;`); + expect(symbols.definitions).toContainEqual( + expect.objectContaining({ name: 'MAX_WALK_DEPTH', kind: 'constant', line: 1 }), + ); + expect(symbols.exports).toContainEqual( + expect.objectContaining({ name: 'MAX_WALK_DEPTH', kind: 'constant', line: 1 }), + ); + }); + + it('lists an exported const initialized with new Set(...) (sibling regression guard)', () => { + const symbols = parseJS(`export const PUNCTUATION_TOKENS = new Set([',', ';']);`); + expect(symbols.exports).toContainEqual( + expect.objectContaining({ name: 'PUNCTUATION_TOKENS', kind: 'constant', line: 1 }), + ); + }); + + it('lists an exported object-literal-with-methods const, without independently exporting its methods (repro 2)', () => { + const symbols = parseJS( + `export const command = {\n name: 'info',\n execute(args, opts, ctx) {},\n};`, + ); + expect(symbols.definitions).toContainEqual( + expect.objectContaining({ name: 'command', kind: 'constant', line: 1 }), + ); + expect(symbols.definitions).toContainEqual( + expect.objectContaining({ name: 'command.execute', kind: 'function' }), + ); + expect(symbols.exports).toContainEqual( + expect.objectContaining({ name: 'command', kind: 'constant', line: 1 }), + ); + // Qualified child methods aren't independently listed as exports — mirrors + // how `Foo.method` isn't exported when only `export class Foo` is (only the + // top-level declared name is; see the class-method exported=0 convention). + expect(symbols.exports.some((e) => e.name === 'command.execute')).toBe(false); + }); + + it('lists an exported arrow-function const with kind "function"', () => { + const symbols = parseJS(`export const add = (a, b) => a + b;`); + expect(symbols.exports).toContainEqual( + expect.objectContaining({ name: 'add', kind: 'function', line: 1 }), + ); + }); + + it('does not list a non-exported const', () => { + const symbols = parseJS(`const INTERNAL = 42;`); + expect(symbols.definitions).toContainEqual( + expect.objectContaining({ name: 'INTERNAL', kind: 'constant' }), + ); + expect(symbols.exports.some((e) => e.name === 'INTERNAL')).toBe(false); + }); + }); }); From 4ead84062278ec31e1d08b26fa962b4026e98c4b Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Sun, 5 Jul 2026 14:56:16 -0600 Subject: [PATCH 06/67] fix: exclude primitive type keywords from ast --kind string matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tree-sitter-typescript's predefined_type production (string, number, boolean, ... primitive type keywords) lexes its keyword as an anonymous grammar token whose type string is identical to the named `string` node type used for real string literals. Both engines matched by node type alone, so `codegraph ast --kind string` spuriously matched bare `string` type annotations (interface fields, parameter types, return types) as if they were string literals. Add an isNamed/is_named() guard scoped to the JS/TS/TSX family on both engines: WASM via astRequiresNamedNode() in ast-analysis/rules/index.ts feeding the shared resolveAstKind() in ast-store-visitor.ts, native via a match guard in javascript.rs's walk_ast_nodes_depth. Genuine string, template, and string-literal-type nodes are always named and unaffected. No user-facing command/language/architecture changes, so README/CLAUDE.md/ ROADMAP.md are unaffected — docs check acknowledged. Fixes #1729 Impact: 8 functions changed, 13 affected --- .../src/extractors/javascript.rs | 9 +- src/ast-analysis/engine.ts | 2 + src/ast-analysis/rules/index.ts | 20 ++ .../visitors/ast-store-visitor.ts | 15 +- src/domain/wasm-worker-entry.ts | 2 + src/features/ast.ts | 2 + src/types.ts | 10 + tests/engines/ast-parity.test.ts | 2 + tests/parsers/ast-nodes.test.ts | 229 ++++++++++++++++++ 9 files changed, 288 insertions(+), 3 deletions(-) diff --git a/crates/codegraph-core/src/extractors/javascript.rs b/crates/codegraph-core/src/extractors/javascript.rs index 21d91a8c6..5c343e820 100644 --- a/crates/codegraph-core/src/extractors/javascript.rs +++ b/crates/codegraph-core/src/extractors/javascript.rs @@ -1896,7 +1896,14 @@ fn walk_ast_nodes_depth(node: &Node, source: &[u8], ast_nodes: &mut Vec } return; } - "string" | "template_string" => { + // Guard on `is_named()`: tree-sitter-typescript's `predefined_type` + // production (the `string`/`number`/`boolean`/... primitive type + // keywords) lexes its keyword as an anonymous token whose `kind()` + // string is identical to the *named* `string` literal node type. + // Without this guard, `name: string` type annotations are + // misclassified as string-literal ast_nodes (#1729). Mirrors the + // WASM-side guard in `ast-store-visitor.ts::resolveAstKind`. + "string" | "template_string" if node.is_named() => { let raw = node_text(node, source); // Strip quotes to get content let content = raw diff --git a/src/ast-analysis/engine.ts b/src/ast-analysis/engine.ts index 6edc65413..706df0161 100644 --- a/src/ast-analysis/engine.ts +++ b/src/ast-analysis/engine.ts @@ -45,6 +45,7 @@ import { computeLOCMetrics, computeMaintainabilityIndex } from './metrics.js'; import { AST_STRING_CONFIGS, AST_TYPE_MAPS, + astRequiresNamedNode, astStopRecurseKinds, CFG_RULES, COMPLEXITY_RULES, @@ -475,6 +476,7 @@ function setupAstVisitor( nodeIdMap, stringConfig, astStopRecurseKinds(langId), + astRequiresNamedNode(langId), ); } diff --git a/src/ast-analysis/rules/index.ts b/src/ast-analysis/rules/index.ts index 01a599eb4..298ce6fb0 100644 --- a/src/ast-analysis/rules/index.ts +++ b/src/ast-analysis/rules/index.ts @@ -378,3 +378,23 @@ export function astStopRecurseKinds(langId: string): ReadonlySet { } return EMPTY_STOP_RECURSE; } + +// ─── Per-language "named-node-required" guard ──────────────────────────── +// +// tree-sitter-typescript's `predefined_type` production (the `string`, +// `number`, `boolean`, `void`, ... primitive type keywords) lexes its +// keyword as an anonymous grammar token whose `type` string is identical to +// the *named* `string` node type used for real string literals — the +// grammar's own node-types.json lists both a `"string", named: true` entry +// (the literal) and a `"string", named: false` entry (the keyword token). +// Matching by `node.type` alone therefore misclassifies `name: string` +// type annotations as string-literal ast_nodes (#1729). Mirrors the native +// `is_named()` guard in `extractors/javascript.rs::walk_ast_nodes_depth`. +// +// Scoped to the JS family: no other bundled grammar currently maps an +// AST_TYPE_MAPS key that collides with an anonymous token of the same name. +const JS_REQUIRES_NAMED_NODE: ReadonlySet = new Set(['javascript', 'typescript', 'tsx']); + +export function astRequiresNamedNode(langId: string): boolean { + return JS_REQUIRES_NAMED_NODE.has(langId); +} diff --git a/src/ast-analysis/visitors/ast-store-visitor.ts b/src/ast-analysis/visitors/ast-store-visitor.ts index dd63515be..963840f9f 100644 --- a/src/ast-analysis/visitors/ast-store-visitor.ts +++ b/src/ast-analysis/visitors/ast-store-visitor.ts @@ -273,9 +273,19 @@ function collectNode(ctx: CollectCtx, node: TreeSitterNode, kind: string): void * Object() function), which then crashes the worker boundary with * "function Object() { [native code] } could not be cloned" when the * resulting astNodes row is structured-cloned back to the main thread. + * + * When `requireNamedNode` is set, anonymous grammar tokens are rejected even + * if their `type` string matches a mapped key — e.g. TypeScript's + * `predefined_type` keyword (`string`, `number`, ...) lexes as an unnamed + * token whose type collides with the named `string` literal node (#1729). */ -function resolveAstKind(node: TreeSitterNode, astTypeMap: Record): string | null { +function resolveAstKind( + node: TreeSitterNode, + astTypeMap: Record, + requireNamedNode: boolean, +): string | null { if (!Object.hasOwn(astTypeMap, node.type)) return null; + if (requireNamedNode && !node.isNamed) return null; return astTypeMap[node.type] || null; } @@ -286,6 +296,7 @@ export function createAstStoreVisitor( nodeIdMap: Map, stringConfig: AstStringConfig = DEFAULT_STRING_CONFIG, stopRecurseKinds: ReadonlySet = new Set(), + requireNamedNode = false, ): Visitor { const newTypes = newTypesFor(astTypeMap); // When nodeIdMap is empty, parentNodeId resolution is wasted work — the @@ -311,7 +322,7 @@ export function createAstStoreVisitor( // unrelated subtree. The parent call's skipChildren handles the intended case. if (ctx.matched.has(node.id)) return; - const kind = resolveAstKind(node, astTypeMap); + const kind = resolveAstKind(node, astTypeMap, requireNamedNode); if (!kind) return; collectNode(ctx, node, kind); diff --git a/src/domain/wasm-worker-entry.ts b/src/domain/wasm-worker-entry.ts index d55f5f7e6..2e02b4ed8 100644 --- a/src/domain/wasm-worker-entry.ts +++ b/src/domain/wasm-worker-entry.ts @@ -30,6 +30,7 @@ import { computeLOCMetrics, computeMaintainabilityIndex } from '../ast-analysis/ import { AST_STRING_CONFIGS, AST_TYPE_MAPS, + astRequiresNamedNode, astStopRecurseKinds, CFG_RULES, COMPLEXITY_RULES, @@ -606,6 +607,7 @@ function buildAstVisitor( new Map(), stringConfig, astStopRecurseKinds(langId), + astRequiresNamedNode(langId), ); } diff --git a/src/features/ast.ts b/src/features/ast.ts index da9aff195..d227b4bcc 100644 --- a/src/features/ast.ts +++ b/src/features/ast.ts @@ -2,6 +2,7 @@ import path from 'node:path'; import { AST_STRING_CONFIGS, AST_TYPE_MAPS, + astRequiresNamedNode, astStopRecurseKinds, } from '../ast-analysis/rules/index.js'; import { buildExtensionSet } from '../ast-analysis/shared.js'; @@ -244,6 +245,7 @@ function walkAst( nodeIdMap, stringConfig, astStopRecurseKinds(langId), + astRequiresNamedNode(langId), ); const results = walkWithVisitors(rootNode, [visitor], langId); const collected = (results['ast-store'] || []) as AstRow[]; diff --git a/src/types.ts b/src/types.ts index 8374a2a0c..81fafbe03 100644 --- a/src/types.ts +++ b/src/types.ts @@ -777,6 +777,16 @@ export interface TreeSitterNode { id: number; type: string; text: string; + /** + * Whether this node is a named grammar production vs. an anonymous token + * (keyword/punctuation). Tree-sitter grammars can define an anonymous + * token whose text matches a *named* node's type string — e.g. + * tree-sitter-typescript's `predefined_type` wraps an anonymous `string` + * keyword token that collides with the named `string` literal node type + * (#1729). Consumers matching by `type` alone must also check `isNamed` + * to avoid misclassifying keyword tokens as literal/expression nodes. + */ + isNamed: boolean; startPosition: { row: number; column: number }; endPosition: { row: number; column: number }; childCount: number; diff --git a/tests/engines/ast-parity.test.ts b/tests/engines/ast-parity.test.ts index a73c5852f..4e4e50192 100644 --- a/tests/engines/ast-parity.test.ts +++ b/tests/engines/ast-parity.test.ts @@ -11,6 +11,7 @@ import { beforeAll, describe, expect, it } from 'vitest'; import { AST_STRING_CONFIGS, AST_TYPE_MAPS, + astRequiresNamedNode, astStopRecurseKinds, } from '../../src/ast-analysis/rules/index.js'; import { walkWithVisitors } from '../../src/ast-analysis/visitor.js'; @@ -420,6 +421,7 @@ let run () = new Map(), stringConfig, astStopRecurseKinds(langId), + astRequiresNamedNode(langId), ); const results = walkWithVisitors(tree.rootNode as any, [visitor], langId); const rows = (results['ast-store'] || []) as unknown[]; diff --git a/tests/parsers/ast-nodes.test.ts b/tests/parsers/ast-nodes.test.ts index 4ebd79f7d..b6d31a503 100644 --- a/tests/parsers/ast-nodes.test.ts +++ b/tests/parsers/ast-nodes.test.ts @@ -182,6 +182,168 @@ describe('buildAstNodes — JS extraction', () => { }); }); +// ─── TypeScript fixture (#1729: predefined_type keyword false-positives) ── +// +// tree-sitter-typescript's `predefined_type` production (the `string`, +// `number`, `boolean`, ... primitive type keywords) lexes its keyword as an +// anonymous token whose `type` string collides with the *named* `string` +// literal node type. `--kind string` must only match the latter. + +const TS_FIXTURE_CODE = ` +interface Person { + name: string; + age: number; +} + +type UserId = "user-id-literal"; + +function greet(name: string): string { + return \`Hello, \${name}!\`; +} + +function processItems(items: string[]): void { + console.log(items); +} + +import { helper } from './helper.js'; + +const greeting = "hello world"; +`; + +describe('buildAstNodes — TypeScript extraction (#1729)', () => { + let tsTmpDir: string, tsDb: any; + + function queryTsAstNodes(kind: string) { + return tsDb.prepare('SELECT * FROM ast_nodes WHERE kind = ? ORDER BY line').all(kind); + } + + beforeAll(async () => { + tsTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ast-ts-extract-')); + const srcDir = path.join(tsTmpDir, 'src'); + fs.mkdirSync(srcDir, { recursive: true }); + fs.mkdirSync(path.join(tsTmpDir, '.codegraph')); + + const fixturePath = path.join(srcDir, 'fixture.ts'); + fs.writeFileSync(fixturePath, TS_FIXTURE_CODE); + + const allSymbols = await parseFilesAuto([fixturePath], tsTmpDir, { engine: 'wasm' }); + const symbols = allSymbols.get('src/fixture.ts'); + if (!symbols) throw new Error('Failed to parse TS fixture file'); + + const dbPath = path.join(tsTmpDir, '.codegraph', 'graph.db'); + tsDb = new Database(dbPath); + tsDb.pragma('journal_mode = WAL'); + initSchema(tsDb); + + const insertNode = tsDb.prepare( + 'INSERT INTO nodes (name, kind, file, line, end_line) VALUES (?, ?, ?, ?, ?)', + ); + for (const def of symbols.definitions) { + insertNode.run(def.name, def.kind, 'src/fixture.ts', def.line, def.endLine); + } + + await buildAstNodes(tsDb, allSymbols, tsTmpDir); + }); + + afterAll(() => { + if (tsDb) tsDb.close(); + fs.rmSync(tsTmpDir, { recursive: true, force: true }); + }); + + test('does not misclassify interface field type annotation as kind:string', () => { + // `name: string;` (line 3) must never surface as a bare, unquoted "string" + // row — genuine literals are always quoted in `text` (e.g. "hello world"). + const nodes = queryTsAstNodes('string'); + expect(nodes.some((n) => n.text === 'string')).toBe(false); + }); + + test('does not misclassify parameter or return type annotations as kind:string', () => { + // `greet(name: string): string` (param + return type, line 9) must not + // contribute bare "string" rows. + const nodes = queryTsAstNodes('string'); + expect(nodes.filter((n) => n.text === 'string').length).toBe(0); + }); + + test('does not misclassify array-of-primitive parameter type as kind:string', () => { + // `processItems(items: string[])` (line 13) wraps predefined_type in + // array_type — must not contribute a bare "string" row either. + const nodes = queryTsAstNodes('string'); + expect(nodes.some((n) => n.line === 13)).toBe(false); + }); + + test('still captures genuine string literals, template literals, and string-literal types', () => { + const nodes = queryTsAstNodes('string'); + const names = nodes.map((n) => n.name); + expect(names).toContain('user-id-literal'); // type UserId = "user-id-literal" + expect(names).toContain('./helper.js'); // import source path + expect(names).toContain('hello world'); // genuine string literal + expect(nodes.some((n) => n.text?.startsWith('`Hello, '))).toBe(true); // template literal + }); + + test('captures exactly the 4 genuine literals — no keyword false-positives', () => { + const nodes = queryTsAstNodes('string'); + expect(nodes.length).toBe(4); + }); +}); + +// ─── TSX fixture (#1729) — shares JS_AST_TYPES + grammar family with TS ─── + +const TSX_FIXTURE_CODE = ` +interface Props { + label: string; +} + +function Greeting(props: Props) { + return
{"hello world"}
; +} +`; + +describe('buildAstNodes — TSX extraction (#1729)', () => { + let tsxTmpDir: string, tsxDb: any; + + beforeAll(async () => { + tsxTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ast-tsx-extract-')); + const srcDir = path.join(tsxTmpDir, 'src'); + fs.mkdirSync(srcDir, { recursive: true }); + fs.mkdirSync(path.join(tsxTmpDir, '.codegraph')); + + const fixturePath = path.join(srcDir, 'fixture.tsx'); + fs.writeFileSync(fixturePath, TSX_FIXTURE_CODE); + + const allSymbols = await parseFilesAuto([fixturePath], tsxTmpDir, { engine: 'wasm' }); + const symbols = allSymbols.get('src/fixture.tsx'); + if (!symbols) throw new Error('Failed to parse TSX fixture file'); + + const dbPath = path.join(tsxTmpDir, '.codegraph', 'graph.db'); + tsxDb = new Database(dbPath); + tsxDb.pragma('journal_mode = WAL'); + initSchema(tsxDb); + + const insertNode = tsxDb.prepare( + 'INSERT INTO nodes (name, kind, file, line, end_line) VALUES (?, ?, ?, ?, ?)', + ); + for (const def of symbols.definitions) { + insertNode.run(def.name, def.kind, 'src/fixture.tsx', def.line, def.endLine); + } + + await buildAstNodes(tsxDb, allSymbols, tsxTmpDir); + }); + + afterAll(() => { + if (tsxDb) tsxDb.close(); + fs.rmSync(tsxTmpDir, { recursive: true, force: true }); + }); + + test('does not misclassify interface field type annotation as kind:string, still captures JSX-embedded literal', () => { + const nodes = tsxDb + .prepare('SELECT * FROM ast_nodes WHERE kind = ? ORDER BY line') + .all('string'); + expect(nodes.some((n) => n.text === 'string')).toBe(false); + expect(nodes.length).toBe(1); + expect(nodes[0].name).toBe('hello world'); + }); +}); + // ─── Native engine AST node extraction ─────────────────────────────── // Check if native addon is available AND supports ast_nodes. @@ -308,3 +470,70 @@ describe.skipIf(!canTestNative)('buildAstNodes — native engine', () => { expect(withParent.length).toBeGreaterThan(0); }); }); + +// ─── Native engine: TypeScript predefined_type false-positives (#1729) ──── + +describe.skipIf(!canTestNative)('buildAstNodes — native TypeScript extraction (#1729)', () => { + let nativeTsTmpDir: string, nativeTsDb: any; + + function queryNativeTsAstNodes(kind: string) { + return nativeTsDb.prepare('SELECT * FROM ast_nodes WHERE kind = ? ORDER BY line').all(kind); + } + + beforeAll(async () => { + nativeTsTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ast-ts-native-')); + const srcDir = path.join(nativeTsTmpDir, 'src'); + fs.mkdirSync(srcDir, { recursive: true }); + fs.mkdirSync(path.join(nativeTsTmpDir, '.codegraph')); + + const fixturePath = path.join(srcDir, 'fixture.ts'); + fs.writeFileSync(fixturePath, TS_FIXTURE_CODE); + + const allSymbols = await parseFilesAuto([fixturePath], nativeTsTmpDir, { engine: 'native' }); + const symbols = allSymbols.get('src/fixture.ts'); + if (!symbols) throw new Error('Failed to parse TS fixture file with native engine'); + + const dbPath = path.join(nativeTsTmpDir, '.codegraph', 'graph.db'); + nativeTsDb = new Database(dbPath); + nativeTsDb.pragma('journal_mode = WAL'); + initSchema(nativeTsDb); + + const insertNode = nativeTsDb.prepare( + 'INSERT INTO nodes (name, kind, file, line, end_line) VALUES (?, ?, ?, ?, ?)', + ); + for (const def of symbols.definitions) { + insertNode.run(def.name, def.kind, 'src/fixture.ts', def.line, def.endLine); + } + + await buildAstNodes(nativeTsDb, allSymbols, nativeTsTmpDir); + }); + + afterAll(() => { + if (nativeTsDb) nativeTsDb.close(); + if (nativeTsTmpDir) fs.rmSync(nativeTsTmpDir, { recursive: true, force: true }); + }); + + test('does not misclassify interface field type annotation as kind:string', () => { + const nodes = queryNativeTsAstNodes('string'); + expect(nodes.some((n) => n.text === 'string')).toBe(false); + }); + + test('does not misclassify parameter, return, or array-element type annotations as kind:string', () => { + const nodes = queryNativeTsAstNodes('string'); + expect(nodes.filter((n) => n.text === 'string').length).toBe(0); + }); + + test('still captures genuine string literals, template literals, and string-literal types', () => { + const nodes = queryNativeTsAstNodes('string'); + const names = nodes.map((n) => n.name); + expect(names).toContain('user-id-literal'); + expect(names).toContain('./helper.js'); + expect(names).toContain('hello world'); + expect(nodes.some((n) => n.text?.startsWith('`Hello, '))).toBe(true); + }); + + test('captures exactly the 4 genuine literals — no keyword false-positives', () => { + const nodes = queryNativeTsAstNodes('string'); + expect(nodes.length).toBe(4); + }); +}); From f6326bff7b3925ce80edf0e6ca0271b5efbdc0e7 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Sun, 5 Jul 2026 15:45:20 -0600 Subject: [PATCH 07/67] fix: resolve call edges through renamed import specifiers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codegraph exports reported zero consumers for an exported function/symbol that is genuinely imported and used elsewhere, whenever the importing file renamed the binding at the import site (import { X as Y } from '...'). Example: collectFiles in src/domain/graph/builder/helpers.ts is imported (as collectFilesUtil) and called by collect-files.ts and native-orchestrator.ts, but `codegraph exports helpers.ts -T --json` showed consumerCount: 0. Root cause (extractor, both engines): extractImportNames (javascript.ts) and its mirrored scan_import_names_depth (javascript.rs) handled import_specifier nodes with `node.childForFieldName('name') || childForFieldName('alias')`. Per the tree-sitter grammar, `name` is *always* present on import_specifier (the name as declared by the source module); `alias` only exists for `X as Y` and holds the *local* binding actually referenced by call sites. Preferring `name` unconditionally meant the local alias was silently dropped for every renamed import — `imp.names` recorded "collectFiles" instead of "collectFilesUtil", so `importedNames` (keyed by call-site text) never had a matching entry and the call fell through unresolved. Fixing only the extractor was not sufficient: once `imp.names` correctly holds the local alias, `resolveCallTargets` (call-resolver.ts) / resolve_call_targets (build_edges.rs) still searched the *target file* for a symbol literally named "collectFilesUtil" — which doesn't exist there (only "collectFiles" does). This required threading a second piece of information end-to-end: for each renamed specifier, the local alias's *original* exported name, so target-file/barrel lookups search by the right name while importedNames stays keyed by the call-site text. Changes (both engines, full-build + incremental/native-orchestrator + native-hybrid paths): - types.ts / types.rs: new `Import.renamedImports` / `Import.renamed_imports` field — { local, imported } pairs, populated only for specifiers that actually rename a binding. - extractors/javascript.ts, .rs: extractImportNames / extract_import_names_with_renames now prefer `alias` (local binding) for import_specifier and record the rename pair. export_specifier is deliberately left unchanged (see scope notes below). Fixed in both the walk path (handleImportStmt) and the query/WASM-worker path (handleImportCapture) on the TS side. - build-edges.ts, build_edges.rs, import_edges.rs, pipeline.rs, call-resolver.ts, incremental.ts: buildImportedNamesMap / collect_imported_names_for_file now also produce a local-alias -> original-name map; resolveCallTargets / resolve_call_targets consult it to search the target file by the original name. Applied consistently to the primary call-resolution path, the Object.defineProperty post-pass, points-to alias resolution, barrel edge / import-type edge emission, and cross-file return-type propagation — every place that previously assumed a call site's local name equals the target file's declared name. Native Rust: touched and verified. Rebuilt locally via `npx napi build --platform --release` (crates/codegraph-core), codesigned (`codesign --sign - --force`), and installed over the loaded node_modules/@optave/codegraph-darwin-arm64/codegraph-core.node so the fix was exercised by the actual native engine, not the prebuilt npm binary. `cargo test --lib`: 427 passed. Tests added: - tests/parsers/javascript.test.ts: extractor-level coverage for the local-name/rename-pair extraction, a mixed renamed+non-renamed specifier list, and confirmation export_specifier is unaffected. - crates/codegraph-core/src/extractors/javascript.rs: matching Rust unit tests. - tests/integration/issue-1730-renamed-import-consumer.test.ts: new end-to-end test building real files through buildGraph() on both wasm and native engines, asserting the calls edge exists, no edge is created against a nonexistent "collectFilesUtil" symbol, and `codegraph exports` credits the consumer — on both engines. tests/integration/exports.test.ts was not extended: that file hand-inserts DB rows and only exercises the query layer, which is unaffected by this bug (the missing piece was edge creation, not the consumer query added for #1724). Verified against this repo's own graph (native engine, rebuilt): `codegraph exports src/domain/graph/builder/helpers.ts -T --json` now credits collectFiles with 2 consumers (collect-files.ts, native-orchestrator.ts); WASM engine agrees exactly. Without -T, also picks up tests/unit/builder.test.ts via the (unrenamed) builder.ts barrel re-export, which already worked pre-fix and was only hidden by -T in the original repro. Scope notes — filed as separate issues rather than expanding this fix: - #1823: export { X as Y } from '...' (barrel re-export with rename) is not tracked — resolveBarrelExport/resolve_barrel_export key off the original name, and export_specifier extraction was deliberately left unchanged here since barrel/reexport tracing is a distinct mechanism. - #1824: dynamic import destructuring rename (const { a: b } = await import(...)) has the same class of bug in extractDynamicImportNames, a different extraction function. - #1825: a renamed import used as a call receiver (import { X as Y }; Y.method()) still fails to resolve — confirmed empirically (no calls edge created) — a different resolution strategy (resolveByReceiver/resolveViaDirectQualifiedMethod in resolver/strategy.ts) than the one this fix addresses. Full test suite (202 files, 3398 tests) and lint pass clean. docs check acknowledged: internal resolver/extractor bug fix, no new feature/language/CLI surface/architecture change -- README.md, CLAUDE.md, and ROADMAP.md are unaffected. Fixes #1730 Impact: 29 functions changed, 47 affected --- .../src/domain/graph/builder/pipeline.rs | 38 +++-- .../graph/builder/stages/build_edges.rs | 38 ++++- .../graph/builder/stages/import_edges.rs | 51 ++++--- .../src/extractors/javascript.rs | 102 ++++++++++++- crates/codegraph-core/src/types.rs | 20 +++ src/domain/graph/builder/call-resolver.ts | 11 +- src/domain/graph/builder/incremental.ts | 89 +++++++++--- .../graph/builder/stages/build-edges.ts | 134 +++++++++++++----- src/extractors/javascript.ts | 47 +++++- src/types.ts | 11 ++ ...issue-1730-renamed-import-consumer.test.ts | 125 ++++++++++++++++ tests/parsers/javascript.test.ts | 46 ++++++ 12 files changed, 618 insertions(+), 94 deletions(-) create mode 100644 tests/integration/issue-1730-renamed-import-consumer.test.ts diff --git a/crates/codegraph-core/src/domain/graph/builder/pipeline.rs b/crates/codegraph-core/src/domain/graph/builder/pipeline.rs index 53f05601d..f191b9f66 100644 --- a/crates/codegraph-core/src/domain/graph/builder/pipeline.rs +++ b/crates/codegraph-core/src/domain/graph/builder/pipeline.rs @@ -1277,17 +1277,22 @@ fn load_file_node_id_map(conn: &Connection) -> HashMap { /// Resolve a file's imports to the list of `ImportedName` entries the edge /// builder consumes. Walks barrel chains to the ultimate definition file so /// the edge builder's name-lookup can find the right target (#976 P1). +/// +/// For renamed specifiers (`import { X as Y }`), `ImportedName.imported` +/// carries the original name (X) so `resolve_call_targets` can look it up in +/// the target file instead of the local alias (Y), which only exists in the +/// importing file (#1730). fn collect_imported_names_for_file( abs_str: &str, symbols: &FileSymbols, import_ctx: &crate::domain::graph::builder::stages::import_edges::ImportEdgeContext, ) -> Vec { use crate::domain::graph::builder::stages::build_edges::ImportedName; + use crate::domain::graph::builder::stages::import_edges::import_name_pairs; let mut imported_names: Vec = Vec::new(); for imp in &symbols.imports { let resolved_path = import_ctx.get_resolved(abs_str, &imp.source); - for name in &imp.names { - let clean_name = name.strip_prefix("* as ").unwrap_or(name).to_string(); + for (local, original) in import_name_pairs(imp) { // CJS require bindings are included in imported_names so the receiver-edge // resolver treats them as import artifacts (not locally-defined symbols). // We use an empty target_file so the import-aware call-target lookup @@ -1295,20 +1300,25 @@ fn collect_imported_names_for_file( // through to the same-file shadow node — matching WASM call-resolution // behaviour where CJS bindings are not in importedNamesMap (#1678). if imp.cjs_require.unwrap_or(false) { - imported_names.push(ImportedName { name: clean_name, file: String::new() }); + imported_names.push(ImportedName { + name: local, + file: String::new(), + imported: None, + }); continue; } let mut target_file = resolved_path.clone(); if import_ctx.is_barrel_file(&resolved_path) { let mut visited = HashSet::new(); if let Some(actual) = - import_ctx.resolve_barrel_export(&resolved_path, &clean_name, &mut visited) + import_ctx.resolve_barrel_export(&resolved_path, &original, &mut visited) { target_file = actual; } } imported_names.push(ImportedName { - name: clean_name, + imported: if original != local { Some(original) } else { None }, + name: local, file: target_file, }); } @@ -1411,8 +1421,14 @@ fn inject_return_types_for_file( let imported_names = collect_imported_names_for_file(abs_str, symbols, import_ctx); // Later entries overwrite earlier ones on duplicate names — same as the // HashMap collect in build_call_edges. - let imported_map: HashMap = - imported_names.into_iter().map(|e| (e.name, e.file)).collect(); + let mut imported_map: HashMap = HashMap::new(); + let mut imported_original_map: HashMap = HashMap::new(); + for e in imported_names { + if let Some(original) = e.imported { + imported_original_map.insert(e.name.clone(), original); + } + imported_map.insert(e.name, e.file); + } let mut injections: Vec = Vec::new(); let mut injected: HashSet = HashSet::new(); @@ -1429,7 +1445,13 @@ fn inject_return_types_for_file( global_return_types.get(&format!("{receiver}.{}", ca.callee_name)) } None => imported_map.get(&ca.callee_name).and_then(|from| { - return_type_index.get(from).and_then(|m| m.get(&ca.callee_name)) + // The return-type index for the imported file is keyed by the + // function's own declared name — use the original (pre-rename) + // name when the callee is a renamed import binding (#1730). + let callee_original_name = imported_original_map + .get(&ca.callee_name) + .unwrap_or(&ca.callee_name); + return_type_index.get(from).and_then(|m| m.get(callee_original_name)) }), }; diff --git a/crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs b/crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs index d86c42872..8d14f7e4d 100644 --- a/crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs +++ b/crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs @@ -46,6 +46,12 @@ pub struct CallInfo { pub struct ImportedName { pub name: String, pub file: String, + /// For renamed specifiers (`import { X as Y }`): the original name + /// exported by `file` (X), when it differs from `name` (the local + /// binding Y). `resolve_call_targets` looks this up in `file` instead of + /// `name` — the renamed local alias only exists in the importing file, + /// not in `file` itself (#1730). + pub imported: Option, } #[napi(object)] @@ -384,6 +390,7 @@ struct PtsAliasCtx<'a> { is_dynamic: u32, rel_path: &'a str, imported_names: &'a HashMap<&'a str, &'a str>, + imported_original_names: &'a HashMap<&'a str, &'a str>, type_map: &'a HashMap<&'a str, (&'a str, f64)>, } @@ -399,6 +406,7 @@ fn emit_pts_alias_edges<'a>( ) { for alias in resolve_via_points_to(alias_ctx.lookup_name, alias_ctx.pts) { let alias_imported_from = alias_ctx.imported_names.get(alias).copied(); + let alias_imported_original_name = alias_ctx.imported_original_names.get(alias).copied(); let alias_call = CallInfo { name: alias.to_string(), line: alias_ctx.call_line, @@ -409,6 +417,7 @@ fn emit_pts_alias_edges<'a>( }; let mut alias_targets = resolve_call_targets( ctx, &alias_call, alias_ctx.rel_path, alias_imported_from, alias_ctx.type_map, alias_ctx.caller_name, + alias_imported_original_name, ); sort_targets_by_confidence(&mut alias_targets, alias_ctx.rel_path, alias_imported_from); for t in &alias_targets { @@ -458,6 +467,11 @@ struct FileContext<'a> { rel_path: &'a str, file_node_id: u32, imported_names: HashMap<&'a str, &'a str>, + /// Local import alias -> original exported name, for renamed specifiers + /// (`import { X as Y }`) only — entries where local === original are + /// omitted. Consulted by `resolve_call_targets` so a call to the local + /// alias resolves against the correct exported symbol (#1730). + imported_original_names: HashMap<&'a str, &'a str>, type_map: HashMap<&'a str, (&'a str, f64)>, defs_with_ids: Vec>, pts_map: Option>>, @@ -567,6 +581,10 @@ fn build_file_context<'a>( .imported_names.iter() .map(|im| (im.name.as_str(), im.file.as_str())) .collect(); + let imported_original_names: HashMap<&str, &str> = file_input + .imported_names.iter() + .filter_map(|im| im.imported.as_deref().map(|orig| (im.name.as_str(), orig))) + .collect(); let type_map = build_type_map(file_input); let file_nodes: Vec<&NodeInfo> = all_nodes.iter().filter(|n| n.file == rel_path).collect(); let defs_with_ids: Vec = file_input.definitions.iter().map(|d| { @@ -590,6 +608,7 @@ fn build_file_context<'a>( rel_path, file_node_id: file_input.file_node_id, imported_names, + imported_original_names, type_map, defs_with_ids, pts_map, @@ -655,6 +674,7 @@ fn emit_no_receiver_pts_edges<'a>( is_dynamic, rel_path: fc.rel_path, imported_names: &fc.imported_names, + imported_original_names: &fc.imported_original_names, type_map: &fc.type_map, }, seen_edges, @@ -697,6 +717,7 @@ fn emit_receiver_pts_edges<'a>( is_dynamic, rel_path: fc.rel_path, imported_names: &fc.imported_names, + imported_original_names: &fc.imported_original_names, type_map: &fc.type_map, }, seen_edges, @@ -734,8 +755,11 @@ fn process_file<'a>( let (caller_id, caller_name) = find_enclosing_caller(&fc.defs_with_ids, call.line, fc.file_node_id); let is_dynamic = if call.dynamic.unwrap_or(false) { 1u32 } else { 0u32 }; let imported_from = fc.imported_names.get(call.name.as_str()).copied(); + let imported_original_name = fc.imported_original_names.get(call.name.as_str()).copied(); - let mut targets = resolve_call_targets(ctx, call, fc.rel_path, imported_from, &fc.type_map, caller_name); + let mut targets = resolve_call_targets( + ctx, call, fc.rel_path, imported_from, &fc.type_map, caller_name, imported_original_name, + ); sort_targets_by_confidence(&mut targets, fc.rel_path, imported_from); emit_call_edges(&targets, caller_id, is_dynamic, fc.rel_path, imported_from, &mut seen_edges, &mut pts_edge_map, edges); @@ -882,6 +906,7 @@ fn resolve_call_targets<'a>( imported_from: Option<&str>, type_map: &HashMap<&str, (&str, f64)>, caller_name: &str, + imported_original_name: Option<&str>, ) -> Vec<&'a NodeInfo> { // Flagged dynamic calls use synthetic names like "". Short-circuit // so they never accidentally match a real symbol via name lookup. @@ -889,10 +914,15 @@ fn resolve_call_targets<'a>( return vec![]; } + // When the call site uses a renamed import binding (`import { X as Y }`), + // the imported file's actual symbol is declared under the *original* name + // (X) — look that up instead of the local alias the call site wrote (#1730). + let target_name = imported_original_name.unwrap_or(call.name.as_str()); + // 1. Import-aware resolution if let Some(imp_file) = imported_from { let targets = ctx.nodes_by_name_and_file - .get(&(call.name.as_str(), imp_file)) + .get(&(target_name, imp_file)) .cloned().unwrap_or_default(); if !targets.is_empty() { return targets; } } @@ -2081,7 +2111,7 @@ mod call_edge_tests { ); // Mark `Calculator` as an imported name so the resolver treats the // same-file kind="function" node as an import artifact and falls through. - file.imported_names = vec![ImportedName { name: "Calculator".to_string(), file: "utils.js".to_string() }]; + file.imported_names = vec![ImportedName { name: "Calculator".to_string(), file: "utils.js".to_string(), imported: None }]; let edges = build_call_edges(vec![file], all_nodes, vec![]); @@ -2464,7 +2494,7 @@ mod call_edge_tests { vec![], vec![], ); - file.imported_names = vec![ImportedName { name: "e4".to_string(), file: "other.js".to_string() }]; + file.imported_names = vec![ImportedName { name: "e4".to_string(), file: "other.js".to_string(), imported: None }]; file.param_bindings = Some(vec![ParamBinding { callee: "f3".to_string(), arg_index: 0, diff --git a/crates/codegraph-core/src/domain/graph/builder/stages/import_edges.rs b/crates/codegraph-core/src/domain/graph/builder/stages/import_edges.rs index fd09d4a2f..c8f0487e8 100644 --- a/crates/codegraph-core/src/domain/graph/builder/stages/import_edges.rs +++ b/crates/codegraph-core/src/domain/graph/builder/stages/import_edges.rs @@ -115,6 +115,29 @@ impl BarrelContext for ImportEdgeContext { } } +/// Pairs each locally-bound name from an import statement with its original +/// (pre-rename) exported name — identical to the local name unless the +/// specifier renames a binding (`import { X as Y }`). Barrel tracing and +/// target-file symbol lookups must search using the *original* name — the +/// renamed local alias only exists in the importing file, not in the file +/// being imported from (#1730). Mirrors `importNamePairs` in build-edges.ts. +pub(crate) fn import_name_pairs(imp: &crate::types::Import) -> Vec<(String, String)> { + let mut original_name_for: HashMap<&str, &str> = HashMap::new(); + if let Some(renamed) = &imp.renamed_imports { + for r in renamed { + original_name_for.insert(&r.local, &r.imported); + } + } + imp.names + .iter() + .map(|name| { + let local = name.strip_prefix("* as ").unwrap_or(name); + let original = original_name_for.get(local).copied().unwrap_or(local); + (local.to_string(), original.to_string()) + }) + .collect() +} + /// Build the reexport map from parsed file symbols. pub fn build_reexport_map(ctx: &ImportEdgeContext) -> HashMap> { let mut reexport_map = HashMap::new(); @@ -249,18 +272,17 @@ fn collect_type_only_lookup_pairs(ctx: &ImportEdgeContext) -> HashSet<(String, S continue; } let resolved_path = ctx.get_resolved(abs_str, &imp.source); - for name in &imp.names { - let clean_name = name.strip_prefix("* as ").unwrap_or(name); + for (_local, original) in import_name_pairs(imp) { let mut target_file = resolved_path.clone(); if ctx.is_barrel_file(&resolved_path) { let mut visited = HashSet::new(); if let Some(actual) = - ctx.resolve_barrel_export(&resolved_path, clean_name, &mut visited) + ctx.resolve_barrel_export(&resolved_path, &original, &mut visited) { target_file = actual; } } - pairs.insert((clean_name.to_string(), target_file)); + pairs.insert((original, target_file)); } } } @@ -303,18 +325,16 @@ fn emit_type_only_symbol_rows( if !imp.type_only.unwrap_or(false) { return; } - for name in &imp.names { - let clean_name = name.strip_prefix("* as ").unwrap_or(name); + for (_local, original) in import_name_pairs(imp) { let mut target_file = resolved_path.to_string(); if ctx.is_barrel_file(resolved_path) { let mut visited = HashSet::new(); - if let Some(actual) = - ctx.resolve_barrel_export(resolved_path, clean_name, &mut visited) + if let Some(actual) = ctx.resolve_barrel_export(resolved_path, &original, &mut visited) { target_file = actual; } } - if let Some(&sym_id) = symbol_node_ids.get(&(clean_name.to_string(), target_file)) { + if let Some(&sym_id) = symbol_node_ids.get(&(original, target_file)) { edges.push(EdgeRow { source_id: file_node_id, target_id: sym_id, @@ -347,14 +367,13 @@ fn emit_barrel_through_rows( _ => "imports", }; let mut resolved_sources: HashSet = HashSet::new(); - for name in &imp.names { - let clean_name = name.strip_prefix("* as ").unwrap_or(name); + for (_local, original) in import_name_pairs(imp) { let mut visited = HashSet::new(); - let actual_source = - match ctx.resolve_barrel_export(resolved_path, clean_name, &mut visited) { - Some(s) => s, - None => continue, - }; + let actual_source = match ctx.resolve_barrel_export(resolved_path, &original, &mut visited) + { + Some(s) => s, + None => continue, + }; if actual_source == resolved_path || !resolved_sources.insert(actual_source.clone()) { continue; } diff --git a/crates/codegraph-core/src/extractors/javascript.rs b/crates/codegraph-core/src/extractors/javascript.rs index 5c343e820..daf2ff9d3 100644 --- a/crates/codegraph-core/src/extractors/javascript.rs +++ b/crates/codegraph-core/src/extractors/javascript.rs @@ -1672,11 +1672,15 @@ fn handle_import_stmt(node: &Node, source: &[u8], symbols: &mut FileSymbols) { if let Some(source_node) = source_node { let mod_path = node_text(&source_node, source) .replace(&['\'', '"'][..], ""); - let names = extract_import_names(node, source); + let mut renamed_imports = Vec::new(); + let names = extract_import_names_with_renames(node, source, &mut renamed_imports); let mut imp = Import::new(mod_path, names, start_line(node)); if is_type_only { imp.type_only = Some(true); } + if !renamed_imports.is_empty() { + imp.renamed_imports = Some(renamed_imports); + } symbols.imports.push(imp); } } @@ -2972,20 +2976,80 @@ fn extract_rest_identifier(rest_node: &Node, source: &[u8], names: &mut Vec Vec { let mut names = Vec::new(); - scan_import_names(node, source, &mut names); + let mut renamed = Vec::new(); + scan_import_names(node, source, &mut names, &mut renamed); + names +} + +/// Extract import names and collect `{ local, imported }` pairs for +/// `import_specifier` nodes that rename a binding (`import { X as Y }`). +/// Mirrors `extractImportNames`'s `renamedOut` parameter in +/// src/extractors/javascript.ts (#1730). +fn extract_import_names_with_renames( + node: &Node, + source: &[u8], + renamed_out: &mut Vec, +) -> Vec { + let mut names = Vec::new(); + scan_import_names(node, source, &mut names, renamed_out); names } -fn scan_import_names(node: &Node, source: &[u8], names: &mut Vec) { - scan_import_names_depth(node, source, names, 0); +fn scan_import_names( + node: &Node, + source: &[u8], + names: &mut Vec, + renamed_out: &mut Vec, +) { + scan_import_names_depth(node, source, names, renamed_out, 0); } -fn scan_import_names_depth(node: &Node, source: &[u8], names: &mut Vec, depth: usize) { +/// Grammar note (see tree-sitter-javascript): for `import_specifier`, the +/// `name` field is *always* present — it holds the name as declared by the +/// source module. `alias` is only present for `X as Y` and holds the *local* +/// binding actually referenced by call sites in this file. Preferring `name` +/// unconditionally (as this function used to, and as the `export_specifier` +/// branch below still deliberately does — see its comment) silently drops the +/// local alias for every renamed import: call sites use `Y`, not `X` (#1730). +fn scan_import_names_depth( + node: &Node, + source: &[u8], + names: &mut Vec, + renamed_out: &mut Vec, + depth: usize, +) { if depth >= MAX_WALK_DEPTH { return; } match node.kind() { - "import_specifier" | "export_specifier" => { + "import_specifier" => { + let source_name_node = node.child_by_field_name("name"); + let alias_node = node.child_by_field_name("alias"); + let local_node = alias_node.or(source_name_node); + if let Some(local_node) = local_node { + names.push(node_text(&local_node, source).to_string()); + if let (Some(alias), Some(source_name)) = (alias_node, source_name_node) { + let alias_text = node_text(&alias, source); + let source_text = node_text(&source_name, source); + if alias_text != source_text { + renamed_out.push(RenamedImport { + local: alias_text.to_string(), + imported: source_text.to_string(), + }); + } + } + } else { + names.push(node_text(node, source).to_string()); + } + } + "export_specifier" => { + // export_specifier's `name` is the local declaration being (re-)exported; + // `alias` is the external name it's exposed as. Barrel/reexport tracing + // keys off the *original* declaration name, so this branch is + // deliberately left picking `name` first — do not unify with the + // import_specifier branch above. Rename-aware barrel tracing for + // `export { X as Y } from …` is a distinct, separate gap (#1730 + // investigation). let name_node = node .child_by_field_name("name") .or_else(|| node.child_by_field_name("alias")); @@ -3009,7 +3073,7 @@ fn scan_import_names_depth(node: &Node, source: &[u8], names: &mut Vec, } for i in 0..node.child_count() { if let Some(child) = node.child(i) { - scan_import_names_depth(&child, source, names, depth + 1); + scan_import_names_depth(&child, source, names, renamed_out, depth + 1); } } } @@ -3659,6 +3723,30 @@ mod tests { assert_eq!(s.imports[0].names, vec!["readFile"]); } + /// Regression test for #1730: `import { X as Y }` must record the *local* + /// binding (Y) in `names` — that's what call sites reference — plus the + /// `{ local: Y, imported: X }` pair in `renamed_imports` so call-edge + /// resolution can recover the original exported name X. + #[test] + fn renamed_import_records_local_name_and_rename_pair() { + let s = parse_js("import { collectFiles as collectFilesUtil } from './helpers';"); + assert_eq!(s.imports.len(), 1); + assert_eq!(s.imports[0].names, vec!["collectFilesUtil"]); + let renamed = s.imports[0] + .renamed_imports + .as_ref() + .expect("renamed_imports should be populated for a renamed specifier"); + assert_eq!(renamed.len(), 1); + assert_eq!(renamed[0].local, "collectFilesUtil"); + assert_eq!(renamed[0].imported, "collectFiles"); + } + + #[test] + fn non_renamed_import_has_no_renamed_imports() { + let s = parse_js("import { readFile } from 'fs';"); + assert!(s.imports[0].renamed_imports.is_none()); + } + #[test] fn finds_calls() { let s = parse_js("function f() { console.log('hi'); foo(); }"); diff --git a/crates/codegraph-core/src/types.rs b/crates/codegraph-core/src/types.rs index a1c3ccdc2..e41c50034 100644 --- a/crates/codegraph-core/src/types.rs +++ b/crates/codegraph-core/src/types.rs @@ -114,6 +114,16 @@ pub struct Call { pub key_expr: Option, } +/// `import { X as Y }`: the local binding name (Y) paired with the original +/// name exported by the source module (X). Mirrors TS `Import.renamedImports`. +/// See `Import.renamed_imports` for why this is needed (#1730). +#[napi(object)] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RenamedImport { + pub local: String, + pub imported: String, +} + #[napi(object)] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Import { @@ -125,6 +135,15 @@ pub struct Import { pub reexport: Option, #[napi(js_name = "wildcardReexport")] pub wildcard_reexport: Option, + /// For `import { X as Y }` specifiers: the local binding name (Y) mapped to + /// the original name exported by the source module (X). `names` always + /// carries the local (post-rename) binding — this field lets call-edge + /// resolution recover the *original* symbol name to look up in the + /// imported file when a call site uses the local alias (#1730). Only + /// populated for specifiers that actually rename a binding. Mirrors TS + /// `Import.renamedImports`. + #[napi(js_name = "renamedImports")] + pub renamed_imports: Option>, // Language-specific flags #[napi(js_name = "pythonImport")] pub python_import: Option, @@ -168,6 +187,7 @@ impl Import { type_only: None, reexport: None, wildcard_reexport: None, + renamed_imports: None, python_import: None, go_import: None, rust_use: None, diff --git a/src/domain/graph/builder/call-resolver.ts b/src/domain/graph/builder/call-resolver.ts index b39a158a7..68a5a2589 100644 --- a/src/domain/graph/builder/call-resolver.ts +++ b/src/domain/graph/builder/call-resolver.ts @@ -217,6 +217,7 @@ export function resolveCallTargets( importedNames: Map, typeMap: Map, callerName?: string | null, + importedOriginalNames?: ReadonlyMap, ): { targets: Array<{ id: number; file: string }>; importedFrom: string | undefined } { // Flagged dynamic calls use synthetic names like ''. Short-circuit // so they never accidentally match a real symbol via lookup.byName. @@ -225,14 +226,18 @@ export function resolveCallTargets( } const importedFrom = importedNames.get(call.name); + // When the call site uses a renamed import binding (`import { X as Y }`), + // the imported file's actual symbol is declared under the *original* name + // (X) — look that up instead of the local alias the call site wrote (#1730). + const targetName = importedOriginalNames?.get(call.name) ?? call.name; let targets: ReadonlyArray<{ id: number; file: string }> | undefined; if (importedFrom) { - targets = lookup.byNameAndFile(call.name, importedFrom); + targets = lookup.byNameAndFile(targetName, importedFrom); if (targets.length === 0 && lookup.isBarrel(importedFrom)) { - const actualSource = lookup.resolveBarrel(importedFrom, call.name); + const actualSource = lookup.resolveBarrel(importedFrom, targetName); if (actualSource) { - targets = lookup.byNameAndFile(call.name, actualSource); + targets = lookup.byNameAndFile(targetName, actualSource); } } } diff --git a/src/domain/graph/builder/incremental.ts b/src/domain/graph/builder/incremental.ts index 863ef00f7..909230fb4 100644 --- a/src/domain/graph/builder/incremental.ts +++ b/src/domain/graph/builder/incremental.ts @@ -16,6 +16,7 @@ import type { BetterSqlite3Database, EngineOpts, ExtractorOutput, + Import, PathAliases, SqliteStatement, } from '../../../types.js'; @@ -209,8 +210,22 @@ function rebuildReverseDepEdges( aliases, skipBarrel ? null : db, ); - const importedNames = buildImportedNamesMap(symbols, rootDir, depRelPath, aliases, db); - edgesAdded += buildCallEdges(db, stmts, depRelPath, symbols, fileNodeRow, importedNames); + const { importedNames, importedOriginalNames } = buildImportedNamesMap( + symbols, + rootDir, + depRelPath, + aliases, + db, + ); + edgesAdded += buildCallEdges( + db, + stmts, + depRelPath, + symbols, + fileNodeRow, + importedNames, + importedOriginalNames, + ); edgesAdded += buildClassHierarchyEdges(stmts, depRelPath, symbols); return edgesAdded; } @@ -304,6 +319,23 @@ function resolveBarrelTarget( return null; } +/** + * Pairs each locally-bound name from an import statement with its original + * (pre-rename) exported name — identical to the local name unless the + * specifier renames a binding (`import { X as Y }`). Barrel tracing and + * target-file symbol lookups must search using the *original* name — the + * renamed local alias only exists in the importing file, not in the file + * being imported from (#1730). Mirrors `importNamePairs` in build-edges.ts. + */ +function importNamePairs(imp: Import): Array<{ local: string; original: string }> { + const originalNameFor = new Map(); + for (const r of imp.renamedImports ?? []) originalNameFor.set(r.local, r.imported); + return imp.names.map((name) => { + const local = name.replace(/^\*\s+as\s+/, ''); + return { local, original: originalNameFor.get(local) ?? local }; + }); +} + /** * Resolve barrel imports for a single import statement and create edges to actual source files. * Shared by buildImportEdges (primary file) and Pass 2 of the reverse-dep cascade. @@ -318,9 +350,8 @@ function resolveBarrelImportEdges( let edgesAdded = 0; if (!isBarrelFile(db, resolvedPath)) return edgesAdded; const resolvedSources = new Set(); - for (const name of imp.names) { - const cleanName = name.replace(/^\*\s+as\s+/, ''); - const actualSource = resolveBarrelTarget(db, resolvedPath, cleanName); + for (const { original } of importNamePairs(imp)) { + const actualSource = resolveBarrelTarget(db, resolvedPath, original); if (actualSource && actualSource !== resolvedPath && !resolvedSources.has(actualSource)) { resolvedSources.add(actualSource); const actualRow = stmts.getNodeId.get(actualSource, 'file', actualSource, 0); @@ -343,14 +374,13 @@ function emitTypeOnlySymbolEdges( fileNodeId: number, ): number { let edgesAdded = 0; - for (const name of imp.names) { - const cleanName = name.replace(/^\*\s+as\s+/, ''); + for (const { original } of importNamePairs(imp)) { let targetFile = resolvedPath; if (db && isBarrelFile(db, resolvedPath)) { - const actual = resolveBarrelTarget(db, resolvedPath, cleanName); + const actual = resolveBarrelTarget(db, resolvedPath, original); if (actual) targetFile = actual; } - const candidates = stmts.findNodeInFile.all(cleanName, targetFile) as Array<{ + const candidates = stmts.findNodeInFile.all(original, targetFile) as Array<{ id: number; file: string; }>; @@ -413,14 +443,23 @@ function buildImportEdges( return edgesAdded; } +/** + * Mirrors the full-build `buildImportedNamesMap` in build-edges.ts: maps each + * locally-bound import name to its defining file (`importedNames`), plus, for + * renamed specifiers (`import { X as Y }`), the *original* exported name + * (`importedOriginalNames`, keyed by local name Y). Barrel tracing and the + * downstream target-file symbol lookup must use the original name — the + * renamed local alias only exists in the importing file (#1730). + */ function buildImportedNamesMap( symbols: ExtractorOutput, rootDir: string, relPath: string, aliases: PathAliases, db: BetterSqlite3Database, -): Map { +): { importedNames: Map; importedOriginalNames: Map } { const importedNames = new Map(); + const importedOriginalNames = new Map(); for (const imp of symbols.imports) { const resolvedPath = resolveImportPath( path.join(rootDir, relPath), @@ -428,21 +467,21 @@ function buildImportedNamesMap( rootDir, aliases, ); - for (const name of imp.names) { - const cleanName = name.replace(/^\*\s+as\s+/, ''); + for (const { local, original } of importNamePairs(imp)) { // Mirror full-build's `buildImportedNamesMap`: follow barrel re-exports so // `importedNames` maps to the *defining* file, not the barrel. This ensures // `computeConfidence` gets `importedFrom === targetFile` and returns 1.0 // instead of the cross-directory fallback (0.3). let targetFile = resolvedPath; if (isBarrelFile(db, resolvedPath)) { - const actual = resolveBarrelTarget(db, resolvedPath, cleanName); + const actual = resolveBarrelTarget(db, resolvedPath, original); if (actual) targetFile = actual; } - importedNames.set(cleanName, targetFile); + importedNames.set(local, targetFile); + if (original !== local) importedOriginalNames.set(local, original); } } - return importedNames; + return { importedNames, importedOriginalNames }; } // ── Class hierarchy edges ─────────────────────────────────────────────── @@ -690,6 +729,7 @@ function buildCallEdges( symbols: ExtractorOutput, fileNodeRow: { id: number }, importedNames: Map, + importedOriginalNames?: ReadonlyMap, ): number { const typeMap = buildIncrementalTypeMap(symbols); const seenCallEdges = new Set(); @@ -707,6 +747,7 @@ function buildCallEdges( importedNames, typeMap, caller.callerName, + importedOriginalNames, ); const targets = applyThisReceiverFallbacks( @@ -773,8 +814,22 @@ function rebuildEdgesForTargetFile( let edgesAdded = buildContainmentEdges(db, stmts, relPath, symbols); edgesAdded += rebuildDirContainment(db, stmts, relPath); edgesAdded += buildImportEdges(stmts, relPath, symbols, rootDir, fileNodeRow.id, aliases, db); - const importedNames = buildImportedNamesMap(symbols, rootDir, relPath, aliases, db); - edgesAdded += buildCallEdges(db, stmts, relPath, symbols, fileNodeRow, importedNames); + const { importedNames, importedOriginalNames } = buildImportedNamesMap( + symbols, + rootDir, + relPath, + aliases, + db, + ); + edgesAdded += buildCallEdges( + db, + stmts, + relPath, + symbols, + fileNodeRow, + importedNames, + importedOriginalNames, + ); edgesAdded += buildClassHierarchyEdges(stmts, relPath, symbols); return edgesAdded; } diff --git a/src/domain/graph/builder/stages/build-edges.ts b/src/domain/graph/builder/stages/build-edges.ts index 011ce246b..33ebafa84 100644 --- a/src/domain/graph/builder/stages/build-edges.ts +++ b/src/domain/graph/builder/stages/build-edges.ts @@ -88,7 +88,7 @@ interface NativeFileEntry { params?: string[]; }>; calls: Call[]; - importedNames: Array<{ name: string; file: string }>; + importedNames: Array<{ name: string; file: string; imported?: string }>; classes: ClassRelation[]; typeMap: Array<{ name: string; typeName: string; confidence: number }>; /** Phase 8.3: function-reference bindings for pts analysis. */ @@ -148,6 +148,23 @@ function importEdgeKind(imp: Import): string { return 'imports'; } +/** + * Pairs each locally-bound name from an import statement with its original + * (pre-rename) exported name — identical to the local name unless the + * specifier renames a binding (`import { X as Y }`). Barrel tracing and + * target-file symbol lookups must search using the *original* name — the + * renamed local alias only exists in the importing file, not in the file + * being imported from (#1730). + */ +function importNamePairs(imp: Import): Array<{ local: string; original: string }> { + const originalNameFor = new Map(); + for (const r of imp.renamedImports ?? []) originalNameFor.set(r.local, r.imported); + return imp.names.map((name) => { + const local = name.replace(/^\*\s+as\s+/, ''); + return { local, original: originalNameFor.get(local) ?? local }; + }); +} + /** * For a `import type` statement, emit symbol-level `imports-type` edges so * the target symbols get fan-in credit and aren't classified as dead code. @@ -160,14 +177,13 @@ function emitTypeOnlySymbolEdges( allEdgeRows: EdgeRowTuple[], ): void { if (!ctx.nodesByNameAndFile) return; - for (const name of imp.names) { - const cleanName = name.replace(/^\*\s+as\s+/, ''); + for (const { original } of importNamePairs(imp)) { let targetFile = resolvedPath; if (isBarrelFile(ctx, resolvedPath)) { - const actual = resolveBarrelExportCached(ctx, resolvedPath, cleanName); + const actual = resolveBarrelExportCached(ctx, resolvedPath, original); if (actual) targetFile = actual; } - const candidates = ctx.nodesByNameAndFile.get(`${cleanName}|${targetFile}`); + const candidates = ctx.nodesByNameAndFile.get(`${original}|${targetFile}`); if (candidates && candidates.length > 0) { allEdgeRows.push([fileNodeId, candidates[0]!.id, 'imports-type', 1.0, 0, null, null]); } @@ -233,9 +249,8 @@ function buildBarrelEdges( edgeRows: EdgeRowTuple[], ): void { const resolvedSources = new Set(); - for (const name of imp.names) { - const cleanName = name.replace(/^\*\s+as\s+/, ''); - const actualSource = resolveBarrelExportCached(ctx, resolvedPath, cleanName); + for (const { original } of importNamePairs(imp)) { + const actualSource = resolveBarrelExportCached(ctx, resolvedPath, original); if (actualSource && actualSource !== resolvedPath && !resolvedSources.has(actualSource)) { resolvedSources.add(actualSource); const actualRow = getNodeIdStmt.get(actualSource, 'file', actualSource, 0); @@ -474,7 +489,12 @@ function propagateReturnTypesAcrossFiles( // file rather than the barrel. This means returnTypeIndex.get(importedFrom) // now finds entries it previously missed, improving cross-file return-type // propagation through re-export chains (Phase 8.2 improvement). - const importedNamesMap = buildImportedNamesMap(ctx, relPath, symbols, rootDir); + const { importedNames: importedNamesMap, importedOriginalNames } = buildImportedNamesMap( + ctx, + relPath, + symbols, + rootDir, + ); for (const ca of symbols.callAssignments) { if (symbols.typeMap.has(ca.varName)) continue; // already resolved locally @@ -484,7 +504,11 @@ function propagateReturnTypesAcrossFiles( returnEntry = globalReturnTypeMap.get(`${ca.receiverTypeName}.${ca.calleeName}`); } else { const importedFrom = importedNamesMap.get(ca.calleeName); - if (importedFrom) returnEntry = returnTypeIndex.get(importedFrom)?.get(ca.calleeName); + // The return-type index for the imported file is keyed by the + // function's own declared name — use the original (pre-rename) name + // when the call-assignment's callee is a renamed import binding (#1730). + const calleeOriginalName = importedOriginalNames.get(ca.calleeName) ?? ca.calleeName; + if (importedFrom) returnEntry = returnTypeIndex.get(importedFrom)?.get(calleeOriginalName); } if (returnEntry) { @@ -639,7 +663,12 @@ function buildDefinePropertyPostPass( const fileNodeRow = getNodeIdStmt.get(relPath, 'file', relPath, 0); if (!fileNodeRow) continue; - const importedNames = buildImportedNamesMap(ctx, relPath, symbols, rootDir); + const { importedNames, importedOriginalNames } = buildImportedNamesMap( + ctx, + relPath, + symbols, + rootDir, + ); const typeMap: Map = symbols.typeMap || new Map(); const definePropertyReceivers = symbols.definePropertyReceivers!; @@ -660,6 +689,7 @@ function buildDefinePropertyPostPass( importedNames, typeMap as Map, caller.callerName, + importedOriginalNames, ); if (directTargets.length > 0) continue; @@ -789,21 +819,28 @@ function buildImportedNamesForNative( relPath: string, symbols: ExtractorOutput, rootDir: string, -): Array<{ name: string; file: string }> { - const importedNames: Array<{ name: string; file: string }> = []; +): Array<{ name: string; file: string; imported?: string }> { + const importedNames: Array<{ name: string; file: string; imported?: string }> = []; // Process dynamic imports first (lower priority), then static imports // (higher priority). Rust HashMap::collect keeps the last entry per key, // so static imports win when both contribute the same name. const addImports = (imp: (typeof symbols.imports)[number]) => { const resolvedPath = getResolved(ctx, path.join(rootDir, relPath), imp.source); - for (const name of imp.names) { - const cleanName = name.replace(/^\*\s+as\s+/, ''); + for (const { local, original } of importNamePairs(imp)) { let targetFile = resolvedPath; if (isBarrelFile(ctx, resolvedPath)) { - const actual = resolveBarrelExportCached(ctx, resolvedPath, cleanName); + const actual = resolveBarrelExportCached(ctx, resolvedPath, original); if (actual) targetFile = actual; } - importedNames.push({ name: cleanName, file: targetFile }); + // `imported` carries the original (pre-rename) exported name so the + // native resolver can look it up in `targetFile` instead of the local + // alias, which only exists in this file (#1730). Omitted when unrenamed. + const entry: { name: string; file: string; imported?: string } = { + name: local, + file: targetFile, + }; + if (original !== local) entry.imported = original; + importedNames.push(entry); } }; for (const imp of symbols.imports) { @@ -831,7 +868,12 @@ function buildCallEdgesJS( const fileNodeRow = getNodeIdStmt.get(relPath, 'file', relPath, 0); if (!fileNodeRow) continue; - const importedNames = buildImportedNamesMap(ctx, relPath, symbols, rootDir); + const { importedNames, importedOriginalNames } = buildImportedNamesMap( + ctx, + relPath, + symbols, + rootDir, + ); const typeMap: Map = new Map( symbols.typeMap instanceof Map ? symbols.typeMap : [], ); @@ -887,48 +929,58 @@ function buildCallEdgesJS( ptsMap, chaCtx, importArtifactNames, + importedOriginalNames, ); buildClassHierarchyEdges(ctx, relPath, symbols, allEdgeRows); } } +/** + * Maps each locally-bound import name in `relPath` to the file it comes from + * (`importedNames`), plus, for renamed specifiers (`import { X as Y }`), the + * *original* exported name (`importedOriginalNames`, keyed by local name Y). + * + * Barrel tracing and downstream target-file symbol lookups must search using + * the original name — the renamed local alias only exists in the importing + * file, not in the file being imported from (#1730). + */ function buildImportedNamesMap( ctx: PipelineContext, relPath: string, symbols: ExtractorOutput, rootDir: string, -): Map { +): { importedNames: Map; importedOriginalNames: Map } { const importedNames = new Map(); - // Process dynamic imports first (lower priority), then static imports - // (higher priority). Static imports represent direct bindings while dynamic - // imports often use aliased destructuring (`{ foo: bar } = await import(…)`). - // When both contribute the same name, the static binding is authoritative. - // + const importedOriginalNames = new Map(); // Phase 8.4: trace through barrel files so that symbol names map to their // actual definition file, not the re-exporting barrel. Mirrors the tracing // already done in buildImportedNamesForNative (the native path). - const traceBarrel = (resolvedPath: string, cleanName: string): string => { + const traceBarrel = (resolvedPath: string, originalName: string): string => { if (!isBarrelFile(ctx, resolvedPath)) return resolvedPath; - const actual = resolveBarrelExportCached(ctx, resolvedPath, cleanName); + const actual = resolveBarrelExportCached(ctx, resolvedPath, originalName); return actual ?? resolvedPath; }; + const addImportNames = (imp: (typeof symbols.imports)[number], resolvedPath: string) => { + for (const { local, original } of importNamePairs(imp)) { + importedNames.set(local, traceBarrel(resolvedPath, original)); + if (original !== local) importedOriginalNames.set(local, original); + } + }; + // Process dynamic imports first (lower priority), then static imports + // (higher priority). Static imports represent direct bindings while dynamic + // imports often use aliased destructuring (`{ foo: bar } = await import(…)`). + // When both contribute the same name, the static binding is authoritative. for (const imp of symbols.imports) { if (!imp.dynamicImport) continue; const resolvedPath = getResolved(ctx, path.join(rootDir, relPath), imp.source); - for (const name of imp.names) { - const cleanName = name.replace(/^\*\s+as\s+/, ''); - importedNames.set(cleanName, traceBarrel(resolvedPath, cleanName)); - } + addImportNames(imp, resolvedPath); } for (const imp of symbols.imports) { if (imp.dynamicImport) continue; const resolvedPath = getResolved(ctx, path.join(rootDir, relPath), imp.source); - for (const name of imp.names) { - const cleanName = name.replace(/^\*\s+as\s+/, ''); - importedNames.set(cleanName, traceBarrel(resolvedPath, cleanName)); - } + addImportNames(imp, resolvedPath); } - return importedNames; + return { importedNames, importedOriginalNames }; } /** @@ -1226,6 +1278,7 @@ function resolveFallbackTargets( lookup: CallNodeLookup, typeMap: Map, definePropertyReceivers: Map | undefined, + importedOriginalNames?: ReadonlyMap, ): { targets: ReadonlyArray<{ id: number; file: string; kind?: string }>; importedFrom: string | null | undefined; @@ -1245,6 +1298,7 @@ function resolveFallbackTargets( importedNames, typeMap as Map, caller.callerName, + importedOriginalNames, ); // Fallback strategies, applied in order until one yields a match. Each @@ -1403,6 +1457,7 @@ function emitPtsNoReceiverEdges( seenCallEdges: Set, ptsEdgeRows: Map, allEdgeRows: EdgeRowTuple[], + importedOriginalNames?: ReadonlyMap, ): void { const scopedPtsKey = caller.callerName != null ? `${caller.callerName}::${call.name}` : null; // Module-level calls (callerName === null) use the '' sentinel emitted by @@ -1445,6 +1500,8 @@ function emitPtsNoReceiverEdges( relPath, importedNames, typeMap as Map, + undefined, + importedOriginalNames, ); const sortedAliasTargets = aliasTargets.length > 1 @@ -1488,6 +1545,7 @@ function emitPtsReceiverEdges( seenCallEdges: Set, ptsEdgeRows: Map, allEdgeRows: EdgeRowTuple[], + importedOriginalNames?: ReadonlyMap, ): void { const receiverKey = `${call.receiver}.${call.name}`; if (!ptsMap.has(receiverKey)) return; @@ -1499,6 +1557,8 @@ function emitPtsReceiverEdges( relPath, importedNames, typeMap as Map, + undefined, + importedOriginalNames, ); const sortedAliasTargets = aliasTargets.length > 1 @@ -1621,6 +1681,7 @@ function buildFileCallEdges( ptsMap?: PointsToMap | null, chaCtx?: ChaContext, importArtifactNames?: ReadonlyMap, + importedOriginalNames?: ReadonlyMap, ): void { // Tracks edges that were inserted by the pts fallback (edgeKey → allEdgeRows index). // Kept separate from seenCallEdges so that a subsequent direct-call edge for the same @@ -1656,6 +1717,7 @@ function buildFileCallEdges( lookup, typeMap, symbols.definePropertyReceivers, + importedOriginalNames, ); // Step 2: Emit direct-call edges (upgrades any pending pts edge in-place). @@ -1687,6 +1749,7 @@ function buildFileCallEdges( seenCallEdges, ptsEdgeRows, allEdgeRows, + importedOriginalNames, ); } @@ -1712,6 +1775,7 @@ function buildFileCallEdges( seenCallEdges, ptsEdgeRows, allEdgeRows, + importedOriginalNames, ); } diff --git a/src/extractors/javascript.ts b/src/extractors/javascript.ts index 7c4b1ea9e..65326e366 100644 --- a/src/extractors/javascript.ts +++ b/src/extractors/javascript.ts @@ -311,12 +311,14 @@ function handleImportCapture(c: Record, imports: Import[ const impNode = c.imp_node!; const isTypeOnly = impNode.text.startsWith('import type'); const modPath = c.imp_source!.text.replace(/['"]/g, ''); - const names = extractImportNames(impNode); + const renamedImports: Array<{ local: string; imported: string }> = []; + const names = extractImportNames(impNode, renamedImports); imports.push({ source: modPath, names, line: nodeStartLine(impNode), typeOnly: isTypeOnly, + ...(renamedImports.length > 0 ? { renamedImports } : {}), }); } @@ -1478,12 +1480,14 @@ function handleImportStmt(node: TreeSitterNode, ctx: ExtractorOutput): void { const source = node.childForFieldName('source') || findChild(node, 'string'); if (source) { const modPath = source.text.replace(/['"]/g, ''); - const names = extractImportNames(node); + const renamedImports: Array<{ local: string; imported: string }> = []; + const names = extractImportNames(node, renamedImports); ctx.imports.push({ source: modPath, names, line: nodeStartLine(node), typeOnly: isTypeOnly, + ...(renamedImports.length > 0 ? { renamedImports } : {}), }); } } @@ -3843,10 +3847,45 @@ function findParentClass(node: TreeSitterNode): string | null { return findParentNode(node, JS_CLASS_TYPES); } -function extractImportNames(node: TreeSitterNode): string[] { +/** + * Extract the local binding names introduced by an import/export statement. + * + * `renamedOut`, when passed, collects `{ local, imported }` pairs for + * `import_specifier` nodes that rename a binding (`import { X as Y }`). + * + * Grammar note (see tree-sitter-javascript): for `import_specifier`, the + * `name` field is *always* present — it holds the name as declared by the + * source module. `alias` is only present for `X as Y` and holds the *local* + * binding actually referenced by call sites in this file. Preferring `name` + * unconditionally (as this function used to, and as the `export_specifier` + * branch below still deliberately does — see its comment) silently drops the + * local alias for every renamed import: call sites use `Y`, not `X` (#1730). + */ +function extractImportNames( + node: TreeSitterNode, + renamedOut?: Array<{ local: string; imported: string }>, +): string[] { const names: string[] = []; function scan(n: TreeSitterNode): void { - if (n.type === 'import_specifier' || n.type === 'export_specifier') { + if (n.type === 'import_specifier') { + const sourceNameNode = n.childForFieldName('name'); + const aliasNode = n.childForFieldName('alias'); + const localNode = aliasNode || sourceNameNode; + if (localNode) { + names.push(localNode.text); + if (aliasNode && sourceNameNode && aliasNode.text !== sourceNameNode.text) { + renamedOut?.push({ local: aliasNode.text, imported: sourceNameNode.text }); + } + } else { + names.push(n.text); + } + } else if (n.type === 'export_specifier') { + // export_specifier's `name` is the local declaration being (re-)exported; + // `alias` is the external name it's exposed as. Barrel/reexport tracing + // (resolveBarrelExport) keys off the *original* declaration name, so this + // branch is deliberately left picking `name` first — do not unify with + // the import_specifier branch above. Rename-aware barrel tracing for + // `export { X as Y } from …` is a distinct, separate gap (#1730 investigation). const nameNode = n.childForFieldName('name') || n.childForFieldName('alias'); if (nameNode) names.push(nameNode.text); else names.push(n.text); diff --git a/src/types.ts b/src/types.ts index 81fafbe03..96f7d2d46 100644 --- a/src/types.ts +++ b/src/types.ts @@ -510,6 +510,17 @@ export interface Import { reexport?: boolean; wildcardReexport?: boolean; dynamicImport?: boolean; + /** + * For `import { X as Y }` specifiers: the local binding name (Y) mapped to + * the original name exported by the source module (X). `names` always + * carries the local (post-rename) binding — this field lets call-edge + * resolution recover the *original* symbol name to look up in the imported + * file when a call site uses the local alias (#1730). Only populated for + * specifiers that actually rename a binding; entries where local === source + * name are omitted. Not populated for `export { X as Y } from …` reexports + * — barrel/reexport tracing is a distinct mechanism (see resolveBarrelExport). + */ + renamedImports?: Array<{ local: string; imported: string }>; // Language-specific flags (mutually exclusive at runtime) pythonImport?: boolean; goImport?: boolean; diff --git a/tests/integration/issue-1730-renamed-import-consumer.test.ts b/tests/integration/issue-1730-renamed-import-consumer.test.ts new file mode 100644 index 000000000..4d43843c0 --- /dev/null +++ b/tests/integration/issue-1730-renamed-import-consumer.test.ts @@ -0,0 +1,125 @@ +/** + * Regression test for #1730: consumer resolution must follow a call site + * through a renamed import specifier (`import { X as Y } from '...'`) back + * to the original exported symbol. + * + * Setup: two files. + * - helpers.js: `export function collectFiles() {...}` + * - consumer.js: `import { collectFiles as collectFilesUtil } from './helpers.js';` + * calls `collectFilesUtil()` from an exported function. + * + * Before the fix, the extractor recorded the *original* exported name + * (`collectFiles`) instead of the local alias actually used at the call site + * (`collectFilesUtil`) — so `importedNames` never had a key matching the call + * site text, and no `calls` edge was ever created. `codegraph exports + * helpers.js` reported zero consumers for a genuinely-used export. + * + * Verified on both engines — this is resolver/extractor logic mirrored in + * `crates/codegraph-core/`, so WASM and native must agree (#1730 root cause + * was duplicated in both). + */ + +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'; +import { exportsData } from '../../src/domain/queries.js'; + +const FILE_HELPERS = ` +export function collectFiles() { + return ['a.js', 'b.js']; +} +`; + +const FILE_CONSUMER = ` +import { collectFiles as collectFilesUtil } from './helpers.js'; + +export function useIt() { + return collectFilesUtil(); +} +`; + +let tmpWasm: string; +let tmpNative: string; + +beforeAll(async () => { + tmpWasm = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-1730-wasm-')); + fs.writeFileSync(path.join(tmpWasm, 'helpers.js'), FILE_HELPERS); + fs.writeFileSync(path.join(tmpWasm, 'consumer.js'), FILE_CONSUMER); + + tmpNative = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-1730-native-')); + fs.writeFileSync(path.join(tmpNative, 'helpers.js'), FILE_HELPERS); + fs.writeFileSync(path.join(tmpNative, 'consumer.js'), FILE_CONSUMER); + + await Promise.all([ + buildGraph(tmpWasm, { incremental: false, skipRegistry: true, engine: 'wasm' }), + buildGraph(tmpNative, { incremental: false, skipRegistry: true, engine: 'native' }), + ]); +}); + +afterAll(() => { + fs.rmSync(tmpWasm, { recursive: true, force: true }); + fs.rmSync(tmpNative, { recursive: true, force: true }); +}); + +function getCallEdges(dbPath: string) { + const db = new Database(dbPath, { readonly: true }); + try { + return db + .prepare( + `SELECT n1.name AS src, n1.file AS src_file, n2.name AS tgt, n2.file AS tgt_file + 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; src_file: string; tgt: string; tgt_file: string }>; + } finally { + db.close(); + } +} + +describe('call-edge resolution through a renamed import specifier (#1730)', () => { + it('WASM: useIt -> collectFiles calls edge exists (resolved through the rename)', () => { + const edges = getCallEdges(path.join(tmpWasm, '.codegraph', 'graph.db')); + const edge = edges.find((e) => e.src === 'useIt' && e.tgt === 'collectFiles'); + expect(edge).toBeDefined(); + expect(edge?.tgt_file).toBe('helpers.js'); + }); + + it('Native: useIt -> collectFiles calls edge exists (resolved through the rename)', () => { + const edges = getCallEdges(path.join(tmpNative, '.codegraph', 'graph.db')); + const edge = edges.find((e) => e.src === 'useIt' && e.tgt === 'collectFiles'); + expect(edge).toBeDefined(); + expect(edge?.tgt_file).toBe('helpers.js'); + }); + + it('no spurious edge is created against a nonexistent "collectFilesUtil" symbol', () => { + for (const dbPath of [ + path.join(tmpWasm, '.codegraph', 'graph.db'), + path.join(tmpNative, '.codegraph', 'graph.db'), + ]) { + const edges = getCallEdges(dbPath); + expect(edges.find((e) => e.tgt === 'collectFilesUtil')).toBeUndefined(); + } + }); + + it('WASM: codegraph exports credits collectFiles with the renamed-import consumer', () => { + const data = exportsData('helpers.js', path.join(tmpWasm, '.codegraph', 'graph.db')); + const collectFiles = data.results.find((r: { name: string }) => r.name === 'collectFiles'); + expect(collectFiles).toBeDefined(); + expect(collectFiles.consumerCount).toBeGreaterThanOrEqual(1); + expect(collectFiles.consumers.map((c: { name: string }) => c.name)).toContain('useIt'); + }); + + it('Native: codegraph exports credits collectFiles with the renamed-import consumer', () => { + const data = exportsData('helpers.js', path.join(tmpNative, '.codegraph', 'graph.db')); + const collectFiles = data.results.find((r: { name: string }) => r.name === 'collectFiles'); + expect(collectFiles).toBeDefined(); + expect(collectFiles.consumerCount).toBeGreaterThanOrEqual(1); + expect(collectFiles.consumers.map((c: { name: string }) => c.name)).toContain('useIt'); + }); +}); diff --git a/tests/parsers/javascript.test.ts b/tests/parsers/javascript.test.ts index c825df2a9..5f9994fbe 100644 --- a/tests/parsers/javascript.test.ts +++ b/tests/parsers/javascript.test.ts @@ -130,6 +130,52 @@ describe('JavaScript parser', () => { expect(symbols.imports[0].names).toContain('bar'); }); + // Regression coverage for #1730: `import { X as Y }` must record the local + // binding (Y) — what call sites actually reference — in `names`, plus the + // `{ local, imported }` rename pair so call-edge resolution can recover the + // original exported symbol (X) when a call site uses the local alias. + describe('renamed import specifiers (#1730)', () => { + it('records the local alias, not the source name, in imports[].names', () => { + const symbols = parseJS(`import { collectFiles as collectFilesUtil } from './helpers';`); + expect(symbols.imports).toHaveLength(1); + expect(symbols.imports[0].names).toEqual(['collectFilesUtil']); + }); + + it('records the local -> original rename pair in renamedImports', () => { + const symbols = parseJS(`import { collectFiles as collectFilesUtil } from './helpers';`); + expect(symbols.imports[0].renamedImports).toEqual([ + { local: 'collectFilesUtil', imported: 'collectFiles' }, + ]); + }); + + it('does not set renamedImports for non-renamed specifiers', () => { + const symbols = parseJS(`import { foo, bar } from './baz';`); + expect(symbols.imports[0].renamedImports).toBeUndefined(); + }); + + it('handles a mix of renamed and non-renamed specifiers in one statement', () => { + const symbols = parseJS( + `import { foo, collectFiles as collectFilesUtil, bar } from './mixed';`, + ); + expect(symbols.imports[0].names).toEqual(['foo', 'collectFilesUtil', 'bar']); + expect(symbols.imports[0].renamedImports).toEqual([ + { local: 'collectFilesUtil', imported: 'collectFiles' }, + ]); + }); + + it('does not apply rename tracking to export_specifier (reexport) statements', () => { + // export_specifier semantics differ (name = local declaration being + // re-exported, alias = external name) — barrel/reexport tracing keys off + // the original declaration name, so this is intentionally left as-is. + // See resolveBarrelExport / issues filed from the #1730 investigation. + const symbols = parseJS(`export { collectFiles as friendlyName } from './helpers';`); + expect(symbols.imports).toHaveLength(1); + expect(symbols.imports[0].reexport).toBe(true); + expect(symbols.imports[0].names).toEqual(['collectFiles']); + expect(symbols.imports[0].renamedImports).toBeUndefined(); + }); + }); + it('extracts call expressions', () => { const symbols = parseJS(`import { foo } from './bar'; foo(); baz();`); expect(symbols.calls).toContainEqual(expect.objectContaining({ name: 'foo' })); From 590f5608c32ab10300b5e49a38f5c39e728aa935 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Sun, 5 Jul 2026 16:26:03 -0600 Subject: [PATCH 08/67] fix: couple file_hashes updates with edge regeneration in incremental builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit insertNodes committed file_hashes for changed files in the same transaction as node insertion, before resolveImports/buildEdges rebuilt those files' edges (a separate, later stage/transaction) — in both the JS/WASM pipeline and the native Rust orchestrator. Any exception, crash, or interruption between the two left a hash that claimed a file was "up to date" while its edges still reflected an older revision. Since change-detection trusts file_hashes exclusively, that divergence was never self-healed by later builds. Defer the file_hashes commit so it only runs once edges have been rebuilt to match: - insert-nodes.ts: insertNodes no longer writes file_hashes for changed files; new commitFileHashes() does it, called from pipeline.ts after buildEdges. - insert_nodes.rs / pipeline.rs / connection.rs: do_insert_nodes no longer upserts file_hashes (only removed-file cleanup, which has no coupling risk); new commit_file_hashes() runs after Stage 7 (edges). Watch-mode's rebuildFile never wrote file_hashes at all, leaving it permanently stale after every edit — also fixed by writing/deleting the hash once a file's edges have been rebuilt or the file is deleted. Adds tests/integration/issue-1731-hash-edge-coupling.test.ts: a fault-injection test that throws inside buildEdges mid-incremental-build and asserts the hash does not advance until edges genuinely match (and that a retry self-heals), plus coverage for rebuildFile keeping file_hashes in sync with its edge rebuilds. Fixes #1731 Impact: 8 functions changed, 13 affected --- crates/codegraph-core/src/db/connection.rs | 16 +- .../src/domain/graph/builder/pipeline.rs | 25 +- .../graph/builder/stages/insert_nodes.rs | 98 ++++--- src/domain/graph/builder/incremental.ts | 26 +- src/domain/graph/builder/pipeline.ts | 10 +- .../graph/builder/stages/insert-nodes.ts | 152 ++++++----- src/domain/graph/watcher.ts | 4 + .../issue-1259-watch-call-resolution.test.ts | 4 + ...issue-1370-incremental-caller-name.test.ts | 4 + .../issue-1731-hash-edge-coupling.test.ts | 251 ++++++++++++++++++ .../integration/watcher-fk-embeddings.test.ts | 4 + tests/integration/watcher-rebuild.test.ts | 4 + 12 files changed, 497 insertions(+), 101 deletions(-) create mode 100644 tests/integration/issue-1731-hash-edge-coupling.test.ts diff --git a/crates/codegraph-core/src/db/connection.rs b/crates/codegraph-core/src/db/connection.rs index f75c34c3d..a41237b83 100644 --- a/crates/codegraph-core/src/db/connection.rs +++ b/crates/codegraph-core/src/db/connection.rs @@ -857,6 +857,14 @@ impl NativeDatabase { /// Batches are received as `serde_json::Value` and deserialized via serde so /// that `null` visibility values map to `None` instead of crashing napi's /// `Option` object conversion (#709). + /// + /// `file_hashes` is committed in its own transaction, separate from node + /// insertion (#1731) — callers that need edge-consistent hashes (i.e. the + /// standard incremental build pipeline) should pass an empty array here + /// and commit hashes themselves once resolveImports/buildEdges have + /// finished rebuilding the affected files' edges (see + /// `insertNodes.commitFileHashes` on the JS side, or + /// `insert_nodes::commit_file_hashes` for the all-Rust orchestrator). #[napi(ts_args_type = "batches: Array<{ file: string; definitions: Array<{ name: string; kind: string; line: number; endLine?: number; visibility?: string; children: Array<{ name: string; kind: string; line: number; endLine?: number; visibility?: string }> }>; exports: Array<{ name: string; kind: string; line: number }> }>, fileHashes: FileHashEntry[], removedFiles: string[]")] pub fn bulk_insert_nodes( &self, @@ -869,9 +877,13 @@ impl NativeDatabase { napi::Error::from_reason(format!("bulk_insert_nodes: invalid batches: {e}")) })?; let conn = self.conn()?; - Ok(insert_nodes::do_insert_nodes(conn, &batches, &file_hashes, &removed_files) + let insert_ok = insert_nodes::do_insert_nodes(conn, &batches, &removed_files) .inspect_err(|e| eprintln!("[NativeDatabase] bulk_insert_nodes failed: {e}")) - .is_ok()) + .is_ok(); + let hashes_ok = insert_nodes::commit_file_hashes(conn, &file_hashes) + .inspect_err(|e| eprintln!("[NativeDatabase] bulk_insert_nodes hash commit failed: {e}")) + .is_ok(); + Ok(insert_ok && hashes_ok) } /// Bulk-insert edge rows using chunked multi-value INSERT statements. diff --git a/crates/codegraph-core/src/domain/graph/builder/pipeline.rs b/crates/codegraph-core/src/domain/graph/builder/pipeline.rs index f191b9f66..ea4442e34 100644 --- a/crates/codegraph-core/src/domain/graph/builder/pipeline.rs +++ b/crates/codegraph-core/src/domain/graph/builder/pipeline.rs @@ -9,10 +9,13 @@ //! 2. Collect files (with gitignore + extension filter) //! 3. Detect changes (tiered: journal/mtime/hash) //! 4. Parse files in parallel (existing `parallel::parse_files_parallel`) -//! 5. Insert nodes (existing `insert_nodes::do_insert_nodes`) +//! 5. Insert nodes (existing `insert_nodes::do_insert_nodes`) — file_hashes +//! for changed files is NOT written here; see step 7 //! 6. Resolve imports (existing `resolve::resolve_imports_batch`) //! 6b. Re-parse barrel candidates (incremental only) -//! 7. Build import edges + call edges + barrel resolution +//! 7. Build import edges + call edges + barrel resolution, then commit +//! file_hashes for changed files (`insert_nodes::commit_file_hashes`) now +//! that their edges match this revision (#1731) //! 8. Structure metrics + role classification //! 9. Finalize (metadata, journal) @@ -505,13 +508,20 @@ pub fn run_pipeline( timing.parse_ms = t0.elapsed().as_secs_f64() * 1000.0; // ── Stage 5: Insert nodes ────────────────────────────────────────── + // file_hashes for these files is deliberately NOT written here — only + // node/edge-adjacent (contains, parameter_of) data plus removed-file hash + // cleanup. Committing a changed file's hash this early (before Stage 7 + // rebuilds its import/call edges) would let the hash claim "up to date" + // even if edge-building later fails or is interrupted, permanently + // desyncing file_hashes from the edges it's supposed to gate re-parsing + // on (#1731). The hash is committed at the end of Stage 7 instead, once + // edges genuinely match this revision — see `commit_file_hashes` below. let t0 = Instant::now(); let insert_batches = build_insert_batches(&file_symbols); let file_hashes = build_file_hash_entries(&parse_changes); let _ = crate::domain::graph::builder::stages::insert_nodes::do_insert_nodes( conn, &insert_batches, - &file_hashes, &change_result.removed, ); detect_changes::heal_metadata(conn, &change_result.metadata_updates); @@ -564,6 +574,15 @@ pub fn run_pipeline( build_and_insert_call_edges(conn, &file_symbols, &import_ctx, !change_result.is_full_build); reconnect_saved_reverse_dep_edges(conn, &saved_reverse_dep_edges); + + // Now that edges reflect this revision, commit file_hashes for the + // changed files (#1731). Deferred from Stage 5 — see the comment there. + if let Err(e) = crate::domain::graph::builder::stages::insert_nodes::commit_file_hashes( + conn, + &file_hashes, + ) { + eprintln!("[codegraph] commit_file_hashes failed: {e}"); + } timing.edges_ms = t0.elapsed().as_secs_f64() * 1000.0; // ── Stage 8: Structure + roles ───────────────────────────────────── diff --git a/crates/codegraph-core/src/domain/graph/builder/stages/insert_nodes.rs b/crates/codegraph-core/src/domain/graph/builder/stages/insert_nodes.rs index 69b4519ab..bb81b8782 100644 --- a/crates/codegraph-core/src/domain/graph/builder/stages/insert_nodes.rs +++ b/crates/codegraph-core/src/domain/graph/builder/stages/insert_nodes.rs @@ -94,17 +94,41 @@ fn query_node_ids( Ok(map) } +/// Insert nodes/children/containment edges, plus hash cleanup for removed +/// files. `file_hashes` for CHANGED files is intentionally NOT written here — +/// see [`commit_file_hashes`] below (#1731): committing a file's hash this +/// early (before import/call edges are built) would let the hash claim +/// "up to date" even if edge-building later fails or is interrupted. Deleting +/// a removed file's hash has no such coupling risk (the file has no edges to +/// keep in sync with), so `removed_files` is still handled immediately. pub(crate) fn do_insert_nodes( conn: &Connection, batches: &[InsertNodesBatch], - file_hashes: &[FileHashEntry], removed_files: &[String], ) -> rusqlite::Result<()> { let tx = conn.unchecked_transaction()?; insert_file_nodes(&tx, batches)?; let (contains_edges, param_of_edges) = insert_symbol_nodes(&tx, batches)?; upsert_node_batch(&tx, &contains_edges, ¶m_of_edges)?; - upsert_file_hashes(&tx, file_hashes, removed_files)?; + delete_removed_file_hashes(&tx, removed_files)?; + tx.commit() +} + +/// Commit `file_hashes` for changed files. Called separately from +/// [`do_insert_nodes`], strictly AFTER import/call edges have been rebuilt +/// for these files (#1731) — see that function's doc comment and the call +/// site in `pipeline.rs` Stage 7. Own transaction: node insertion and hash +/// commit are two distinct steps in the coupling this fix establishes, not +/// one atomic unit. +pub(crate) fn commit_file_hashes( + conn: &Connection, + file_hashes: &[FileHashEntry], +) -> rusqlite::Result<()> { + if file_hashes.is_empty() { + return Ok(()); + } + let tx = conn.unchecked_transaction()?; + upsert_file_hashes(&tx, file_hashes)?; tx.commit() } @@ -297,43 +321,57 @@ fn upsert_node_batch( Ok(()) } -/// Phase 4: upsert file hashes and remove hashes for deleted files. No-ops -/// gracefully when the `file_hashes` table has not been created yet (e.g. -/// during the initial schema migration). +/// Returns true iff the `file_hashes` table exists. Both halves of the old +/// combined upsert/delete function (now [`upsert_file_hashes`] and +/// [`delete_removed_file_hashes`]) no-op gracefully when it's absent — e.g. +/// during the initial schema migration. +fn has_file_hashes_table(tx: &rusqlite::Transaction) -> bool { + tx.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='file_hashes'") + .and_then(|mut s| s.query_row([], |_| Ok(true))) + .unwrap_or(false) +} + +/// Phase 4a: upsert file hashes for changed files. Split out from the +/// removed-files delete (below) so the two can be committed at different +/// points in the pipeline — see [`commit_file_hashes`] (#1731). fn upsert_file_hashes( tx: &rusqlite::Transaction, file_hashes: &[FileHashEntry], - removed_files: &[String], ) -> rusqlite::Result<()> { - let has_file_hashes = tx - .prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='file_hashes'") - .and_then(|mut s| s.query_row([], |_| Ok(true))) - .unwrap_or(false); - - if !has_file_hashes { + if file_hashes.is_empty() || !has_file_hashes_table(tx) { return Ok(()); } - { - let mut upsert = tx.prepare_cached( - "INSERT OR REPLACE INTO file_hashes (file, hash, mtime, size) \ - VALUES (?1, ?2, ?3, ?4)", - )?; - for entry in file_hashes { - upsert.execute(params![ - &entry.file, - &entry.hash, - entry.mtime as i64, - entry.size as i64 - ])?; - } + let mut upsert = tx.prepare_cached( + "INSERT OR REPLACE INTO file_hashes (file, hash, mtime, size) \ + VALUES (?1, ?2, ?3, ?4)", + )?; + for entry in file_hashes { + upsert.execute(params![ + &entry.file, + &entry.hash, + entry.mtime as i64, + entry.size as i64 + ])?; } - if !removed_files.is_empty() { - let mut delete = tx.prepare_cached("DELETE FROM file_hashes WHERE file = ?1")?; - for file in removed_files { - delete.execute(params![file])?; - } + Ok(()) +} + +/// Phase 4b: remove file_hashes rows for deleted files. Safe to run +/// immediately alongside node insertion (unlike the upsert above) — a +/// removed file has no edges that need to stay in sync with its hash. +fn delete_removed_file_hashes( + tx: &rusqlite::Transaction, + removed_files: &[String], +) -> rusqlite::Result<()> { + if removed_files.is_empty() || !has_file_hashes_table(tx) { + return Ok(()); + } + + let mut delete = tx.prepare_cached("DELETE FROM file_hashes WHERE file = ?1")?; + for file in removed_files { + delete.execute(params![file])?; } Ok(()) diff --git a/src/domain/graph/builder/incremental.ts b/src/domain/graph/builder/incremental.ts index 909230fb4..cde9e3a78 100644 --- a/src/domain/graph/builder/incremental.ts +++ b/src/domain/graph/builder/incremental.ts @@ -29,7 +29,7 @@ import { resolveReceiverEdge, resolveSameClassQualifiedMethod, } from './call-resolver.js'; -import { BUILTIN_RECEIVERS, readFileSafe } from './helpers.js'; +import { BUILTIN_RECEIVERS, fileHash, fileStat, readFileSafe } from './helpers.js'; // ── Local types ───────────────────────────────────────────────────────── @@ -42,6 +42,14 @@ export interface IncrementalStmts { listSymbols: { all: (...params: unknown[]) => unknown[] }; findNodeInFile: { all: (...params: unknown[]) => unknown[] }; findNodeByName: { all: (...params: unknown[]) => unknown[] }; + /** + * Upsert a `file_hashes` row: `(relPath, hash, mtime, size)`. Called only + * after a file's edges have been fully rebuilt (#1731) — see the call site + * in `rebuildFile` for why this can't happen any earlier. + */ + upsertFileHash: { run: (...params: unknown[]) => unknown }; + /** Delete a `file_hashes` row for a file removed from disk. */ + deleteFileHash: { run: (...params: unknown[]) => unknown }; } interface RebuildResult { @@ -955,6 +963,10 @@ export async function rebuildFile( if (!fs.existsSync(filePath)) { if (cache) (cache as { remove(p: string): void }).remove(filePath); + // The file no longer exists, so it has no edges to keep in sync with a + // hash — delete it immediately (mirrors the full-build removed-file path + // in insertNodes.ts, which is likewise unconditional). + stmts.deleteFileHash.run(relPath); return buildDeletionResult(relPath, oldNodes, edgesBefore, oldSymbols, diffSymbols); } @@ -999,6 +1011,18 @@ export async function rebuildFile( // their edges unchanged. const totalEdgesBefore = edgesBefore + reverseDepsEdgesBefore; + // Commit file_hashes now that relPath's edges have been fully rebuilt to + // match `code` (#1731). Writing this any earlier — or not at all, as + // before this fix — would leave file_hashes stale relative to the edges + // rebuildEdgesForTargetFile just wrote, so the next full/incremental + // `codegraph build` would either redundantly reprocess an already-correct + // file (stale-hash direction) or, combined with other divergent writers, + // risk trusting a hash that doesn't actually reflect these edges. + const stat = fileStat(filePath); + if (stat) { + stmts.upsertFileHash.run(relPath, fileHash(code), stat.mtime, stat.size); + } + const symbolDiff = diffSymbols ? diffSymbols(oldSymbols, newSymbols) : null; const event = oldNodes === 0 ? 'added' : 'modified'; diff --git a/src/domain/graph/builder/pipeline.ts b/src/domain/graph/builder/pipeline.ts index 28e4eb0df..7f3bf09b8 100644 --- a/src/domain/graph/builder/pipeline.ts +++ b/src/domain/graph/builder/pipeline.ts @@ -39,7 +39,7 @@ import { buildStructure } from './stages/build-structure.js'; import { collectFiles } from './stages/collect-files.js'; import { detectChanges, detectNoChanges } from './stages/detect-changes.js'; import { finalize } from './stages/finalize.js'; -import { insertNodes } from './stages/insert-nodes.js'; +import { commitFileHashes, insertNodes } from './stages/insert-nodes.js'; import { closeNativeDb, refreshJsDb, @@ -319,6 +319,14 @@ async function runPipelineStages(ctx: PipelineContext): Promise { await resolveImports(ctx); await buildEdges(ctx); + + // Commit file_hashes for changed files now that their edges have been + // rebuilt to match (#1731) — see commitFileHashes() for the rationale. + // Placed before buildStructure/runAnalyses so a failure there still + // benefits from a hash that accurately reflects the (now-consistent) + // node+edge state committed so far. + commitFileHashes(ctx); + await buildStructure(ctx); // Reopen nativeDb for feature modules (ast, cfg, complexity, dataflow). diff --git a/src/domain/graph/builder/stages/insert-nodes.ts b/src/domain/graph/builder/stages/insert-nodes.ts index c0459a2c5..0d786d249 100644 --- a/src/domain/graph/builder/stages/insert-nodes.ts +++ b/src/domain/graph/builder/stages/insert-nodes.ts @@ -2,11 +2,16 @@ * Stage: insertNodes * * Batch-inserts file nodes, definitions, exports, children, and contains/parameter_of edges. - * Updates file hashes for incremental builds. * * When the native engine is available, delegates all SQLite writes to Rust via * `bulkInsertNodes` — eliminating JS↔C boundary overhead. Falls back to the * JS implementation on failure or when native is unavailable. + * + * Does NOT write file_hashes for changed files (only removed-file cleanup, + * which carries no coupling risk — see commitFileHashes below). Hashes for + * changed files are committed later, once resolveImports/buildEdges have + * finished rebuilding their edges, so a hash can never claim a file is + * "up to date" while its edges still reflect an older revision (#1731). */ import path from 'node:path'; import { performance } from 'node:perf_hooks'; @@ -19,7 +24,6 @@ import type { ExtractorOutput, FileToParse, MetadataUpdate, - SqliteStatement, } from '../../../../types.js'; import type { PipelineContext } from '../context.js'; import { @@ -218,28 +222,26 @@ export function buildFileHashes( function tryNativeInsert(ctx: PipelineContext): boolean { if (!ctx.nativeDb?.bulkInsertNodes) return false; - const { allSymbols, filesToParse, metadataUpdates, rootDir, removed } = ctx; + const { allSymbols, removed } = ctx; const batches = marshalSymbolBatches(allSymbols); - const precomputedData = new Map(); - for (const item of filesToParse) { - if (item.relPath) precomputedData.set(item.relPath, item as PrecomputedFileData); - } - const fileHashes = buildFileHashes(filesToParse, precomputedData, metadataUpdates, rootDir); - - // In native-first mode (single rusqlite connection), no WAL dance is needed. - // In dual-connection mode, checkpoint JS side before native write, then - // checkpoint native side after (#696, #709, #715, #717). + // file_hashes is intentionally NOT written here. Committing a file's hash + // this early (before resolveImports/buildEdges have run) would let the + // hash claim "up to date" even if edge-building later throws or is + // interrupted — see commitFileHashes below, called once edges are in + // place (#1731). `removed` is still passed through: deleting a removed + // file's hash has no such coupling risk (the file has no edges to keep + // in sync with). let result: boolean; if (ctx.nativeFirstProxy) { - result = ctx.nativeDb!.bulkInsertNodes(batches, fileHashes, removed); + result = ctx.nativeDb!.bulkInsertNodes(batches, [], removed); } else { try { if (ctx.db) { ctx.db.pragma('wal_checkpoint(TRUNCATE)'); } - result = ctx.nativeDb!.bulkInsertNodes(batches, fileHashes, removed); + result = ctx.nativeDb!.bulkInsertNodes(batches, [], removed); } finally { try { ctx.nativeDb?.exec('PRAGMA wal_checkpoint(TRUNCATE)'); @@ -380,36 +382,10 @@ function insertChildrenAndEdges( batchInsertEdges(db, edgeRows); } -// ── JS fallback: Phase 4 ──────────────────────────────────────────── - -function updateFileHashes( - _db: BetterSqlite3Database, - filesToParse: FileToParse[], - precomputedData: Map, - metadataUpdates: MetadataUpdate[], - rootDir: string, - upsertHash: SqliteStatement | null, -): void { - if (!upsertHash) return; - - // Iterate every collected file (#1068): files that produced zero symbols - // (empty, parser no-op, or grammar-missing optional language) still need a - // hash row, otherwise the next no-op rebuild's fast-skip pre-flight rejects. - for (const record of iterFileHashRecords( - filesToParse, - precomputedData, - metadataUpdates, - rootDir, - 'updateFileHashes', - )) { - upsertHash.run(record.file, record.hash, record.mtime, record.size); - } -} - // ── Main entry point ──────────────────────────────────────────────── export async function insertNodes(ctx: PipelineContext): Promise { - const { allSymbols, filesToParse, metadataUpdates, rootDir, removed } = ctx; + const { allSymbols, removed } = ctx; // Populate fileSymbols before any DB writes (used by later stages) for (const [relPath, symbols] of allSymbols) { @@ -423,7 +399,9 @@ export async function insertNodes(ctx: PipelineContext): Promise { try { if (tryNativeInsert(ctx)) { ctx.timing.insertMs = performance.now() - t0; - // Removed-file hash cleanup is handled inside the native call + // Removed-file hash cleanup is handled inside the native call. + // Content-changed files' hashes are committed later by + // commitFileHashes(), once their edges exist (#1731). return; } } catch (e) { @@ -431,36 +409,82 @@ export async function insertNodes(ctx: PipelineContext): Promise { } } - // JS fallback - const precomputedData = new Map(); - for (const item of filesToParse) { - if (item.relPath) precomputedData.set(item.relPath, item as PrecomputedFileData); - } - - let upsertHash: SqliteStatement | null; - try { - upsertHash = ctx.db.prepare( - 'INSERT OR REPLACE INTO file_hashes (file, hash, mtime, size) VALUES (?, ?, ?, ?)', - ); - } catch (e) { - debug(`insertNodes: file_hashes prepare failed (table may not exist): ${toErrorMessage(e)}`); - upsertHash = null; - } - + // JS fallback — node/edge insertion only. file_hashes for changed files is + // intentionally NOT written here: see commitFileHashes() below (#1731). const insertAll = ctx.db.transaction(() => { insertDefinitionsAndExports(ctx.db, allSymbols); insertChildrenAndEdges(ctx.db, allSymbols); - updateFileHashes(ctx.db, filesToParse, precomputedData, metadataUpdates, rootDir, upsertHash); }); insertAll(); ctx.timing.insertMs = performance.now() - t0; - // Clean up removed file hashes - if (upsertHash && removed.length > 0) { - const deleteHash = ctx.db.prepare('DELETE FROM file_hashes WHERE file = ?'); - for (const relPath of removed) { - deleteHash.run(relPath); + // Clean up removed file hashes. Safe to do immediately (unlike the upsert + // path): a removed file has no edges that need to stay in sync with its hash. + if (removed.length > 0) { + try { + const deleteHash = ctx.db.prepare('DELETE FROM file_hashes WHERE file = ?'); + for (const relPath of removed) { + deleteHash.run(relPath); + } + } catch (e) { + debug( + `insertNodes: removed-file hash cleanup failed (table may not exist): ${toErrorMessage(e)}`, + ); + } + } +} + +// ── Deferred file_hashes commit ────────────────────────────────────── + +/** + * Commit `file_hashes` for every changed/parsed file, plus metadata-only + * healing entries. Called by the pipeline strictly AFTER resolveImports and + * buildEdges have finished rebuilding those files' edges. + * + * This is the fix for #1731: previously, `insertNodes` wrote file_hashes in + * the same transaction as node insertion — BEFORE resolveImports/buildEdges + * ran. Any exception, crash, or interruption between that write and the + * (separate) edge-building transaction(s) left the DB with a hash that + * claimed "up to date" while edges still reflected the previous revision (or + * were missing entirely) — and since change-detection trusts file_hashes + * exclusively, that divergence was never self-healed by later builds. + * + * Deferring the write here restores the invariant: a file's hash only ever + * advances once its edges have been rebuilt to match. If anything upstream + * throws before this point, the hash keeps its old value, so the next build + * correctly detects the file as still needing (re)processing. + */ +export function commitFileHashes(ctx: PipelineContext): void { + const { filesToParse, metadataUpdates, rootDir } = ctx; + + const precomputedData = new Map(); + for (const item of filesToParse) { + if (item.relPath) precomputedData.set(item.relPath, item as PrecomputedFileData); + } + const fileHashes = buildFileHashes(filesToParse, precomputedData, metadataUpdates, rootDir); + if (fileHashes.length === 0) return; + + if (ctx.engineName === 'native' && ctx.nativeDb?.healFileMetadata) { + try { + ctx.nativeDb.healFileMetadata(fileHashes); + } catch (e) { + debug(`commitFileHashes: native healFileMetadata failed: ${toErrorMessage(e)}`); } + return; + } + + try { + const upsertHash = ctx.db.prepare( + 'INSERT OR REPLACE INTO file_hashes (file, hash, mtime, size) VALUES (?, ?, ?, ?)', + ); + const commitTx = ctx.db.transaction(() => { + for (const record of fileHashes) { + upsertHash.run(record.file, record.hash, record.mtime, record.size); + } + }); + commitTx(); + } catch (e) { + debug(`commitFileHashes: file_hashes write failed (table may not exist): ${toErrorMessage(e)}`); } } diff --git a/src/domain/graph/watcher.ts b/src/domain/graph/watcher.ts index 703a45247..97b86d066 100644 --- a/src/domain/graph/watcher.ts +++ b/src/domain/graph/watcher.ts @@ -44,6 +44,10 @@ function prepareWatcherStatements(db: ReturnType): IncrementalStm "SELECT id, file, kind FROM nodes WHERE name = ? AND kind IN ('function', 'method', 'class', 'interface', 'type', 'struct', 'enum', 'trait', 'record', 'module', 'constant')", ), listSymbols: db.prepare("SELECT name, kind, line FROM nodes WHERE file = ? AND kind != 'file'"), + upsertFileHash: db.prepare( + 'INSERT OR REPLACE INTO file_hashes (file, hash, mtime, size) VALUES (?, ?, ?, ?)', + ), + deleteFileHash: db.prepare('DELETE FROM file_hashes WHERE file = ?'), }; } diff --git a/tests/integration/issue-1259-watch-call-resolution.test.ts b/tests/integration/issue-1259-watch-call-resolution.test.ts index affbf8c85..78e563c33 100644 --- a/tests/integration/issue-1259-watch-call-resolution.test.ts +++ b/tests/integration/issue-1259-watch-call-resolution.test.ts @@ -99,6 +99,10 @@ function makeStmts(db: ReturnType) { "SELECT id, file, kind FROM nodes WHERE name = ? AND kind IN ('function', 'method', 'class', 'interface', 'type', 'struct', 'enum', 'trait', 'record', 'module', 'constant')", ), listSymbols: db.prepare("SELECT name, kind, line FROM nodes WHERE file = ? AND kind != 'file'"), + upsertFileHash: db.prepare( + 'INSERT OR REPLACE INTO file_hashes (file, hash, mtime, size) VALUES (?, ?, ?, ?)', + ), + deleteFileHash: db.prepare('DELETE FROM file_hashes WHERE file = ?'), }; } diff --git a/tests/integration/issue-1370-incremental-caller-name.test.ts b/tests/integration/issue-1370-incremental-caller-name.test.ts index 8751d449b..ecdca2079 100644 --- a/tests/integration/issue-1370-incremental-caller-name.test.ts +++ b/tests/integration/issue-1370-incremental-caller-name.test.ts @@ -95,6 +95,10 @@ function makeStmts(db: ReturnType) { "SELECT id, file, kind FROM nodes WHERE name = ? AND kind IN ('function', 'method', 'class', 'interface', 'type', 'struct', 'enum', 'trait', 'record', 'module', 'constant')", ), listSymbols: db.prepare("SELECT name, kind, line FROM nodes WHERE file = ? AND kind != 'file'"), + upsertFileHash: db.prepare( + 'INSERT OR REPLACE INTO file_hashes (file, hash, mtime, size) VALUES (?, ?, ?, ?)', + ), + deleteFileHash: db.prepare('DELETE FROM file_hashes WHERE file = ?'), }; } diff --git a/tests/integration/issue-1731-hash-edge-coupling.test.ts b/tests/integration/issue-1731-hash-edge-coupling.test.ts new file mode 100644 index 000000000..0fee9b45b --- /dev/null +++ b/tests/integration/issue-1731-hash-edge-coupling.test.ts @@ -0,0 +1,251 @@ +/** + * Regression test for #1731: `file_hashes` must never claim a file is + * "up to date" while its edges still reflect an older revision. + * + * Root cause: `insertNodes` committed `file_hashes` for changed files in the + * same transaction as node insertion — BEFORE `resolveImports`/`buildEdges` + * had rebuilt those files' edges (a separate, later stage/transaction). Any + * exception thrown while rebuilding edges left the DB with a hash that + * matched the file's CURRENT on-disk content while its edges still reflected + * the PREVIOUS content (or were missing entirely, since the old edges had + * already been purged). Because change-detection trusts `file_hashes` + * exclusively, that divergence was never self-healed by later builds — the + * file would be silently skipped forever, permanently showing stale/missing + * edges via `codegraph deps` / `where --file`. + * + * The fix defers the `file_hashes` commit so it only happens once + * `resolveImports`/`buildEdges` have finished rebuilding a file's edges + * (`commitFileHashes` in `insert-nodes.ts`, called from the pipeline after + * `buildEdges`). Separately, watch-mode's `rebuildFile` never wrote + * `file_hashes` at all — also fixed here, coupling the write to a + * successful edge rebuild. + * + * This file has two suites: + * 1. Fault-injects an exception inside `buildEdges` during an incremental + * build (the adversarial case that would have caught the original bug) + * and asserts the hash does not advance until edges genuinely match. + * 2. Exercises `rebuildFile` (the watch-mode path) directly and asserts it + * now keeps `file_hashes` in sync with the edges it rebuilds. + */ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import Database from 'better-sqlite3'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { getNodeId as getNodeIdQuery, initSchema, openDb } from '../../src/db/index.js'; +import { fileHash } from '../../src/domain/graph/builder/helpers.js'; +import { rebuildFile } from '../../src/domain/graph/builder/incremental.js'; +import { buildGraph } from '../../src/domain/graph/builder.js'; + +// ── Fault injection for suite 1 ────────────────────────────────────────── +// A `vi.hoisted` object is used (rather than a plain module-scope `let`) so +// the mutable flag is visible both inside the hoisted `vi.mock` factory and +// in the test body that arms/disarms it. +const injection = vi.hoisted(() => ({ armed: false })); + +vi.mock('../../src/domain/graph/builder/stages/build-edges.js', async (importOriginal) => { + const mod = + await importOriginal(); + return { + ...mod, + buildEdges: async (ctx: Parameters[0]) => { + if (injection.armed) { + injection.armed = false; + throw new Error('simulated buildEdges failure (#1731 regression test)'); + } + return mod.buildEdges(ctx); + }, + }; +}); + +// ── Shared fixture helpers ──────────────────────────────────────────────── + +/** Writes a.js importing from whichever of b.js/c.js are named in `imports`. */ +function writeProject(dir: string, imports: string[]) { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'b.js'), 'export function b() { return 1; }\n'); + fs.writeFileSync(path.join(dir, 'c.js'), 'export function c() { return 2; }\n'); + const importLines = imports.map((m) => `import { ${m} } from './${m}.js';`).join('\n'); + const body = imports.map((m) => `${m}()`).join(' + ') || '0'; + fs.writeFileSync( + path.join(dir, 'a.js'), + `${importLines}\nexport function run() { return ${body}; }\n`, + ); +} + +function readFileHashRow(dbPath: string, file: string): { hash: string } | undefined { + const db = new Database(dbPath, { readonly: true }); + try { + return db.prepare('SELECT hash FROM file_hashes WHERE file = ?').get(file) as + | { hash: string } + | undefined; + } finally { + db.close(); + } +} + +function readImportEdgeTargets(dbPath: string, file: string): string[] { + const db = new Database(dbPath, { readonly: true }); + try { + return ( + db + .prepare( + `SELECT n2.file AS tgt FROM edges e + JOIN nodes n1 ON e.source_id = n1.id + JOIN nodes n2 ON e.target_id = n2.id + WHERE n1.file = ? AND n1.kind = 'file' AND e.kind = 'imports' + ORDER BY n2.file`, + ) + .all(file) as Array<{ tgt: string }> + ).map((r) => r.tgt); + } finally { + db.close(); + } +} + +// ── Suite 1: incremental build pipeline ────────────────────────────────── + +describe('Issue #1731: file_hashes/edges coupling survives a mid-build failure', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-1731-')); + injection.armed = false; + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('does not commit file_hashes when buildEdges throws mid-incremental-build, and self-heals on retry', async () => { + writeProject(tmpDir, ['b', 'c']); + // engine: 'wasm' pins this to the JS pipeline (insertNodes/resolveImports/ + // buildEdges as separate stages) — the code path this fix changes. The + // native orchestrator runs the equivalent pipeline entirely in Rust and + // isn't reachable via a mocked JS module. + await buildGraph(tmpDir, { incremental: false, skipRegistry: true, engine: 'wasm' }); + + const dbPath = path.join(tmpDir, '.codegraph', 'graph.db'); + const originalHash = fileHash(fs.readFileSync(path.join(tmpDir, 'a.js'), 'utf-8')); + expect(readFileHashRow(dbPath, 'a.js')?.hash).toBe(originalHash); + expect(readImportEdgeTargets(dbPath, 'a.js')).toEqual(['b.js', 'c.js']); + + // Edit a.js to drop its import of c.js. + writeProject(tmpDir, ['b']); + const editedHash = fileHash(fs.readFileSync(path.join(tmpDir, 'a.js'), 'utf-8')); + expect(editedHash).not.toBe(originalHash); + + // Arm the fault injection and run the incremental build — it must reject + // partway through, after node insertion but before edges are rebuilt. + injection.armed = true; + await expect( + buildGraph(tmpDir, { incremental: true, skipRegistry: true, engine: 'wasm' }), + ).rejects.toThrow(/simulated buildEdges failure/); + + // The hash must NOT have advanced to match the edited content. If it + // had (the pre-fix behavior), the file would be permanently stuck: the + // next build would see the hash as "up to date" and skip reprocessing + // it forever, leaving its edges stale or missing. + expect(readFileHashRow(dbPath, 'a.js')?.hash).toBe(originalHash); + + // Retry without fault injection — the resulting hash/content mismatch + // must be detected so the file gets fully reprocessed. + injection.armed = false; + await buildGraph(tmpDir, { incremental: true, skipRegistry: true, engine: 'wasm' }); + + expect(readFileHashRow(dbPath, 'a.js')?.hash).toBe(editedHash); + expect(readImportEdgeTargets(dbPath, 'a.js')).toEqual(['b.js']); + }); +}); + +// ── Suite 2: watch-mode rebuildFile ─────────────────────────────────────── + +function makeStmts(db: ReturnType) { + return { + insertNode: db.prepare( + 'INSERT OR IGNORE INTO nodes (name, kind, file, line, end_line) VALUES (?, ?, ?, ?, ?)', + ), + getNodeId: { + get: (name: string, kind: string, file: string, line: number) => { + const id = getNodeIdQuery(db, name, kind, file, line); + return id != null ? { id } : undefined; + }, + }, + insertEdge: db.prepare( + 'INSERT INTO edges (source_id, target_id, kind, confidence, dynamic) VALUES (?, ?, ?, ?, ?)', + ), + countNodes: db.prepare('SELECT COUNT(*) as c FROM nodes WHERE file = ?'), + countEdges: db.prepare( + 'SELECT COUNT(*) as c FROM edges WHERE source_id IN (SELECT id FROM nodes WHERE file = ?)', + ), + findNodeInFile: db.prepare( + "SELECT id, kind, file FROM nodes WHERE name = ? AND kind IN ('function', 'method', 'class', 'interface', 'type', 'struct', 'enum', 'trait', 'record', 'module', 'constant') AND file = ?", + ), + findNodeByName: db.prepare( + "SELECT id, file, kind FROM nodes WHERE name = ? AND kind IN ('function', 'method', 'class', 'interface', 'type', 'struct', 'enum', 'trait', 'record', 'module', 'constant')", + ), + listSymbols: db.prepare("SELECT name, kind, line FROM nodes WHERE file = ? AND kind != 'file'"), + upsertFileHash: db.prepare( + 'INSERT OR REPLACE INTO file_hashes (file, hash, mtime, size) VALUES (?, ?, ?, ?)', + ), + deleteFileHash: db.prepare('DELETE FROM file_hashes WHERE file = ?'), + }; +} + +describe('Issue #1731: watch-mode rebuildFile keeps file_hashes in sync with edges', () => { + let tmpDir: string; + let dbPath: string; + + beforeEach(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-1731-watch-')); + writeProject(tmpDir, ['b', 'c']); + await buildGraph(tmpDir, { incremental: false, skipRegistry: true, engine: 'wasm' }); + dbPath = path.join(tmpDir, '.codegraph', 'graph.db'); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('updates file_hashes to match the edited content after a rebuild, not just the edges', async () => { + const originalHash = readFileHashRow(dbPath, 'a.js')?.hash; + expect(originalHash).toBeTruthy(); + + // Edit a.js to drop its import of c.js, then rebuild via the watcher's + // single-file path. + writeProject(tmpDir, ['b']); + const editedHash = fileHash(fs.readFileSync(path.join(tmpDir, 'a.js'), 'utf-8')); + expect(editedHash).not.toBe(originalHash); + + const db = openDb(dbPath); + try { + initSchema(db); + const stmts = makeStmts(db); + await rebuildFile(db, tmpDir, path.join(tmpDir, 'a.js'), stmts, { engine: 'wasm' }, null); + } finally { + db.close(); + } + + // Before the fix, rebuildFile rebuilt edges correctly but never touched + // file_hashes at all, leaving it permanently stuck at originalHash. + expect(readFileHashRow(dbPath, 'a.js')?.hash).toBe(editedHash); + expect(readImportEdgeTargets(dbPath, 'a.js')).toEqual(['b.js']); + }); + + it('removes the file_hashes row when the file is deleted', async () => { + expect(readFileHashRow(dbPath, 'a.js')).toBeTruthy(); + + fs.rmSync(path.join(tmpDir, 'a.js')); + + const db = openDb(dbPath); + try { + initSchema(db); + const stmts = makeStmts(db); + await rebuildFile(db, tmpDir, path.join(tmpDir, 'a.js'), stmts, { engine: 'wasm' }, null); + } finally { + db.close(); + } + + expect(readFileHashRow(dbPath, 'a.js')).toBeUndefined(); + }); +}); diff --git a/tests/integration/watcher-fk-embeddings.test.ts b/tests/integration/watcher-fk-embeddings.test.ts index 6d75cf55d..2bac2c16d 100644 --- a/tests/integration/watcher-fk-embeddings.test.ts +++ b/tests/integration/watcher-fk-embeddings.test.ts @@ -54,6 +54,10 @@ function makeStmts(db: Database.Database): Parameters[3] { "SELECT id, file FROM nodes WHERE name = ? AND kind IN ('function', 'method', 'class', 'interface', 'type', 'struct', 'enum', 'trait', 'record', 'module', 'constant')", ), listSymbols: db.prepare("SELECT name, kind, line FROM nodes WHERE file = ? AND kind != 'file'"), + upsertFileHash: db.prepare( + 'INSERT OR REPLACE INTO file_hashes (file, hash, mtime, size) VALUES (?, ?, ?, ?)', + ), + deleteFileHash: db.prepare('DELETE FROM file_hashes WHERE file = ?'), } as Parameters[3]; } diff --git a/tests/integration/watcher-rebuild.test.ts b/tests/integration/watcher-rebuild.test.ts index 95c286a72..5b92d486d 100644 --- a/tests/integration/watcher-rebuild.test.ts +++ b/tests/integration/watcher-rebuild.test.ts @@ -77,6 +77,10 @@ function makeStmts(db) { "SELECT id, kind, file FROM nodes WHERE name = ? AND kind IN ('function', 'method', 'class', 'interface', 'type', 'struct', 'enum', 'trait', 'record', 'module', 'constant')", ), listSymbols: db.prepare("SELECT name, kind, line FROM nodes WHERE file = ? AND kind != 'file'"), + upsertFileHash: db.prepare( + 'INSERT OR REPLACE INTO file_hashes (file, hash, mtime, size) VALUES (?, ?, ?, ?)', + ), + deleteFileHash: db.prepare('DELETE FROM file_hashes WHERE file = ?'), }; } From d3ea4a498d329627cc14141131a23962a4aeba45 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Sun, 5 Jul 2026 16:41:09 -0600 Subject: [PATCH 09/67] fix: compare signature-change diffs using new-file line ranges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit checkNoSignatureChanges compared def.line (post-change, new-file coordinates, from the rebuilt graph) against diff.oldRanges (pre-change, old-file coordinates). Any diff that shifts line counts before a given point in a file — virtually all deletions/insertions — could make an untouched symbol's new line number coincidentally fall inside the old hunk's range, producing a false "signature change" violation. checkMaxBlastRadius already used diff.changedRanges (new-file positions) against the same db; checkNoSignatureChanges now does the same. The parameter is renamed from oldRanges to changedRanges to make the expected coordinate space explicit. diff.oldRanges remains computed by parseDiffOutput and is still exercised directly by its own tests. No README/CLAUDE.md/ROADMAP updates needed — internal predicate logic fix, no new commands, languages, or architecture changes (docs check acknowledged). Fixes #1732 Fixes #1737 Impact: 2 functions changed, 5 affected --- src/features/check.ts | 14 ++++- tests/integration/check.test.ts | 97 +++++++++++++++++++++++++++++---- 2 files changed, 96 insertions(+), 15 deletions(-) diff --git a/src/features/check.ts b/src/features/check.ts index 2e9fc9df6..26f057e98 100644 --- a/src/features/check.ts +++ b/src/features/check.ts @@ -300,14 +300,22 @@ interface SignatureResult { violations: SignatureViolation[]; } +/** + * `db` reflects the current working-tree (post-change) file content, so + * `nodes.line` values are in new-file coordinates. `changedRanges` must be + * in the same coordinate space — i.e. `diff.changedRanges`, not + * `diff.oldRanges` (which is pre-change/old-file and would only line up + * with `db` by coincidence once a hunk changes the file's total line + * count). See issues #1732 and #1737. + */ export function checkNoSignatureChanges( db: BetterSqlite3Database, - oldRanges: Map, + changedRanges: Map, noTests: boolean, ): SignatureResult { const violations: SignatureViolation[] = []; - for (const [file, ranges] of oldRanges) { + for (const [file, ranges] of changedRanges) { if (ranges.length === 0) continue; if (noTests && isTestFile(file)) continue; @@ -491,7 +499,7 @@ function runPredicates( if (flags.enableSignatures) { predicates.push({ name: 'signatures', - ...checkNoSignatureChanges(db, diff.oldRanges, noTests), + ...checkNoSignatureChanges(db, diff.changedRanges, noTests), }); } if (flags.enableBoundaries) { diff --git a/tests/integration/check.test.ts b/tests/integration/check.test.ts index 3fc78fbbc..aa3546fa8 100644 --- a/tests/integration/check.test.ts +++ b/tests/integration/check.test.ts @@ -281,23 +281,23 @@ describe('checkMaxBlastRadius', () => { describe('checkNoSignatureChanges', () => { test('passes for body-only changes', () => { // add is at line 1. Changing lines 3-5 (body only) should pass - const oldRanges = new Map([['src/math.js', [{ start: 3, end: 5 }]]]); - const result = checkNoSignatureChanges(db, oldRanges, false); + const changedRanges = new Map([['src/math.js', [{ start: 3, end: 5 }]]]); + const result = checkNoSignatureChanges(db, changedRanges, false); expect(result.passed).toBe(true); }); test('fails when declaration line is in a changed hunk', () => { // add is at line 1. Changing lines 1-2 (includes declaration) should fail - const oldRanges = new Map([['src/math.js', [{ start: 1, end: 2 }]]]); - const result = checkNoSignatureChanges(db, oldRanges, false); + const changedRanges = new Map([['src/math.js', [{ start: 1, end: 2 }]]]); + const result = checkNoSignatureChanges(db, changedRanges, false); expect(result.passed).toBe(false); expect(result.violations.length).toBeGreaterThanOrEqual(1); expect(result.violations[0].name).toBe('add'); }); test('skips test files when noTests is true', () => { - const oldRanges = new Map([['tests/math.test.js', [{ start: 1, end: 5 }]]]); - const result = checkNoSignatureChanges(db, oldRanges, true); + const changedRanges = new Map([['tests/math.test.js', [{ start: 1, end: 5 }]]]); + const result = checkNoSignatureChanges(db, changedRanges, true); expect(result.passed).toBe(true); }); @@ -306,15 +306,15 @@ describe('checkNoSignatureChanges', () => { // exact "adopt shared helper, drop the file-local duplicate" pattern // grind performs — must not trip this check: every caller of a // private helper lives in the same file and is already part of the diff. - const oldRanges = new Map([['src/math.js', [{ start: 14, end: 16 }]]]); - const result = checkNoSignatureChanges(db, oldRanges, false); + const changedRanges = new Map([['src/math.js', [{ start: 14, end: 16 }]]]); + const result = checkNoSignatureChanges(db, changedRanges, false); expect(result.passed).toBe(true); }); test('still flags an exported symbol even when a private symbol shares the file', () => { // Sanity check that the exported-only filter doesn't accidentally // suppress real violations on exported declarations in the same file. - const oldRanges = new Map([ + const changedRanges = new Map([ [ 'src/math.js', [ @@ -323,7 +323,7 @@ describe('checkNoSignatureChanges', () => { ], ], ]); - const result = checkNoSignatureChanges(db, oldRanges, false); + const result = checkNoSignatureChanges(db, changedRanges, false); expect(result.passed).toBe(false); expect(result.violations.map((v) => v.name)).toEqual(['add']); }); @@ -348,12 +348,85 @@ describe('checkNoSignatureChanges', () => { ' }', // context, old line 9 ].join('\n'); - const { oldRanges } = parseDiffOutput(diff); + const { oldRanges, changedRanges } = parseDiffOutput(diff); + // parseDiffOutput's old-side tracking is still verified directly here... expect(oldRanges.get('src/math.js')).toEqual([{ start: 6, end: 6 }]); - const result = checkNoSignatureChanges(db, oldRanges, false); + // ...but checkNoSignatureChanges itself is driven by changedRanges + // (new-file coordinates). This hunk has no added lines, so there is + // nothing to compare against and multiply is correctly left alone. + const result = checkNoSignatureChanges(db, changedRanges, false); expect(result.passed).toBe(true); }); + + test('regression: coordinate-space mismatch does not falsely flag an untouched function after a shifting deletion (issues #1732, #1737)', () => { + // Real-world repro shape: an exported function sits right after an + // 11-line block that gets deleted outright. The db reflects the + // POST-change file (the block is gone, the function shifted up 11 + // lines), while the diff's old-side range still spans the pre-change + // line numbers. Before the fix, checkNoSignatureChanges was wired to + // `oldRanges`, so the function's new (post-change) line would + // coincidentally fall inside the old hunk's numeric range and get + // falsely flagged, even though its body is byte-for-byte identical. + insertNode(db, 'isPidAlive', 'function', 'src/coordshift.js', 2, 4, 1); + + const diff = [ + '--- a/src/coordshift.js', + '+++ b/src/coordshift.js', + '@@ -2,11 +1,0 @@', + '-function helperToRemove() {', + '- return 1;', + '-}', + '-', + '-function alsoUnused() {', + '- return 2;', + '-}', + '-', + '-function oneMoreUnused() {', + '- return 3;', + '-}', + ].join('\n'); + + const parsed = parseDiffOutput(diff); + // Pure deletion: the old side covers exactly the 11 removed lines; the + // new side has nothing added, so changedRanges is empty for this file. + expect(parsed.oldRanges.get('src/coordshift.js')).toEqual([{ start: 2, end: 12 }]); + expect(parsed.changedRanges.get('src/coordshift.js')).toEqual([]); + + // Prove the regression is real: had the call site still passed + // oldRanges, isPidAlive's post-change line (2) falls inside the old + // range [2, 12] and would be wrongly flagged. + const buggyResult = checkNoSignatureChanges(db, parsed.oldRanges, false); + expect(buggyResult.passed).toBe(false); + expect(buggyResult.violations.map((v) => v.name)).toContain('isPidAlive'); + + // The fix: changedRanges is empty for a pure deletion, so there is + // nothing to compare against and isPidAlive is correctly left alone. + const fixedResult = checkNoSignatureChanges(db, parsed.changedRanges, false); + expect(fixedResult.passed).toBe(true); + }); + + test('a genuinely touched exported function IS flagged via changedRanges (issues #1732, #1737)', () => { + // Complements the untouched-function case above: when a hunk actually + // replaces a declaration line, the post-change db line falls inside + // the *new*-side range and must still be flagged. + insertNode(db, 'someExportedFn', 'function', 'src/coordshift2.js', 1, 3, 1); + + const diff = [ + '--- a/src/coordshift2.js', + '+++ b/src/coordshift2.js', + '@@ -1,1 +1,1 @@', + '-function someExportedFn() {', + '+function someExportedFn(extra) {', + ].join('\n'); + + const { changedRanges } = parseDiffOutput(diff); + expect(changedRanges.get('src/coordshift2.js')).toEqual([{ start: 1, end: 1 }]); + + const result = checkNoSignatureChanges(db, changedRanges, false); + expect(result.passed).toBe(false); + expect(result.violations.map((v) => v.name)).toContain('someExportedFn'); + }); }); // ─── checkNoBoundaryViolations ──────────────────────────────────────── From c30681218d221e8f99f5a57bb6c205cd61c8de2f Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Sun, 5 Jul 2026 17:54:07 -0600 Subject: [PATCH 10/67] feat: add environment doctor check for stale native binary and missing WASM grammars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every git worktree gets its own untracked node_modules/ and grammars/, so a worktree set up before a host Node upgrade (or where npm install was interrupted) can end up with a better-sqlite3 binary compiled for the wrong Node ABI, or an incomplete grammars/ directory — both fail deep inside a build or test run with confusing, unrelated-looking errors rather than a clear diagnosis. Adds src/infrastructure/doctor.ts with two checks whose decision logic is pure and unit-tested in isolation from the I/O: - native ABI compatibility, via a real require() attempt - grammar completeness against the full LANGUAGE_REGISTRY, split by the registry's own required flag: a missing required (JS/TS/TSX) grammar fails the check, but a missing optional grammar only warns. Non-required parsers are designed to fail gracefully at runtime (per this repo's own CLAUDE.md), so a worktree missing one rarely-used language's grammar must stay able to run npm test, not get hard-blocked before a single test starts. scripts/doctor.ts is the CLI entry point (npm run doctor, optionally with --fix for a scoped, worktree-local repair covering both blocking and non-blocking findings) and is also wired as pretest so npm test fails fast on a genuinely blocking problem instead of a wall of unrelated failures. Fixes #1733 docs check acknowledged: CLAUDE.md already covers the new npm run doctor command and infrastructure/doctor.ts (added in this same change); README.md does not document npm-run dev scripts (only the shipped codegraph CLI), and ROADMAP.md has no phase this bug-fix-sized change affects. Impact: 6 functions changed, 3 affected --- CLAUDE.md | 2 + CONTRIBUTING.md | 11 ++ package.json | 2 + scripts/doctor.ts | 112 +++++++++++++ src/domain/parser.ts | 10 +- src/infrastructure/doctor.ts | 291 ++++++++++++++++++++++++++++++++ tests/unit/doctor.test.ts | 316 +++++++++++++++++++++++++++++++++++ 7 files changed, 743 insertions(+), 1 deletion(-) create mode 100644 scripts/doctor.ts create mode 100644 src/infrastructure/doctor.ts create mode 100644 tests/unit/doctor.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index cb147dac9..f309cace4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,6 +74,7 @@ npm run test:coverage # Coverage report npx vitest run tests/parsers/javascript.test.ts # Single test file npx vitest run -t "finds cycles" # Single test by name npm run build:wasm # Rebuild WASM grammars from devDeps (built automatically on npm install) +npm run doctor # Check this worktree for a stale native binary / missing WASM grammars (runs automatically via pretest) ``` **Linter/Formatter:** [Biome](https://biomejs.dev/) — config in `biome.json`, scoped to `src/` and `tests/`. @@ -103,6 +104,7 @@ Source is TypeScript in `src/`, compiled via `tsup`. The Rust native engine live | `shared/paginate.ts` | Pagination helpers for bounded query results | | **`infrastructure/`** | **Platform and I/O plumbing** | | `infrastructure/config.ts` | `.codegraphrc.json` loading, env overrides, `apiKeyCommand` secret resolution | +| `infrastructure/doctor.ts` | Environment health checks — stale `better-sqlite3` native ABI, incomplete `grammars/`; see `npm run doctor` | | `infrastructure/logger.ts` | Structured logging (`warn`, `debug`, `info`, `error`) | | `infrastructure/native.ts` | Native napi-rs addon loader with WASM fallback | | `infrastructure/registry.ts` | Global repo registry (`~/.codegraph/registry.json`) for multi-repo MCP | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d6d883298..c51b8b470 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,6 +17,16 @@ npm test # run the full test suite **Requirements:** Node.js >= 20 +**Working in multiple git worktrees?** Each worktree gets its own untracked +`node_modules/` and `grammars/` — neither is shared via git — so every fresh +`git worktree add` needs its own `npm install`. A worktree set up before a +host Node upgrade, or where `npm install` was interrupted, can be left with a +`better-sqlite3` binary compiled for the wrong Node ABI or an incomplete +`grammars/` directory; both fail in confusing ways deep inside a build or test +run. Run `npm run doctor` to check (or `npm run doctor -- --fix` to repair +in place, scoped to the current worktree) — it also runs automatically before +`npm test` via `pretest`. + ## Contributor License Agreement (CLA) All contributors must sign the [Contributor License Agreement](CLA.md) before @@ -88,6 +98,7 @@ npm run test:coverage # Coverage report npx vitest run tests/parsers/go.test.js # Single test file npx vitest run -t "finds cycles" # Single test by name npm run build:wasm # Rebuild WASM grammars +npm run doctor # Check for a stale native binary / missing WASM grammars ``` ## Branch Naming Convention diff --git a/package.json b/package.json index 1d51ba687..0ea841824 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,8 @@ "build:wasm": "node scripts/node-ts.js scripts/build-wasm.ts", "typecheck": "tsc --noEmit", "verify-imports": "node scripts/node-ts.js scripts/verify-imports.ts", + "doctor": "node --experimental-strip-types --import ./scripts/ts-resolve-loader.js scripts/doctor.ts", + "pretest": "npm run doctor", "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", diff --git a/scripts/doctor.ts b/scripts/doctor.ts new file mode 100644 index 000000000..1dfeb6674 --- /dev/null +++ b/scripts/doctor.ts @@ -0,0 +1,112 @@ +#!/usr/bin/env node +/** + * codegraph environment doctor (issue #1733). + * + * Detects two classes of silent per-worktree environment drift: a stale + * better-sqlite3 native binary (ABI mismatch after a Node upgrade) and an + * incomplete grammars/ directory (interrupted or skipped `npm run build:wasm`). + * Both are untracked, worktree-local state — see CLAUDE.md "Parallel + * Sessions" — so every fresh `git worktree add` needs this checked at least + * once, and a long-lived worktree needs it re-checked after a host Node + * upgrade. + * + * Usage: + * npm run doctor # report only; exits 1 only on a blocking ('fail') check + * npm run doctor -- --fix # also run the scoped fix command(s), then re-check + * + * A missing *optional*-language grammar reports as a non-blocking WARN, not + * FAIL — this repo's own parsers are designed to degrade gracefully when a + * non-required grammar is unavailable (see CLAUDE.md), so `npm test` must + * stay runnable in that case, just with narrower language coverage. + * + * Also wired as the `pretest` lifecycle script (report-only, no --fix) so + * `npm test` fails fast with one actionable message instead of a wall of + * unrelated-looking failures scattered across the suite — but only for a + * genuinely blocking problem (stale native binary, missing required grammar). + * + * `--fix` is opt-in rather than automatic: the fixes themselves (`npm rebuild + * better-sqlite3`, `npm run build:wasm`) can take anywhere from seconds to + * over a minute, so running them unattended on every `npm test` would add + * unpredictable latency to a hot dev-loop command. Detect-and-report is the + * safe default; healing is one explicit flag away. Both fix commands always + * run with cwd pinned to this script's own repo root (never process.cwd()), + * so they can never touch a different worktree or a global install. + */ +import { execFileSync } from 'node:child_process'; +import os from 'node:os'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { runDoctorChecks } from '../src/infrastructure/doctor.ts'; + +// doctor.ts's DoctorCheck/DoctorReport interfaces are intentionally not +// exported (nothing outside that module constructs one independently), so +// this derives the shapes structurally rather than importing them by name. +type DoctorReport = ReturnType; +type DoctorCheck = DoctorReport['checks'][number]; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + +// npm on Windows is npm.cmd; Node refuses to spawn .cmd/.bat without a shell. +const NPM_SHELL = os.platform() === 'win32'; + +const STATUS_MARK: Record = { + ok: 'OK ', + warn: 'WARN', + fail: 'FAIL', +}; + +function printReport(report: DoctorReport): void { + for (const check of report.checks) { + console.log(`[${STATUS_MARK[check.status]}] ${check.label}: ${check.detail}`); + if (check.status !== 'ok' && check.fixCommand) { + console.log(` fix: ${check.fixCommand}`); + } + } +} + +/** Run a non-ok check's fix command, scoped to this repo's own root. */ +function runFix(check: DoctorCheck): void { + if (!check.fixCommand) return; + const [cmd, ...args] = check.fixCommand.split(' '); + if (!cmd) return; + console.log(`\n> ${check.fixCommand}`); + try { + execFileSync(cmd, args, { cwd: repoRoot, stdio: 'inherit', shell: NPM_SHELL }); + } catch (err) { + console.error(` fix command failed: ${(err as Error).message}`); + } +} + +const shouldFix = process.argv.includes('--fix'); + +let report = runDoctorChecks(); +printReport(report); + +// --fix repairs anything with something to fix — a non-blocking 'warn' (e.g. +// a missing optional grammar) is still worth fixing when explicitly asked, +// even though it wouldn't block pretest/npm test on its own. +const needsFix = report.checks.filter((c) => c.status !== 'ok'); +if (shouldFix && needsFix.length > 0) { + console.log(`\n--fix passed — attempting scoped repairs in ${repoRoot}`); + for (const check of needsFix) runFix(check); + + console.log('\nRe-checking...'); + report = runDoctorChecks(); + printReport(report); +} + +if (!report.ok) { + console.error('\ncodegraph doctor: environment is NOT healthy — see fix command(s) above.'); + if (!shouldFix) { + console.error('Re-run with --fix to attempt an automatic, worktree-scoped repair:'); + console.error(' npm run doctor -- --fix'); + } + process.exit(1); +} + +const hasWarnings = report.checks.some((c) => c.status === 'warn'); +console.log( + hasWarnings + ? '\ncodegraph doctor: environment healthy (non-blocking warnings above).' + : '\ncodegraph doctor: environment healthy.', +); diff --git a/src/domain/parser.ts b/src/domain/parser.ts index 2042c2099..5208222c2 100644 --- a/src/domain/parser.ts +++ b/src/domain/parser.ts @@ -105,8 +105,16 @@ import { const __dirname = path.dirname(fileURLToPath(import.meta.url)); +/** + * Absolute path to the `grammars/` directory holding pre-built WASM grammar + * files. Exported so other modules (e.g. `infrastructure/doctor.ts`) can + * verify installation completeness without duplicating this relative-path + * computation. + */ +export const GRAMMARS_DIR = path.join(__dirname, '..', '..', 'grammars'); + function grammarPath(name: string): string { - return path.join(__dirname, '..', '..', 'grammars', name); + return path.join(GRAMMARS_DIR, name); } let _initialized: boolean = false; diff --git a/src/infrastructure/doctor.ts b/src/infrastructure/doctor.ts new file mode 100644 index 000000000..c5f3ea1ba --- /dev/null +++ b/src/infrastructure/doctor.ts @@ -0,0 +1,291 @@ +/** + * Environment health checks ("doctor") for a codegraph checkout. + * + * Every git worktree (see CLAUDE.md "Parallel Sessions") gets its own + * untracked `node_modules/` and `grammars/` — neither is shared via git, so + * every fresh `git worktree add` needs its own `npm install`. Two classes of + * drift are both silent until something fails deep inside a build or test + * run, surfacing as a cryptic native-module stack trace or a swallowed parse + * failure rather than a clear diagnosis (issue #1733): + * + * 1. `better-sqlite3`'s compiled `.node` binary is a classic V8/NAN addon + * (not N-API), so it is tied to the exact Node ABI + * (`process.versions.modules`) it was compiled under. Upgrading Node in + * place without `npm rebuild` leaves a stale binary that throws on load + * — and better-sqlite3 sits on the hot path for nearly every command + * (see `db/builder/pipeline.ts`), so this one failure looks like almost + * everything is broken. + * 2. `grammars/*.wasm` is populated by `npm run build:wasm` (via the + * `prepare` lifecycle script) from tree-sitter grammar devDependencies. + * A worktree set up before that step finished — or where it failed + * partway — is left with only a partial grammar set. + * + * Grammar completeness is NOT all-or-nothing: `LANGUAGE_REGISTRY` marks only + * JS/TS/TSX as `required: true` — every other language is designed to fail + * gracefully at runtime when its grammar is missing (see this repo's own + * CLAUDE.md: "Non-required parsers ... fail gracefully if their WASM grammar + * is unavailable"). A worktree that can't fetch one optional grammar's + * devDependency (e.g. a sandboxed environment and a `git+ssh` grammar + * package) should still be able to build/test with reduced language coverage + * — not get hard-blocked before a single test runs. So `checkWasmGrammars` + * only fails (blocks `pretest`) when a *required* grammar is missing; a + * missing optional grammar is surfaced as a non-blocking 'warn'. + * + * Design: each check's *decision* logic is a pure function (`parseAbiMismatchError`, + * `findMissingGrammars`) that takes plain data and is trivial to unit test with + * fake inputs. The public `checkXxx` functions wrap that logic with the real + * I/O (a `require()` attempt, a directory listing) behind an injectable + * parameter, so tests can simulate a broken environment without touching this + * worktree's real native binary or grammars/ directory. + * + * This module only detects — it never mutates the environment. `scripts/doctor.ts` + * is the CLI entry point that also knows how to *fix* what this module reports. + */ +import { readdirSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { GRAMMARS_DIR, LANGUAGE_REGISTRY } from '../domain/parser.js'; +import type { LanguageRegistryEntry } from '../types.js'; + +const _require = createRequire(import.meta.url); + +/** + * 'ok' — nothing to report. 'warn' — non-blocking (e.g. an optional grammar + * is missing); does not fail the check or the overall report. 'fail' — + * blocking; fails the overall report and `pretest`. + */ +type DoctorCheckStatus = 'ok' | 'warn' | 'fail'; + +/** + * Result of a single doctor check. Not exported by name — consumers (e.g. + * `scripts/doctor.ts`) type against `DoctorReport['checks'][number]` / + * `ReturnType` structurally rather than importing + * this interface, since nothing outside this module currently constructs a + * `DoctorCheck` independently of calling `checkBetterSqlite3Abi` / + * `checkWasmGrammars` / `runDoctorChecks`. + */ +interface DoctorCheck { + /** Stable machine-readable id, e.g. 'better-sqlite3-abi'. */ + id: string; + /** Human-readable label for report output. */ + label: string; + status: DoctorCheckStatus; + /** One-line human-readable explanation of the result. */ + detail: string; + /** Shell command that resolves this failure/warning. Absent when status is 'ok'. */ + fixCommand?: string; +} + +/** Aggregate result of running every doctor check. Not exported — see DoctorCheck. */ +interface DoctorReport { + /** False only when some check's status is 'fail' — a 'warn' never flips this. */ + ok: boolean; + checks: DoctorCheck[]; +} + +/** + * Parsed ABI numbers from a Node native-addon load-failure error message. + * Not exported — it's only ever consumed as `parseAbiMismatchError`'s return + * value; callers destructure the fields rather than naming the type. + */ +interface AbiMismatchInfo { + /** NODE_MODULE_VERSION the binary was compiled against. */ + compiledVersion: number; + /** NODE_MODULE_VERSION the current Node process requires. */ + requiredVersion: number; +} + +// Node's native-addon ABI mismatch error has been stable for many major +// versions, e.g.: +// "The module '/path/to/better_sqlite3.node' +// was compiled against a different Node.js version using +// NODE_MODULE_VERSION 127. This version of Node.js requires +// NODE_MODULE_VERSION 131. Please try re-compiling or re-installing +// the module (for instance, using `npm rebuild` or `npm install`)." +// Detection never depends on this regex matching — any thrown error from +// loadModule() is already treated as "broken" by checkBetterSqlite3Abi. +// This only extracts the two version numbers for a friendlier message; if a +// future Node release rewords the message, the check still fails correctly, +// it just falls back to the raw error text instead of the parsed numbers. +const ABI_MISMATCH_RE = /NODE_MODULE_VERSION (\d+)\.[\s\S]*?NODE_MODULE_VERSION (\d+)/; + +/** + * Parse Node's native-addon ABI mismatch error text into structured version + * numbers. Returns null for any message that doesn't match the known format + * (e.g. a missing-module error, a permission error, or reworded future text). + */ +export function parseAbiMismatchError(message: string): AbiMismatchInfo | null { + const match = ABI_MISMATCH_RE.exec(message); + if (!match) return null; + return { compiledVersion: Number(match[1]), requiredVersion: Number(match[2]) }; +} + +/** + * Check whether better-sqlite3's compiled native binary loads under the + * current Node process. + * + * Requiring it is the only reliable way to learn a prebuilt `.node` file's + * ABI compatibility — Node exposes no static introspection API for a + * candidate binary's compiled-against version — so this performs a real (but + * fast, in-process, side-effect-free beyond module caching) require() rather + * than shelling out to a subprocess. + * + * `loadModule` is injectable so tests can simulate a stale binary, a missing + * install, or a healthy load without touching this worktree's real + * node_modules/. + */ +export function checkBetterSqlite3Abi( + loadModule: () => unknown = () => _require('better-sqlite3'), +): DoctorCheck { + const id = 'better-sqlite3-abi'; + const label = 'better-sqlite3 native binary'; + try { + loadModule(); + return { + id, + label, + status: 'ok', + detail: `loads cleanly under Node ${process.version} (ABI ${process.versions.modules})`, + }; + } catch (err) { + const code = (err as NodeJS.ErrnoException)?.code; + const message = err instanceof Error ? err.message : String(err); + + if (code === 'MODULE_NOT_FOUND') { + return { + id, + label, + status: 'fail', + detail: 'better-sqlite3 is not installed in this worktree', + fixCommand: 'npm install', + }; + } + + const mismatch = parseAbiMismatchError(message); + const detail = mismatch + ? `compiled for NODE_MODULE_VERSION ${mismatch.compiledVersion}, but Node ${process.version} requires ${mismatch.requiredVersion} — likely a stale binary from before a Node upgrade` + : `failed to load: ${message.split('\n')[0]}`; + return { id, label, status: 'fail', detail, fixCommand: 'npm rebuild better-sqlite3' }; + } +} + +/** + * The subset of a language registry entry the grammar-completeness check + * needs. Not exported — `checkWasmGrammars`'s default parameter is the real + * `LANGUAGE_REGISTRY`; this narrower shape only exists so callers (tests) can + * pass minimal fake entries without fabricating an `extractor`/`extensions`. + */ +type GrammarRegistryEntry = Pick; + +/** + * Pure: given the language registry and the set of grammar filenames that + * actually exist on disk, return the entries whose `.wasm` file is missing. + */ +export function findMissingGrammars( + registry: readonly GrammarRegistryEntry[], + existingFiles: ReadonlySet, +): GrammarRegistryEntry[] { + return registry.filter((entry) => !existingFiles.has(entry.grammarFile)); +} + +/** List `.wasm` filenames present in the real `grammars/` directory. */ +function listInstalledGrammarFiles(): ReadonlySet { + try { + return new Set(readdirSync(GRAMMARS_DIR).filter((f) => f.endsWith('.wasm'))); + } catch { + return new Set(); // grammars/ doesn't exist at all + } +} + +/** Render up to `max` grammar filenames, with a "+N more" suffix beyond that. */ +function sampleList(entries: readonly GrammarRegistryEntry[], max = 5): string { + const sample = entries + .slice(0, max) + .map((e) => e.grammarFile) + .join(', '); + const suffix = entries.length > max ? `, +${entries.length - max} more` : ''; + return `${sample}${suffix}`; +} + +/** + * Check that every language in the registry has its WASM grammar file + * present on disk. Checks the *full* registry (all 30+ languages), not just + * the `required` tier (JS/TS/TSX) that `isWasmAvailable()` in `domain/parser.ts` + * gates parser startup on — a worktree can start up fine with only the + * required grammars while silently missing most language support. + * + * A missing `required` grammar (JS/TS/TSX) is a hard failure — the parser + * can't function without it. A missing *optional* grammar is reported as a + * non-blocking warning: non-required parsers are designed to fail gracefully + * at runtime (see the module doc comment), so this must never flip `status` + * to 'fail' on its own — that would incorrectly block `pretest`/`npm test` + * for every worktree missing even one rarely-used language's grammar. + * + * Both `registry` and `listGrammarFiles` are injectable so tests can supply a + * fake registry and a fake "what's on disk" set without touching the real + * grammars/ directory. + */ +export function checkWasmGrammars( + registry: readonly GrammarRegistryEntry[] = LANGUAGE_REGISTRY, + listGrammarFiles: () => ReadonlySet = listInstalledGrammarFiles, +): DoctorCheck { + const id = 'wasm-grammars'; + const label = 'WASM tree-sitter grammars'; + const existing = listGrammarFiles(); + const missing = findMissingGrammars(registry, existing); + + if (missing.length === 0) { + return { + id, + label, + status: 'ok', + detail: `all ${registry.length} grammar files present in grammars/`, + }; + } + + const missingRequired = missing.filter((e) => e.required); + const missingOptional = missing.filter((e) => !e.required); + + if (missingRequired.length > 0) { + const optionalNote = + missingOptional.length > 0 + ? `; ${missingOptional.length} optional grammar file(s) also missing` + : ''; + return { + id, + label, + status: 'fail', + detail: + `${missingRequired.length} required grammar file(s) missing ` + + `(${sampleList(missingRequired)})${optionalNote}`, + fixCommand: 'npm run build:wasm', + }; + } + + // Only optional grammars are missing — all required (JS/TS/TSX) grammars + // are present, so parsing isn't broken, just narrower. Non-blocking. + return { + id, + label, + status: 'warn', + detail: + `all required grammar files present; ${missingOptional.length} optional ` + + `grammar file(s) missing, non-blocking (${sampleList(missingOptional)})`, + fixCommand: 'npm run build:wasm', + }; +} + +/** + * Run every doctor check and aggregate the result. Fast and read-only — safe + * to call frequently (e.g. from a `pretest` hook) since neither check spawns + * a subprocess or mutates the environment. + * + * `checks` is injectable (defaulting to the two real checks) so the + * ok/'fail'-only aggregation rule — a 'warn' must never flip the overall + * report unhealthy — can be unit tested directly with fake check results, + * independent of the real native binary or grammars/ directory. + */ +export function runDoctorChecks( + checks: readonly DoctorCheck[] = [checkBetterSqlite3Abi(), checkWasmGrammars()], +): DoctorReport { + return { ok: checks.every((c) => c.status !== 'fail'), checks: [...checks] }; +} diff --git a/tests/unit/doctor.test.ts b/tests/unit/doctor.test.ts new file mode 100644 index 000000000..1cb19a9ed --- /dev/null +++ b/tests/unit/doctor.test.ts @@ -0,0 +1,316 @@ +/** + * Unit tests for src/infrastructure/doctor.ts (issue #1733). + * + * Covers the two failure classes reported against a stale worktree: a + * better-sqlite3 native binary compiled for an older Node ABI, and a + * grammars/ directory missing most of its .wasm files. Every check function + * takes its I/O (require(), directory listing) as an injectable parameter, + * so these tests simulate both broken states with fakes — no real native + * binary or grammars/ file is touched or modified. + * + * Grammar-completeness tests specifically cover the required-vs-optional + * split: a missing *required* (JS/TS/TSX) grammar must fail the check and the + * overall report (blocking `pretest`), but a missing *optional* grammar must + * only warn — never flip the report unhealthy — per this repo's own + * CLAUDE.md ("Non-required parsers ... fail gracefully if their WASM grammar + * is unavailable"). + */ +import { mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { LANGUAGE_REGISTRY } from '../../src/domain/parser.js'; +import { + checkBetterSqlite3Abi, + checkWasmGrammars, + findMissingGrammars, + parseAbiMismatchError, + runDoctorChecks, +} from '../../src/infrastructure/doctor.js'; + +describe('parseAbiMismatchError', () => { + it('parses the real Node native-addon ABI mismatch message', () => { + const message = `The module '/repo/node_modules/better-sqlite3/build/Release/better_sqlite3.node' +was compiled against a different Node.js version using +NODE_MODULE_VERSION 137. This version of Node.js requires +NODE_MODULE_VERSION 147. Please try re-compiling or re-installing +the module (for instance, using \`npm rebuild\` or \`npm install\`).`; + + expect(parseAbiMismatchError(message)).toEqual({ + compiledVersion: 137, + requiredVersion: 147, + }); + }); + + it('returns null for unrelated error text', () => { + expect(parseAbiMismatchError("Cannot find module 'better-sqlite3'")).toBeNull(); + expect(parseAbiMismatchError('Segmentation fault (core dumped)')).toBeNull(); + expect(parseAbiMismatchError('')).toBeNull(); + }); + + it('returns null when only one NODE_MODULE_VERSION mention is present', () => { + expect(parseAbiMismatchError('NODE_MODULE_VERSION 137 only mentioned once')).toBeNull(); + }); +}); + +describe('checkBetterSqlite3Abi', () => { + it('reports ok when loadModule succeeds', () => { + const result = checkBetterSqlite3Abi(() => ({ Database: class {} })); + expect(result.status).toBe('ok'); + expect(result.id).toBe('better-sqlite3-abi'); + expect(result.fixCommand).toBeUndefined(); + expect(result.detail).toContain(process.version); + }); + + it('reports a parsed ABI mismatch with the rebuild fix command', () => { + const message = `The module '/repo/node_modules/better-sqlite3/build/Release/better_sqlite3.node' +was compiled against a different Node.js version using +NODE_MODULE_VERSION 137. This version of Node.js requires +NODE_MODULE_VERSION 147. Please try re-compiling or re-installing +the module (for instance, using \`npm rebuild\` or \`npm install\`).`; + const result = checkBetterSqlite3Abi(() => { + throw new Error(message); + }); + + expect(result.status).toBe('fail'); + expect(result.fixCommand).toBe('npm rebuild better-sqlite3'); + expect(result.detail).toContain('137'); + expect(result.detail).toContain('147'); + }); + + it('reports a missing install with the npm install fix command', () => { + const result = checkBetterSqlite3Abi(() => { + const err = Object.assign(new Error("Cannot find module 'better-sqlite3'"), { + code: 'MODULE_NOT_FOUND', + }); + throw err; + }); + + expect(result.status).toBe('fail'); + expect(result.fixCommand).toBe('npm install'); + expect(result.detail).toMatch(/not installed/i); + }); + + it('falls back to the raw message and a generic rebuild fix when the error is unrecognized', () => { + // Simulates a future Node version rewording the ABI-mismatch message — + // detection must still fail closed rather than silently reporting healthy. + const result = checkBetterSqlite3Abi(() => { + throw new Error('Segmentation fault (core dumped)'); + }); + + expect(result.status).toBe('fail'); + expect(result.fixCommand).toBe('npm rebuild better-sqlite3'); + expect(result.detail).toContain('Segmentation fault'); + }); + + it('handles non-Error throws gracefully', () => { + const result = checkBetterSqlite3Abi(() => { + throw 'boom'; + }); + expect(result.status).toBe('fail'); + expect(result.detail).toContain('boom'); + }); +}); + +describe('findMissingGrammars (pure)', () => { + const registry = [ + { id: 'javascript', grammarFile: 'tree-sitter-javascript.wasm', required: true }, + { id: 'python', grammarFile: 'tree-sitter-python.wasm', required: false }, + { id: 'rust', grammarFile: 'tree-sitter-rust.wasm', required: false }, + ]; + + it('returns entries whose grammar file is absent from the existing set', () => { + const missing = findMissingGrammars(registry, new Set(['tree-sitter-javascript.wasm'])); + expect(missing.map((m) => m.id)).toEqual(['python', 'rust']); + }); + + it('returns an empty array when every grammar file is present', () => { + const all = new Set(registry.map((r) => r.grammarFile)); + expect(findMissingGrammars(registry, all)).toEqual([]); + }); + + it('returns the full registry when nothing is installed', () => { + expect(findMissingGrammars(registry, new Set())).toEqual(registry); + }); + + it('does not mutate its inputs', () => { + const existing = new Set(['tree-sitter-javascript.wasm']); + const registryCopy = [...registry]; + findMissingGrammars(registry, existing); + expect(registry).toEqual(registryCopy); + expect(existing.size).toBe(1); + }); +}); + +describe('checkWasmGrammars (fake registry + fake listGrammarFiles)', () => { + // Mirrors the real LANGUAGE_REGISTRY shape: a small required tier (like + // JS/TS/TSX) plus a larger optional tier (like every other language). + const registry = [ + { id: 'javascript', grammarFile: 'tree-sitter-javascript.wasm', required: true }, + { id: 'typescript', grammarFile: 'tree-sitter-typescript.wasm', required: true }, + { id: 'python', grammarFile: 'tree-sitter-python.wasm', required: false }, + { id: 'rust', grammarFile: 'tree-sitter-rust.wasm', required: false }, + { id: 'go', grammarFile: 'tree-sitter-go.wasm', required: false }, + { id: 'java', grammarFile: 'tree-sitter-java.wasm', required: false }, + { id: 'ruby', grammarFile: 'tree-sitter-ruby.wasm', required: false }, + { id: 'php', grammarFile: 'tree-sitter-php.wasm', required: false }, + ]; + const requiredFiles = registry.filter((r) => r.required).map((r) => r.grammarFile); + + it('reports ok when every grammar file is present', () => { + const result = checkWasmGrammars(registry, () => new Set(registry.map((r) => r.grammarFile))); + expect(result.status).toBe('ok'); + expect(result.id).toBe('wasm-grammars'); + expect(result.detail).toBe(`all ${registry.length} grammar files present in grammars/`); + expect(result.fixCommand).toBeUndefined(); + }); + + it('reports warn — never fail — when only optional grammars are missing and all required are present', () => { + // All required (javascript, typescript) present; all 6 optional missing. + const result = checkWasmGrammars(registry, () => new Set(requiredFiles)); + expect(result.status).toBe('warn'); + expect(result.status).not.toBe('fail'); + expect(result.fixCommand).toBe('npm run build:wasm'); + expect(result.detail).toContain('all required grammar files present'); + expect(result.detail).toContain('6 optional'); + expect(result.detail).toContain('non-blocking'); + // 6 missing optional entries, truncated sample: 5 shown + "+1 more". + expect(result.detail).toContain('+1 more'); + }); + + it('reports fail when a required grammar is missing, even if it is the only one missing', () => { + const present = new Set(registry.map((r) => r.grammarFile)); + present.delete('tree-sitter-javascript.wasm'); // drop one required grammar + const result = checkWasmGrammars(registry, () => present); + + expect(result.status).toBe('fail'); + expect(result.fixCommand).toBe('npm run build:wasm'); + expect(result.detail).toContain('1 required grammar file(s) missing'); + expect(result.detail).toContain('tree-sitter-javascript.wasm'); + // No optional grammars are missing in this scenario. + expect(result.detail).not.toContain('optional grammar file(s) also missing'); + }); + + it('reports fail and still mentions optional grammars missing alongside a required one', () => { + // Only typescript present: javascript (required) AND all 6 optional missing. + const result = checkWasmGrammars(registry, () => new Set(['tree-sitter-typescript.wasm'])); + + expect(result.status).toBe('fail'); + expect(result.detail).toContain('1 required grammar file(s) missing'); + expect(result.detail).toContain('tree-sitter-javascript.wasm'); + expect(result.detail).toContain('6 optional grammar file(s) also missing'); + }); + + it('reports fail when the grammars directory does not exist (empty set)', () => { + const result = checkWasmGrammars(registry, () => new Set()); + expect(result.status).toBe('fail'); + expect(result.detail).toContain(`${requiredFiles.length} required grammar file(s) missing`); + }); +}); + +describe('checkWasmGrammars (real fs I/O against a temp directory, real LANGUAGE_REGISTRY)', () => { + let tmpDir: string; + + afterEach(() => { + if (tmpDir) rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('detects a healthy directory containing every registered grammar file', () => { + tmpDir = mkdtempSync(path.join(os.tmpdir(), 'cg-doctor-grammars-healthy-')); + for (const entry of LANGUAGE_REGISTRY) { + writeFileSync(path.join(tmpDir, entry.grammarFile), ''); + } + + const result = checkWasmGrammars(LANGUAGE_REGISTRY, () => new Set(readdirSync(tmpDir))); + expect(result.status).toBe('ok'); + expect(result.detail).toBe( + `all ${LANGUAGE_REGISTRY.length} grammar files present in grammars/`, + ); + }); + + it('detects a near-empty directory (mirrors the reported worktree: 1 of ~40 files) — required grammars missing, so it fails', () => { + tmpDir = mkdtempSync(path.join(os.tmpdir(), 'cg-doctor-grammars-broken-')); + // Only the erlang (optional) grammar present, exactly as in the original + // bug report — JS/TS/TSX (required) are among the 35 missing files, so + // this must still be a hard failure, not just a warning. + writeFileSync(path.join(tmpDir, 'tree-sitter-erlang.wasm'), ''); + + const result = checkWasmGrammars(LANGUAGE_REGISTRY, () => new Set(readdirSync(tmpDir))); + expect(result.status).toBe('fail'); + expect(result.fixCommand).toBe('npm run build:wasm'); + expect(result.detail).toContain('required grammar file(s) missing'); + expect(result.detail).toContain('tree-sitter-javascript.wasm'); + }); + + it('detects a directory missing exactly one required grammar', () => { + tmpDir = mkdtempSync(path.join(os.tmpdir(), 'cg-doctor-grammars-partial-')); + const [, ...rest] = LANGUAGE_REGISTRY; // drop the first entry (javascript, required) + for (const entry of rest) { + writeFileSync(path.join(tmpDir, entry.grammarFile), ''); + } + + const result = checkWasmGrammars(LANGUAGE_REGISTRY, () => new Set(readdirSync(tmpDir))); + expect(result.status).toBe('fail'); + expect(result.detail).toContain('tree-sitter-javascript.wasm'); + expect(result.detail).toContain('1 required grammar file(s) missing'); + }); + + it('detects a directory missing only an optional grammar (all required present) — warns, does not fail', () => { + tmpDir = mkdtempSync(path.join(os.tmpdir(), 'cg-doctor-grammars-optional-gap-')); + // tree-sitter-erlang.wasm is an optional (required: false) grammar. + for (const entry of LANGUAGE_REGISTRY) { + if (entry.grammarFile === 'tree-sitter-erlang.wasm') continue; + writeFileSync(path.join(tmpDir, entry.grammarFile), ''); + } + + const result = checkWasmGrammars(LANGUAGE_REGISTRY, () => new Set(readdirSync(tmpDir))); + expect(result.status).toBe('warn'); + expect(result.status).not.toBe('fail'); + expect(result.detail).toContain('tree-sitter-erlang.wasm'); + expect(result.detail).toContain('non-blocking'); + }); +}); + +describe('runDoctorChecks', () => { + it('a non-blocking warn check does not flip the overall report unhealthy', () => { + const report = runDoctorChecks([ + { id: 'a', label: 'A', status: 'ok', detail: 'fine' }, + { id: 'b', label: 'B', status: 'warn', detail: 'minor issue, non-blocking' }, + ]); + expect(report.ok).toBe(true); + }); + + it('a fail check flips the overall report unhealthy, even alongside ok/warn checks', () => { + const report = runDoctorChecks([ + { id: 'a', label: 'A', status: 'ok', detail: 'fine' }, + { id: 'b', label: 'B', status: 'warn', detail: 'minor issue, non-blocking' }, + { id: 'c', label: 'C', status: 'fail', detail: 'broken' }, + ]); + expect(report.ok).toBe(false); + }); + + it('an all-ok report is healthy', () => { + const report = runDoctorChecks([{ id: 'a', label: 'A', status: 'ok', detail: 'fine' }]); + expect(report.ok).toBe(true); + }); + + it('runs both real checks against the real environment and returns a well-formed report', () => { + // Uses the real defaults (real require('better-sqlite3'), real grammars/ + // directory) — by the time this test runs, `pretest` has already gated + // `npm test` on a healthy environment, but this asserts shape rather than + // hard-coding a specific status so a single-file run on a mid-repair + // machine doesn't fail on an unrelated assertion. + const report = runDoctorChecks(); + + expect(typeof report.ok).toBe('boolean'); + expect(report.checks).toHaveLength(2); + expect(report.checks.map((c) => c.id)).toEqual(['better-sqlite3-abi', 'wasm-grammars']); + for (const check of report.checks) { + expect(['ok', 'warn', 'fail']).toContain(check.status); + expect(typeof check.label).toBe('string'); + expect(typeof check.detail).toBe('string'); + expect(check.detail.length).toBeGreaterThan(0); + } + expect(report.ok).toBe(report.checks.every((c) => c.status !== 'fail')); + }); +}); From 8bb589327471a2a0a56c8a30784c91cc62389322 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Sun, 5 Jul 2026 18:42:21 -0600 Subject: [PATCH 11/67] fix: eliminate non-deterministic ordering in community detection `codegraph communities --drift` produced different modularity and community assignments across separate full rebuilds of byte-identical source. Two independent, compounding causes in the native Rust engine: 1. The build pipeline collected parsed file symbols into a `std::collections::HashMap` (pipeline.rs), whose iteration order is randomized per-process. That order drove node/edge insertion order into SQLite, so the same file could get a different autoincrement `id` (and therefore a different position in the in-memory graph) on every rebuild. Fixed by switching `file_symbols` to `BTreeMap` throughout the pipeline, import-edge, and structure-metrics stages, so insertion order is always sorted by file path. 2. The native Louvain local-move phase (louvain.rs) accumulated per-candidate-community weights in a `HashMap`. A genuine tie in modularity gain between candidate communities was broken by hashmap bucket order instead of a reproducible rule -- non-deterministic even with a fixed random seed, since the seed only controls visitation order, not this tie-break. Fixed by switching `cur_edges`/`comm_w` to `BTreeMap`. Also added `ORDER BY` to the node/edge read queries used to build the community-detection graph (both native `graph_read.rs` and the JS/WASM `graph-read.ts` mirror), as defense in depth: SQLite's row order for a bare `WHERE` scan is otherwise unspecified. Verified via 10+ full rebuilds of this repo's own ~900-file graph producing byte-identical `communities --drift --json` output, versus differing modularity/community counts on every rebuild beforehand. This is an internal determinism fix with no new features, commands, or language support changes, so no README/CLAUDE.md/ROADMAP.md updates are needed (docs check acknowledged). Fixes #1734 Impact: 4 functions changed, 17 affected --- .../src/db/repository/graph_read.rs | 19 ++- .../src/domain/graph/builder/pipeline.rs | 48 +++---- .../graph/builder/stages/import_edges.rs | 6 +- .../codegraph-core/src/features/structure.rs | 22 ++-- .../src/graph/algorithms/louvain.rs | 91 ++++++++++++-- src/db/repository/graph-read.ts | 20 +-- tests/graph/algorithms/louvain.test.ts | 91 ++++++++++++++ ...issue-1734-communities-determinism.test.ts | 117 ++++++++++++++++++ 8 files changed, 357 insertions(+), 57 deletions(-) create mode 100644 tests/integration/issue-1734-communities-determinism.test.ts diff --git a/crates/codegraph-core/src/db/repository/graph_read.rs b/crates/codegraph-core/src/db/repository/graph_read.rs index 5f36382e8..aa21de34e 100644 --- a/crates/codegraph-core/src/db/repository/graph_read.rs +++ b/crates/codegraph-core/src/db/repository/graph_read.rs @@ -1675,8 +1675,15 @@ impl NativeDatabase { .map(|k| format!("'{k}'")) .collect::>() .join(","); + // ORDER BY id: without an explicit order, SQLite's row order for a + // bare WHERE scan is unspecified — it happened to track physical/ + // insertion order, which is only deterministic now that the build + // pipeline inserts nodes in a fixed (BTreeMap-sorted) order (#1734). + // Sorting explicitly here removes the dependency on that unspecified + // behavior so downstream consumers (e.g. community detection) build + // the same graph on every run regardless of how rows are stored. let sql = format!( - "SELECT id, name, kind, file FROM nodes WHERE kind IN ({kinds_sql})" + "SELECT id, name, kind, file FROM nodes WHERE kind IN ({kinds_sql}) ORDER BY id" ); let mut stmt = conn .prepare_cached(&sql) @@ -1701,7 +1708,8 @@ impl NativeDatabase { let conn = self.conn()?; let mut stmt = conn .prepare_cached( - "SELECT source_id, target_id, confidence FROM edges WHERE kind = 'calls'", + "SELECT source_id, target_id, confidence FROM edges WHERE kind = 'calls' \ + ORDER BY source_id, target_id", ) .map_err(|e| napi::Error::from_reason(format!("get_call_edges prepare: {e}")))?; let rows = stmt @@ -1721,8 +1729,10 @@ impl NativeDatabase { #[napi] pub fn get_file_nodes_all(&self) -> napi::Result> { let conn = self.conn()?; + // ORDER BY id — see the comment in get_callable_nodes for why an + // explicit order matters for build-to-build determinism (#1734). let mut stmt = conn - .prepare_cached("SELECT id, name, file FROM nodes WHERE kind = 'file'") + .prepare_cached("SELECT id, name, file FROM nodes WHERE kind = 'file' ORDER BY id") .map_err(|e| napi::Error::from_reason(format!("get_file_nodes_all prepare: {e}")))?; let rows = stmt .query_map([], |row| { @@ -1743,7 +1753,8 @@ impl NativeDatabase { let conn = self.conn()?; let mut stmt = conn .prepare_cached( - "SELECT source_id, target_id FROM edges WHERE kind IN ('imports','imports-type')", + "SELECT source_id, target_id FROM edges WHERE kind IN ('imports','imports-type') \ + ORDER BY source_id, target_id", ) .map_err(|e| napi::Error::from_reason(format!("get_import_edges prepare: {e}")))?; let rows = stmt diff --git a/crates/codegraph-core/src/domain/graph/builder/pipeline.rs b/crates/codegraph-core/src/domain/graph/builder/pipeline.rs index ea4442e34..9b1bee2e9 100644 --- a/crates/codegraph-core/src/domain/graph/builder/pipeline.rs +++ b/crates/codegraph-core/src/domain/graph/builder/pipeline.rs @@ -33,7 +33,7 @@ use crate::features::structure; use crate::types::{FileSymbols, ImportResolutionInput, TypeMapEntry}; use rusqlite::Connection; use serde::Serialize; -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::path::Path; use std::time::Instant; @@ -218,12 +218,12 @@ fn parse_and_index_files( root_dir: &str, include_dataflow: bool, include_ast: bool, -) -> HashMap { +) -> BTreeMap { let files_to_parse: Vec = parse_changes.iter().map(|c| c.abs_path.clone()).collect(); let parsed = parallel::parse_files_parallel(&files_to_parse, root_dir, include_dataflow, include_ast); - let mut file_symbols: HashMap = HashMap::new(); + let mut file_symbols: BTreeMap = BTreeMap::new(); for mut sym in parsed { let rel = relative_path(root_dir, &sym.file); sym.file = rel.clone(); @@ -235,7 +235,7 @@ fn parse_and_index_files( /// Build the batched import-resolution input set and run resolution, returning /// `(batch_resolved, known_files)`. Mirrors stage 6 of `run_pipeline`. fn resolve_pipeline_imports( - file_symbols: &HashMap, + file_symbols: &BTreeMap, collect_files: &[String], root_dir: &str, napi_aliases: &crate::types::PathAliases, @@ -288,7 +288,7 @@ fn reconnect_saved_reverse_dep_edges( /// are present (reverse-deps are reconnected, not re-parsed). fn run_structure_phase( conn: &Connection, - file_symbols: &HashMap, + file_symbols: &BTreeMap, collect_directories: &HashSet, root_dir: &str, line_count_map: &HashMap, @@ -326,7 +326,7 @@ fn run_structure_phase( /// nodes are gone (#1027). fn run_role_classification( conn: &Connection, - file_symbols: &HashMap, + file_symbols: &BTreeMap, removal_reverse_deps: Vec, is_full_build: bool, ) { @@ -367,7 +367,7 @@ struct AnalysisPersistenceResult { /// analysis scope. fn run_analysis_persistence( conn: &Connection, - file_symbols: &HashMap, + file_symbols: &BTreeMap, analysis_scope: Option<&Vec>, opts: &BuildOpts, include_ast: bool, @@ -765,7 +765,7 @@ fn reparse_barrel_candidates( root_dir: &str, napi_aliases: &crate::types::PathAliases, known_files: &HashSet, - file_symbols: &mut HashMap, + file_symbols: &mut BTreeMap, batch_resolved: &mut HashMap, ) { // Find all barrel files from DB (files that have 'reexports' edges) @@ -892,7 +892,7 @@ fn collect_imported_barrel_candidates( from_files: &[String], batch_resolved: &HashMap, barrel_files_in_db: &HashSet, - file_symbols: &HashMap, + file_symbols: &BTreeMap, ) -> Vec { let mut out = Vec::new(); for rel_path in from_files { @@ -926,7 +926,7 @@ fn collect_reexport_from_barrels( conn: &Connection, root_dir: &str, changed_files: &[String], - file_symbols: &HashMap, + file_symbols: &BTreeMap, ) -> Vec { let mut out = Vec::new(); let mut stmt = match conn.prepare( @@ -1018,7 +1018,7 @@ fn check_version_mismatch(conn: &Connection) -> bool { /// Build InsertNodesBatch from parsed file symbols. fn build_insert_batches( - file_symbols: &HashMap, + file_symbols: &BTreeMap, ) -> Vec { file_symbols .iter() @@ -1155,7 +1155,7 @@ const EDGE_NODE_KIND_FILTER: &str = "kind IN ('function','method','class','inter /// ultimate definition files barrel chains resolve to. Mirrors the JS /// `relevantFiles` accumulation in `loadNodes` (#976, greptile P1). fn compute_edge_relevant_files( - file_symbols: &HashMap, + file_symbols: &BTreeMap, import_ctx: &crate::domain::graph::builder::stages::import_edges::ImportEdgeContext, ) -> HashSet { let mut relevant_files: HashSet = file_symbols.keys().cloned().collect(); @@ -1192,7 +1192,7 @@ fn compute_edge_relevant_files( /// `Vec` suitable for the native edge builder. fn load_edge_node_set( conn: &Connection, - file_symbols: &HashMap, + file_symbols: &BTreeMap, import_ctx: &crate::domain::graph::builder::stages::import_edges::ImportEdgeContext, is_incremental: bool, ) -> Vec { @@ -1354,7 +1354,7 @@ fn collect_imported_names_for_file( /// so method calls and receiver edges on that variable resolve. Must run /// before `build_and_insert_call_edges`. fn propagate_return_types_across_files( - file_symbols: &mut HashMap, + file_symbols: &mut BTreeMap, import_ctx: &ImportEdgeContext, ) { use crate::domain::graph::builder::stages::build_edges::PROPAGATION_HOP_PENALTY; @@ -1386,7 +1386,7 @@ fn propagate_return_types_across_files( /// - `global_return_types`: flat map for qualified `Type.method` lookups; higher /// confidence wins, tie-break is deterministic (paths visited in sorted order). fn build_return_type_index( - file_symbols: &HashMap, + file_symbols: &BTreeMap, ) -> ( HashMap>, HashMap, @@ -1511,7 +1511,7 @@ fn insert_call_edge_rows(conn: &Connection, edges: &[crate::domain::graph::build /// Full builds always load every node — there is no smaller set anyway. fn build_and_insert_call_edges( conn: &Connection, - file_symbols: &HashMap, + file_symbols: &BTreeMap, import_ctx: &ImportEdgeContext, is_incremental: bool, ) { @@ -1669,7 +1669,7 @@ fn build_analysis_node_map( /// Convert FileSymbols AST nodes to FileAstBatch format for `ast::do_insert_ast_nodes`. fn build_ast_batches( - file_symbols: &HashMap, + file_symbols: &BTreeMap, analysis_files: &HashSet<&str>, ) -> Vec { let mut batches = Vec::new(); @@ -1698,7 +1698,7 @@ fn build_ast_batches( /// Write complexity metrics from parsed definitions to the `function_complexity` table. fn write_complexity( conn: &Connection, - file_symbols: &HashMap, + file_symbols: &BTreeMap, analysis_files: &HashSet<&str>, node_id_map: &HashMap<(String, String, u32), i64>, ) -> bool { @@ -1777,7 +1777,7 @@ fn write_complexity( /// Write CFG blocks and edges from parsed definitions to DB tables. fn write_cfg( conn: &Connection, - file_symbols: &HashMap, + file_symbols: &BTreeMap, analysis_files: &HashSet<&str>, node_id_map: &HashMap<(String, String, u32), i64>, ) -> bool { @@ -1882,7 +1882,7 @@ fn write_def_cfg( /// `makeNodeResolver` logic (prefer same-file match, fall back to global). fn write_dataflow( conn: &Connection, - file_symbols: &HashMap, + file_symbols: &BTreeMap, analysis_files: &HashSet<&str>, ) -> bool { let tx = match conn.unchecked_transaction() { @@ -2050,7 +2050,7 @@ mod tests { use super::*; use crate::types::{Import, PathAliases}; - fn make_import_ctx(file_symbols: &HashMap) -> ImportEdgeContext { + fn make_import_ctx(file_symbols: &BTreeMap) -> ImportEdgeContext { let mut batch_resolved = HashMap::new(); batch_resolved.insert("/repo/driver.js|./service.js".to_string(), "service.js".to_string()); ImportEdgeContext { @@ -2089,7 +2089,7 @@ mod tests { receiver_type_name: None, }); - let mut file_symbols = HashMap::new(); + let mut file_symbols = BTreeMap::new(); file_symbols.insert("service.js".to_string(), service); file_symbols.insert("driver.js".to_string(), driver); let import_ctx = make_import_ctx(&file_symbols); @@ -2120,7 +2120,7 @@ mod tests { receiver_type_name: Some("Factory".to_string()), }); - let mut file_symbols = HashMap::new(); + let mut file_symbols = BTreeMap::new(); file_symbols.insert("factory.js".to_string(), factory); file_symbols.insert("driver.js".to_string(), driver); let import_ctx = make_import_ctx(&file_symbols); @@ -2151,7 +2151,7 @@ mod tests { receiver_type_name: None, }); - let mut file_symbols = HashMap::new(); + let mut file_symbols = BTreeMap::new(); file_symbols.insert("service.js".to_string(), service); file_symbols.insert("driver.js".to_string(), driver); let import_ctx = make_import_ctx(&file_symbols); diff --git a/crates/codegraph-core/src/domain/graph/builder/stages/import_edges.rs b/crates/codegraph-core/src/domain/graph/builder/stages/import_edges.rs index c8f0487e8..9f1f97d2b 100644 --- a/crates/codegraph-core/src/domain/graph/builder/stages/import_edges.rs +++ b/crates/codegraph-core/src/domain/graph/builder/stages/import_edges.rs @@ -8,7 +8,7 @@ use crate::domain::graph::builder::barrel_resolution::{self, BarrelContext, Reex use crate::domain::graph::resolve; use crate::types::{FileSymbols, PathAliases}; use rusqlite::Connection; -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::path::Path; /// A resolved reexport entry for a barrel file. @@ -28,7 +28,7 @@ pub struct ImportEdgeContext { /// Set of files that are barrel-only (reexport count >= definition count). pub barrel_only_files: HashSet, /// Parsed symbols per relative path. - pub file_symbols: HashMap, + pub file_symbols: BTreeMap, /// Root directory. pub root_dir: String, /// Path aliases. @@ -601,7 +601,7 @@ mod tests { #[test] fn barrel_detection() { - let mut file_symbols = HashMap::new(); + let mut file_symbols = BTreeMap::new(); // 1 def, 2 reexports → barrel file_symbols.insert( "src/index.ts".to_string(), diff --git a/crates/codegraph-core/src/features/structure.rs b/crates/codegraph-core/src/features/structure.rs index 7b9897cb7..ebcf6d62c 100644 --- a/crates/codegraph-core/src/features/structure.rs +++ b/crates/codegraph-core/src/features/structure.rs @@ -9,7 +9,7 @@ use crate::types::FileSymbols; use rusqlite::Connection; -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; /// Per-file metrics to upsert into node_metrics. #[derive(Debug, Clone)] @@ -25,7 +25,7 @@ pub struct FileMetrics { /// Build line count map from parsed file symbols. pub fn build_line_count_map( - file_symbols: &HashMap, + file_symbols: &BTreeMap, root_dir: &str, ) -> HashMap { let mut map = HashMap::new(); @@ -50,7 +50,7 @@ pub fn update_changed_file_metrics( conn: &Connection, changed_files: &[String], line_count_map: &HashMap, - file_symbols: &HashMap, + file_symbols: &BTreeMap, ) { if changed_files.is_empty() { return; @@ -250,7 +250,7 @@ struct ImportEdge { /// and contains-edge insertion to affected directories only. pub fn build_full_structure( conn: &Connection, - file_symbols: &HashMap, + file_symbols: &BTreeMap, discovered_dirs: &HashSet, root_dir: &str, line_count_map: &HashMap, @@ -408,7 +408,7 @@ fn load_file_paths_in_dirs(conn: &Connection, dirs: &HashSet) -> Vec, + file_symbols: &BTreeMap, all_file_paths: &[String], affected_dirs: Option<&HashSet>, ) { @@ -508,7 +508,7 @@ fn restore_unchanged_dir_edges( fn insert_contains_edges( conn: &Connection, - file_symbols: &HashMap, + file_symbols: &BTreeMap, all_dirs: &HashSet, changed_files: Option<&[String]>, ) { @@ -595,7 +595,7 @@ fn compute_import_edge_maps( fn compute_file_metrics( conn: &Connection, - file_symbols: &HashMap, + file_symbols: &BTreeMap, line_count_map: &HashMap, fan_in_map: &HashMap, fan_out_map: &HashMap, @@ -718,7 +718,7 @@ fn record_file_in_ancestor_dirs<'a>( fn build_dir_files_map<'a>( all_dirs: &'a HashSet, all_db_files: &'a [String], - file_symbols: &'a HashMap, + file_symbols: &'a BTreeMap, ) -> HashMap<&'a str, Vec<&'a str>> { let mut dir_files: HashMap<&str, Vec<&str>> = HashMap::new(); for dir in all_dirs { @@ -831,7 +831,7 @@ fn count_distinct_definitions(sym: &FileSymbols) -> i64 { fn compute_dir_symbol_counts<'a>( dir_files: &HashMap<&'a str, Vec<&'a str>>, db_symbol_counts: &HashMap, - file_symbols: &HashMap, + file_symbols: &BTreeMap, ) -> HashMap<&'a str, i64> { let mut dir_symbol_counts: HashMap<&str, i64> = HashMap::new(); for (dir, files) in dir_files { @@ -897,7 +897,7 @@ fn write_directory_metric_rows( fn compute_directory_metrics( conn: &Connection, - file_symbols: &HashMap, + file_symbols: &BTreeMap, all_dirs: &HashSet, import_edges: &[ImportEdge], ) { @@ -920,7 +920,7 @@ mod tests { #[test] fn line_count_map_from_symbols() { - let mut file_symbols = HashMap::new(); + let mut file_symbols = BTreeMap::new(); let mut sym = FileSymbols::new("src/a.ts".to_string()); sym.line_count = Some(42); file_symbols.insert("src/a.ts".to_string(), sym.clone()); diff --git a/crates/codegraph-core/src/graph/algorithms/louvain.rs b/crates/codegraph-core/src/graph/algorithms/louvain.rs index 165a5c27b..98826a926 100644 --- a/crates/codegraph-core/src/graph/algorithms/louvain.rs +++ b/crates/codegraph-core/src/graph/algorithms/louvain.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use crate::shared::constants::{ DEFAULT_RANDOM_SEED, LOUVAIN_MAX_LEVELS, LOUVAIN_MAX_PASSES, LOUVAIN_MIN_GAIN, @@ -48,9 +48,16 @@ pub fn louvain_communities( } /// Internal state for the Louvain multi-level loop. +/// +/// `cur_edges` uses `BTreeMap` (not `HashMap`) so that iteration order is +/// deterministic across process runs. Rust's default `HashMap` hasher is +/// randomly seeded per-process (DoS resistance), so iterating a `HashMap` +/// here would silently reorder the adjacency list built in +/// `local_move_phase` on every run, changing which local optimum the greedy +/// local-move phase converges to even with a fixed `rng_state` seed (#1734). struct LouvainState { cur_n: usize, - cur_edges: HashMap<(usize, usize), f64>, + cur_edges: BTreeMap<(usize, usize), f64>, cur_degree: Vec, original_community: Vec, rng_state: u32, @@ -61,15 +68,17 @@ fn louvain_init( edges: &[GraphEdge], node_ids: &[String], seed: u32, -) -> (HashMap<(usize, usize), f64>, f64, LouvainState) { +) -> (BTreeMap<(usize, usize), f64>, f64, LouvainState) { let n = node_ids.len(); let mut id_to_idx: HashMap<&str, usize> = HashMap::with_capacity(n); for (i, id) in node_ids.iter().enumerate() { id_to_idx.insert(id.as_str(), i); } - // Build undirected weighted edge list (deduplicate, merge parallel edges) - let mut edge_map: HashMap<(usize, usize), f64> = HashMap::new(); + // Build undirected weighted edge list (deduplicate, merge parallel edges). + // BTreeMap keeps this deterministically ordered by (src, tgt) — see the + // `LouvainState.cur_edges` doc comment above for why this matters. + let mut edge_map: BTreeMap<(usize, usize), f64> = BTreeMap::new(); for edge in edges { if let (Some(&src), Some(&tgt)) = ( id_to_idx.get(edge.source.as_str()), @@ -145,7 +154,12 @@ fn local_move_phase( let node_comm = level_comm[node]; let node_deg = state.cur_degree[node]; - let mut comm_w: HashMap = HashMap::new(); + // BTreeMap (not HashMap) so the best-move scan below visits + // candidate communities in a fixed, deterministic order — + // otherwise a genuine tie in `gain` would be broken by Rust's + // per-process-randomized HashMap iteration order instead of a + // reproducible rule (#1734). + let mut comm_w: BTreeMap = BTreeMap::new(); for &(neighbor, w) in &adj[node] { *comm_w.entry(level_comm[neighbor]).or_insert(0.0) += w; } @@ -216,7 +230,7 @@ fn aggregation_phase( } // Build coarse graph for next level - let mut coarse_edge_map: HashMap<(usize, usize), f64> = HashMap::new(); + let mut coarse_edge_map: BTreeMap<(usize, usize), f64> = BTreeMap::new(); for (&(src, tgt), &w) in &state.cur_edges { let cu = level_comm[src]; let cv = level_comm[tgt]; @@ -241,7 +255,7 @@ fn aggregation_phase( /// Compute final modularity score: Q = sum_c [ L_c / m - gamma * (k_c / 2m)^2 ] fn compute_modularity( - edge_map: &HashMap<(usize, usize), f64>, + edge_map: &BTreeMap<(usize, usize), f64>, original_community: &[usize], total_weight: f64, resolution: f64, @@ -398,4 +412,65 @@ mod tests { assert_eq!(map["a"], map["b"]); assert_eq!(map["b"], map["c"]); } + + /// Regression test for #1734: `codegraph communities --drift` produced + /// different modularity/community assignments across separate full + /// rebuilds of byte-identical source. Root cause: `local_move_phase` + /// accumulated per-candidate-community weights in a `HashMap`, whose + /// iteration order is randomized per-process — so a genuine tie in + /// modularity gain between candidate communities was broken by hashmap + /// bucket order instead of a reproducible rule, even with a fixed + /// `random_seed`. Fixed by switching `cur_edges`/`comm_w` to `BTreeMap`. + /// + /// This graph is symmetric by construction — three disjoint triangles + /// plus a bridge node connected with equal weight to one member of each + /// triangle — so moving the bridge node into any of the three triangles + /// yields the exact same modularity gain, forcing a genuine tie on every + /// run of the local-move phase. + #[test] + fn test_louvain_deterministic_across_repeated_calls_with_tie() { + let edges = vec![ + edge("a1", "a2"), + edge("a2", "a3"), + edge("a3", "a1"), + edge("b1", "b2"), + edge("b2", "b3"), + edge("b3", "b1"), + edge("c1", "c2"), + edge("c2", "c3"), + edge("c3", "c1"), + edge("x", "a1"), + edge("x", "b1"), + edge("x", "c1"), + ]; + let nodes: Vec = vec!["a1", "a2", "a3", "b1", "b2", "b3", "c1", "c2", "c3", "x"] + .into_iter() + .map(String::from) + .collect(); + + let mut snapshots: Vec<(String, f64)> = Vec::new(); + for _ in 0..30 { + let result = louvain_communities(edges.clone(), nodes.clone(), Some(1.0), Some(42)); + let mut pairs: Vec = result + .assignments + .iter() + .map(|a| format!("{}:{}", a.node, a.community)) + .collect(); + pairs.sort(); + snapshots.push((pairs.join(","), result.modularity)); + } + + let first = &snapshots[0]; + for (i, snapshot) in snapshots.iter().enumerate().skip(1) { + assert_eq!( + snapshot.0, first.0, + "run {i} produced a different assignment than run 0 — \ + tie-breaking is not deterministic" + ); + assert_eq!( + snapshot.1, first.1, + "run {i} produced a different modularity than run 0" + ); + } + } } diff --git a/src/db/repository/graph-read.ts b/src/db/repository/graph-read.ts index 5dc0e9180..3c5ed1b3f 100644 --- a/src/db/repository/graph-read.ts +++ b/src/db/repository/graph-read.ts @@ -19,44 +19,50 @@ const CALLABLE_KINDS_SQL = CORE_SYMBOL_KINDS.map((k: string) => `'${k}'`).join(' /** * Get callable nodes (all core symbol kinds) for graph construction. + * + * `ORDER BY id` — without an explicit order, SQLite's row order for a bare + * WHERE scan is unspecified. Consumers (e.g. community detection's graph + * builder) rely on a stable iteration order for run-to-run determinism, so + * sort explicitly rather than depending on incidental physical/insertion + * order (#1734). Mirrors `get_callable_nodes` in the native `graph_read.rs`. */ export function getCallableNodes(db: BetterSqlite3Database): CallableNodeRow[] { return cachedStmt( _getCallableNodesStmt, db, - `SELECT id, name, kind, file FROM nodes WHERE kind IN (${CALLABLE_KINDS_SQL})`, + `SELECT id, name, kind, file FROM nodes WHERE kind IN (${CALLABLE_KINDS_SQL}) ORDER BY id`, ).all(); } /** - * Get all 'calls' edges. + * Get all 'calls' edges. Ordered for determinism — see `getCallableNodes`. */ export function getCallEdges(db: BetterSqlite3Database): CallEdgeRow[] { return cachedStmt( _getCallEdgesStmt, db, - "SELECT source_id, target_id, confidence FROM edges WHERE kind = 'calls'", + "SELECT source_id, target_id, confidence FROM edges WHERE kind = 'calls' ORDER BY source_id, target_id", ).all(); } /** - * Get all file-kind nodes. + * Get all file-kind nodes. Ordered for determinism — see `getCallableNodes`. */ export function getFileNodesAll(db: BetterSqlite3Database): FileNodeRow[] { return cachedStmt( _getFileNodesAllStmt, db, - "SELECT id, name, file FROM nodes WHERE kind = 'file'", + "SELECT id, name, file FROM nodes WHERE kind = 'file' ORDER BY id", ).all(); } /** - * Get all import edges. + * Get all import edges. Ordered for determinism — see `getCallableNodes`. */ export function getImportEdges(db: BetterSqlite3Database): ImportGraphEdgeRow[] { return cachedStmt( _getImportEdgesStmt, db, - "SELECT source_id, target_id FROM edges WHERE kind IN ('imports','imports-type')", + "SELECT source_id, target_id FROM edges WHERE kind IN ('imports','imports-type') ORDER BY source_id, target_id", ).all(); } diff --git a/tests/graph/algorithms/louvain.test.ts b/tests/graph/algorithms/louvain.test.ts index d7bcc8110..0002873e0 100644 --- a/tests/graph/algorithms/louvain.test.ts +++ b/tests/graph/algorithms/louvain.test.ts @@ -47,6 +47,97 @@ describe('louvainCommunities', () => { expect(modularity).toBe(0); }); + // Regression test for #1734: `codegraph communities --drift` produced + // different modularity/community assignments across separate full rebuilds + // of byte-identical source. Root cause: the native Rust local-move phase + // accumulated per-candidate-community weights in a `std::collections::HashMap` + // (randomized per-process hasher), so a genuine tie in modularity gain + // between two or more candidate communities was broken by hashmap iteration + // order instead of a reproducible rule. Fixed by switching the relevant + // maps to `BTreeMap` (deterministic, sorted iteration). + // + // This graph is symmetric by construction: three disjoint triangles, plus a + // bridge node connected with equal weight to one member of each triangle. + // Moving the bridge node into any of the three triangles yields the exact + // same modularity gain, forcing the local-move phase to break a genuine + // tie on every run. + describe('determinism (#1734)', () => { + function buildTieGraph(): CodeGraph { + const g = new CodeGraph(); + g.addEdge('a1', 'a2'); + g.addEdge('a2', 'a3'); + g.addEdge('a3', 'a1'); + g.addEdge('b1', 'b2'); + g.addEdge('b2', 'b3'); + g.addEdge('b3', 'b1'); + g.addEdge('c1', 'c2'); + g.addEdge('c2', 'c3'); + g.addEdge('c3', 'c1'); + // Bridge node tied equally between all three triangles. + g.addEdge('x', 'a1'); + g.addEdge('x', 'b1'); + g.addEdge('x', 'c1'); + return g; + } + + /** Sorted "node:community" pairs — stable snapshot for deep-equal comparison. */ + function snapshotAssignments(assignments: Map): string[] { + return [...assignments.entries()].map(([node, community]) => `${node}:${community}`).sort(); + } + + it('produces byte-identical modularity and assignments across repeated runs', () => { + const runs = Array.from({ length: 20 }, () => { + const g = buildTieGraph(); + return louvainCommunities(g); + }); + + const firstModularity = runs[0]!.modularity; + const firstAssignments = snapshotAssignments(runs[0]!.assignments); + + for (const run of runs.slice(1)) { + expect(run.modularity).toBe(firstModularity); + expect(snapshotAssignments(run.assignments)).toEqual(firstAssignments); + } + }); + + // Note: `buildTieGraph()` above is *symmetric by design* — the bridge + // node's three-way tie means a different edge-insertion order can validly + // land it in a different (but equally optimal) community. That is + // expected Louvain behavior, not a bug, so it is not asserted here. + // Order-independence is only a meaningful invariant when the optimal + // partition is unambiguous, as below. + it('produces an equivalent partition regardless of edge insertion order (unambiguous graph)', () => { + function buildUnambiguousGraph(edges: Array<[string, string]>): CodeGraph { + const g = new CodeGraph(); + for (const [src, tgt] of edges) g.addEdge(src, tgt); + return g; + } + + // Two tightly-connected triangles joined by a single weak bridge edge — + // the best partition (two triangles) is unambiguous, so insertion order + // must not change which nodes end up grouped together. + const edges: Array<[string, string]> = [ + ['a', 'b'], + ['b', 'c'], + ['c', 'a'], + ['x', 'y'], + ['y', 'z'], + ['z', 'x'], + ['c', 'x'], + ]; + + const forwardResult = louvainCommunities(buildUnambiguousGraph(edges)); + const reversedResult = louvainCommunities(buildUnambiguousGraph(edges.slice().reverse())); + + expect(reversedResult.modularity).toBe(forwardResult.modularity); + expect(reversedResult.assignments.get('a')).toBe(reversedResult.assignments.get('b')); + expect(reversedResult.assignments.get('b')).toBe(reversedResult.assignments.get('c')); + expect(reversedResult.assignments.get('x')).toBe(reversedResult.assignments.get('y')); + expect(reversedResult.assignments.get('y')).toBe(reversedResult.assignments.get('z')); + expect(reversedResult.assignments.get('a')).not.toBe(reversedResult.assignments.get('x')); + }); + }); + describe('Leiden-knob parity logging', () => { let stderrSpy: ReturnType; diff --git a/tests/integration/issue-1734-communities-determinism.test.ts b/tests/integration/issue-1734-communities-determinism.test.ts new file mode 100644 index 000000000..17edc0415 --- /dev/null +++ b/tests/integration/issue-1734-communities-determinism.test.ts @@ -0,0 +1,117 @@ +/** + * Regression test for #1734: `codegraph communities --drift` produced + * different modularity/community assignments across separate full rebuilds + * of byte-identical source code. + * + * Root causes (both fixed): + * 1. The native build pipeline collected parsed file symbols into a + * `std::collections::HashMap` (`crates/codegraph-core/.../pipeline.rs`), + * whose iteration order is randomized per-process. That order drove + * node/edge insertion order into SQLite, so the same file could get a + * different autoincrement `id` — and hence a different position in the + * in-memory graph — on every rebuild. + * 2. The native Louvain local-move phase (`graph/algorithms/louvain.rs`) + * accumulated per-candidate-community weights in a `HashMap`, so a + * genuine tie in modularity gain was broken by hashmap iteration order + * instead of a reproducible rule, even with a fixed random seed. + * + * This test builds a small but non-trivial fixture (three tightly-connected + * clusters bridged by one file that imports equally from each) into two + * independent full rebuilds and asserts the community-detection output is + * identical between them — exercising the real end-to-end pipeline rather + * than just the in-memory algorithm. + */ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { buildGraph } from '../../src/domain/graph/builder/pipeline.js'; +import { communitiesData } from '../../src/features/communities.js'; + +let tmpDir: string; +const dbPathA = () => path.join(tmpDir, 'a.db'); +const dbPathB = () => path.join(tmpDir, 'b.db'); + +function clusterFile(clusterName: string, index: number, peers: number[]): string { + const imports = peers + .map((p) => `import { ${clusterName}${p} } from './${clusterName}${p}.js';`) + .join('\n'); + const uses = peers.map((p) => ` ${clusterName}${p}();`).join('\n'); + return `${imports} +export function ${clusterName}${index}() { +${uses} + return ${index}; +} +`; +} + +beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-communities-determinism-')); + + // Three fully-connected triangles (clusters A, B, C) — each file imports + // both peers in its own cluster. + for (const cluster of ['a', 'b', 'c']) { + for (let i = 1; i <= 3; i++) { + const peers = [1, 2, 3].filter((p) => p !== i); + fs.writeFileSync(path.join(tmpDir, `${cluster}${i}.js`), clusterFile(cluster, i, peers)); + } + } + + // Bridge file imports equally from one member of each cluster — symmetric + // three-way tie, mirroring the unit-level regression test in + // tests/graph/algorithms/louvain.test.ts. + fs.writeFileSync( + path.join(tmpDir, 'bridge.js'), + `import { a1 } from './a1.js'; +import { b1 } from './b1.js'; +import { c1 } from './c1.js'; +export function bridge() { + a1(); + b1(); + c1(); +} +`, + ); + fs.writeFileSync(path.join(tmpDir, 'package.json'), '{"name":"test"}'); + + // Two independent full rebuilds of the identical source, into separate DBs. + await buildGraph(tmpDir, { dbPath: dbPathA(), incremental: false }); + await buildGraph(tmpDir, { dbPath: dbPathB(), incremental: false }); +}); + +afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe('communities determinism across independent full rebuilds (#1734)', () => { + it('produces identical modularity and community structure', () => { + const resultA = communitiesData(dbPathA(), { noTests: true }); + const resultB = communitiesData(dbPathB(), { noTests: true }); + + expect(resultA.modularity).toBe(resultB.modularity); + expect((resultA.summary as { communityCount: number }).communityCount).toBe( + (resultB.summary as { communityCount: number }).communityCount, + ); + expect((resultA.summary as { driftScore: number }).driftScore).toBe( + (resultB.summary as { driftScore: number }).driftScore, + ); + + // Compare full community structure (files grouped per community, not raw + // numeric community IDs — those are arbitrary labels and may legitimately + // differ in assignment order between independent runs even when the + // underlying grouping is identical). + type CommunityShape = { members?: Array<{ file: string }> }; + const toFileSets = (data: Record): string[][] => + (data.communities as CommunityShape[]) + .map((c) => (c.members ?? []).map((m) => m.file).sort()) + .sort((x, y) => x.join(',').localeCompare(y.join(','))); + + expect(toFileSets(resultA)).toEqual(toFileSets(resultB)); + + // Drift output (the exact shape returned by `communities --drift`) must + // also match byte-for-byte. + const driftA = communitiesData(dbPathA(), { noTests: true, drift: true }); + const driftB = communitiesData(dbPathB(), { noTests: true, drift: true }); + expect(driftA).toEqual(driftB); + }); +}); From 46037b1ac943dfebccb5f6fd6415eb68d972b34e Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Sun, 5 Jul 2026 19:08:45 -0600 Subject: [PATCH 12/67] fix: sync update-graph.sh hook extension allowlist with EXTENSIONS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PostToolUse hook's hardcoded extension case-statement was a hand-copied subset of EXTENSIONS (src/shared/constants.ts) that had drifted out of sync — .mjs/.cjs were missing, so editing those files silently skipped the incremental rebuild and left .codegraph/graph.db stale. The hook now prefers dist/hook-extensions.txt, a plain-text snapshot of EXTENSIONS generated by scripts/gen-hook-extensions.mjs as part of `npm run build`, checked with a native `grep -qxF` (no extra Node startup per edit). A synced hardcoded case list remains as a fallback for before the first build. tests/unit/hook-extensions.test.ts fails if that fallback ever drifts behind EXTENSIONS again. This only touches internal dev-tooling (a Claude Code hook and its build-time codegen script) — no language support, CLI feature, or architecture surface changed, so README/CLAUDE.md/ROADMAP do not need updates. docs check acknowledged. Fixes #1736 --- .claude/hooks/update-graph.sh | 39 ++++++++++---- package.json | 2 +- scripts/gen-hook-extensions.mjs | 30 +++++++++++ tests/unit/hook-extensions.test.ts | 86 ++++++++++++++++++++++++++++++ 4 files changed, 146 insertions(+), 11 deletions(-) create mode 100644 scripts/gen-hook-extensions.mjs create mode 100644 tests/unit/hook-extensions.test.ts diff --git a/.claude/hooks/update-graph.sh b/.claude/hooks/update-graph.sh index ab8a15946..cf2a2cbdf 100644 --- a/.claude/hooks/update-graph.sh +++ b/.claude/hooks/update-graph.sh @@ -24,15 +24,35 @@ if [ -z "$FILE_PATH" ]; then exit 0 fi -# Only rebuild for source files codegraph tracks -# Skip docs, configs, test fixtures, and non-code files -case "$FILE_PATH" in - *.js|*.ts|*.tsx|*.jsx|*.py|*.go|*.rs|*.java|*.cs|*.php|*.rb|*.tf|*.hcl) - ;; - *) - exit 0 - ;; -esac +PROJECT_DIR=$(git rev-parse --show-toplevel 2>/dev/null) || PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}" + +# Only rebuild for source files codegraph tracks. +# Skip docs, configs, test fixtures, and non-code files. +# +# The real allowlist is EXTENSIONS (src/shared/constants.ts), derived from +# LANGUAGE_REGISTRY (src/domain/parser.ts) — the single source of truth for +# every language codegraph parses. `npm run build` snapshots it to +# dist/hook-extensions.txt (see scripts/gen-hook-extensions.mjs) so this +# hook can do a fast native bash/grep check on every Edit/Write instead of +# spawning a second Node process, or hand-copying the list, on every edit. +# +# The case statement below is only a fallback for before the first build +# (no dist/hook-extensions.txt yet). tests/unit/hook-extensions.test.ts +# fails if it ever drifts behind EXTENSIONS — keep it updated when +# LANGUAGE_REGISTRY gains a new extension. +EXT=".${FILE_PATH##*.}" +GENERATED_EXT_LIST="$PROJECT_DIR/dist/hook-extensions.txt" +if [ -f "$GENERATED_EXT_LIST" ]; then + grep -qxF "$EXT" "$GENERATED_EXT_LIST" || exit 0 +else + case "$EXT" in + .R|.bash|.c|.cc|.cjs|.clj|.cljc|.cljs|.cpp|.cs|.cu|.cuh|.cxx|.dart|.erl|.ex|.exs|.fs|.fsi|.fsx|.gemspec|.gleam|.go|.groovy|.gvy|.h|.hcl|.hpp|.hrl|.hs|.java|.jl|.js|.jsx|.kt|.kts|.lua|.m|.mjs|.ml|.mli|.php|.phtml|.py|.pyi|.r|.rake|.rb|.rs|.scala|.sh|.sol|.sv|.swift|.tf|.ts|.tsx|.v|.zig) + ;; + *) + exit 0 + ;; + esac +fi # Skip test fixtures — they're copied to tmp dirs anyway if echo "$FILE_PATH" | grep -qE '(fixtures|__fixtures__|testdata)/'; then @@ -40,7 +60,6 @@ if echo "$FILE_PATH" | grep -qE '(fixtures|__fixtures__|testdata)/'; then fi # Guard: codegraph DB must exist (project has been built at least once) -PROJECT_DIR=$(git rev-parse --show-toplevel 2>/dev/null) || PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}" DB_PATH="$PROJECT_DIR/.codegraph/graph.db" if [ ! -f "$DB_PATH" ]; then exit 0 diff --git a/package.json b/package.json index 0ea841824..7dbc9baa1 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,7 @@ "node": ">=22.12.0" }, "scripts": { - "build": "tsc && node -e \"require('fs').writeFileSync('dist/index.cjs',require('fs').readFileSync('src/index.cjs','utf8').replaceAll('./index.ts','./index.js'))\"", + "build": "tsc && node -e \"require('fs').writeFileSync('dist/index.cjs',require('fs').readFileSync('src/index.cjs','utf8').replaceAll('./index.ts','./index.js'))\" && node scripts/gen-hook-extensions.mjs", "build:wasm": "node scripts/node-ts.js scripts/build-wasm.ts", "typecheck": "tsc --noEmit", "verify-imports": "node scripts/node-ts.js scripts/verify-imports.ts", diff --git a/scripts/gen-hook-extensions.mjs b/scripts/gen-hook-extensions.mjs new file mode 100644 index 000000000..bdae9c9ce --- /dev/null +++ b/scripts/gen-hook-extensions.mjs @@ -0,0 +1,30 @@ +#!/usr/bin/env node +/** + * Generates dist/hook-extensions.txt: a plain-text, one-extension-per-line + * snapshot of EXTENSIONS (src/shared/constants.ts), which is itself derived + * from LANGUAGE_REGISTRY (src/domain/parser.ts) — the single source of + * truth for every language codegraph parses. + * + * Consumed by .claude/hooks/update-graph.sh, a PostToolUse hook that fires + * on every Edit/Write in this repo. Reading this pre-generated list with a + * native `grep -qxF` lets the hook decide in ~1ms whether an edited file's + * extension is one codegraph tracks, without spawning a second Node process + * (tens of ms of startup cost) just to check a file extension, and without + * hand-copying the extension list a second time where it can silently drift + * out of sync (see issue #1736 — `.mjs`/`.cjs` were missing from the old + * hardcoded copy). + * + * Runs as part of `npm run build`, right after `tsc`, so the snapshot is + * regenerated automatically whenever LANGUAGE_REGISTRY changes. + */ +import { writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { EXTENSIONS } from '../dist/shared/constants.js'; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +const outFile = join(root, 'dist', 'hook-extensions.txt'); +const sorted = [...EXTENSIONS].sort(); + +writeFileSync(outFile, `${sorted.join('\n')}\n`); +console.log(`[gen-hook-extensions] wrote ${outFile} (${sorted.length} extensions)`); diff --git a/tests/unit/hook-extensions.test.ts b/tests/unit/hook-extensions.test.ts new file mode 100644 index 000000000..33d708807 --- /dev/null +++ b/tests/unit/hook-extensions.test.ts @@ -0,0 +1,86 @@ +/** + * Drift guard for .claude/hooks/update-graph.sh's extension allowlist. + * + * That PostToolUse hook fires on every Edit/Write and needs a fast + * (no extra Node-startup) way to decide whether an edited file's extension + * is one codegraph tracks. It prefers dist/hook-extensions.txt — generated + * from EXTENSIONS by scripts/gen-hook-extensions.mjs as part of + * `npm run build` — and falls back to a hardcoded `case "$EXT" in` list for + * before the first build. + * + * That fallback list is a hand-maintained second copy of EXTENSIONS, so it + * can silently drift out of sync (this is exactly what happened in issue + * #1736: `.mjs`/`.cjs` were missing, so editing those files never + * triggered a rebuild). This test fails the moment EXTENSIONS and the + * hook's fallback list disagree, in either direction. + */ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { EXTENSIONS } from '../../src/shared/constants.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(__dirname, '..', '..'); +const HOOK_PATH = path.join(REPO_ROOT, '.claude', 'hooks', 'update-graph.sh'); +const DIST_CONSTANTS_PATH = path.join(REPO_ROOT, 'dist', 'shared', 'constants.js'); + +/** Extracts the `.ext|.ext|...` pattern from the hook's `case "$EXT" in` fallback arm. */ +function parseFallbackAllowlist(script: string): string[] { + const match = script.match(/case "\$EXT" in\s*\n\s*([.\w|]+)\)/); + if (!match) { + throw new Error( + 'Could not find `case "$EXT" in )` fallback allowlist in update-graph.sh', + ); + } + return match[1].split('|'); +} + +describe('update-graph.sh hook extension allowlist', () => { + const script = fs.readFileSync(HOOK_PATH, 'utf8'); + + it('fallback allowlist matches EXTENSIONS exactly (no drift)', () => { + const fallback = new Set(parseFallbackAllowlist(script)); + + const missingFromHook = [...EXTENSIONS].filter((ext) => !fallback.has(ext)).sort(); + const staleInHook = [...fallback].filter((ext) => !EXTENSIONS.has(ext)).sort(); + + expect( + missingFromHook, + `EXTENSIONS has extensions the update-graph.sh fallback allowlist doesn't know ` + + `about: ${missingFromHook.join(', ')}. Sync the \`case "$EXT" in\` list in ` + + '.claude/hooks/update-graph.sh with EXTENSIONS (src/shared/constants.ts).', + ).toEqual([]); + expect( + staleInHook, + `update-graph.sh fallback allowlist has extensions no longer in EXTENSIONS: ` + + `${staleInHook.join(', ')}. Remove them from the \`case "$EXT" in\` list in ` + + '.claude/hooks/update-graph.sh.', + ).toEqual([]); + }); + + it('reads the generated dist/hook-extensions.txt snapshot as its primary source', () => { + expect(script).toContain('dist/hook-extensions.txt'); + expect(script).toContain('scripts/gen-hook-extensions.mjs'); + }); + + // Only meaningful once `npm run build` has produced dist/shared/constants.js. + // Skipped (not failed) otherwise so this test doesn't require a build step + // that isn't a normal prerequisite of `npm test` itself. + it.skipIf(!fs.existsSync(DIST_CONSTANTS_PATH))( + 'scripts/gen-hook-extensions.mjs generates a snapshot covering every EXTENSIONS entry', + () => { + const generatedPath = path.join(REPO_ROOT, 'dist', 'hook-extensions.txt'); + execFileSync('node', [path.join(REPO_ROOT, 'scripts', 'gen-hook-extensions.mjs')], { + cwd: REPO_ROOT, + }); + + expect(fs.existsSync(generatedPath)).toBe(true); + const generated = new Set(fs.readFileSync(generatedPath, 'utf8').split('\n').filter(Boolean)); + + expect([...EXTENSIONS].filter((ext) => !generated.has(ext))).toEqual([]); + expect([...generated].filter((ext) => !EXTENSIONS.has(ext))).toEqual([]); + }, + ); +}); From 6a84cf9e7bd2d0c40a9804b5333db7f5763c4e68 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Sun, 5 Jul 2026 20:03:51 -0600 Subject: [PATCH 13/67] fix: recompute directory structure metrics for affected directories on incremental rebuild codegraph structure --depth 2 --json reported stale fileCount/symbolCount/ fanIn/cohesion/density for a directory after an incremental rebuild added or removed a file in it. Only a full rebuild (--no-incremental) produced correct numbers. Root cause: the small-incremental fast path (updateChangedFileMetrics in domain/graph/builder/stages/build-structure.ts, mirrored by update_changed_file_metrics in crates/codegraph-core/src/features/ structure.rs) only ever updated per-FILE node_metrics rows. It never touched directories at all -- no directory-metrics recompute, no `contains` edge for the new file, and no directory node for a brand-new directory. This path triggers whenever an incremental build touches at most smallFilesThreshold (5) files and the repo already has more than 20 files -- i.e. almost every normal edit-and-rebuild cycle on a non-trivial repo, including a pure-removal build (0 parsed files, which trivially satisfies the "<=5" gate). The full (non-fast-path) incremental branch was already correct in both engines -- it always recomputes every current directory's metrics unconditionally. Fix: added refreshAffectedDirectoryMetrics (build-structure.ts) and its mirror refresh_affected_directory_metrics (structure.rs), which run alongside the existing per-file fast path whenever it's taken. They recompute metrics for the ancestor directories of every file touched by the build (added, removed, or modified), plus any directory reachable from them via a live cross-directory import edge (a changed file gaining/losing an import into a sibling package shifts that package's fan-in/fan-out too, even though none of its own files changed -- mirrors the one-hop neighbour-expansion classifyNodeRolesIncremental already does for role classification). Directory/edge bookkeeping (node creation, `contains` edges) is wired up idempotently, so a file landing in a brand-new (possibly multi-level) directory is handled too. All of this uses indexed point queries against the live DB state, bounded by (changed files x path depth) rather than repo size, so it stays cheap enough to run unconditionally on the fast path. getAncestorDirs was promoted from a features/structure.ts-private helper to shared/constants.ts so both the fast path and the full path use the same implementation. Filed #1839 for a narrower residual gap this does not cover: a directory whose only link to the touched file set was an edge to/from a file that was itself just removed can't be discovered here, since that edge's evidence is already deleted by the purge step that runs earlier in the pipeline. Verified against the real incremental pipeline (not just unit-level): a throwaway repro script confirmed the stale fileCount/symbolCount and a missing `contains` edge for the new file before the fix, and correct, full-rebuild-matching output after, on both engines (native rebuilt via napi build + codesign for local verification). Added tests/integration/issue-1738-structure-metrics-incremental.test.ts, which diffs incremental-rebuild output against a from-scratch full build of the identical final file set across add/remove/new-nested-directory/ cross-directory-neighbour scenarios, run against both WASM and native. npm test: 207 files / 3444 tests passed, 0 failed. npm run lint: clean. cargo check / cargo test -p codegraph-core: clean. This is an internal bug fix to incremental-build correctness -- no new language support, CLI commands, or architecture surface changed, so README/CLAUDE.md/ROADMAP.md do not need updates (docs check acknowledged). Fixes #1738 Impact: 3 functions changed, 8 affected --- .../src/domain/graph/builder/pipeline.rs | 8 + .../codegraph-core/src/features/structure.rs | 236 +++++++++++++++ .../graph/builder/stages/build-structure.ts | 191 +++++++++++- src/features/structure.ts | 14 +- src/shared/constants.ts | 20 ++ ...1738-structure-metrics-incremental.test.ts | 277 ++++++++++++++++++ 6 files changed, 730 insertions(+), 16 deletions(-) create mode 100644 tests/integration/issue-1738-structure-metrics-incremental.test.ts diff --git a/crates/codegraph-core/src/domain/graph/builder/pipeline.rs b/crates/codegraph-core/src/domain/graph/builder/pipeline.rs index 9b1bee2e9..2d2471457 100644 --- a/crates/codegraph-core/src/domain/graph/builder/pipeline.rs +++ b/crates/codegraph-core/src/domain/graph/builder/pipeline.rs @@ -286,6 +286,11 @@ fn reconnect_saved_reverse_dep_edges( /// structure rebuild based on the same gates as the JS pipeline. The change /// set is read from `file_symbols.keys()` because only truly-changed files /// are present (reverse-deps are reconnected, not re-parsed). +/// +/// `removed_files` is threaded through separately from `parse_changes_len` +/// (which only counts re-parsed files) so the fast path's directory-metrics +/// refresh also covers files deleted from a directory, not just files added +/// or modified within it (#1738). fn run_structure_phase( conn: &Connection, file_symbols: &BTreeMap, @@ -293,6 +298,7 @@ fn run_structure_phase( root_dir: &str, line_count_map: &HashMap, parse_changes_len: usize, + removed_files: &[String], is_full_build: bool, ) { let changed_files: Vec = file_symbols.keys().cloned().collect(); @@ -303,6 +309,7 @@ fn run_structure_phase( if use_fast_path { structure::update_changed_file_metrics(conn, &changed_files, line_count_map, file_symbols); + structure::refresh_affected_directory_metrics(conn, &changed_files, removed_files); } else { let changed_for_structure: Option> = if is_full_build { None @@ -603,6 +610,7 @@ pub fn run_pipeline( root_dir, &line_count_map, parse_changes.len(), + &change_result.removed, change_result.is_full_build, ); timing.structure_ms = t0.elapsed().as_secs_f64() * 1000.0; diff --git a/crates/codegraph-core/src/features/structure.rs b/crates/codegraph-core/src/features/structure.rs index ebcf6d62c..08e737b9d 100644 --- a/crates/codegraph-core/src/features/structure.rs +++ b/crates/codegraph-core/src/features/structure.rs @@ -141,6 +141,242 @@ pub fn get_existing_file_count(conn: &Connection) -> i64 { .unwrap_or(0) } +/// Directories connected to `dir` via a live import/imports-type edge in +/// either direction — the cross-directory neighbours whose own fan-in/out +/// may have shifted even though none of their files changed. Used by +/// `refresh_affected_directory_metrics` to expand its affected-directory set +/// by exactly one hop. +fn find_neighbor_files(conn: &Connection, dir: &str) -> Vec { + let lo = format!("{dir}/"); + let hi = format!("{dir}0"); + let mut stmt = match conn.prepare( + "SELECT n2.file AS other FROM edges e \ + JOIN nodes n1 ON e.source_id = n1.id JOIN nodes n2 ON e.target_id = n2.id \ + WHERE e.kind IN ('imports', 'imports-type') AND n1.file != n2.file \ + AND n1.file >= ?1 AND n1.file < ?2 \ + UNION \ + SELECT n1.file AS other FROM edges e \ + JOIN nodes n1 ON e.source_id = n1.id JOIN nodes n2 ON e.target_id = n2.id \ + WHERE e.kind IN ('imports', 'imports-type') AND n1.file != n2.file \ + AND n2.file >= ?1 AND n2.file < ?2", + ) { + Ok(s) => s, + Err(_) => return Vec::new(), + }; + let result = match stmt.query_map(rusqlite::params![lo, hi], |row| row.get::<_, String>(0)) { + Ok(rows) => rows.flatten().collect(), + Err(_) => Vec::new(), + }; + result +} + +/// Targeted directory-metrics refresh for the small-incremental fast path. +/// +/// `update_changed_file_metrics` only ever touches per-file `node_metrics` +/// rows — it never looks at directories. Any file added to, removed from, or +/// edited within a directory left that directory's +/// fileCount/symbolCount/fanIn/fanOut/cohesion stale until the next full +/// rebuild (#1738), and a file added under a brand-new directory never even +/// got a directory node or a `contains` edge from its parent. +/// +/// This recomputes metrics for the ancestor directories of the files that +/// changed in this build (added, removed, or modified), PLUS any directory +/// reachable from them via a live cross-directory import edge — a changed +/// file that gains (or loses) an import into a sibling package shifts that +/// package's fan-in/fan-out/cohesion even though none of its own files were +/// touched. One level of expansion only (mirrors the neighbour-expansion +/// `classifyNodeRolesIncremental`/`do_classify_incremental` already does for +/// role classification) — bounded by (changed files × path depth) rather +/// than the size of the repo, so it stays cheap enough to run +/// unconditionally alongside the fast path. +/// +/// Removed files need no edge/node cleanup of their own — the purge step +/// already deleted their nodes and every edge referencing them (including +/// their old `contains` edge) earlier in the pipeline; only their ancestor +/// directories' aggregates need recomputing here. Note this expansion can't +/// reach a directory whose ONLY relationship to the touched set was an edge +/// to/from a file that was JUST removed — that edge is already gone by the +/// time this runs, so there's nothing left to discover it from (tracked +/// separately, see #1738 follow-up). +pub fn refresh_affected_directory_metrics( + conn: &Connection, + changed_files: &[String], + removed_files: &[String], +) { + let mut touched: Vec = Vec::with_capacity(changed_files.len() + removed_files.len()); + touched.extend_from_slice(changed_files); + touched.extend_from_slice(removed_files); + let mut affected_dirs = get_ancestor_dirs(&touched); + if affected_dirs.is_empty() { + return; + } + + let seed_dirs: Vec = affected_dirs.iter().cloned().collect(); + for dir in &seed_dirs { + let neighbor_files = find_neighbor_files(conn, dir); + for ancestor in get_ancestor_dirs(&neighbor_files) { + affected_dirs.insert(ancestor); + } + } + + let tx = match conn.unchecked_transaction() { + Ok(tx) => tx, + Err(_) => return, + }; + + // 1. Ensure directory nodes exist for the whole affected ancestor chain — + // handles a file added under a brand-new (possibly multi-level) directory. + { + let mut insert_dir = match tx.prepare( + "INSERT OR IGNORE INTO nodes (name, kind, file, line, end_line) VALUES (?, 'directory', ?, 0, NULL)", + ) { + Ok(s) => s, + Err(_) => return, + }; + for dir in &affected_dirs { + let _ = insert_dir.execute(rusqlite::params![dir, dir]); + } + } + + { + let mut insert_edge = match tx.prepare( + "INSERT INTO edges (source_id, target_id, kind, confidence, dynamic) \ + SELECT ?, ?, 'contains', 1.0, 0 \ + WHERE NOT EXISTS (SELECT 1 FROM edges WHERE source_id = ? AND target_id = ? AND kind = 'contains')", + ) { + Ok(s) => s, + Err(_) => return, + }; + + // 2. Wire dir -> parent-dir contains edges for the chain. + for dir in &affected_dirs { + if let Some(parent) = parent_dir(dir) { + if let (Some(parent_id), Some(dir_id)) = ( + get_node_id(&tx, &parent, "directory", &parent, 0), + get_node_id(&tx, dir, "directory", dir, 0), + ) { + let _ = + insert_edge.execute(rusqlite::params![parent_id, dir_id, parent_id, dir_id]); + } + } + } + + // 3. Wire dir -> file contains edges for changed (added/modified) files. + // Removed files' nodes and edges are already purged upstream. + for rel_path in changed_files { + if let Some(dir) = parent_dir(rel_path) { + if let (Some(dir_id), Some(file_id)) = ( + get_node_id(&tx, &dir, "directory", &dir, 0), + get_node_id(&tx, rel_path, "file", rel_path, 0), + ) { + let _ = + insert_edge.execute(rusqlite::params![dir_id, file_id, dir_id, file_id]); + } + } + } + } + + // 4. Recompute each affected directory's metrics from the live DB state. + { + // fileCount/symbolCount: transitive counts under `dir`, matching + // compute_directory_metrics below. `file >= dir/ AND file < dir0` is + // an index-friendly prefix-range scan equivalent to `file LIKE + // 'dir/%'` — '0' (0x30) is the character immediately after '/' + // (0x2F), so this bound matches exactly the paths nested under `dir`. + let mut count_files = match tx.prepare( + "SELECT COUNT(*) FROM nodes WHERE kind = 'file' AND file >= ?1 AND file < ?2", + ) { + Ok(s) => s, + Err(_) => return, + }; + let mut count_symbols = match tx.prepare( + "SELECT COUNT(*) FROM nodes WHERE kind != 'file' AND kind != 'directory' AND file >= ?1 AND file < ?2", + ) { + Ok(s) => s, + Err(_) => return, + }; + // Edges sourced from a file inside dir: intra (target also inside dir) vs fan-out. + let mut outbound = match tx.prepare( + "SELECT \ + COALESCE(SUM(CASE WHEN n2.file >= ?1 AND n2.file < ?2 THEN 1 ELSE 0 END), 0), \ + COUNT(*) \ + FROM edges e \ + JOIN nodes n1 ON e.source_id = n1.id \ + JOIN nodes n2 ON e.target_id = n2.id \ + WHERE e.kind IN ('imports', 'imports-type') \ + AND n1.file != n2.file \ + AND n2.kind = 'file' \ + AND n1.file >= ?1 AND n1.file < ?2", + ) { + Ok(s) => s, + Err(_) => return, + }; + // Edges targeting a file inside dir, sourced from a file outside dir (fan-in only). + let mut inbound = match tx.prepare( + "SELECT COUNT(*) \ + FROM edges e \ + JOIN nodes n1 ON e.source_id = n1.id \ + JOIN nodes n2 ON e.target_id = n2.id \ + WHERE e.kind IN ('imports', 'imports-type') \ + AND n1.file != n2.file \ + AND n2.kind = 'file' \ + AND n2.file >= ?1 AND n2.file < ?2 \ + AND NOT (n1.file >= ?1 AND n1.file < ?2)", + ) { + Ok(s) => s, + Err(_) => return, + }; + let mut upsert = match tx.prepare( + "INSERT OR REPLACE INTO node_metrics \ + (node_id, line_count, symbol_count, import_count, export_count, fan_in, fan_out, cohesion, file_count) \ + VALUES (?, NULL, ?, NULL, NULL, ?, ?, ?, ?)", + ) { + Ok(s) => s, + Err(_) => return, + }; + + for dir in &affected_dirs { + let dir_id = match get_node_id(&tx, dir, "directory", dir, 0) { + Some(id) => id, + None => continue, + }; + let lo = format!("{dir}/"); + let hi = format!("{dir}0"); + + let file_count: i64 = count_files + .query_row(rusqlite::params![lo, hi], |r| r.get(0)) + .unwrap_or(0); + let symbol_count: i64 = count_symbols + .query_row(rusqlite::params![lo, hi], |r| r.get(0)) + .unwrap_or(0); + let (intra, total): (i64, i64) = outbound + .query_row(rusqlite::params![lo, hi], |r| Ok((r.get(0)?, r.get(1)?))) + .unwrap_or((0, 0)); + let fan_out = total - intra; + let fan_in: i64 = inbound + .query_row(rusqlite::params![lo, hi], |r| r.get(0)) + .unwrap_or(0); + let total_edges = intra + fan_in + fan_out; + let cohesion: Option = if total_edges > 0 { + Some(intra as f64 / total_edges as f64) + } else { + None + }; + + let _ = upsert.execute(rusqlite::params![ + dir_id, + symbol_count, + fan_in, + fan_out, + cohesion, + file_count + ]); + } + } + + let _ = tx.commit(); +} + // ── Full structure computation ────────────────────────────────────────── /// Normalize a path to use forward slashes only. diff --git a/src/domain/graph/builder/stages/build-structure.ts b/src/domain/graph/builder/stages/build-structure.ts index 144537dfe..fc4fd152c 100644 --- a/src/domain/graph/builder/stages/build-structure.ts +++ b/src/domain/graph/builder/stages/build-structure.ts @@ -6,7 +6,7 @@ import path from 'node:path'; import { performance } from 'node:perf_hooks'; import { debug } from '../../../../infrastructure/logger.js'; -import { normalizePath } from '../../../../shared/constants.js'; +import { getAncestorDirs, normalizePath } from '../../../../shared/constants.js'; import type { ExtractorOutput } from '../../../../types.js'; import type { PipelineContext } from '../context.js'; import { readFileSafe } from '../helpers.js'; @@ -55,6 +55,7 @@ async function buildDirectoryStructure( ): Promise { if (useSmallIncrementalFastPath) { updateChangedFileMetrics(ctx, changedFileList!); + refreshAffectedDirectoryMetrics(ctx, changedFileList!, ctx.removed ?? []); return; } @@ -185,8 +186,9 @@ export async function buildStructure(ctx: PipelineContext): Promise { * (~8ms) and full structure rebuild (~15ms), replacing them with per-file * indexed queries (~1-2ms total for 1-5 files). * - * Directory metrics are not recomputed — a 1-5 file change won't - * meaningfully alter directory-level cohesion or symbol counts. + * Directory-level metrics are handled separately by + * `refreshAffectedDirectoryMetrics` below — this function only ever touches + * per-file rows. */ function updateChangedFileMetrics(ctx: PipelineContext, changedFiles: string[]): void { const { db } = ctx; @@ -250,6 +252,189 @@ function updateChangedFileMetrics(ctx: PipelineContext, changedFiles: string[]): debug(`Structure (fast path): updated metrics for ${changedFiles.length} files`); } +/** + * Targeted directory-metrics refresh for the small-incremental fast path. + * + * `updateChangedFileMetrics` only ever touches per-file `node_metrics` rows — + * it never looks at directories. Any file added to, removed from, or edited + * within a directory left that directory's fileCount/symbolCount/fanIn/ + * fanOut/cohesion stale until the next full rebuild (#1738), and a file added + * under a brand-new directory never even got a directory node or a `contains` + * edge from its parent. + * + * This recomputes metrics for the ancestor directories of the files that + * changed in this build (added, removed, or modified), PLUS any directory + * reachable from them via a live cross-directory import edge — a changed + * file that gains (or loses) an import into a sibling package shifts that + * package's fan-in/fan-out/cohesion even though none of its own files were + * touched. One level of expansion only (mirrors the neighbour-expansion + * `classifyNodeRolesIncremental` already does for role classification) — + * bounded by (changed files × path depth) rather than the size of the repo, + * so it stays cheap enough to run unconditionally alongside the fast path. + * + * Removed files need no edge/node cleanup of their own — `purgeFilesData` + * already deleted their nodes and every edge referencing them (including + * their old `contains` edge) earlier in the pipeline; only their ancestor + * directories' aggregates need recomputing here. Note this expansion can't + * reach a directory whose ONLY relationship to the touched set was an edge + * to/from a file that was JUST removed — that edge is already gone by the + * time this runs, so there's nothing left to discover it from (tracked + * separately, see #1738 follow-up). + */ +function refreshAffectedDirectoryMetrics( + ctx: PipelineContext, + changedFiles: string[], + removedFiles: string[], +): void { + const { db } = ctx; + const affectedDirs = getAncestorDirs([...changedFiles, ...removedFiles]); + if (affectedDirs.size === 0) return; + + const getDirId = db.prepare( + "SELECT id FROM nodes WHERE name = ? AND kind = 'directory' AND file = ? AND line = 0", + ); + // Directories connected to `dir` via a live import/imports-type edge in + // either direction — the cross-directory neighbours whose own fan-in/out + // may have shifted even though none of their files changed. + const neighborFiles = db.prepare(` + SELECT n2.file AS other FROM edges e + JOIN nodes n1 ON e.source_id = n1.id JOIN nodes n2 ON e.target_id = n2.id + WHERE e.kind IN ('imports', 'imports-type') AND n1.file != n2.file + AND n1.file >= @lo AND n1.file < @hi + UNION + SELECT n1.file AS other FROM edges e + JOIN nodes n1 ON e.source_id = n1.id JOIN nodes n2 ON e.target_id = n2.id + WHERE e.kind IN ('imports', 'imports-type') AND n1.file != n2.file + AND n2.file >= @lo AND n2.file < @hi + `); + for (const dir of [...affectedDirs]) { + const otherFiles = neighborFiles.all({ lo: `${dir}/`, hi: `${dir}0` }) as Array<{ + other: string; + }>; + for (const ancestor of getAncestorDirs(otherFiles.map((r) => r.other))) { + affectedDirs.add(ancestor); + } + } + + const insertDirNode = db.prepare( + 'INSERT OR IGNORE INTO nodes (name, kind, file, line, end_line) VALUES (?, ?, ?, ?, ?)', + ); + const getFileId = db.prepare( + "SELECT id FROM nodes WHERE name = ? AND kind = 'file' AND file = ? AND line = 0", + ); + const insertContainsIfMissing = db.prepare(` + INSERT INTO edges (source_id, target_id, kind, confidence, dynamic) + SELECT ?, ?, 'contains', 1.0, 0 + WHERE NOT EXISTS ( + SELECT 1 FROM edges WHERE source_id = ? AND target_id = ? AND kind = 'contains' + ) + `); + // fileCount/symbolCount: transitive counts under `dir`, matching + // computeDirectoryMetrics in features/structure.ts. `file >= dir/ AND + // file < dir0` is an index-friendly prefix-range scan equivalent to + // `file LIKE 'dir/%'` — '0' (0x30) is the character immediately after + // '/' (0x2F), so this bound matches exactly the paths nested under `dir`. + const countFiles = db.prepare( + "SELECT COUNT(*) AS c FROM nodes WHERE kind = 'file' AND file >= ? AND file < ?", + ); + const countSymbols = db.prepare( + "SELECT COUNT(*) AS c FROM nodes WHERE kind != 'file' AND kind != 'directory' AND file >= ? AND file < ?", + ); + // Edges sourced from a file inside dir: intra (target also inside dir) vs fan-out. + const outboundEdges = db.prepare(` + SELECT + COALESCE(SUM(CASE WHEN n2.file >= @lo AND n2.file < @hi THEN 1 ELSE 0 END), 0) AS intra, + COUNT(*) AS total + FROM edges e + JOIN nodes n1 ON e.source_id = n1.id + JOIN nodes n2 ON e.target_id = n2.id + WHERE e.kind IN ('imports', 'imports-type') + AND n1.file != n2.file + AND n2.kind = 'file' + AND n1.file >= @lo AND n1.file < @hi + `); + // Edges targeting a file inside dir, sourced from a file outside dir (fan-in only). + const inboundEdges = db.prepare(` + SELECT COUNT(*) AS c + FROM edges e + JOIN nodes n1 ON e.source_id = n1.id + JOIN nodes n2 ON e.target_id = n2.id + WHERE e.kind IN ('imports', 'imports-type') + AND n1.file != n2.file + AND n2.kind = 'file' + AND n2.file >= @lo AND n2.file < @hi + AND NOT (n1.file >= @lo AND n1.file < @hi) + `); + const upsertMetric = db.prepare(` + INSERT OR REPLACE INTO node_metrics + (node_id, line_count, symbol_count, import_count, export_count, fan_in, fan_out, cohesion, file_count) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + + db.transaction(() => { + // Ensure directory nodes exist for the whole affected ancestor chain — + // handles a file added under a brand-new (possibly multi-level) directory. + for (const dir of affectedDirs) { + insertDirNode.run(dir, 'directory', dir, 0, null); + } + + // Wire dir -> parent-dir contains edges for the chain. + for (const dir of affectedDirs) { + const parent = normalizePath(path.dirname(dir)); + if (!parent || parent === '.' || parent === dir) continue; + const parentRow = getDirId.get(parent, parent) as { id: number } | undefined; + const childRow = getDirId.get(dir, dir) as { id: number } | undefined; + if (parentRow && childRow) { + insertContainsIfMissing.run(parentRow.id, childRow.id, parentRow.id, childRow.id); + } + } + + // Wire dir -> file contains edges for changed (added/modified) files. + // Removed files' nodes and edges are already purged upstream. + for (const relPath of changedFiles) { + const dir = normalizePath(path.dirname(relPath)); + if (!dir || dir === '.') continue; + const dirRow = getDirId.get(dir, dir) as { id: number } | undefined; + const fileRow = getFileId.get(relPath, relPath) as { id: number } | undefined; + if (dirRow && fileRow) { + insertContainsIfMissing.run(dirRow.id, fileRow.id, dirRow.id, fileRow.id); + } + } + + // Recompute each affected directory's metrics from the live DB state. + for (const dir of affectedDirs) { + const dirRow = getDirId.get(dir, dir) as { id: number } | undefined; + if (!dirRow) continue; + + const lo = `${dir}/`; + const hi = `${dir}0`; + const fileCount = (countFiles.get(lo, hi) as { c: number }).c; + const symbolCount = (countSymbols.get(lo, hi) as { c: number }).c; + const out = outboundEdges.get({ lo, hi }) as { intra: number; total: number }; + const fanOut = out.total - out.intra; + const fanIn = (inboundEdges.get({ lo, hi }) as { c: number }).c; + const totalEdges = out.intra + fanIn + fanOut; + const cohesion = totalEdges > 0 ? out.intra / totalEdges : null; + + upsertMetric.run( + dirRow.id, + null, + symbolCount, + null, + null, + fanIn, + fanOut, + cohesion, + fileCount, + ); + } + })(); + + debug( + `Structure (fast path): refreshed metrics for ${affectedDirs.size} affected director${affectedDirs.size === 1 ? 'y' : 'ies'}`, + ); +} + // ── Full incremental DB load (medium/large changes) ────────────────────── function loadUnchangedFilesFromDb(ctx: PipelineContext): void { diff --git a/src/features/structure.ts b/src/features/structure.ts index 85f3afff9..885b1102d 100644 --- a/src/features/structure.ts +++ b/src/features/structure.ts @@ -1,7 +1,7 @@ import path from 'node:path'; import { getBuildMeta, getNodeId, setBuildMeta, testFilterSQL } from '../db/index.js'; import { debug } from '../infrastructure/logger.js'; -import { normalizePath } from '../shared/constants.js'; +import { getAncestorDirs, normalizePath } from '../shared/constants.js'; import type { BetterSqlite3Database } from '../types.js'; // ─── Build-time helpers ─────────────────────────────────────────────── @@ -17,18 +17,6 @@ interface FileSymbolData { calls?: unknown[]; } -function getAncestorDirs(filePaths: string[]): Set { - const dirs = new Set(); - for (const f of filePaths) { - let d = normalizePath(path.dirname(f)); - while (d && d !== '.') { - dirs.add(d); - d = normalizePath(path.dirname(d)); - } - } - return dirs; -} - function cleanupPreviousData( db: BetterSqlite3Database, getNodeIdStmt: NodeIdStmt, diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 9dc88056e..bff1cab4c 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -77,3 +77,23 @@ export function isSupportedFile(filePath: string): boolean { export function normalizePath(filePath: string): string { return filePath.split(path.sep).join('/'); } + +/** + * Walk every ancestor directory of each given file path (not just the direct + * parent) and return the union across all files. Shared by the full + * directory-structure build (`features/structure.ts`) and the incremental + * fast path (`domain/graph/builder/stages/build-structure.ts`), which both + * need to scope work to exactly the directories whose file composition may + * have changed (#1738). + */ +export function getAncestorDirs(filePaths: Iterable): Set { + const dirs = new Set(); + for (const f of filePaths) { + let d = normalizePath(path.dirname(f)); + while (d && d !== '.') { + dirs.add(d); + d = normalizePath(path.dirname(d)); + } + } + return dirs; +} diff --git a/tests/integration/issue-1738-structure-metrics-incremental.test.ts b/tests/integration/issue-1738-structure-metrics-incremental.test.ts new file mode 100644 index 000000000..0086446f0 --- /dev/null +++ b/tests/integration/issue-1738-structure-metrics-incremental.test.ts @@ -0,0 +1,277 @@ +/** + * Regression test for #1738: `codegraph structure --depth 2 --json` reported + * stale fileCount/symbolCount/fanIn/cohesion/density for a directory after an + * INCREMENTAL rebuild added or removed a file in it. A full rebuild + * (`--no-incremental`) always produced the correct numbers. + * + * Root cause: the small-incremental fast path (`updateChangedFileMetrics` in + * `domain/graph/builder/stages/build-structure.ts`, mirrored by + * `update_changed_file_metrics` in `crates/codegraph-core/src/features/ + * structure.rs`) only ever updated per-FILE `node_metrics` rows. It never + * touched directories at all — no directory-metrics recompute, no `contains` + * edge for the new file, and no directory node for a brand-new directory. + * This path triggers whenever an incremental build touches at most + * `smallFilesThreshold` (5) files and the repo already has more than 20 + * files — i.e. almost every normal edit-and-rebuild cycle on a non-trivial + * repo, including a pure-removal build (0 parsed files, which trivially + * satisfies the "<=5" gate). + * + * Fixed by `refreshAffectedDirectoryMetrics` / + * `refresh_affected_directory_metrics`, which recomputes metrics (and wires + * up any missing directory nodes/contains edges) for the ancestor + * directories of the files touched by the incremental build, PLUS any + * directory reachable from them via a live cross-directory import edge (a + * changed file gaining/losing an import into a sibling package shifts that + * package's fan-in/fan-out too, even though none of its own files changed) + * — cheap because it's bounded by (changed files x path depth) rather than + * the size of the repo. See #1839 for a narrower residual gap this does not + * cover: a directory whose only link to the touched set was an edge to/from + * a file that was itself just removed (that edge's evidence is gone by the + * time the refresh runs). + * + * Strategy: build a fixture with >20 files (crossing the fast-path's + * `existingFileCount > 20` gate), mutate it incrementally, then diff the + * resulting directory metrics against a from-scratch full build of the exact + * same final file set — the full build is what the issue itself uses as + * "ground truth" (full rebuild fixes it). + */ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { buildGraph } from '../../src/domain/graph/builder.js'; +import { structureData } from '../../src/features/structure.js'; +import { isNativeAvailable } from '../../src/infrastructure/native.js'; + +// Small-incremental fast path requires existingFileCount > 20 — keep a +// healthy margin above that boundary. +const BASE_FILE_COUNT = 24; +const DIR = 'src/pkg'; + +function fileContent(i: number): string { + return `export function fn${i}() { return ${i}; }\n`; +} + +/** Write `count` standalone (no cross-imports) files into `/src/pkg`. */ +function writeBaseFixture(root: string, count: number, skip: Set = new Set()): void { + fs.mkdirSync(path.join(root, DIR), { recursive: true }); + for (let i = 0; i < count; i++) { + if (skip.has(i)) continue; + fs.writeFileSync(path.join(root, DIR, `file${i}.js`), fileContent(i)); + } +} + +interface DirSnapshot { + fileCount: number; + symbolCount: number; + fanIn: number; + fanOut: number; + cohesion: number | null; + fileNames: string[]; +} + +function snapshotDir(dbPath: string, dirName: string): DirSnapshot { + const data = structureData(dbPath, { directory: dirName, full: true }); + const entry = data.directories.find((d) => d.directory === dirName); + expect(entry, `${dirName} directory missing from structureData output`).toBeDefined(); + return { + fileCount: entry!.fileCount, + symbolCount: entry!.symbolCount, + fanIn: entry!.fanIn, + fanOut: entry!.fanOut, + cohesion: entry!.cohesion, + fileNames: entry!.files.map((f) => f.file).sort(), + }; +} + +function runScenario(engine: 'wasm' | 'native'): void { + describe(`directory structure metrics after incremental rebuild (#1738) — ${engine}`, () => { + let incrDir: string; + const tmpDirs: string[] = []; + const incrDbPath = () => path.join(incrDir, '.codegraph', 'graph.db'); + + function mkTmp(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tmpDirs.push(dir); + return dir; + } + + beforeAll(async () => { + incrDir = mkTmp(`cg-1738-incr-${engine}-`); + writeBaseFixture(incrDir, BASE_FILE_COUNT); + await buildGraph(incrDir, { engine, incremental: false, skipRegistry: true }); + }, 60_000); + + afterAll(() => { + for (const dir of tmpDirs) fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('baseline full build reports the expected file/symbol counts', () => { + const snap = snapshotDir(incrDbPath(), DIR); + expect(snap.fileCount).toBe(BASE_FILE_COUNT); + expect(snap.symbolCount).toBe(BASE_FILE_COUNT); + }); + + it('reflects a newly added file after an incremental rebuild (matches a full rebuild of the same file set)', async () => { + // Mutate the incremental repo: add one new file to the existing directory. + // A single added file stays within smallFilesThreshold (5), so this + // exercises the fast path. + fs.writeFileSync( + path.join(incrDir, DIR, 'new-file.js'), + "export function brandNew() { return 'new'; }\n", + ); + await buildGraph(incrDir, { engine, skipRegistry: true }); // incremental (default) + const incremental = snapshotDir(incrDbPath(), DIR); + + // Ground truth: an independent repo built in one full pass with the + // identical final file set (BASE_FILE_COUNT existing files + new-file.js). + const refDir = mkTmp(`cg-1738-ref-add-${engine}-`); + writeBaseFixture(refDir, BASE_FILE_COUNT); + fs.writeFileSync( + path.join(refDir, DIR, 'new-file.js'), + "export function brandNew() { return 'new'; }\n", + ); + await buildGraph(refDir, { engine, incremental: false, skipRegistry: true }); + const reference = snapshotDir(path.join(refDir, '.codegraph', 'graph.db'), DIR); + + expect( + incremental.fileCount, + 'directory fileCount is stale after incremental rebuild added a file (#1738)', + ).toBe(reference.fileCount); + expect(incremental.symbolCount).toBe(reference.symbolCount); + expect(incremental.fanIn).toBe(reference.fanIn); + expect(incremental.fanOut).toBe(reference.fanOut); + expect(incremental.cohesion).toBe(reference.cohesion); + expect( + incremental.fileNames, + 'new file is missing a contains edge from its parent directory (#1738)', + ).toEqual(reference.fileNames); + expect(incremental.fileNames).toContain(`${DIR}/new-file.js`); + }, 60_000); + + it('reflects a removed file after an incremental rebuild (matches a full rebuild of the same file set)', async () => { + // Mutate the incremental repo further: remove one pre-existing file. + // Zero re-parsed files also stays within smallFilesThreshold, so this + // exercises the fast path's pure-removal case. + fs.rmSync(path.join(incrDir, DIR, 'file0.js')); + await buildGraph(incrDir, { engine, skipRegistry: true }); + const incremental = snapshotDir(incrDbPath(), DIR); + + // Ground truth: BASE_FILE_COUNT files minus file0, plus new-file.js + // (added by the previous test), built from scratch in one full pass. + const refDir = mkTmp(`cg-1738-ref-remove-${engine}-`); + writeBaseFixture(refDir, BASE_FILE_COUNT, new Set([0])); + fs.writeFileSync( + path.join(refDir, DIR, 'new-file.js'), + "export function brandNew() { return 'new'; }\n", + ); + await buildGraph(refDir, { engine, incremental: false, skipRegistry: true }); + const reference = snapshotDir(path.join(refDir, '.codegraph', 'graph.db'), DIR); + + expect( + incremental.fileCount, + 'directory fileCount is stale after incremental rebuild removed a file (#1738)', + ).toBe(reference.fileCount); + expect(incremental.symbolCount).toBe(reference.symbolCount); + expect(incremental.fileNames).toEqual(reference.fileNames); + expect(incremental.fileNames).not.toContain(`${DIR}/file0.js`); + }, 60_000); + + it('creates a directory node and metrics for a file added under a brand-new nested directory', async () => { + // A file under a directory that has never existed before must get a + // directory node (and ancestor contains edges) created for it, not + // just a metrics update to a pre-existing row. + fs.mkdirSync(path.join(incrDir, DIR, 'newdir', 'nested'), { recursive: true }); + fs.writeFileSync( + path.join(incrDir, DIR, 'newdir', 'nested', 'deep.js'), + 'export function deepFn() { return 1; }\n', + ); + await buildGraph(incrDir, { engine, skipRegistry: true }); + + const nestedSnap = snapshotDir(incrDbPath(), `${DIR}/newdir/nested`); + expect(nestedSnap.fileCount).toBe(1); + expect(nestedSnap.symbolCount).toBe(1); + expect(nestedSnap.fileNames).toEqual([`${DIR}/newdir/nested/deep.js`]); + + // The ancestor's transitive fileCount must include the nested file too. + const parentSnap = snapshotDir(incrDbPath(), DIR); + expect(parentSnap.fileCount).toBeGreaterThanOrEqual( + BASE_FILE_COUNT /* original */ - + 1 /* file0 removed */ + + 1 /* new-file.js */ + + 1 /* deep.js */, + ); + }, 60_000); + + it("reflects a cross-directory import gained by a changed file on the OTHER (untouched) directory's fan-in/fan-out", async () => { + // A neighbor directory can have zero files of its own added, removed, + // or modified and still need its fan-in/fan-out/cohesion refreshed, + // because a changed file elsewhere gained or lost a cross-directory + // import edge touching it. + const crossDir = mkTmp(`cg-1738-cross-${engine}-`); + fs.mkdirSync(path.join(crossDir, 'src', 'pkgA'), { recursive: true }); + fs.mkdirSync(path.join(crossDir, 'src', 'pkgB'), { recursive: true }); + writeBaseFixture(crossDir, BASE_FILE_COUNT); // padding to cross the fast-path gate + fs.writeFileSync( + path.join(crossDir, 'src', 'pkgA', 'a1.js'), + "import { a2 } from './a2.js';\nexport function a1() { return a2(); }\n", + ); + fs.writeFileSync( + path.join(crossDir, 'src', 'pkgA', 'a2.js'), + 'export function a2() { return 2; }\n', + ); + fs.writeFileSync( + path.join(crossDir, 'src', 'pkgB', 'b1.js'), + 'export function b1() { return 1; }\n', + ); + await buildGraph(crossDir, { engine, incremental: false, skipRegistry: true }); + + const crossDbPath = () => path.join(crossDir, '.codegraph', 'graph.db'); + const baseline = snapshotDir(crossDbPath(), 'src/pkgB'); + expect(baseline.fanIn).toBe(0); + + // pkgA/a1.js (already-existing, gets modified) now ALSO imports + // pkgB/b1.js — pkgB itself has no file of its own touched. + fs.writeFileSync( + path.join(crossDir, 'src', 'pkgA', 'a1.js'), + "import { a2 } from './a2.js';\nimport { b1 } from '../pkgB/b1.js';\nexport function a1() { return a2() + b1(); }\n", + ); + await buildGraph(crossDir, { engine, skipRegistry: true }); + const incremental = snapshotDir(crossDbPath(), 'src/pkgB'); + + // Ground truth: an independent from-scratch full build of the same + // final source. + const refDir = mkTmp(`cg-1738-cross-ref-${engine}-`); + fs.mkdirSync(path.join(refDir, 'src', 'pkgA'), { recursive: true }); + fs.mkdirSync(path.join(refDir, 'src', 'pkgB'), { recursive: true }); + writeBaseFixture(refDir, BASE_FILE_COUNT); + fs.writeFileSync( + path.join(refDir, 'src', 'pkgA', 'a1.js'), + "import { a2 } from './a2.js';\nimport { b1 } from '../pkgB/b1.js';\nexport function a1() { return a2() + b1(); }\n", + ); + fs.writeFileSync( + path.join(refDir, 'src', 'pkgA', 'a2.js'), + 'export function a2() { return 2; }\n', + ); + fs.writeFileSync( + path.join(refDir, 'src', 'pkgB', 'b1.js'), + 'export function b1() { return 1; }\n', + ); + await buildGraph(refDir, { engine, incremental: false, skipRegistry: true }); + const reference = snapshotDir(path.join(refDir, '.codegraph', 'graph.db'), 'src/pkgB'); + + expect( + incremental.fanIn, + "pkgB's fanIn is stale — a cross-directory import gained by pkgA was not reflected on its neighbor (#1738)", + ).toBe(reference.fanIn); + expect(incremental.fanIn).toBe(1); + expect(incremental.cohesion).toBe(reference.cohesion); + }, 60_000); + }); +} + +runScenario('wasm'); + +describe.skipIf(!isNativeAvailable())('native engine coverage', () => { + runScenario('native'); +}); From 8d936d1fb1d5138660cc228627aca14946734f0d Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Sun, 5 Jul 2026 20:17:02 -0600 Subject: [PATCH 14/67] refactor: register hardcoded execFileSync/execSync maxBuffer values in DEFAULTS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves the remaining inline maxBuffer magic numbers for git subprocess calls into DEFAULTS, following the same pattern already applied to resolveSecrets's apiKeyCommand execFileSync options: - DEFAULTS.coChange.execMaxBufferBytes (50 MB) — git log in cochange.ts - DEFAULTS.check.execMaxBufferBytes (10 MB) — git diff in check.ts and diff-impact.ts (same operation, shared constant) - DEFAULTS.build.execMaxBufferBytes (100 MB) — git check-ignore in native-orchestrator.ts Call sites that already had a resolved config in scope (check.ts, diff-impact.ts, native-orchestrator.ts) now read the value off it so .codegraphrc.json overrides apply; cochange.ts reads DEFAULTS directly, matching its existing convention for other coChange fields. No behavioral change — numeric values are unchanged. docs check acknowledged: no README/CLAUDE.md/ROADMAP.md updates needed — this is a config-registration fix with no language/architecture/roadmap impact; docs/guides/configuration.md was updated with the three new keys. Fixes #1739 Impact: 10 functions changed, 16 affected --- docs/guides/configuration.md | 3 +++ src/domain/analysis/diff-impact.ts | 5 +++-- .../builder/stages/native-orchestrator.ts | 14 +++++++++++--- src/features/check.ts | 10 +++++++--- src/features/cochange.ts | 2 +- src/infrastructure/config.ts | 16 ++++++++++++++++ src/types.ts | 18 ++++++++++++++++++ 7 files changed, 59 insertions(+), 9 deletions(-) diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md index ff338f20d..2230ef5ae 100644 --- a/docs/guides/configuration.md +++ b/docs/guides/configuration.md @@ -156,6 +156,7 @@ Controls graph construction. | `dbPath` | `string` | `".codegraph/graph.db"` | Path to the SQLite database, relative to the project root. | | `driftThreshold` | `number` | `0.2` | Fraction (0–1). If incremental rebuild changes node or edge counts by more than this, codegraph warns and suggests `--no-incremental`. | | `smallFilesThreshold` | `number` | `5` | When ≤ this many files change in an incremental build, codegraph takes faster code paths (skips full rebuilds of structure metrics, scoped barrel re-parsing, JS fallback for inserts). | +| `execMaxBufferBytes` | `number` | `104857600` | Max stdout buffer size (bytes) for the `git check-ignore` subprocess spawned while detecting gitignored files during native-orchestrator drop detection. | --- @@ -318,6 +319,7 @@ Toggles for the lightweight `codegraph check` command (separate from manifesto). | `signatures` | `boolean` | `true` | Warn on signature changes in the diff. | | `boundaries` | `boolean` | `true` | Honor the `manifesto.boundaries` rules. | | `depth` | `number` | `3` | Transitive depth for blast-radius calculation. | +| `execMaxBufferBytes` | `number` | `10485760` | Max stdout buffer size (bytes) for the `git diff` subprocess spawned to compute changed-line ranges (also used by `diff-impact`). | --- @@ -331,6 +333,7 @@ Configures `codegraph co-changes` (files that historically change together based | `minSupport` | `number` | `3` | Minimum number of co-occurring commits before a pair is reported. | | `minJaccard` | `number` | `0.3` | Minimum Jaccard similarity (`|A∩B| / |A∪B|`) for a pair. | | `maxFilesPerCommit` | `number` | `50` | Skip commits touching more than this many files (avoids noise from large refactors / merges). | +| `execMaxBufferBytes` | `number` | `52428800` | Max stdout buffer size (bytes) for the `git log` subprocess spawned while scanning commit history. | --- diff --git a/src/domain/analysis/diff-impact.ts b/src/domain/analysis/diff-impact.ts index 2d263edbd..e16622cef 100644 --- a/src/domain/analysis/diff-impact.ts +++ b/src/domain/analysis/diff-impact.ts @@ -41,6 +41,7 @@ function findGitRoot(repoRoot: string): boolean { function runGitDiff( repoRoot: string, opts: { staged?: boolean; ref?: string }, + maxBuffer: number, ): { output: string; error?: never } | { error: string; output?: never } { try { const args = opts.staged @@ -49,7 +50,7 @@ function runGitDiff( const output = execFileSync('git', args, { cwd: repoRoot, encoding: 'utf-8', - maxBuffer: 10 * 1024 * 1024, + maxBuffer, stdio: ['pipe', 'pipe', 'pipe'], }); return { output }; @@ -281,7 +282,7 @@ export function diffImpactData( return { error: `Not a git repository: ${repoRoot}` }; } - const gitResult = runGitDiff(repoRoot, opts); + const gitResult = runGitDiff(repoRoot, opts, config.check.execMaxBufferBytes); if ('error' in gitResult) return { error: gitResult.error }; if (!gitResult.output.trim()) { diff --git a/src/domain/graph/builder/stages/native-orchestrator.ts b/src/domain/graph/builder/stages/native-orchestrator.ts index b5d0c0467..edb4fe986 100644 --- a/src/domain/graph/builder/stages/native-orchestrator.ts +++ b/src/domain/graph/builder/stages/native-orchestrator.ts @@ -1457,7 +1457,11 @@ function groupByExtension(relPaths: Iterable): Map { * the result set can be matched directly against the `expected` set in * `detectDroppedLanguageGap` without any further path manipulation. */ -function queryGitIgnoredFiles(rootDir: string, relPaths: Iterable): Set { +function queryGitIgnoredFiles( + rootDir: string, + relPaths: Iterable, + maxBuffer: number, +): Set { const ignored = new Set(); const paths = [...relPaths]; if (paths.length === 0) return ignored; @@ -1467,7 +1471,7 @@ function queryGitIgnoredFiles(rootDir: string, relPaths: Iterable): Set< cwd: rootDir, input: stdin, encoding: 'utf-8', - maxBuffer: 100 * 1024 * 1024, + maxBuffer, // git check-ignore exits with 1 when none of the paths are ignored — // that is not an error for our purposes. stdio: 'pipe' lets us capture // stdout without swallowing stderr, and the try/catch handles the @@ -1530,7 +1534,11 @@ function detectDroppedLanguageGap(ctx: PipelineContext): DroppedLanguageGap { // (e.g. NAPI-RS generated crates/codegraph-core/index.js / index.d.ts) appear // in `expected` but not in the DB, causing a spurious "native extractor bug" // WARN and triggering an unnecessary WASM backfill (#1626). - const gitIgnored = queryGitIgnoredFiles(ctx.rootDir, expectedRaw); + const gitIgnored = queryGitIgnoredFiles( + ctx.rootDir, + expectedRaw, + ctx.config.build.execMaxBufferBytes, + ); const expected = new Set( gitIgnored.size > 0 ? expectedRaw.filter((r) => !gitIgnored.has(r)) : expectedRaw, ); diff --git a/src/features/check.ts b/src/features/check.ts index 26f057e98..7b959df94 100644 --- a/src/features/check.ts +++ b/src/features/check.ts @@ -452,14 +452,18 @@ function findGitRoot(repoRoot: string): string | null { } /** Run git diff and return the raw output string. */ -function getGitDiff(repoRoot: string, opts: { staged?: boolean; ref?: string }): string { +function getGitDiff( + repoRoot: string, + opts: { staged?: boolean; ref?: string }, + maxBuffer: number, +): string { const args = opts.staged ? ['diff', '--cached', '--unified=0', '--no-color'] : ['diff', opts.ref || 'HEAD', '--unified=0', '--no-color']; return execFileSync('git', args, { cwd: repoRoot, encoding: 'utf-8', - maxBuffer: 10 * 1024 * 1024, + maxBuffer, stdio: ['pipe', 'pipe', 'pipe'], }); } @@ -539,7 +543,7 @@ export function checkData(customDbPath: string | undefined, opts: CheckOpts = {} let diffOutput: string; try { - diffOutput = getGitDiff(repoRoot, opts); + diffOutput = getGitDiff(repoRoot, opts, config.check.execMaxBufferBytes); } catch (e) { return { error: `Failed to run git diff: ${(e as Error).message}` }; } diff --git a/src/features/cochange.ts b/src/features/cochange.ts index 48bed2c90..80ee0194d 100644 --- a/src/features/cochange.ts +++ b/src/features/cochange.ts @@ -78,7 +78,7 @@ export function scanGitHistory( output = execFileSync('git', buildGitLogArgs(opts), { cwd: repoRoot, encoding: 'utf-8', - maxBuffer: 50 * 1024 * 1024, + maxBuffer: DEFAULTS.coChange.execMaxBufferBytes, stdio: ['pipe', 'pipe', 'pipe'], }); } catch (e: unknown) { diff --git a/src/infrastructure/config.ts b/src/infrastructure/config.ts index ceba048f3..84b2eed35 100644 --- a/src/infrastructure/config.ts +++ b/src/infrastructure/config.ts @@ -59,6 +59,11 @@ export const DEFAULTS = deepFreeze({ typescriptResolver: true, engine: 'auto' as 'auto' | 'native' | 'wasm', fastSkipDiag: false, + /** + * Max stdout buffer size (bytes) for the `git check-ignore` subprocess spawned via + * `execFileSync` in `queryGitIgnoredFiles()` (src/domain/graph/builder/stages/native-orchestrator.ts). + */ + execMaxBufferBytes: 100 * 1024 * 1024, }, db: { /** @@ -116,12 +121,23 @@ export const DEFAULTS = deepFreeze({ signatures: true, boundaries: true, depth: 3, + /** + * Max stdout buffer size (bytes) for the `git diff` subprocess spawned via + * `execFileSync` in `getGitDiff()` (src/features/check.ts) and `runGitDiff()` + * (src/domain/analysis/diff-impact.ts). + */ + execMaxBufferBytes: 10 * 1024 * 1024, }, coChange: { since: '1 year ago', minSupport: 3, minJaccard: 0.3, maxFilesPerCommit: 50, + /** + * Max stdout buffer size (bytes) for the `git log` subprocess spawned via + * `execFileSync` in `scanGitHistory()` (src/features/cochange.ts). + */ + execMaxBufferBytes: 50 * 1024 * 1024, }, analysis: { impactDepth: 3, diff --git a/src/types.ts b/src/types.ts index 96f7d2d46..1d41d4e02 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1363,6 +1363,12 @@ export interface CodegraphConfig { * Default: false. */ fastSkipDiag: boolean; + /** + * Max stdout buffer size (bytes) for the `git check-ignore` subprocess spawned via + * `execFileSync` in the native-orchestrator build stage (used to filter gitignored + * files out of the dropped-language-gap detection). Default: 104857600 (100 MB). + */ + execMaxBufferBytes: number; }; db: { @@ -1449,6 +1455,12 @@ export interface CodegraphConfig { signatures: boolean; boundaries: boolean; depth: number; + /** + * Max stdout buffer size (bytes) for the `git diff` subprocess spawned via + * `execFileSync` when computing changed-line ranges for `codegraph check` + * and `codegraph diff-impact`. Default: 10485760 (10 MB). + */ + execMaxBufferBytes: number; }; coChange: { @@ -1456,6 +1468,12 @@ export interface CodegraphConfig { minSupport: number; minJaccard: number; maxFilesPerCommit: number; + /** + * Max stdout buffer size (bytes) for the `git log` subprocess spawned via + * `execFileSync` when scanning commit history for co-change analysis. + * Default: 52428800 (50 MB). + */ + execMaxBufferBytes: number; }; analysis: { From 11c84be4db8d09831ab2c9c35cfe7af86f9f0fa1 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Sun, 5 Jul 2026 20:45:50 -0600 Subject: [PATCH 15/67] fix: gate blast-radius check on newly introduced risk, not pre-existing fan-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit checkMaxBlastRadius previously failed any staged change touching a function whose absolute transitive-caller count exceeded the threshold, regardless of whether the diff actually changed anything risky. A function reachable only through a near-universally-called "spine" function (e.g. resolveSecrets via loadConfig) would fail on any edit at all, including fully behavior-preserving ones like replacing an inline literal with a named constant. checkMaxBlastRadius now exempts a touched function from the threshold unless the diff changed its call graph shape: its own declaration line was touched (signature/name risk), or the set of paren-preceded tokens referenced in its body changed (a callee was added, removed, or swapped). This is a mechanical, diff-text heuristic rather than a full pre/post call-graph reconstruction — parseDiffOutput now pairs each added-line run with whatever removed text it replaced (scoped to a single hunk, never crossing hunk boundaries) so the comparison needs no re-parsing and stays fully synchronous. Missing edit data (e.g. hand-built ranges) conservatively falls back to the old always-gate behavior, so this is purely opt-in via real diff data. Known limitation: paren-less call syntax (Ruby's `foo x, y`, Lua's `foo "arg"`) is invisible to the token-set comparison, so a newly introduced paren-less call could be missed and its function wrongly exempted. This is a deliberate, documented trade-off for a non-parsing check. Also updates titan-gate SKILL.md Step 8 (both the internal and docs/examples copies) to defer to Step 1's now-authoritative, shape-aware blast-radius predicate instead of re-deriving a pass/fail from diff-impact's raw absolute counts. No user-facing feature, command, or language changed -- README/ROADMAP left as-is; docs check acknowledged. Fixes #1740 Impact: 17 functions changed, 10 affected --- .claude/skills/titan-gate/SKILL.md | 8 +- .../claude-code-skills/titan-gate/SKILL.md | 8 +- src/cli/commands/check.ts | 5 +- src/features/check.ts | 183 ++++++++++++++++-- tests/integration/check.test.ts | 168 +++++++++++++++- 5 files changed, 347 insertions(+), 25 deletions(-) diff --git a/.claude/skills/titan-gate/SKILL.md b/.claude/skills/titan-gate/SKILL.md index a660bcae5..fbd495838 100644 --- a/.claude/skills/titan-gate/SKILL.md +++ b/.claude/skills/titan-gate/SKILL.md @@ -45,6 +45,8 @@ codegraph check --staged --cycles --blast-radius 30 --boundaries -T --json This checks: manifesto rules, new cycle introduction, blast radius threshold, and architecture boundary violations. Exit code 0 = pass, 1 = fail. +The `blast-radius` predicate is call-graph-shape-aware: a touched function's absolute transitive-caller count only counts toward the threshold if the diff actually changed that function's own declaration line or the set of call targets referenced in its body. A function whose body changed without touching a call expression or its own signature (e.g. an inline literal replaced by a named constant) is exempt even with a high absolute caller count — that fan-in is pre-existing risk already accepted by the codebase, not risk this diff introduced. This is the authoritative blast-radius pass/fail — see Step 8. + Also run detailed impact analysis: ```bash @@ -302,9 +304,9 @@ Advisory — prevents jumping ahead and creating conflicts. ## Step 8 — Blast radius check -From diff-impact results: -- Transitive blast radius > 30 → FAIL -- Transitive blast radius > 15 → WARN +The FAIL/PASS decision comes from Step 1's `codegraph check --staged --blast-radius 30` predicate — do not re-derive it from diff-impact's raw numbers, which are absolute transitive-caller counts and are NOT shape-aware: +- Step 1's `blast-radius` predicate failed → FAIL (a touched function's own call graph shape genuinely changed and its transitive callers exceed 30) +- Step 1's `blast-radius` predicate passed but a changed function's raw `transitiveCallers` (from diff-impact) > 15 → WARN (worth a second look even though not gating — this may include functions Step 1 exempted as pre-existing fan-in) - Historically coupled file NOT staged? → WARN ("consider also updating X") --- diff --git a/docs/examples/claude-code-skills/titan-gate/SKILL.md b/docs/examples/claude-code-skills/titan-gate/SKILL.md index a660bcae5..fbd495838 100644 --- a/docs/examples/claude-code-skills/titan-gate/SKILL.md +++ b/docs/examples/claude-code-skills/titan-gate/SKILL.md @@ -45,6 +45,8 @@ codegraph check --staged --cycles --blast-radius 30 --boundaries -T --json This checks: manifesto rules, new cycle introduction, blast radius threshold, and architecture boundary violations. Exit code 0 = pass, 1 = fail. +The `blast-radius` predicate is call-graph-shape-aware: a touched function's absolute transitive-caller count only counts toward the threshold if the diff actually changed that function's own declaration line or the set of call targets referenced in its body. A function whose body changed without touching a call expression or its own signature (e.g. an inline literal replaced by a named constant) is exempt even with a high absolute caller count — that fan-in is pre-existing risk already accepted by the codebase, not risk this diff introduced. This is the authoritative blast-radius pass/fail — see Step 8. + Also run detailed impact analysis: ```bash @@ -302,9 +304,9 @@ Advisory — prevents jumping ahead and creating conflicts. ## Step 8 — Blast radius check -From diff-impact results: -- Transitive blast radius > 30 → FAIL -- Transitive blast radius > 15 → WARN +The FAIL/PASS decision comes from Step 1's `codegraph check --staged --blast-radius 30` predicate — do not re-derive it from diff-impact's raw numbers, which are absolute transitive-caller counts and are NOT shape-aware: +- Step 1's `blast-radius` predicate failed → FAIL (a touched function's own call graph shape genuinely changed and its transitive callers exceed 30) +- Step 1's `blast-radius` predicate passed but a changed function's raw `transitiveCallers` (from diff-impact) > 15 → WARN (worth a second look even though not gating — this may include functions Step 1 exempted as pre-existing fan-in) - Historically coupled file NOT staged? → WARN ("consider also updating X") --- diff --git a/src/cli/commands/check.ts b/src/cli/commands/check.ts index a00cec970..9bac0c693 100644 --- a/src/cli/commands/check.ts +++ b/src/cli/commands/check.ts @@ -29,7 +29,10 @@ export const command: CommandDefinition = { ['--staged', 'Analyze staged changes'], ['--rules', 'Also run manifesto rules alongside diff predicates'], ['--cycles', 'Assert no dependency cycles involve changed files'], - ['--blast-radius ', 'Assert no function exceeds N transitive callers'], + [ + '--blast-radius ', + 'Assert no function whose call graph shape changed (signature or call targets) exceeds N transitive callers', + ], ['--signatures', 'Assert no exported function/method/class declaration lines were modified'], ['--boundaries', 'Assert no cross-owner boundary violations'], ['--depth ', 'Max BFS depth for blast radius (default: 3)'], diff --git a/src/features/check.ts b/src/features/check.ts index 7b959df94..95a172e16 100644 --- a/src/features/check.ts +++ b/src/features/check.ts @@ -20,6 +20,19 @@ interface ParsedDiff { changedRanges: Map; oldRanges: Map; newFiles: Set; + /** + * One entry per `changedRanges` run (same file, same start/end), carrying + * the actual added-line text plus whatever removed-line text immediately + * preceded it within the same hunk (empty for a pure insertion). Powers + * `checkMaxBlastRadius`'s call-graph-shape exemption — see issue #1740. + */ + changedEdits: Map; +} + +/** An added-line run paired with whatever it replaced, for shape comparison. */ +interface DiffTextEdit extends DiffRange { + addedText: string[]; + removedText: string[]; } const HUNK_RE = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/; @@ -65,10 +78,21 @@ class DiffLineTracker { private removedRunEnd: number | null = null; private addedRunStart: number | null = null; private addedRunEnd: number | null = null; + private currentRemovedText: string[] = []; + private currentAddedText: string[] = []; + /** + * Text of the most recently closed removed run, staged so the next added + * run (if any, within the same hunk) can be paired with it into one + * `DiffTextEdit`. Cleared at the start of every hunk (`startHunk`) so + * pairing never crosses a hunk boundary — an unpaired trailing deletion in + * one hunk must never be attributed to an unrelated insertion in the next. + */ + private pendingRemovedText: string[] = []; startHunk(oldStart: number, newStart: number): void { this.oldLineCursor = oldStart; this.newLineCursor = newStart; + this.pendingRemovedText = []; } /** @@ -84,11 +108,13 @@ class DiffLineTracker { file: string, oldRanges: Map, changedRanges: Map, + changedEdits: Map, ): void { if (line.startsWith('-')) { - this.flushAdded(file, changedRanges); + this.flushAdded(file, changedRanges, changedEdits); if (this.removedRunStart === null) this.removedRunStart = this.oldLineCursor; this.removedRunEnd = this.oldLineCursor; + this.currentRemovedText.push(line.slice(1)); this.oldLineCursor++; return; } @@ -96,33 +122,56 @@ class DiffLineTracker { this.flushRemoved(file, oldRanges); if (this.addedRunStart === null) this.addedRunStart = this.newLineCursor; this.addedRunEnd = this.newLineCursor; + this.currentAddedText.push(line.slice(1)); this.newLineCursor++; return; } // A context line or a "\ No newline" marker ends both runs. this.flushRemoved(file, oldRanges); - this.flushAdded(file, changedRanges); + this.flushAdded(file, changedRanges, changedEdits); if (line.startsWith(' ')) { this.oldLineCursor++; this.newLineCursor++; } } - /** Closes out the current removed-line run, if any, into `oldRanges`. */ + /** + * Closes out the current removed-line run, if any, into `oldRanges` and + * stages its text as `pendingRemovedText` for the next added run to + * (optionally) pair with. + */ flushRemoved(file: string, oldRanges: Map): void { if (this.removedRunStart !== null) { oldRanges.get(file)!.push({ start: this.removedRunStart, end: this.removedRunEnd! }); this.removedRunStart = null; this.removedRunEnd = null; + this.pendingRemovedText = this.currentRemovedText; + this.currentRemovedText = []; } } - /** Closes out the current added-line run, if any, into `changedRanges`. */ - flushAdded(file: string, changedRanges: Map): void { + /** + * Closes out the current added-line run, if any, into `changedRanges` and + * records the paired `DiffTextEdit` (added text + whatever removed text was + * staged immediately before it) into `changedEdits`. + */ + flushAdded( + file: string, + changedRanges: Map, + changedEdits: Map, + ): void { if (this.addedRunStart !== null) { - changedRanges.get(file)!.push({ start: this.addedRunStart, end: this.addedRunEnd! }); + const range = { start: this.addedRunStart, end: this.addedRunEnd! }; + changedRanges.get(file)!.push(range); + changedEdits.get(file)!.push({ + ...range, + addedText: this.currentAddedText, + removedText: this.pendingRemovedText, + }); this.addedRunStart = null; this.addedRunEnd = null; + this.currentAddedText = []; + this.pendingRemovedText = []; } } @@ -131,15 +180,17 @@ class DiffLineTracker { file: string, oldRanges: Map, changedRanges: Map, + changedEdits: Map, ): void { this.flushRemoved(file, oldRanges); - this.flushAdded(file, changedRanges); + this.flushAdded(file, changedRanges, changedEdits); } } export function parseDiffOutput(diffOutput: string): ParsedDiff { const changedRanges = new Map(); const oldRanges = new Map(); + const changedEdits = new Map(); const newFiles = new Set(); let currentFile: string | null = null; let prevIsDevNull = false; @@ -156,10 +207,11 @@ export function parseDiffOutput(diffOutput: string): ParsedDiff { } const newFile = extractNewFileName(line); if (newFile) { - if (currentFile) tracker.flush(currentFile, oldRanges, changedRanges); + if (currentFile) tracker.flush(currentFile, oldRanges, changedRanges, changedEdits); currentFile = newFile; if (!changedRanges.has(currentFile)) changedRanges.set(currentFile, []); if (!oldRanges.has(currentFile)) oldRanges.set(currentFile, []); + if (!changedEdits.has(currentFile)) changedEdits.set(currentFile, []); if (prevIsDevNull) newFiles.add(currentFile); prevIsDevNull = false; continue; @@ -172,7 +224,7 @@ export function parseDiffOutput(diffOutput: string): ParsedDiff { // clear the file context instead — the deleted file's hunk body that // follows has no corresponding DB entry to check against anyway (its // nodes are purged from the graph), so there is nothing to track here. - if (currentFile) tracker.flush(currentFile, oldRanges, changedRanges); + if (currentFile) tracker.flush(currentFile, oldRanges, changedRanges, changedEdits); currentFile = null; prevIsDevNull = false; continue; @@ -181,16 +233,16 @@ export function parseDiffOutput(diffOutput: string): ParsedDiff { const hunkMatch = line.match(HUNK_RE); if (hunkMatch) { - tracker.flush(currentFile, oldRanges, changedRanges); + tracker.flush(currentFile, oldRanges, changedRanges, changedEdits); tracker.startHunk(parseInt(hunkMatch[1]!, 10), parseInt(hunkMatch[3]!, 10)); continue; } - tracker.consume(line, currentFile, oldRanges, changedRanges); + tracker.consume(line, currentFile, oldRanges, changedRanges, changedEdits); } - if (currentFile) tracker.flush(currentFile, oldRanges, changedRanges); + if (currentFile) tracker.flush(currentFile, oldRanges, changedRanges, changedEdits); - return { changedRanges, oldRanges, newFiles }; + return { changedRanges, oldRanges, newFiles, changedEdits }; } // ─── Predicates ─────────────────────────────────────────────────────── @@ -223,6 +275,9 @@ interface BlastRadiusResult { maxFound: number; threshold: number; violations: BlastRadiusViolation[]; + /** Count of touched functions whose call graph shape didn't change — see issue #1740. */ + exemptedCount: number; + note?: string; } type DefRow = { @@ -245,15 +300,87 @@ function defEndLine(def: DefRow, nextDef: DefRow | undefined): number { return def.end_line || (nextDef ? nextDef.line - 1 : 999999); } +// ─── Call-graph shape detection (issue #1740) ────────────────────────── +// +// A function's absolute transitive-caller count is a property of the whole +// codebase's dependency structure, not of what a given diff changed inside +// that function. Gating on the raw count means any touch to a high-fan-in +// "spine" function (e.g. one reachable only through another near-universally +// called function) always fails, even fully behavior-preserving edits like +// replacing an inline literal with a named constant. The functions below +// let `checkMaxBlastRadius` tell "pre-existing fan-in" (already accepted by +// the codebase) apart from "risk introduced by this diff": only a def whose +// diff touches its own declaration line or changes the set of call targets +// referenced in its body counts its absolute caller count toward the gate. + +/** + * Matches an identifier immediately followed by `(`. Used only to compare + * the SET of such tokens between an edit's removed and added text — not to + * classify any single line as "a call" — which sidesteps having to tell a + * real call apart from `if (...)`/`while (...)`/a declaration's own `(...)` + * on syntax alone: if a token (call, keyword, or otherwise) appears on both + * sides, it nets out and isn't treated as a change; only a token gained or + * lost between the two sides counts. + * + * Known limitation: paren-less call syntax (e.g. Ruby's `foo x, y`, Lua's + * `foo "arg"`) is invisible to this heuristic, so a newly introduced + * paren-less call could be missed and its function wrongly exempted. This is + * a deliberate, documented trade-off for a mechanical, non-parsing check — + * see issue #1740. + */ +const PAREN_TOKEN_RE = /([A-Za-z_$][A-Za-z0-9_$]*)\s*\(/g; + +function parenTokens(lines: string[]): Set { + const tokens = new Set(); + for (const line of lines) { + for (const m of line.matchAll(PAREN_TOKEN_RE)) tokens.add(m[1]!); + } + return tokens; +} + +function sameTokenSet(a: Set, b: Set): boolean { + if (a.size !== b.size) return false; + for (const t of a) if (!b.has(t)) return false; + return true; +} + +/** + * Returns true if the diff altered `def`'s call graph shape: an overlapping + * edit touches the declaration line itself (signature/name risk — existing + * callers may need to change), or changes the set of paren-preceded tokens + * referenced in its body (call-target risk — a callee was added, removed, or + * swapped). A range that overlaps the def but has no matching entry in + * `edits` (e.g. hand-built ranges in tests, or any future caller that only + * has range data) is conservatively treated as shape-changed — missing data + * must never silently exempt a def. + */ +function callGraphShapeChanged( + defLine: number, + endLine: number, + ranges: DiffRange[], + edits: DiffTextEdit[], +): boolean { + for (const range of ranges) { + if (range.start > endLine || range.end < defLine) continue; + if (defLine >= range.start && defLine <= range.end) return true; + const edit = edits.find((e) => e.start === range.start && e.end === range.end); + if (!edit) return true; + if (!sameTokenSet(parenTokens(edit.removedText), parenTokens(edit.addedText))) return true; + } + return false; +} + export function checkMaxBlastRadius( db: BetterSqlite3Database, changedRanges: Map, + changedEdits: Map, threshold: number, noTests: boolean, maxDepth: number, ): BlastRadiusResult { const violations: BlastRadiusViolation[] = []; let maxFound = 0; + let exemptedCount = 0; const defsStmt = db.prepare( `SELECT * FROM nodes WHERE file = ? AND kind IN ('function', 'method', 'class') ORDER BY line`, ); @@ -261,12 +388,21 @@ export function checkMaxBlastRadius( for (const [file, ranges] of changedRanges) { if (noTests && isTestFile(file)) continue; const defs = defsStmt.all(file) as DefRow[]; + const edits = changedEdits.get(file) ?? []; for (let i = 0; i < defs.length; i++) { const def = defs[i]!; const endLine = defEndLine(def, defs[i + 1]); if (!rangesOverlap(def.line, endLine, ranges)) continue; + // The diff touched this def, but not its call graph shape — its + // absolute caller count is pre-existing risk, not risk this diff + // introduced. Skip the (potentially expensive) BFS entirely. + if (!callGraphShapeChanged(def.line, endLine, ranges, edits)) { + exemptedCount++; + continue; + } + const { totalDependents: totalCallers } = bfsTransitiveCallers(db, def.id, { noTests, maxDepth, @@ -285,7 +421,17 @@ export function checkMaxBlastRadius( } } - return { passed: violations.length === 0, maxFound, threshold, violations }; + const result: BlastRadiusResult = { + passed: violations.length === 0, + maxFound, + threshold, + violations, + exemptedCount, + }; + if (exemptedCount > 0) { + result.note = `${exemptedCount} touched function(s) exempted — no call graph shape change detected (pre-existing fan-in, not new risk introduced by this diff)`; + } + return result; } interface SignatureViolation { @@ -497,7 +643,14 @@ function runPredicates( if (flags.blastRadiusThreshold != null) { predicates.push({ name: 'blast-radius', - ...checkMaxBlastRadius(db, diff.changedRanges, flags.blastRadiusThreshold, noTests, maxDepth), + ...checkMaxBlastRadius( + db, + diff.changedRanges, + diff.changedEdits, + flags.blastRadiusThreshold, + noTests, + maxDepth, + ), }); } if (flags.enableSignatures) { diff --git a/tests/integration/check.test.ts b/tests/integration/check.test.ts index aa3546fa8..ccb750d3a 100644 --- a/tests/integration/check.test.ts +++ b/tests/integration/check.test.ts @@ -225,6 +225,76 @@ describe('parseDiffOutput', () => { expect(changedRanges.has('src/a.js')).toBe(true); expect(changedRanges.has('src/b.js')).toBe(true); }); + + // ─── changedEdits (issue #1740) ─────────────────────────────────────── + + test('changedEdits pairs a replacement run with its added/removed text', () => { + const diff = [ + '--- a/src/math.js', + '+++ b/src/math.js', + '@@ -3,1 +3,1 @@', + '- return a + b + 0;', + '+ return a + b + ZERO_OFFSET;', + ].join('\n'); + + const { changedRanges, changedEdits } = parseDiffOutput(diff); + expect(changedRanges.get('src/math.js')).toEqual([{ start: 3, end: 3 }]); + expect(changedEdits.get('src/math.js')).toEqual([ + { + start: 3, + end: 3, + addedText: [' return a + b + ZERO_OFFSET;'], + removedText: [' return a + b + 0;'], + }, + ]); + }); + + test('changedEdits records empty removedText for a pure insertion', () => { + const diff = ['--- a/src/math.js', '+++ b/src/math.js', '@@ -3,0 +4,1 @@', '+ // note'].join( + '\n', + ); + + const { changedEdits } = parseDiffOutput(diff); + expect(changedEdits.get('src/math.js')).toEqual([ + { start: 4, end: 4, addedText: [' // note'], removedText: [] }, + ]); + }); + + test('changedEdits does not pair a pure deletion with an unrelated later hunk', () => { + // First hunk is a pure deletion (no added lines) so it never becomes a + // changedEdits entry; the second hunk's pure insertion must NOT be + // paired with the first hunk's removed text (they are unrelated edits). + const diff = [ + '--- a/src/math.js', + '+++ b/src/math.js', + '@@ -3,1 +3,0 @@', + '- // stale comment', + '@@ -10,0 +9,1 @@', + '+ // fresh comment', + ].join('\n'); + + const { changedEdits } = parseDiffOutput(diff); + expect(changedEdits.get('src/math.js')).toEqual([ + { start: 9, end: 9, addedText: [' // fresh comment'], removedText: [] }, + ]); + }); + + test('changedEdits pairs multi-line replacement blocks as a single edit', () => { + const diff = [ + '--- a/src/math.js', + '+++ b/src/math.js', + '@@ -1,2 +1,2 @@', + '-line A', + '-line A2', + '+line B', + '+line B2', + ].join('\n'); + + const { changedEdits } = parseDiffOutput(diff); + expect(changedEdits.get('src/math.js')).toEqual([ + { start: 1, end: 2, addedText: ['line B', 'line B2'], removedText: ['line A', 'line A2'] }, + ]); + }); }); // ─── checkNoNewCycles ───────────────────────────────────────────────── @@ -253,14 +323,17 @@ describe('checkMaxBlastRadius', () => { // multiply has callers: add(d1), handleRequest+formatResult(d2), processNode+parseInput(d3) = 5 // With depth 1 only, multiply has 1 caller (add), so threshold 3 passes const ranges = new Map([['src/math.js', [{ start: 7, end: 12 }]]]); - const result = checkMaxBlastRadius(db, ranges, 3, false, 1); + const result = checkMaxBlastRadius(db, ranges, new Map(), 3, false, 1); expect(result.passed).toBe(true); }); test('fails when max callers exceeds threshold', () => { // add has callers: handleRequest, formatResult at depth 1; parseInput, processNode at depth 2 + // No changedEdits data is provided (empty map) — a range with no matching + // edit entry is conservatively treated as a call-graph-shape change, so + // this predates and is unaffected by the issue #1740 exemption. const ranges = new Map([['src/math.js', [{ start: 1, end: 5 }]]]); - const result = checkMaxBlastRadius(db, ranges, 1, false, 3); + const result = checkMaxBlastRadius(db, ranges, new Map(), 1, false, 3); expect(result.passed).toBe(false); expect(result.violations.length).toBe(1); expect(result.violations[0].name).toBe('add'); @@ -270,10 +343,99 @@ describe('checkMaxBlastRadius', () => { test('respects maxDepth', () => { // With depth 1, add has 2 direct callers (handleRequest, formatResult) const ranges = new Map([['src/math.js', [{ start: 1, end: 5 }]]]); - const result = checkMaxBlastRadius(db, ranges, 10, false, 1); + const result = checkMaxBlastRadius(db, ranges, new Map(), 10, false, 1); expect(result.passed).toBe(true); expect(result.maxFound).toBeLessThanOrEqual(10); }); + + // ─── Call-graph shape exemption (issue #1740) ──────────────────────── + // + // `add` (src/math.js, lines 1-5) has several transitive callers in the + // fixture graph (handleRequest, formatResult direct; parseInput, + // processNode transitive) — a real "high fan-in spine function" shape. + // These tests drive `checkMaxBlastRadius` through `parseDiffOutput` (not + // hand-built ranges) so `changedEdits` is populated for real, proving the + // exemption logic itself rather than just the safe-fallback path above. + + test('passes for a high-fan-in function when only an internal literal changes (no call graph shape change)', () => { + // Body-only edit on line 3 (inside add's 1-5 span), declaration + // untouched, and neither side of the edit contains any call syntax. + const diff = [ + '--- a/src/math.js', + '+++ b/src/math.js', + '@@ -3,1 +3,1 @@', + '- return a + b + 0;', + '+ return a + b + ZERO_OFFSET;', + ].join('\n'); + const { changedRanges, changedEdits } = parseDiffOutput(diff); + + // Threshold 1 would normally fail on add's multiple callers (as proven + // by the "fails when max callers exceeds threshold" test above using + // the same threshold) — but this diff never changed add's call graph + // shape, so its pre-existing fan-in should not fail the gate. + const result = checkMaxBlastRadius(db, changedRanges, changedEdits, 1, false, 3); + expect(result.passed).toBe(true); + expect(result.violations).toEqual([]); + expect(result.exemptedCount).toBe(1); + expect(result.note).toMatch(/exempted/); + }); + + test('still fails for a high-fan-in function when the diff adds a new call', () => { + // Same line, but the replacement introduces a brand new call target + // (`validate`) that wasn't referenced before — a genuine call-graph + // shape change, so the pre-existing fan-in must still gate. + const diff = [ + '--- a/src/math.js', + '+++ b/src/math.js', + '@@ -3,1 +3,1 @@', + '- return a + b;', + '+ return validate(a) + b;', + ].join('\n'); + const { changedRanges, changedEdits } = parseDiffOutput(diff); + + const result = checkMaxBlastRadius(db, changedRanges, changedEdits, 1, false, 3); + expect(result.passed).toBe(false); + expect(result.violations.length).toBe(1); + expect(result.violations[0].name).toBe('add'); + expect(result.exemptedCount).toBe(0); + }); + + test('still fails for a high-fan-in function when the diff changes its own declaration line', () => { + // add's declaration is at line 1. Even though neither side references a + // new call target, touching the declaration line itself is a signature + // risk and must not be exempted. + const diff = [ + '--- a/src/math.js', + '+++ b/src/math.js', + '@@ -1,1 +1,1 @@', + '-function add(a, b) {', + '+function add(a, b, c) {', + ].join('\n'); + const { changedRanges, changedEdits } = parseDiffOutput(diff); + + const result = checkMaxBlastRadius(db, changedRanges, changedEdits, 1, false, 3); + expect(result.passed).toBe(false); + expect(result.violations.length).toBe(1); + expect(result.violations[0].name).toBe('add'); + }); + + test('still fails when a call target is swapped even though a paren token is reused', () => { + // Both sides reference identifiers followed by `(`, but the SETS differ + // (formatResult replaced by parseInput) — a real callee swap, not a + // no-op, so it must not be exempted. + const diff = [ + '--- a/src/math.js', + '+++ b/src/math.js', + '@@ -3,1 +3,1 @@', + '- return formatResult(a) + b;', + '+ return parseInput(a) + b;', + ].join('\n'); + const { changedRanges, changedEdits } = parseDiffOutput(diff); + + const result = checkMaxBlastRadius(db, changedRanges, changedEdits, 1, false, 3); + expect(result.passed).toBe(false); + expect(result.exemptedCount).toBe(0); + }); }); // ─── checkNoSignatureChanges ────────────────────────────────────────── From 963301ac23a3ec4162d57febdf9a45a224959089 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Sun, 5 Jul 2026 21:15:02 -0600 Subject: [PATCH 16/67] fix: gate identifier-argument dynamic call edges on callback-accepting callees MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit extractCallbackReferenceCalls (and its native mirror) emitted a dynamic call edge for every bare identifier argument passed to any call expression, with no gating on the callee — unlike member_expression args, which were already gated by CALLBACK_ACCEPTING_CALLEES/HTTP_VERB_CALLEES (#974/#1191). When an identifier's name collided with an unrelated exported function elsewhere in the repo, the resolver's global-fallback confidence scoring bound the two together, fabricating a call edge and, in codegraph's own graph, a phantom cycle between src/features/communities.ts and src/presentation/communities.ts (analyzeDrift/communitiesData both call analyzeDrift(communities, ...), where `communities` is a plain parameter — not a call to the unrelated `communities` CLI command). Apply the same allowlist gate to identifier args that member_expression args already use, in both the TS/WASM extractor (extractCallbackReferenceCalls, shared by the walk and query extraction paths) and the native Rust mirror (extract_callback_reference_calls). Legitimate callback-by-reference patterns (e.g. arr.forEach(myCallback), router.use(handleToken)) are preserved since their callees are already in the allowlist. On codegraph's own repo this eliminates the two fabricated communities.ts edges and the phantom 3-node cycle, and reduces the broader dynamic=1/confidence=0.5/kind=calls edge signature from 342 to 14 (native) across the whole graph. Recalibrates the jelly-micro `classes` recall floor (6/31 -> 5/31): the one lost edge (f4 -> f1, from `function f4(x) { return x(f1); }`) was only ever matched because the removed heuristic fabricated it — `x` is an unresolvable parameter call, syntactically identical to the false-positive shape this fix removes, and only Jelly's points-to analysis can resolve it precisely. Fixes #1741 Internal extractor/resolver bug fix — no new language support, CLI surface, architecture, or roadmap changes. docs check acknowledged. Impact: 1 functions changed, 6 affected --- .../src/extractors/javascript.rs | 80 ++++++++++++++++--- src/extractors/javascript.ts | 47 ++++++----- .../benchmarks/resolution/jelly-micro.test.ts | 15 +++- tests/engines/parity.test.ts | 21 +++++ tests/engines/query-walk-parity.test.ts | 15 ++++ tests/parsers/javascript.test.ts | 37 +++++++++ 6 files changed, 185 insertions(+), 30 deletions(-) diff --git a/crates/codegraph-core/src/extractors/javascript.rs b/crates/codegraph-core/src/extractors/javascript.rs index daf2ff9d3..b171553fe 100644 --- a/crates/codegraph-core/src/extractors/javascript.rs +++ b/crates/codegraph-core/src/extractors/javascript.rs @@ -2245,12 +2245,13 @@ fn extract_implements_depth(node: &Node, source: &[u8], result: &mut Vec } } -/// Callee names that idiomatically accept callback references. Member-expression -/// args (e.g. `auth.validate`) are only emitted as dynamic callback calls when -/// the callee is in this set; otherwise plain property reads passed as data -/// (`store.set(user.id, user)`) would emit spurious `id` calls with receiver -/// `user`. Identifier args are always emitted — collateral damage from dropping -/// them outweighs the FP risk for plain identifier data args. +/// Callee names that idiomatically accept callback references. Both identifier +/// (e.g. `handleToken`) and member-expression (e.g. `auth.validate`) args are +/// only emitted as dynamic callback calls when the callee is in this set; +/// otherwise plain values passed as data (`store.set(user.id, user)`, +/// `findMergeCandidates(communities)`) would emit spurious calls — e.g. `id` +/// with receiver `user`, or a fabricated edge to an unrelated same-named +/// function (issue #1741). /// /// Mirrors `CALLBACK_ACCEPTING_CALLEES` in `src/extractors/javascript.ts`. const CALLBACK_ACCEPTING_CALLEES: &[&str] = &[ @@ -2327,20 +2328,24 @@ fn extract_callback_reference_calls(call_node: &Node, source: &[u8], calls: &mut if matches!(callee_name, Some("call") | Some("apply") | Some("bind")) { return; } - let mut member_expr_args_allowed = callee_name + let mut callback_args_allowed = callee_name .map(|n| CALLBACK_ACCEPTING_CALLEES.contains(&n)) .unwrap_or(false); - if member_expr_args_allowed { + if callback_args_allowed { if let Some(name) = callee_name { if HTTP_VERB_CALLEES.contains(&name) { // HTTP verbs require a string-literal route path to be treated as a // callback-accepting API; otherwise `cache.get(user.id)` etc. would // still emit `id` as a dynamic call. - member_expr_args_allowed = first_arg_is_string_literal(&args); + callback_args_allowed = first_arg_is_string_literal(&args); } } } + if !callback_args_allowed { + return; + } + for i in 0..args.child_count() { let Some(child) = args.child(i) else { continue }; match child.kind() { @@ -2353,7 +2358,7 @@ fn extract_callback_reference_calls(call_node: &Node, source: &[u8], calls: &mut ..Default::default() }); } - "member_expression" if member_expr_args_allowed => { + "member_expression" => { if let Some(prop) = child.child_by_field_name("property") { let receiver = child.child_by_field_name("object") .map(|obj| extract_receiver_name(&obj, source)); @@ -4195,6 +4200,61 @@ mod tests { assert_eq!(cb.unwrap().receiver.as_deref(), Some("handlers")); } + #[test] + fn no_identifier_callback_for_non_allowlisted_callee_issue_1741() { + // Regression guard for #1741: `findMergeCandidates(communities)` and + // `analyzeDrift(communities, communityDirs)` pass `communities` as a + // plain DATA argument, not a callback reference. Neither + // `findMergeCandidates` nor `analyzeDrift` is a callback-accepting + // callee, so identifier args must be gated exactly like + // member_expression args — otherwise the global-fallback resolver + // can bind the identifier to an unrelated same-named function + // elsewhere in the repo, fabricating a call edge (and, transitively, + // a phantom cycle — see codegraph's own src/features/communities.ts + // vs src/presentation/communities.ts). + let s = parse_js("findMergeCandidates(communities);"); + assert!( + !s.calls.iter().any(|c| c.dynamic == Some(true) && c.name == "communities"), + "findMergeCandidates non-allowlisted callee must not emit `communities` as dynamic call; got: {:?}", + s.calls, + ); + + let s2 = parse_js("analyzeDrift(communities, communityDirs);"); + assert!( + !s2.calls.iter().any(|c| c.dynamic == Some(true)), + "analyzeDrift non-allowlisted callee must not emit any dynamic calls; got: {:?}", + s2.calls, + ); + } + + #[test] + fn emits_identifier_callback_for_allowlisted_callee_issue_1741() { + // Positive companion to the #1741 fix: identifier args passed to a + // genuine callback-accepting callee must still be resolved, e.g. + // `arr.forEach(myNamedCallback)` — the exact pattern the original + // "identifier args are always emitted" trade-off existed to preserve. + let s = parse_js("arr.forEach(myNamedCallback);"); + assert!( + s.calls.iter().any(|c| c.dynamic == Some(true) && c.name == "myNamedCallback"), + "arr.forEach must still emit myNamedCallback as dynamic call; got: {:?}", + s.calls, + ); + } + + #[test] + fn no_identifier_callback_for_cache_or_map_get() { + // Identifier-arg counterpart to `no_member_expr_callback_for_cache_or_map_get`: + // `cache.get(someKey)` shares the verb name `get` with Express routes + // but has no string-literal route path first arg, so the identifier + // arg must not be emitted as a dynamic call either. + let s = parse_js("cache.get(someKey);"); + assert!( + !s.calls.iter().any(|c| c.dynamic == Some(true) && c.name == "someKey"), + "cache.get(someKey) must not emit `someKey` as dynamic call; got: {:?}", + s.calls, + ); + } + #[test] fn no_dynamic_call_for_dynamic_import_arg() { // Parity with TS walk path: callback-reference extraction must be skipped diff --git a/src/extractors/javascript.ts b/src/extractors/javascript.ts index 65326e366..6d882c0ac 100644 --- a/src/extractors/javascript.ts +++ b/src/extractors/javascript.ts @@ -3378,15 +3378,21 @@ function extractSubscriptCallInfo(fn: TreeSitterNode, callNode: TreeSitterNode): /** * Callee names that idiomatically accept callback references. Used to gate - * member_expression args in {@link extractCallbackReferenceCalls}: arguments - * like `user.id` are only emitted as dynamic callback calls when the callee - * is a known callback-accepting API (router/middleware, promises, array - * methods, event emitters, scheduling APIs). This avoids false positives - * from plain property reads passed as data, e.g. `store.set(user.id, user)`. + * both identifier and member_expression args in + * {@link extractCallbackReferenceCalls}: arguments are only emitted as + * dynamic callback calls when the callee is a known callback-accepting API + * (router/middleware, promises, array methods, event emitters, scheduling + * APIs). This avoids false positives from plain values passed as data, e.g. + * `store.set(user.id, user)` or `findMergeCandidates(communities)`. * - * Identifier args (e.g. `router.use(handleToken)`) are always emitted — the - * collateral damage of dropping them is larger than the FP risk, since plain - * identifier data args rarely collide with real function names. + * Identifier args used to be exempted from this gate on the theory that + * plain identifier data args rarely collide with real function names — but + * issue #1741 found a concrete counter-example (`analyzeDrift(communities, + * communityDirs)` colliding with the unrelated `communities` CLI command), + * which the global-fallback resolver then bound into a fabricated call edge + * (and, transitively, a phantom cycle). Gating identifiers the same way + * removes that FP class while still preserving legitimate callback-by- + * reference patterns like `arr.forEach(myCallback)`. */ const CALLBACK_ACCEPTING_CALLEES: ReadonlySet = new Set([ // Express / router / middleware @@ -3505,16 +3511,17 @@ function firstArgIsStringLiteral(argsNode: TreeSitterNode): boolean { * `app.use(auth.validate)` yields a call to validate with receiver auth. * Skips literals, objects, arrays, anonymous functions, and call expressions (already handled). * - * To avoid false positives where plain property reads are passed as data - * (e.g. `store.set(user.id, user)` — `user.id` is a value, not a callback), - * member_expression args are only emitted when the callee is in - * {@link CALLBACK_ACCEPTING_CALLEES}. Identifier args are always emitted. + * To avoid false positives where plain values are passed as data (e.g. + * `store.set(user.id, user)` — `user.id` is a value, not a callback; or + * `findMergeCandidates(communities)` — `communities` is a data argument, not + * a callback), both identifier and member_expression args are only emitted + * when the callee is in {@link CALLBACK_ACCEPTING_CALLEES}. * * HTTP-verb callees (`get`, `post`, `put`, `delete`, `patch`, `options`, * `head`, `all`) double as Map/cache/repository method names, so their - * member-expr args are only emitted when the first argument is a string - * literal route path — matching Express/router shape and skipping - * `cache.get(user.id)`-style calls. + * args are only emitted when the first argument is a string literal route + * path — matching Express/router shape and skipping `cache.get(user.id)`-style + * calls. * * `.call()` / `.apply()` / `.bind()` — the first arg is the `this` context (not a callback of * the enclosing function) and subsequent args flow into the delegated function's parameters. @@ -3532,14 +3539,16 @@ function extractCallbackReferenceCalls(callNode: TreeSitterNode): Call[] { // This-rebinding (fn::this → ctx) is handled separately by extractThisCallBindingsWalk. if (calleeName === 'call' || calleeName === 'apply' || calleeName === 'bind') return []; - let memberExprArgsAllowed = calleeName !== null && CALLBACK_ACCEPTING_CALLEES.has(calleeName); - if (memberExprArgsAllowed && calleeName !== null && HTTP_VERB_CALLEES.has(calleeName)) { + let callbackArgsAllowed = calleeName !== null && CALLBACK_ACCEPTING_CALLEES.has(calleeName); + if (callbackArgsAllowed && calleeName !== null && HTTP_VERB_CALLEES.has(calleeName)) { // HTTP verbs require a string-literal route path to be treated as a // callback-accepting API; otherwise `cache.get(user.id)` etc. would // still emit `id` as a dynamic call. - memberExprArgsAllowed = firstArgIsStringLiteral(args); + callbackArgsAllowed = firstArgIsStringLiteral(args); } + if (!callbackArgsAllowed) return []; + const result: Call[] = []; const callLine = nodeStartLine(callNode); @@ -3549,7 +3558,7 @@ function extractCallbackReferenceCalls(callNode: TreeSitterNode): Call[] { if (child.type === 'identifier') { result.push({ name: child.text, line: callLine, dynamic: true }); - } else if (child.type === 'member_expression' && memberExprArgsAllowed) { + } else if (child.type === 'member_expression') { const prop = child.childForFieldName('property'); const obj = child.childForFieldName('object'); if (prop) { diff --git a/tests/benchmarks/resolution/jelly-micro.test.ts b/tests/benchmarks/resolution/jelly-micro.test.ts index 757444546..b4b8b4cde 100644 --- a/tests/benchmarks/resolution/jelly-micro.test.ts +++ b/tests/benchmarks/resolution/jelly-micro.test.ts @@ -65,11 +65,24 @@ function discoverTests(): string[] { * * Note: more1 was moved to the pts-javascript fixture set in #1383 and is * no longer part of jelly-micro. + * + * `classes` lowered 6/31 → 5/31 by #1741: `f4(x) { return x(f1); }` / + * `f4(f4)` only matched Jelly's `f4 -> f1` ground truth because the + * pre-#1741 extractor emitted a dynamic call for *every* bare identifier + * argument (here, `f1` in `x(f1)`), with no gating on the callee. `x` is an + * unresolvable parameter call — syntactically indistinguishable from a + * plain data argument like `findMergeCandidates(communities)` — so + * recognizing this specific edge would require points-to/interprocedural + * analysis (tracking that `f4(f4)` aliases `x` to `f4`, which recursively + * aliases to `f1`), not a local callee-name gate. #1741 gates identifier + * args on CALLBACK_ACCEPTING_CALLEES the same way member_expression args + * already are, trading this one coincidental match for eliminating a class + * of fabricated cross-file call edges (and phantom cycles). */ const RECALL_FLOORS: Record = { accessors3: 1.0, // 1/1 arguments: 1.0, // 1/1 - classes: 0.19, // 6/31 + classes: 0.16, // 5/31 (was 6/31 — see #1741 note above) defineProperty: 0.5, // 3/6 fun: 1.0, // 4/4 generators: 1.0, // 9/9 diff --git a/tests/engines/parity.test.ts b/tests/engines/parity.test.ts index f9c5b5c4d..8f59750e5 100644 --- a/tests/engines/parity.test.ts +++ b/tests/engines/parity.test.ts @@ -187,6 +187,27 @@ cache.get(user.id); router.get('/users/:id', auth.check); promise.then(handlers.onSuccess); emitter?.on('tick', handlers.fn); +`, + }, + { + // Regression guard for #1741: native must gate IDENTIFIER args by + // CALLBACK_ACCEPTING_CALLEES the same way it already gates + // member_expression args. Without the gate, native (like WASM used to) + // over-emits dynamic calls for identifier args of non-allowlisted + // callees (e.g. `findMergeCandidates(communities)` → bogus call to + // `communities`), which the global-fallback resolver can then bind to + // an unrelated same-named function elsewhere in the repo. + // 1. non-allowlisted callee → drop identifier arg + // 2. cache/Map .get → drop (HTTP verb without string-literal path) + // 3. array .forEach → keep (always-allowlisted callback API) + // 4. router HTTP route → keep (HTTP verb WITH string-literal path) + name: 'JavaScript — identifier-arg callback gating must agree between engines', + file: 'identifier-callbacks.js', + code: ` +findMergeCandidates(communities); +cache.get(someKey); +arr.forEach(myNamedCallback); +router.get('/users/:id', authHandler); `, }, { diff --git a/tests/engines/query-walk-parity.test.ts b/tests/engines/query-walk-parity.test.ts index 37f660264..1d9ed3bfc 100644 --- a/tests/engines/query-walk-parity.test.ts +++ b/tests/engines/query-walk-parity.test.ts @@ -160,6 +160,21 @@ router.post('/api/items', async (req, res) => { save(); }); emitter.on('data', (chunk) => { process(chunk); }); server.once('listening', () => { log(); }); emitter.on('error', handleError); +`, + }, + { + // Regression guard for #1741: identifier args must be gated by + // CALLBACK_ACCEPTING_CALLEES the same way as member_expression args on + // BOTH extraction paths. `findMergeCandidates(communities)` is a plain + // data argument (non-allowlisted callee) and must not appear as a + // dynamic call on either path; `arr.forEach(myNamedCallback)` is a + // genuine callback reference (allowlisted callee) and must appear on + // both. + name: 'identifier-arg callback gating (issue #1741)', + file: 'test.js', + code: ` +findMergeCandidates(communities); +arr.forEach(myNamedCallback); `, }, { diff --git a/tests/parsers/javascript.test.ts b/tests/parsers/javascript.test.ts index 5f9994fbe..61d3b0afb 100644 --- a/tests/parsers/javascript.test.ts +++ b/tests/parsers/javascript.test.ts @@ -896,6 +896,43 @@ describe('JavaScript parser', () => { ); }); + it('does not treat identifier args as callbacks for non-allowlisted callees (issue #1741)', () => { + // Regression guard for #1741: `findMergeCandidates(communities)` and + // `analyzeDrift(communities, communityDirs)` pass `communities` as a + // plain DATA argument, not a callback reference. `findMergeCandidates` + // and `analyzeDrift` are not callback-accepting callees, so identifier + // args must be gated exactly like member_expression args — otherwise + // the global-fallback resolver can bind the identifier to an unrelated + // same-named function elsewhere in the repo, fabricating a call edge + // (and, transitively, a phantom cycle — see codegraph's own + // src/features/communities.ts vs src/presentation/communities.ts). + const symbols = parseJS(`findMergeCandidates(communities);`); + expect(symbols.calls.filter((c) => c.dynamic && c.name === 'communities')).toHaveLength(0); + + const symbols2 = parseJS(`analyzeDrift(communities, communityDirs);`); + expect(symbols2.calls.filter((c) => c.dynamic)).toHaveLength(0); + }); + + it('still emits identifier args for allowlisted callees (regression guard)', () => { + // Positive companion to the #1741 fix: identifier args passed to a + // genuine callback-accepting callee must still be resolved, e.g. + // `arr.forEach(myNamedCallback)` — the exact pattern the original + // "identifier args are always emitted" trade-off existed to preserve. + const symbols = parseJS(`arr.forEach(myNamedCallback);`); + expect(symbols.calls).toContainEqual( + expect.objectContaining({ name: 'myNamedCallback', dynamic: true }), + ); + }); + + it('does not treat identifier args to cache/Map .get/.put as callback-accepting (HTTP-verb guard)', () => { + // Identifier-arg counterpart to the existing member-expression HTTP-verb + // guard: `cache.get(someKey)` shares the verb name `get` with Express + // routes but has no string-literal route path first arg, so the + // identifier arg must not be emitted as a dynamic call either. + const symbols = parseJS(`cache.get(someKey);`); + expect(symbols.calls.filter((c) => c.dynamic && c.name === 'someKey')).toHaveLength(0); + }); + it('extracts callback in plain function calls like setTimeout', () => { const symbols = parseJS(`setTimeout(tick, 1000);`); expect(symbols.calls).toContainEqual( From 3e8035de4b149e8fcd55012f4501b7ce6a31a4a9 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Sun, 5 Jul 2026 21:36:15 -0600 Subject: [PATCH 17/67] fix: gate Array.from's callback arg by position, not just callee name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up refinement to #1741's identifier-argument gating. A peer audit of the resolution-benchmark fixtures found two edges the naive name-only gate would break: - tests/benchmarks/resolution/fixtures/pts-javascript/array-from.js: `Array.from(arr, mapCallback)` — a legitimate, well-known stdlib callback pattern, not a name-collision false positive. `Array.from`'s callee name is `from`, which wasn't in CALLBACK_ACCEPTING_CALLEES at all, so the callback arg (mapCallback) was dropped. - tests/benchmarks/resolution/fixtures/typescript/callbacks.ts: `processEach(users, logUser)` — logUser passed to a project-defined higher-order function. No name allowlist can enumerate arbitrary user code, so this class of edge is a genuine, harder gap (see below). Fix Array.from properly rather than just adding 'from' to the general allowlist: `Array.from(arrayLike, mapFn, thisArg)` puts the callback at argument index 1, not 0 — naively allowlisting 'from' the same way as '.map'/'.forEach' would treat `arrayLike` (plain data at index 0) as a callback candidate too, reintroducing the exact name-collision false-positive class #1741 fixes. Added POSITIONAL_CALLBACK_ARG_INDEX (TS) / positional_callback_arg_index (Rust) so a callee can restrict eligibility to one specific argument index instead of "any position" (the existing behavior, still needed for variadic Express/Router middleware chains like `app.get(path, mw1, mw2, handler)`). Applies uniformly to every TypedArray constructor's .from (Uint8Array.from, Int32Array.from, etc.) since they share the same signature convention. This restores pts-javascript to 13/13 (100% recall/precision, matching its pre-#1741 baseline exactly). The processEach/filterThen case (arbitrary user-defined higher-order functions) is NOT fixed here — recognizing it needs the callee's own parameter type (function-shaped?), which is a resolver-level, cross-engine feature, not a name/position extension of this gate. Left the 3 affected expected-edges.json entries in typescript/expected-edges.json unchanged (they're real, decidable facts, not fabricated edges) and documented the gap in resolution-benchmark.test.ts's THRESHOLDS.typescript comment: recall is now 44/47 (93.6%), still well above the 0.72 floor. Tracked as a follow-up in issue #1845 (not fixed by this commit). Verified via tests/benchmarks/resolution/resolution-benchmark.test.ts (--reporter=verbose): pts-javascript 100%/100% (was 92.3% recall with the naive gate), typescript 95.7%/93.6% (unchanged by this commit, gap tracked). Cross-engine parity (native rebuilt + codesigned) and query/walk-path parity both re-verified with new dedicated test cases. Internal extractor/resolver refinement — no new language support, CLI surface, architecture, or roadmap changes. docs check acknowledged. Impact: 1 functions changed, 6 affected --- .../src/extractors/javascript.rs | 106 +++++++++++++++++- src/extractors/javascript.ts | 54 ++++++++- .../resolution/resolution-benchmark.test.ts | 8 ++ tests/engines/parity.test.ts | 15 +++ tests/engines/query-walk-parity.test.ts | 10 ++ tests/parsers/javascript.test.ts | 33 ++++++ 6 files changed, 219 insertions(+), 7 deletions(-) diff --git a/crates/codegraph-core/src/extractors/javascript.rs b/crates/codegraph-core/src/extractors/javascript.rs index b171553fe..bad64596c 100644 --- a/crates/codegraph-core/src/extractors/javascript.rs +++ b/crates/codegraph-core/src/extractors/javascript.rs @@ -2253,6 +2253,15 @@ fn extract_implements_depth(node: &Node, source: &[u8], result: &mut Vec /// with receiver `user`, or a fabricated edge to an unrelated same-named /// function (issue #1741). /// +/// Known gap: arbitrary user-defined higher-order functions (e.g. +/// `processEach(users, fn: UserProcessor)`) are neither name-allowlisted nor +/// position-mapped (see `positional_callback_arg_index`), so `processEach(users, +/// logUser)` no longer emits a `logUser` reference from the caller. +/// Recognizing these would require looking at the callee's own parameter type +/// (is it function-shaped?) rather than its name — a resolver-level, +/// cross-engine feature, not a small extension of this gate. Tracked as a +/// follow-up. +/// /// Mirrors `CALLBACK_ACCEPTING_CALLEES` in `src/extractors/javascript.ts`. const CALLBACK_ACCEPTING_CALLEES: &[&str] = &[ // Express / router / middleware @@ -2286,6 +2295,34 @@ const HTTP_VERB_CALLEES: &[&str] = &[ "get", "post", "put", "delete", "patch", "options", "head", "all", ]; +/// Callees whose callback argument sits at one specific positional index +/// rather than "any position" (the assumption behind `CALLBACK_ACCEPTING_CALLEES`, +/// needed for variadic Express/Router middleware chains like +/// `app.get(path, mw1, mw2, handler)`). +/// +/// `Array.from(arrayLike, mapFn, thisArg)` (also every TypedArray constructor, +/// e.g. `Uint8Array.from`) is the motivating case: `arrayLike` (index 0) is +/// plain data — treating it as a callback candidate would reintroduce the +/// exact name-collision false-positive class issue #1741 fixes — while +/// `mapFn` (index 1) is a genuine callback reference that should still +/// resolve. A callee listed here is implicitly callback-accepting (no +/// separate `CALLBACK_ACCEPTING_CALLEES` entry needed); only the arg at its +/// listed index is eligible. +/// +/// Name-based, not receiver-typed, so it can't distinguish `Array.from(x, +/// mapFn)` from an unrelated `.from(x, y)` shaped differently (e.g. +/// `Buffer.from(data, encoding)`) — that residual risk is far narrower than +/// the unconditional-emission bug this gate fixes, so it's accepted rather +/// than adding receiver-type tracking. +/// +/// Mirrors `POSITIONAL_CALLBACK_ARG_INDEX` in `src/extractors/javascript.ts`. +fn positional_callback_arg_index(callee_name: &str) -> Option { + match callee_name { + "from" => Some(1), + _ => None, + } +} + /// Extract the callee's final name (function identifier or member expression /// property) for callback-eligibility filtering. Returns `None` if the callee /// shape is not analyzable (e.g. computed subscripts, IIFEs). @@ -2342,12 +2379,21 @@ fn extract_callback_reference_calls(call_node: &Node, source: &[u8], calls: &mut } } - if !callback_args_allowed { + let positional_index = callee_name.and_then(positional_callback_arg_index); + if !callback_args_allowed && positional_index.is_none() { return; } - for i in 0..args.child_count() { - let Some(child) = args.child(i) else { continue }; + for (arg_index, child) in iter_children(&args, PUNCTUATION_TOKENS).enumerate() { + // A positional entry restricts eligibility to its one designated + // index, regardless of what the generic (any-position) gate above + // decided. + if let Some(idx) = positional_index { + if arg_index != idx { + continue; + } + } + match child.kind() { "identifier" => { calls.push(Call { @@ -4255,6 +4301,60 @@ mod tests { ); } + #[test] + fn emits_array_from_mapfn_but_not_arraylike() { + // Regression guard for #1741 follow-up: `Array.from(arrayLike, mapFn)` is + // a well-known stdlib callback pattern (also every TypedArray.from), but + // the callback is the SECOND positional argument, not the first. Emitting + // `arrayLike` too would reintroduce the exact name-collision false-positive + // class #1741 fixes for the data argument; only `mapFn` should resolve. + let s = parse_js("Array.from(arr, mapCallback);"); + assert!( + s.calls.iter().any(|c| c.dynamic == Some(true) && c.name == "mapCallback"), + "Array.from(arr, mapCallback) must emit mapCallback as dynamic call; got: {:?}", + s.calls, + ); + assert!( + !s.calls.iter().any(|c| c.dynamic == Some(true) && c.name == "arr"), + "Array.from(arr, mapCallback) must not emit `arr` (index 0) as dynamic call; got: {:?}", + s.calls, + ); + } + + #[test] + fn emits_only_index_one_for_array_from_with_this_arg() { + // `Array.from(arrayLike, mapFn, thisArg)` — thisArg (index 2) is a `this` + // binding context, not a callback, and must not be emitted either. + let s = parse_js("Array.from(arr, mapCallback, thisArg);"); + let dynamic_names: Vec<&str> = s.calls.iter() + .filter(|c| c.dynamic == Some(true)) + .map(|c| c.name.as_str()) + .collect(); + assert_eq!( + dynamic_names, vec!["mapCallback"], + "only index-1 mapCallback should be dynamic; got: {:?}", s.calls, + ); + } + + #[test] + fn applies_array_from_positional_gate_to_typed_array_constructors() { + // Every TypedArray constructor (Uint8Array, Int32Array, etc.) mirrors + // Array.from's (arrayLike, mapFn, thisArg) signature; the gate is + // name-based on the property `from`, not receiver-typed, so it applies + // uniformly. + let s = parse_js("Uint8Array.from(arr, mapCallback);"); + assert!( + s.calls.iter().any(|c| c.dynamic == Some(true) && c.name == "mapCallback"), + "Uint8Array.from(arr, mapCallback) must emit mapCallback; got: {:?}", + s.calls, + ); + assert!( + !s.calls.iter().any(|c| c.dynamic == Some(true) && c.name == "arr"), + "Uint8Array.from(arr, mapCallback) must not emit `arr`; got: {:?}", + s.calls, + ); + } + #[test] fn no_dynamic_call_for_dynamic_import_arg() { // Parity with TS walk path: callback-reference extraction must be skipped diff --git a/src/extractors/javascript.ts b/src/extractors/javascript.ts index 6d882c0ac..ad7f09bcd 100644 --- a/src/extractors/javascript.ts +++ b/src/extractors/javascript.ts @@ -3468,6 +3468,31 @@ const HTTP_VERB_CALLEES: ReadonlySet = new Set([ 'all', ]); +/** + * Callees whose callback argument sits at one specific positional index + * rather than "any position" (the assumption behind {@link CALLBACK_ACCEPTING_CALLEES}, + * needed for variadic Express/Router middleware chains like + * `app.get(path, mw1, mw2, handler)`). + * + * `Array.from(arrayLike, mapFn, thisArg)` (also `Int8Array.from`, `Uint8Array.from`, + * etc. — every TypedArray constructor mirrors the same signature) is the + * motivating case: `arrayLike` (index 0) is plain data — treating it as a + * callback candidate would reintroduce the exact name-collision false-positive + * class issue #1741 fixes — while `mapFn` (index 1) is a genuine callback + * reference that should still resolve. A callee listed here is implicitly + * callback-accepting (no separate {@link CALLBACK_ACCEPTING_CALLEES} entry + * needed); only the arg at its listed index is eligible. + * + * This is name-based, not receiver-typed (consistent with the rest of this + * gate), so it can't distinguish `Array.from(x, mapFn)` from an unrelated + * `.from(x, y)` on some other object shaped differently — e.g. `Buffer.from(data, + * encoding)`, where `encoding` is conventionally a string but could in principle + * be a colliding identifier. That residual risk is far narrower than the + * unconditional-emission bug this gate fixes, so it's accepted rather than + * adding receiver-type tracking here. + */ +const POSITIONAL_CALLBACK_ARG_INDEX: ReadonlyMap = new Map([['from', 1]]); + /** * Extract the callee's final name (function identifier or member expression * property) for callback-eligibility filtering. Returns null if the callee @@ -3515,7 +3540,10 @@ function firstArgIsStringLiteral(argsNode: TreeSitterNode): boolean { * `store.set(user.id, user)` — `user.id` is a value, not a callback; or * `findMergeCandidates(communities)` — `communities` is a data argument, not * a callback), both identifier and member_expression args are only emitted - * when the callee is in {@link CALLBACK_ACCEPTING_CALLEES}. + * when the callee is in {@link CALLBACK_ACCEPTING_CALLEES}, or the argument + * sits at the specific index a {@link POSITIONAL_CALLBACK_ARG_INDEX} entry + * designates (e.g. `Array.from(arrayLike, mapFn)` — only index 1 is eligible; + * `arrayLike` at index 0 stays ungated data). * * HTTP-verb callees (`get`, `post`, `put`, `delete`, `patch`, `options`, * `head`, `all`) double as Map/cache/repository method names, so their @@ -3527,6 +3555,14 @@ function firstArgIsStringLiteral(argsNode: TreeSitterNode): boolean { * the enclosing function) and subsequent args flow into the delegated function's parameters. * Emitting them here would produce false-positive edges from the *calling* function. * This-rebinding (fn::this → ctx) is handled separately by extractThisCallBindingsWalk. + * + * Known gap: arbitrary user-defined higher-order functions (e.g. + * `processEach(users, fn: UserProcessor)`) are neither name-allowlisted nor + * position-mapped, so `processEach(users, logUser)` no longer emits a + * `logUser` reference from the caller. Recognizing these would require + * looking at the callee's own parameter type (is it function-shaped?) rather + * than its name — a resolver-level, cross-engine feature, not a small + * extension of this name/position gate. Tracked as a follow-up. */ function extractCallbackReferenceCalls(callNode: TreeSitterNode): Call[] { const args = callNode.childForFieldName('arguments') || findChild(callNode, 'arguments'); @@ -3547,18 +3583,28 @@ function extractCallbackReferenceCalls(callNode: TreeSitterNode): Call[] { callbackArgsAllowed = firstArgIsStringLiteral(args); } - if (!callbackArgsAllowed) return []; + const positionalIndex = + calleeName !== null ? POSITIONAL_CALLBACK_ARG_INDEX.get(calleeName) : undefined; + if (!callbackArgsAllowed && positionalIndex === undefined) return []; const result: Call[] = []; const callLine = nodeStartLine(callNode); + let argIndex = -1; for (let i = 0; i < args.childCount; i++) { const child = args.child(i); if (!child) continue; + const t = child.type; + if (t === '(' || t === ')' || t === ',') continue; + argIndex++; + + // A positional entry restricts eligibility to its one designated index, + // regardless of what the generic (any-position) gate above decided. + if (positionalIndex !== undefined && argIndex !== positionalIndex) continue; - if (child.type === 'identifier') { + if (t === 'identifier') { result.push({ name: child.text, line: callLine, dynamic: true }); - } else if (child.type === 'member_expression') { + } else if (t === 'member_expression') { const prop = child.childForFieldName('property'); const obj = child.childForFieldName('object'); if (prop) { diff --git a/tests/benchmarks/resolution/resolution-benchmark.test.ts b/tests/benchmarks/resolution/resolution-benchmark.test.ts index 0a07b3404..ccc14aba1 100644 --- a/tests/benchmarks/resolution/resolution-benchmark.test.ts +++ b/tests/benchmarks/resolution/resolution-benchmark.test.ts @@ -155,6 +155,14 @@ const THRESHOLDS: Record = { // TS 0.72: Phase 8.3e adds this.method() same-class resolution (Shape.describe → Shape.area), // lifting recall from 69.4% to 72.2%. Remaining gap (interface-dispatch, CHA) is tracked // in Phase 8.5 (TSC enrichment) and Phase 8.7 (CHA on JS/TS). + // #1741 (identifier-arg callback gating) intentionally drops 3 callback-mode edges + // (runCallbackDemo -> logUser/upperUser/hasEmail in callbacks.ts — bare identifiers + // passed to the project-defined higher-order functions processEach/filterThen, which + // aren't and can't be in a name allowlist). Actual recall is now 44/47 (93.6%), still + // well above this floor — the 3 edges stay in expected-edges.json because they're real, + // decidable facts, not fabricated ones. Recognizing them needs the callee's own parameter + // type (function-shaped?), not its name; tracked as a follow-up (#1845) rather than + // expanding the name/position gate in #1741. typescript: { precision: 0.85, recall: 0.72 }, tsx: { precision: 0.85, recall: 0.8 }, // TODO: raise thresholds once bash call resolution is implemented diff --git a/tests/engines/parity.test.ts b/tests/engines/parity.test.ts index 8f59750e5..83b54e832 100644 --- a/tests/engines/parity.test.ts +++ b/tests/engines/parity.test.ts @@ -208,6 +208,21 @@ findMergeCandidates(communities); cache.get(someKey); arr.forEach(myNamedCallback); router.get('/users/:id', authHandler); +`, + }, + { + // Regression guard for #1741 follow-up: native must apply the same + // positional (not just name-based) gate as WASM for Array.from-shaped + // callees. Without it, native either over-emits `arr`/`thisArg` (data + // args, reintroducing the name-collision FP class #1741 fixes) or + // under-emits `mapCallback` (a genuine, well-known stdlib callback + // reference), depending on which side of the bug it lands on. + name: 'JavaScript — Array.from positional callback gating must agree between engines', + file: 'array-from-callbacks.js', + code: ` +Array.from(arr, mapCallback); +Array.from(arr, mapCallback, thisArg); +Uint8Array.from(arr, mapCallback); `, }, { diff --git a/tests/engines/query-walk-parity.test.ts b/tests/engines/query-walk-parity.test.ts index 1d9ed3bfc..32b2ade21 100644 --- a/tests/engines/query-walk-parity.test.ts +++ b/tests/engines/query-walk-parity.test.ts @@ -175,6 +175,16 @@ emitter.on('error', handleError); code: ` findMergeCandidates(communities); arr.forEach(myNamedCallback); +`, + }, + { + // Regression guard for #1741 follow-up: Array.from's positional callback + // gate (index 1 only) must behave identically on both extraction paths. + name: 'Array.from positional callback gating (issue #1741 follow-up)', + file: 'test.js', + code: ` +Array.from(arr, mapCallback); +Array.from(arr, mapCallback, thisArg); `, }, { diff --git a/tests/parsers/javascript.test.ts b/tests/parsers/javascript.test.ts index 61d3b0afb..14b36008d 100644 --- a/tests/parsers/javascript.test.ts +++ b/tests/parsers/javascript.test.ts @@ -933,6 +933,39 @@ describe('JavaScript parser', () => { expect(symbols.calls.filter((c) => c.dynamic && c.name === 'someKey')).toHaveLength(0); }); + it('emits Array.from mapFn (index 1) but not arrayLike (index 0)', () => { + // Regression guard for #1741 follow-up: `Array.from(arrayLike, mapFn)` is a + // well-known stdlib callback pattern (also every TypedArray.from), but the + // callback is the SECOND positional argument, not the first. Emitting + // `arrayLike` too would reintroduce the exact name-collision false-positive + // class #1741 fixes for the data argument; only `mapFn` should resolve. + const symbols = parseJS(`Array.from(arr, mapCallback);`); + expect(symbols.calls).toContainEqual( + expect.objectContaining({ name: 'mapCallback', dynamic: true }), + ); + expect(symbols.calls.filter((c) => c.dynamic && c.name === 'arr')).toHaveLength(0); + }); + + it('emits only the index-1 mapFn for Array.from with a thisArg (index 2)', () => { + // `Array.from(arrayLike, mapFn, thisArg)` — thisArg (index 2) is a `this` + // binding context, not a callback, and must not be emitted either. + const symbols = parseJS(`Array.from(arr, mapCallback, thisArg);`); + const dynamicNames = symbols.calls.filter((c) => c.dynamic).map((c) => c.name); + expect(dynamicNames).toEqual(['mapCallback']); + }); + + it('applies the same Array.from positional gate to TypedArray constructors', () => { + // Every TypedArray constructor (Uint8Array, Int32Array, etc.) mirrors + // Array.from's (arrayLike, mapFn, thisArg) signature; the gate is + // name-based on the property `from`, not receiver-typed, so it applies + // uniformly. + const symbols = parseJS(`Uint8Array.from(arr, mapCallback);`); + expect(symbols.calls).toContainEqual( + expect.objectContaining({ name: 'mapCallback', dynamic: true }), + ); + expect(symbols.calls.filter((c) => c.dynamic && c.name === 'arr')).toHaveLength(0); + }); + it('extracts callback in plain function calls like setTimeout', () => { const symbols = parseJS(`setTimeout(tick, 1000);`); expect(symbols.calls).toContainEqual( From 879635e3cd316b8394a92e6e6b43e20e618d1a7a Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Sun, 5 Jul 2026 22:15:47 -0600 Subject: [PATCH 18/67] fix: scope reexportedSymbols to actually-named re-export specifiers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `codegraph exports ` treated any file-level `reexports` edge as proof that every export of the target file was re-exported, so a single `export { X } from 'Y'` caused `reexportedSymbols` to dump all of Y's exports (including symbols never mentioned in any reexport clause, and symbols only ever imported as a type). The file-level edge only proves a reexport *relationship* exists with a target file — it never carried the specific symbol name(s). Both engines now also emit a symbol-level `reexports` edge straight to the specifically-named symbol for `export { X }` / `export { X as Z }` clauses, mirroring the existing `imports-type` symbol-level edge from #1724 (JS/WASM: build-edges.ts + incremental.ts watch-mode path; native: import_edges.rs primary pipeline + build_edges.rs FFI fallback). The query layer prefers these precise edges when present and only falls back to a target's full export list when none exist — i.e. a genuine `export * from 'Y'` wildcard, which really does re-export everything. Fixes #1742 docs check acknowledged — internal query/edge-emission bug fix, no new commands, languages, or architecture to document. Impact: 14 functions changed, 14 affected --- .../graph/builder/stages/build_edges.rs | 140 ++++++++++++++++- .../graph/builder/stages/import_edges.rs | 63 ++++++-- src/domain/analysis/exports.ts | 37 ++++- src/domain/graph/builder/incremental.ts | 19 ++- .../graph/builder/stages/build-edges.ts | 36 ++++- .../all-helpers.ts | 2 + .../enrichment.ts | 16 ++ .../helpers.ts | 7 + .../viewer.ts | 15 ++ tests/integration/exports.test.ts | 125 +++++++++++++++ .../issue-1742-reexport-symbol-scope.test.ts | 145 ++++++++++++++++++ 11 files changed, 571 insertions(+), 34 deletions(-) create mode 100644 tests/fixtures/issue-1742-reexport-symbol-scope/all-helpers.ts create mode 100644 tests/fixtures/issue-1742-reexport-symbol-scope/enrichment.ts create mode 100644 tests/fixtures/issue-1742-reexport-symbol-scope/helpers.ts create mode 100644 tests/fixtures/issue-1742-reexport-symbol-scope/viewer.ts create mode 100644 tests/integration/issue-1742-reexport-symbol-scope.test.ts diff --git a/crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs b/crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs index 8d14f7e4d..8a48743ed 100644 --- a/crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs +++ b/crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs @@ -1573,17 +1573,35 @@ fn classify_import_edge_kind(imp: &ImportInfo) -> &'static str { } } -/// For a `type` import targeting a barrel or resolved file, emit one -/// symbol-level `imports-type` edge per named symbol so the target symbols -/// receive fan-in credit and aren't misclassified as dead code. -fn emit_type_only_symbol_edges( +/// True for a named (non-wildcard) re-export — `export { X } from 'Y'` or +/// `export { X as Z } from 'Y'`. Wildcard re-exports (`export * from 'Y'`) +/// carry no specific names, so they're excluded here and handled instead by +/// the file-level `reexports` edge + the query layer's full-export fallback. +fn is_named_reexport(imp: &ImportInfo) -> bool { + imp.reexport && !imp.wildcard_reexport +} + +/// For a `type` import or a named re-export targeting a barrel or resolved +/// file, emit one symbol-level edge per named symbol so the target symbols +/// receive fan-in credit and aren't misclassified as dead code +/// (`imports-type`, #1724), or so `codegraph exports` can report the +/// precise re-export surface instead of the target's full export list +/// (`reexports`, #1742). `kind` selects which edge kind to emit. +/// +/// `imp.names` holds the *original* declaration name for export specifiers +/// (see `extractImportNames` in the JS extractor) even when renamed +/// externally, so this naturally resolves `export { X as Z }` against `X`'s +/// own node — the emitted edge (and downstream `reexportedSymbols` entry) +/// is reported under the symbol's own declared name, not the barrel alias. +fn emit_named_symbol_edges( edges: &mut Vec, file_input: &ImportEdgeFileInput, imp: &ImportInfo, resolved_path: &str, + kind: &str, ctx: &ImportEdgeContext, ) { - if !imp.type_only || ctx.symbol_node_map.is_empty() { + if ctx.symbol_node_map.is_empty() { return; } for name in &imp.names { @@ -1602,7 +1620,7 @@ fn emit_type_only_symbol_edges( edges.push(ComputedEdge { source_id: file_input.file_node_id, target_id: id, - kind: "imports-type".to_string(), + kind: kind.to_string(), confidence: 1.0, dynamic: 0, dynamic_kind: None, @@ -1692,7 +1710,12 @@ fn process_single_import( dynamic: 0, dynamic_kind: None, }); - emit_type_only_symbol_edges(edges, file_input, imp, resolved_path, ctx); + if imp.type_only { + emit_named_symbol_edges(edges, file_input, imp, resolved_path, "imports-type", ctx); + } + if is_named_reexport(imp) { + emit_named_symbol_edges(edges, file_input, imp, resolved_path, "reexports", ctx); + } emit_barrel_through_edges(edges, file_input, imp, resolved_path, edge_kind, ctx); } @@ -1761,6 +1784,109 @@ mod import_edge_tests { assert_eq!(edges[0].kind, "reexports"); } + #[test] + fn named_reexport_emits_symbol_level_edge() { + // `export { foo } from './utils'` in src/index.ts, where `foo` is a + // specific symbol defined in src/utils.ts. Alongside the file-level + // `reexports` edge, a symbol-level `reexports` edge should point at + // `foo`'s own node — not at every export of utils.ts (#1742). + let files = vec![make_file("src/index.ts", 1, vec![ + make_import("./utils", vec!["foo"], true, false, false), + ], vec![])]; + let resolved = vec![make_resolved("/root/src/index.ts", "./utils", "src/utils.ts")]; + let node_ids = vec![make_node_entry("src/index.ts", 1), make_node_entry("src/utils.ts", 2)]; + let symbol_nodes = vec![SymbolNodeEntry { + name: "foo".to_string(), + file: "src/utils.ts".to_string(), + node_id: 99, + }]; + + let edges = build_import_edges( + files, + resolved, + vec![], + node_ids, + vec![], + "/root".to_string(), + Some(symbol_nodes), + ); + assert_eq!(edges.len(), 2); + // File-level edge: index.ts -> utils.ts file node. + assert_eq!(edges[0].kind, "reexports"); + assert_eq!(edges[0].target_id, 2); + // Symbol-level edge: index.ts -> foo's own node. + assert_eq!(edges[1].kind, "reexports"); + assert_eq!(edges[1].target_id, 99); + } + + #[test] + fn wildcard_reexport_emits_no_symbol_level_edge() { + // `export * from './utils'` carries no specific names, so only the + // file-level `reexports` edge is emitted — the query layer falls + // back to the target's full export list for genuine wildcards. + let files = vec![make_file("src/index.ts", 1, vec![ + ImportInfo { + source: "./utils".to_string(), + names: vec![], + reexport: true, + type_only: false, + dynamic_import: false, + wildcard_reexport: true, + }, + ], vec![])]; + let resolved = vec![make_resolved("/root/src/index.ts", "./utils", "src/utils.ts")]; + let node_ids = vec![make_node_entry("src/index.ts", 1), make_node_entry("src/utils.ts", 2)]; + let symbol_nodes = vec![SymbolNodeEntry { + name: "foo".to_string(), + file: "src/utils.ts".to_string(), + node_id: 99, + }]; + + let edges = build_import_edges( + files, + resolved, + vec![], + node_ids, + vec![], + "/root".to_string(), + Some(symbol_nodes), + ); + assert_eq!(edges.len(), 1); + assert_eq!(edges[0].kind, "reexports"); + assert_eq!(edges[0].target_id, 2); + } + + #[test] + fn renamed_reexport_resolves_original_name() { + // `export { foo as bar } from './utils'` — the JS extractor stores + // the *original* declaration name ("foo") in `names`, not the + // external alias ("bar"). The symbol-level edge must resolve + // against foo's own node. + let files = vec![make_file("src/index.ts", 1, vec![ + make_import("./utils", vec!["foo"], true, false, false), + ], vec![])]; + let resolved = vec![make_resolved("/root/src/index.ts", "./utils", "src/utils.ts")]; + let node_ids = vec![make_node_entry("src/index.ts", 1), make_node_entry("src/utils.ts", 2)]; + let symbol_nodes = vec![ + SymbolNodeEntry { name: "foo".to_string(), file: "src/utils.ts".to_string(), node_id: 99 }, + // A decoy under the external alias name must NOT be matched. + SymbolNodeEntry { name: "bar".to_string(), file: "src/utils.ts".to_string(), node_id: 100 }, + ]; + + let edges = build_import_edges( + files, + resolved, + vec![], + node_ids, + vec![], + "/root".to_string(), + Some(symbol_nodes), + ); + assert_eq!(edges.len(), 2); + assert_eq!(edges[1].kind, "reexports"); + assert_eq!(edges[1].target_id, 99); + } + #[test] fn type_only_edge() { let files = vec![make_file("src/app.ts", 1, vec![ diff --git a/crates/codegraph-core/src/domain/graph/builder/stages/import_edges.rs b/crates/codegraph-core/src/domain/graph/builder/stages/import_edges.rs index 9f1f97d2b..06cf98aad 100644 --- a/crates/codegraph-core/src/domain/graph/builder/stages/import_edges.rs +++ b/crates/codegraph-core/src/domain/graph/builder/stages/import_edges.rs @@ -258,17 +258,28 @@ fn load_symbol_node_ids( map } -/// Walk type-only imports in `ctx.file_symbols` and return the distinct -/// `(name, file)` pairs that `build_import_edges` will need to look up. -/// Resolves barrel files the same way the edge-building loop does so the -/// pre-computed set matches the actual lookup keys. -fn collect_type_only_lookup_pairs(ctx: &ImportEdgeContext) -> HashSet<(String, String)> { +/// True for a named (non-wildcard) re-export — `export { X } from 'Y'` or +/// `export { X as Z } from 'Y'`. Wildcard re-exports (`export * from 'Y'`) +/// carry no specific names, so they're excluded here and handled instead by +/// the file-level `reexports` edge + the query layer's full-export fallback. +fn is_named_reexport(imp: &crate::types::Import) -> bool { + imp.reexport.unwrap_or(false) && !imp.wildcard_reexport.unwrap_or(false) +} + +/// Walk type-only imports and named re-exports in `ctx.file_symbols` and +/// return the distinct `(name, file)` pairs that `build_import_edges` will +/// need to look up. Resolves barrel files the same way the edge-building +/// loop does so the pre-computed set matches the actual lookup keys. +/// Shared by symbol-level `imports-type` (#1724) and `reexports` (#1742) +/// edges — both name specific symbols requiring a (name, file) → node-id +/// lookup. +fn collect_symbol_lookup_pairs(ctx: &ImportEdgeContext) -> HashSet<(String, String)> { let mut pairs = HashSet::new(); for (rel_path, symbols) in &ctx.file_symbols { let abs_file = Path::new(&ctx.root_dir).join(rel_path); let abs_str = abs_file.to_str().unwrap_or(""); for imp in &symbols.imports { - if !imp.type_only.unwrap_or(false) { + if !imp.type_only.unwrap_or(false) && !is_named_reexport(imp) { continue; } let resolved_path = ctx.get_resolved(abs_str, &imp.source); @@ -312,19 +323,20 @@ fn classify_import_kind(imp: &crate::types::Import) -> &'static str { } } -/// For a `type` import, emit one symbol-level `imports-type` edge per name -/// so the target symbols receive fan-in credit and aren't classified dead. -fn emit_type_only_symbol_rows( +/// For a `type` import or a named re-export, emit one symbol-level edge per +/// name so the target symbols receive fan-in credit and aren't classified +/// dead (`imports-type`, #1724), or so `codegraph exports` can report the +/// precise re-export surface instead of the target's full export list +/// (`reexports`, #1742). `kind` selects which edge kind to emit. +fn emit_named_symbol_rows( edges: &mut Vec, file_node_id: i64, imp: &crate::types::Import, resolved_path: &str, + kind: &str, ctx: &ImportEdgeContext, symbol_node_ids: &HashMap<(String, String), i64>, ) { - if !imp.type_only.unwrap_or(false) { - return; - } for (_local, original) in import_name_pairs(imp) { let mut target_file = resolved_path.to_string(); if ctx.is_barrel_file(resolved_path) { @@ -338,7 +350,7 @@ fn emit_type_only_symbol_rows( edges.push(EdgeRow { source_id: file_node_id, target_id: sym_id, - kind: "imports-type".to_string(), + kind: kind.to_string(), confidence: 1.0, dynamic: 0, }); @@ -417,7 +429,28 @@ fn emit_edges_for_import( confidence: 1.0, dynamic: 0, }); - emit_type_only_symbol_rows(edges, file_node_id, imp, &resolved_path, ctx, symbol_node_ids); + if imp.type_only.unwrap_or(false) { + emit_named_symbol_rows( + edges, + file_node_id, + imp, + &resolved_path, + "imports-type", + ctx, + symbol_node_ids, + ); + } + if is_named_reexport(imp) { + emit_named_symbol_rows( + edges, + file_node_id, + imp, + &resolved_path, + "reexports", + ctx, + symbol_node_ids, + ); + } emit_barrel_through_rows( edges, file_node_id, @@ -433,7 +466,7 @@ pub fn build_import_edges(conn: &Connection, ctx: &ImportEdgeContext) -> Vec = new WeakMap(); const _reexportsFromStmtCache: StmtCache<{ file: string }> = new WeakMap(); const _reexportsToStmtCache: StmtCache<{ file: string }> = new WeakMap(); +const _reexportSymbolsStmtCache: StmtCache = new WeakMap(); export function exportsData( file: string, @@ -94,19 +95,39 @@ export function exportsData( }); } -/** Collect symbols re-exported through barrel files. */ +/** + * Collect symbols re-exported through barrel files. + * + * `export { X } from 'Y'` records a symbol-level `reexports` edge straight to + * `X`'s own node (emitted by `emitNamedSymbolEdges` in build-edges.ts / + * incremental.ts, and the mirrored Rust extractors) — so for any target file + * reached with at least one such edge, only those specifically-named symbols + * are reported. `export * from 'Y'` (and any other reexport whose specific + * symbol couldn't be resolved) carries no symbol-level edge, so it falls + * back to the target's full export list — a wildcard genuinely does + * re-export everything, unlike a named specifier (#1742). + */ function collectReexportedSymbols( db: BetterSqlite3Database, fileNodeId: number, reexportsToStmt: ReturnType, + reexportSymbolsStmt: ReturnType, getFileLines: (file: string) => string[] | null, buildSymbolResult: (s: NodeRow, fileLines: string[] | null) => any, ) { const reexportTargets = reexportsToStmt.all(fileNodeId) as Array<{ file: string }>; + const namedSymbols = reexportSymbolsStmt.all(fileNodeId) as NodeRow[]; + const namedByFile = new Map(); + for (const s of namedSymbols) { + if (!namedByFile.has(s.file)) namedByFile.set(s.file, []); + namedByFile.get(s.file)!.push(s); + } + const reexportedSymbols: Array & { originFile: string }> = []; for (const reexTarget of reexportTargets) { - const targetExported = findExportedNodesByFile(db, reexTarget.file); + const targetExported = + namedByFile.get(reexTarget.file) ?? findExportedNodesByFile(db, reexTarget.file); for (const s of targetExported) { reexportedSymbols.push({ ...buildSymbolResult(s, getFileLines(reexTarget.file)), @@ -159,6 +180,17 @@ function exportsFileImpl( `SELECT DISTINCT n.file FROM edges e JOIN nodes n ON e.target_id = n.id WHERE e.source_id = ? AND e.kind = 'reexports'`, ); + // Symbol-level `reexports` edges — the specific symbols named in + // `export { X } from 'Y'` clauses (target is the symbol node itself, not + // a file node). Distinct from reexportsToStmt above, which only proves a + // reexport *relationship* exists with a target file (#1742). + const reexportSymbolsStmt = cachedStmt( + _reexportSymbolsStmtCache, + db, + `SELECT DISTINCT n.* FROM edges e JOIN nodes n ON e.target_id = n.id + WHERE e.source_id = ? AND e.kind = 'reexports' AND n.kind != 'file' + ORDER BY n.line`, + ); return fileNodes.map((fn) => { const symbols = findNodesByFile(db, fn.file) as NodeRow[]; @@ -200,6 +232,7 @@ function exportsFileImpl( db, fn.id, reexportsToStmt, + reexportSymbolsStmt, getFileLines, buildSymbolResult, ); diff --git a/src/domain/graph/builder/incremental.ts b/src/domain/graph/builder/incremental.ts index cde9e3a78..9ef64d638 100644 --- a/src/domain/graph/builder/incremental.ts +++ b/src/domain/graph/builder/incremental.ts @@ -373,13 +373,21 @@ function resolveBarrelImportEdges( return edgesAdded; } -/** Emit symbol-level `imports-type` edges for a single `import type` statement. */ -function emitTypeOnlySymbolEdges( +/** + * Emit one symbol-level edge per named specifier — shared by `import type` + * statements (`imports-type`, #1724) and named re-exports (`reexports`, + * #1742). Wildcard re-exports (`export * from 'Y'`) carry no specific names, + * so the loop is a no-op for them; the query layer falls back to the + * target's full export list for anything reached only by the file-level + * edge. Mirrors `emitNamedSymbolEdges` in build-edges.ts (full-build path). + */ +function emitNamedSymbolEdges( db: BetterSqlite3Database | null, stmts: IncrementalStmts, imp: ExtractorOutput['imports'][number], resolvedPath: string, fileNodeId: number, + edgeKind: 'imports-type' | 'reexports', ): number { let edgesAdded = 0; for (const { original } of importNamePairs(imp)) { @@ -393,7 +401,7 @@ function emitTypeOnlySymbolEdges( file: string; }>; if (candidates.length === 0) continue; - stmts.insertEdge.run(fileNodeId, candidates[0]!.id, 'imports-type', 1.0, 0); + stmts.insertEdge.run(fileNodeId, candidates[0]!.id, edgeKind, 1.0, 0); edgesAdded++; } return edgesAdded; @@ -427,7 +435,10 @@ function emitEdgesForImport( let edgesAdded = 1; if (imp.typeOnly) { - edgesAdded += emitTypeOnlySymbolEdges(db, stmts, imp, resolvedPath, fileNodeId); + edgesAdded += emitNamedSymbolEdges(db, stmts, imp, resolvedPath, fileNodeId, 'imports-type'); + } + if (imp.reexport && !imp.wildcardReexport) { + edgesAdded += emitNamedSymbolEdges(db, stmts, imp, resolvedPath, fileNodeId, 'reexports'); } if (!imp.reexport && db) { edgesAdded += resolveBarrelImportEdges(db, stmts, fileNodeId, resolvedPath, imp); diff --git a/src/domain/graph/builder/stages/build-edges.ts b/src/domain/graph/builder/stages/build-edges.ts index 33ebafa84..33d8e100b 100644 --- a/src/domain/graph/builder/stages/build-edges.ts +++ b/src/domain/graph/builder/stages/build-edges.ts @@ -166,15 +166,36 @@ function importNamePairs(imp: Import): Array<{ local: string; original: string } } /** - * For a `import type` statement, emit symbol-level `imports-type` edges so - * the target symbols get fan-in credit and aren't classified as dead code. + * Emit one symbol-level edge per named specifier in `imp`, pointing at the + * specific target symbol (resolved through barrel chains when needed). + * + * Shared by two statement shapes that name specific symbols without a plain + * file-level `imports`/`reexports` edge fully capturing the relationship: + * - `import type { X } from 'Y'` → kind `imports-type`, so the target gets + * fan-in credit and isn't classified as dead code (#1724). + * - `export { X } from 'Y'` / `export { X as Z } from 'Y'` → kind + * `reexports`, so `codegraph exports` can report exactly which symbols + * are re-exported instead of conflating the file-level barrel edge with + * "every export of Y" (#1742). + * + * `imp.names` always carries the *original* declaration name for export + * specifiers, even when renamed externally (see `extractImportNames`), so + * the emitted edge — and the resulting `reexportedSymbols` entry — reports + * the symbol under its own declared name, not the barrel's external alias. + * + * Wildcard re-exports (`export * from 'Y'`) carry no specific names + * (`imp.names` is empty), so the loop below is a no-op for them — a file + * only gets a precise symbol-level edge when a name is actually spelled + * out; the query layer falls back to the target's full export list for + * anything reached only by the file-level edge (genuine wildcard semantics). */ -function emitTypeOnlySymbolEdges( +function emitNamedSymbolEdges( ctx: PipelineContext, imp: Import, resolvedPath: string, fileNodeId: number, allEdgeRows: EdgeRowTuple[], + edgeKind: 'imports-type' | 'reexports', ): void { if (!ctx.nodesByNameAndFile) return; for (const { original } of importNamePairs(imp)) { @@ -185,14 +206,14 @@ function emitTypeOnlySymbolEdges( } const candidates = ctx.nodesByNameAndFile.get(`${original}|${targetFile}`); if (candidates && candidates.length > 0) { - allEdgeRows.push([fileNodeId, candidates[0]!.id, 'imports-type', 1.0, 0, null, null]); + allEdgeRows.push([fileNodeId, candidates[0]!.id, edgeKind, 1.0, 0, null, null]); } } } /** * Process a single import statement and emit all resulting edges (file→file, - * type-only symbol-level, and barrel re-export targets). + * named-symbol-level, and barrel re-export targets). */ function emitEdgesForImport( ctx: PipelineContext, @@ -210,7 +231,10 @@ function emitEdgesForImport( allEdgeRows.push([fileNodeId, targetRow.id, edgeKind, 1.0, 0, null, null]); if (imp.typeOnly) { - emitTypeOnlySymbolEdges(ctx, imp, resolvedPath, fileNodeId, allEdgeRows); + emitNamedSymbolEdges(ctx, imp, resolvedPath, fileNodeId, allEdgeRows, 'imports-type'); + } + if (imp.reexport && !imp.wildcardReexport) { + emitNamedSymbolEdges(ctx, imp, resolvedPath, fileNodeId, allEdgeRows, 'reexports'); } if (!imp.reexport && isBarrelFile(ctx, resolvedPath)) { diff --git a/tests/fixtures/issue-1742-reexport-symbol-scope/all-helpers.ts b/tests/fixtures/issue-1742-reexport-symbol-scope/all-helpers.ts new file mode 100644 index 000000000..f64e272d6 --- /dev/null +++ b/tests/fixtures/issue-1742-reexport-symbol-scope/all-helpers.ts @@ -0,0 +1,2 @@ +// Pure wildcard barrel — genuinely re-exports everything from helpers.ts. +export * from './helpers.js'; diff --git a/tests/fixtures/issue-1742-reexport-symbol-scope/enrichment.ts b/tests/fixtures/issue-1742-reexport-symbol-scope/enrichment.ts new file mode 100644 index 000000000..0cee200ed --- /dev/null +++ b/tests/fixtures/issue-1742-reexport-symbol-scope/enrichment.ts @@ -0,0 +1,16 @@ +// Combined specifier list: one plain re-export (loadPlotConfig) and one +// renamed re-export (buildLayoutOptions -> buildOptions) in a single +// statement, alongside a type-only import that must NOT be treated as a +// reexport. Two own definitions keep this file from being (mis)classified +// as barrel-only (own-def count > reexport-statement count) — an unrelated, +// separately-tracked engine divergence (#1848) that would otherwise drop +// the type-only import's edges on native full builds. +export { buildLayoutOptions as buildOptions, loadPlotConfig } from './viewer.js'; + +import type { PlotConfig } from './viewer.js'; + +export function useConfig(): PlotConfig { + return { width: 1 }; +} + +export function noop(): void {} diff --git a/tests/fixtures/issue-1742-reexport-symbol-scope/helpers.ts b/tests/fixtures/issue-1742-reexport-symbol-scope/helpers.ts new file mode 100644 index 000000000..d0c57f511 --- /dev/null +++ b/tests/fixtures/issue-1742-reexport-symbol-scope/helpers.ts @@ -0,0 +1,7 @@ +export function formatDate(d: Date): string { + return d.toISOString(); +} + +export function formatNumber(n: number): string { + return n.toFixed(2); +} diff --git a/tests/fixtures/issue-1742-reexport-symbol-scope/viewer.ts b/tests/fixtures/issue-1742-reexport-symbol-scope/viewer.ts new file mode 100644 index 000000000..9a577ccf2 --- /dev/null +++ b/tests/fixtures/issue-1742-reexport-symbol-scope/viewer.ts @@ -0,0 +1,15 @@ +export function loadPlotConfig(): { width: number } { + return { width: 100 }; +} + +export function buildLayoutOptions(): { margin: number } { + return { margin: 10 }; +} + +export function escapeHtml(input: string): string { + return input.replace(/ { expect(unused.consumers).toEqual([]); }); }); + +// ─── reexportedSymbols scoped to actually-named specifiers (#1742) ─────── +// +// Regression coverage for: a single named re-export (`export { X } from 'Y'`) +// was treated as if the file transitively re-exported EVERY export of `Y`, +// even symbols never mentioned in any reexport clause (and even symbols only +// imported as a type, never re-exported). The builder now emits a +// symbol-level `reexports` edge straight to the specifically-named symbol +// (mirroring the existing `imports-type` symbol-level edge from #1724) — +// see `emitNamedSymbolEdges` in build-edges.ts / incremental.ts and the +// mirrored Rust extractors. `collectReexportedSymbols` only falls back to a +// target's full export list when no symbol-level edge was recorded for it +// (i.e. a genuine `export * from 'Y'` wildcard, which really does re-export +// everything). + +describe('exportsData — reexportedSymbols scoped to named specifiers (#1742)', () => { + let tmpDir3: string, dbPath3: string; + + beforeAll(() => { + tmpDir3 = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-exports-reexport-scope-')); + fs.mkdirSync(path.join(tmpDir3, '.codegraph')); + dbPath3 = path.join(tmpDir3, '.codegraph', 'graph.db'); + + const db = new Database(dbPath3); + db.pragma('journal_mode = WAL'); + initSchema(db); + + // File nodes + const fViewer = insertNode(db, 'viewer.ts', 'file', 'viewer.ts', 0); + const fHelpers = insertNode(db, 'helpers.ts', 'file', 'helpers.ts', 0); + const fBarrel = insertNode(db, 'enrichment.ts', 'file', 'enrichment.ts', 0); + + // viewer.ts exports four symbols; only two are ever re-exported by + // enrichment.ts, one of them under a renamed external alias. + const loadPlotConfig = insertNode(db, 'loadPlotConfig', 'function', 'viewer.ts', 1); + const buildLayoutOptions = insertNode(db, 'buildLayoutOptions', 'function', 'viewer.ts', 10); + const escapeHtml = insertNode(db, 'escapeHtml', 'function', 'viewer.ts', 20); + const plotConfig = insertNode(db, 'PlotConfig', 'interface', 'viewer.ts', 30); + + // helpers.ts is reached only via a wildcard re-export (`export * from`) — + // no symbol-level edges are ever recorded for it. + const formatDate = insertNode(db, 'formatDate', 'function', 'helpers.ts', 1); + const formatNumber = insertNode(db, 'formatNumber', 'function', 'helpers.ts', 10); + + const markExported = db.prepare('UPDATE nodes SET exported = 1 WHERE id = ?'); + for (const id of [ + loadPlotConfig, + buildLayoutOptions, + escapeHtml, + plotConfig, + formatDate, + formatNumber, + ]) { + markExported.run(id); + } + + // enrichment.ts: + // export { loadPlotConfig } from './viewer'; (named, no rename) + // export { buildLayoutOptions as buildOptions } from './viewer'; (named, renamed) + // import type { PlotConfig } from './viewer'; (type-only, NOT a reexport) + // export * from './helpers'; (wildcard) + // + // File-level `reexports` edges (barrel-relationship proof, one per target): + insertEdge(db, fBarrel, fViewer, 'reexports'); + insertEdge(db, fBarrel, fHelpers, 'reexports'); + // Symbol-level `reexports` edges (the precise named specifiers). The + // rename target still points at buildLayoutOptions's own node — `names` + // always carries the pre-rename declaration name (see + // `extractImportNames` / `does not apply rename tracking to + // export_specifier` in tests/parsers/javascript.test.ts). + insertEdge(db, fBarrel, loadPlotConfig, 'reexports'); + insertEdge(db, fBarrel, buildLayoutOptions, 'reexports'); + // Type-only import — must never contribute to reexportedSymbols. + insertEdge(db, fBarrel, plotConfig, 'imports-type'); + + db.close(); + }); + + afterAll(() => { + if (tmpDir3) fs.rmSync(tmpDir3, { recursive: true, force: true }); + }); + + test('only the specifically-named symbols are reported, not every export of the target file', () => { + const data = exportsData('enrichment.ts', dbPath3); + const fromViewer = data.reexportedSymbols.filter((s) => s.originFile === 'viewer.ts'); + const names = fromViewer.map((s) => s.name).sort(); + expect(names).toEqual(['buildLayoutOptions', 'loadPlotConfig']); + // escapeHtml is exported by viewer.ts but never re-exported by + // enrichment.ts — it must not leak in. + expect(names).not.toContain('escapeHtml'); + }); + + test('a symbol imported as a type (not re-exported) is excluded', () => { + const data = exportsData('enrichment.ts', dbPath3); + const names = data.reexportedSymbols.map((s) => s.name); + expect(names).not.toContain('PlotConfig'); + }); + + test('a renamed re-export (`export { X as Y } from ...`) resolves to X, not the external alias', () => { + const data = exportsData('enrichment.ts', dbPath3); + const renamed = data.reexportedSymbols.find( + (s) => s.originFile === 'viewer.ts' && s.name === 'buildLayoutOptions', + ); + expect(renamed).toBeDefined(); + expect(renamed.kind).toBe('function'); + // The external alias name is never used as the reported name. + expect(data.reexportedSymbols.some((s) => s.name === 'buildOptions')).toBe(false); + }); + + test('a wildcard re-export (`export * from ...`) still reports every export of its target, distinctly from the named case', () => { + const data = exportsData('enrichment.ts', dbPath3); + const fromHelpers = data.reexportedSymbols.filter((s) => s.originFile === 'helpers.ts'); + const names = fromHelpers.map((s) => s.name).sort(); + expect(names).toEqual(['formatDate', 'formatNumber']); + }); + + test('total reexported count reflects only the correctly-scoped symbols', () => { + const data = exportsData('enrichment.ts', dbPath3); + // 2 named from viewer.ts (loadPlotConfig, buildLayoutOptions) + 2 wildcard + // from helpers.ts (formatDate, formatNumber) = 4. Not 6 (which would + // include the stray escapeHtml/PlotConfig leak from the pre-fix bug). + expect(data.reexportedSymbols.length).toBe(4); + expect(data.totalReexported).toBe(4); + }); +}); diff --git a/tests/integration/issue-1742-reexport-symbol-scope.test.ts b/tests/integration/issue-1742-reexport-symbol-scope.test.ts new file mode 100644 index 000000000..98f5dc5fe --- /dev/null +++ b/tests/integration/issue-1742-reexport-symbol-scope.test.ts @@ -0,0 +1,145 @@ +/** + * Regression for #1742: `codegraph exports ` treated a single named + * re-export (`export { X } from 'Y'`) as if the file transitively + * re-exported EVERY export of `Y`, even symbols never mentioned in any + * reexport clause (and even symbols only ever imported as a type). + * + * Fixture: + * viewer.ts — defines loadPlotConfig, buildLayoutOptions, escapeHtml + * (function) and PlotConfig (interface) + * helpers.ts — defines formatDate, formatNumber + * enrichment.ts — `export { loadPlotConfig, buildLayoutOptions as + * buildOptions } from './viewer.js'` (named, one plain + + * one renamed specifier in a single statement) plus + * `import type { PlotConfig } from './viewer.js'` + * (type-only — NOT a reexport) + * all-helpers.ts — `export * from './helpers.js'` (pure wildcard barrel) + * + * Before the fix, `reexportedSymbols` for enrichment.ts dumped all four of + * viewer.ts's exports (including escapeHtml and PlotConfig, neither of + * which is re-exported) merely because a file-level `reexports` edge to + * viewer.ts existed. It should report exactly loadPlotConfig and + * buildLayoutOptions from viewer.ts. all-helpers.ts's wildcard re-export + * should keep reporting every export of helpers.ts — genuinely different + * semantics from a named specifier, handled distinctly. + */ + +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'; +import { exportsData } from '../../src/domain/queries.js'; +import type { EngineMode } from '../../src/types.js'; + +const FIXTURE_DIR = path.join( + import.meta.dirname, + '..', + 'fixtures', + 'issue-1742-reexport-symbol-scope', +); + +interface EdgeRow { + source_file: string; + target_file: string; + target_name: string; + target_kind: string; + kind: string; +} + +function readReexportEdges(dbPath: string): EdgeRow[] { + const db = new Database(dbPath, { readonly: true }); + try { + return db + .prepare( + `SELECT n1.file AS source_file, n2.file AS target_file, + n2.name AS target_name, n2.kind AS target_kind, e.kind + FROM edges e + JOIN nodes n1 ON e.source_id = n1.id + JOIN nodes n2 ON e.target_id = n2.id + WHERE e.kind = 'reexports' + ORDER BY n1.file, n2.file, n2.name`, + ) + .all() as EdgeRow[]; + } finally { + db.close(); + } +} + +const ENGINES: EngineMode[] = ['wasm', 'native']; + +describe.each(ENGINES)('#1742 reexportedSymbols scoping (%s)', (engine) => { + let tmpDir: string; + let dbPath: string; + let reexportEdges: EdgeRow[]; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), `codegraph-1742-${engine}-`)); + fs.cpSync(FIXTURE_DIR, tmpDir, { recursive: true }); + + await buildGraph(tmpDir, { incremental: false, skipRegistry: true, engine }); + dbPath = path.join(tmpDir, '.codegraph', 'graph.db'); + reexportEdges = readReexportEdges(dbPath); + }, 60_000); + + afterAll(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('emits a symbol-level reexports edge straight to loadPlotConfig', () => { + const edge = reexportEdges.find( + (e) => e.source_file === 'enrichment.ts' && e.target_name === 'loadPlotConfig', + ); + expect( + edge, + `Expected a symbol-level reexports edge to loadPlotConfig.\nActual reexports edges:\n${JSON.stringify(reexportEdges, null, 2)}`, + ).toBeDefined(); + expect(edge!.target_file).toBe('viewer.ts'); + expect(edge!.target_kind).not.toBe('file'); + }); + + it('emits a symbol-level reexports edge to buildLayoutOptions (original name, not the external alias)', () => { + const edge = reexportEdges.find( + (e) => e.source_file === 'enrichment.ts' && e.target_name === 'buildLayoutOptions', + ); + expect(edge).toBeDefined(); + expect(edge!.target_file).toBe('viewer.ts'); + expect(reexportEdges.some((e) => e.target_name === 'buildOptions')).toBe(false); + }); + + it('does NOT emit a symbol-level reexports edge to escapeHtml or PlotConfig', () => { + expect(reexportEdges.some((e) => e.target_name === 'escapeHtml')).toBe(false); + expect(reexportEdges.some((e) => e.target_name === 'PlotConfig')).toBe(false); + }); + + it('does NOT emit any symbol-level reexports edge for the wildcard barrel', () => { + const fromAllHelpers = reexportEdges.filter((e) => e.source_file === 'all-helpers.ts'); + // Only the file-level edge (target_kind === 'file') should exist — no + // specific names are ever spelled out in `export * from './helpers.js'`. + expect(fromAllHelpers.every((e) => e.target_kind === 'file')).toBe(true); + expect(fromAllHelpers.length).toBeGreaterThan(0); + }); + + it('exportsData reports only the specifically-named symbols from viewer.ts', () => { + const data = exportsData('enrichment.ts', dbPath); + const fromViewer = data.reexportedSymbols + .filter((s: { originFile: string }) => s.originFile === 'viewer.ts') + .map((s: { name: string }) => s.name) + .sort(); + expect(fromViewer).toEqual(['buildLayoutOptions', 'loadPlotConfig']); + }); + + it('exportsData totalReexported for enrichment.ts is exactly 2 — not the 4 that the pre-fix leak would report', () => { + const data = exportsData('enrichment.ts', dbPath); + expect(data.reexportedSymbols.length).toBe(2); + expect(data.totalReexported).toBe(2); + }); + + it('exportsData still reports the full wildcard re-export list for the pure-barrel file', () => { + const data = exportsData('all-helpers.ts', dbPath); + const names = data.reexportedSymbols.map((s: { name: string }) => s.name).sort(); + expect(names).toEqual(['formatDate', 'formatNumber']); + expect(data.totalReexported).toBe(2); + }); +}); From 0fe9fc3241fc106bce4638d3ccbefe943b8063de Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Sun, 5 Jul 2026 22:59:00 -0600 Subject: [PATCH 19/67] fix: stop CFG block/edge count from overriding AST-derived cyclomatic complexity storeCfgResults / storeNativeCfgResults in ast-analysis/engine.ts (and a duplicate copy in domain/wasm-worker-entry.ts) overwrote the correctly computed cyclomatic complexity with a CFG-derived value (edges - blocks + 2). That formula doesn't model short-circuit logical operators (&&, ||, ??), optional chaining (?.), or nested function/closure bodies, all of which the AST-based cyclomatic walk correctly counts (matching cognitive complexity's treatment of closures). The override ran on every WASM build and on native builds whenever the Rust orchestrator was bypassed (e.g. an engine switch or schema/version change triggers forceFullRebuild), silently corrupting cyclomatic for any function using those constructs while leaving cognitive, Halstead, and LOC untouched. The native engine never had this override, so it was already correct. Cyclomatic is now always the single AST-derived value on both engines. Fixes #1743 Impact: 5 functions changed, 13 affected --- src/ast-analysis/engine.ts | 95 +----- src/domain/wasm-worker-entry.ts | 24 +- ...3-incremental-cyclomatic-staleness.test.ts | 273 ++++++++++++++++++ 3 files changed, 294 insertions(+), 98 deletions(-) create mode 100644 tests/integration/issue-1743-incremental-cyclomatic-staleness.test.ts diff --git a/src/ast-analysis/engine.ts b/src/ast-analysis/engine.ts index 706df0161..36fbf49d9 100644 --- a/src/ast-analysis/engine.ts +++ b/src/ast-analysis/engine.ts @@ -278,26 +278,19 @@ function storeNativeComplexityResults( } } -/** Override a definition's cyclomatic complexity with a CFG-derived value and recompute MI. */ -function overrideCyclomaticFromCfg(def: Definition, cfgCyclomatic: number): void { - if (!def.complexity) return; - if (cfgCyclomatic <= 0) { - debug(`overrideCyclomaticFromCfg: skipping ${def.name} — cfgCyclomatic=${cfgCyclomatic}`); - return; - } - def.complexity.cyclomatic = cfgCyclomatic; - const { loc, halstead } = def.complexity; - const volume = halstead ? halstead.volume : 0; - const commentRatio = loc && loc.loc > 0 ? loc.commentLines / loc.loc : 0; - def.complexity.maintainabilityIndex = computeMaintainabilityIndex( - volume, - cfgCyclomatic, - loc?.sloc ?? 0, - commentRatio, - ); -} - -/** Store native CFG results on definitions, matched by line number. */ +/** + * Store native CFG results on definitions, matched by line number. + * + * Intentionally does NOT touch `def.complexity.cyclomatic`: cyclomatic + * complexity is computed once, correctly, from the AST DFS walk (which + * counts short-circuit logical operators, optional chaining, and nested + * function bodies — see computeFunctionComplexity / compute_all_metrics). + * The CFG's block/edge count (E - N + 2) does NOT model any of those, so + * using it to overwrite the AST-derived cyclomatic silently corrupts the + * metric for any function using &&/||/??/?. or containing a closure + * (issue #1743) — CFG blocks/edges are stored here purely for CFG + * queries/visualization (`codegraph cfg`), not as a complexity source. + */ function storeNativeCfgResults(results: NativeFunctionCfgResult[], defs: Definition[]): void { const byLine = indexNativeByLine(results); @@ -311,48 +304,6 @@ function storeNativeCfgResults(results: NativeFunctionCfgResult[], defs: Definit const match = matchNativeResult(byLine.get(def.line), def.name); if (!match) continue; def.cfg = match.cfg; - - // Override complexity cyclomatic with CFG-derived value - const { edges, blocks } = match.cfg; - if (edges && blocks) { - overrideCyclomaticFromCfg(def, edges.length - blocks.length + 2); - } - } - } -} - -// ─── CFG cyclomatic reconciliation ────────────────────────────────────── - -/** - * Apply CFG-derived cyclomatic override for definitions that already have both - * `complexity` and `cfg` with blocks/edges but whose cyclomatic was never - * overridden (e.g., native extractors provide both fields inline, so the - * normal override path in storeNativeCfgResults / storeCfgResults is skipped). - */ -/** Type guard for cfg objects with blocks and edges arrays. */ -function hasCfgBlocksAndEdges(cfg: unknown): cfg is { blocks: unknown[]; edges: unknown[] } { - return ( - cfg != null && - typeof cfg === 'object' && - Array.isArray((cfg as { blocks?: unknown }).blocks) && - Array.isArray((cfg as { edges?: unknown }).edges) - ); -} - -function reconcileCfgCyclomatic(fileSymbols: Map): void { - for (const [, symbols] of fileSymbols) { - const defs = symbols.definitions || []; - for (const def of defs) { - if ( - (def.kind === 'function' || def.kind === 'method') && - def.complexity && - hasCfgBlocksAndEdges(def.cfg) - ) { - const cfgCyclomatic = Math.max(def.cfg.edges.length - def.cfg.blocks.length + 2, 1); - if (cfgCyclomatic !== def.complexity.cyclomatic) { - overrideCyclomaticFromCfg(def, cfgCyclomatic); - } - } } } } @@ -610,6 +561,9 @@ function storeComplexityResults(results: WalkResults, defs: Definition[], langId } } +// Note: intentionally does not touch def.complexity.cyclomatic — see the +// docblock on storeNativeCfgResults for why the CFG's block/edge count must +// never override the AST-derived cyclomatic complexity (issue #1743). function storeCfgResults(results: WalkResults, defs: Definition[]): void { const byLine = indexByLine((results.cfg || []) as CfgFuncResult[]); for (const def of defs) { @@ -621,11 +575,6 @@ function storeCfgResults(results: WalkResults, defs: Definition[]): void { const cfgResult = matchResultToDef(byLine.get(def.line), def.name); if (!cfgResult) continue; def.cfg = { blocks: cfgResult.blocks, edges: cfgResult.edges }; - - // Override complexity's cyclomatic with CFG-derived value (single source of truth) - if (cfgResult.cyclomatic != null) { - overrideCyclomaticFromCfg(def, cfgResult.cyclomatic); - } } } } @@ -868,9 +817,6 @@ async function runFastPathIfApplicable( if (!allNativeDataComplete(fileSymbols, opts)) return false; debug('native full-analysis fast path: all data present, skipping WASM/visitor passes'); - const doComplexity = opts.complexity !== false; - const doCfg = opts.cfg !== false; - if (doComplexity && doCfg) reconcileCfgCyclomatic(fileSymbols); await delegateToBuildFunctions(db, fileSymbols, rootDir, opts, engineOpts, timing); return true; } @@ -912,15 +858,6 @@ export async function runAnalyses( // per-phase timers above, not additive). timing._unifiedWalkMs = runUnifiedWalkPass(db, fileSymbols, extToLang, opts, timing); - // Reconcile: apply CFG-derived cyclomatic override for any definitions that have - // both precomputed complexity and CFG data but whose cyclomatic was never overridden. - // This closes a parity gap where native extractors provide both fields inline but - // the override step (storeNativeCfgResults / storeCfgResults) is skipped because - // detectNativeNeeds sees both as already present. - if (doComplexity && doCfg) { - reconcileCfgCyclomatic(fileSymbols); - } - // Delegate to buildXxx functions for DB writes + native fallback await delegateToBuildFunctions(db, fileSymbols, rootDir, opts, engineOpts, timing); diff --git a/src/domain/wasm-worker-entry.ts b/src/domain/wasm-worker-entry.ts index 2e02b4ed8..bd5084b31 100644 --- a/src/domain/wasm-worker-entry.ts +++ b/src/domain/wasm-worker-entry.ts @@ -516,22 +516,6 @@ function matchResultToDef( ); } -/** Override a definition's cyclomatic complexity with a CFG-derived value and recompute MI. */ -function overrideCyclomaticFromCfg(def: Definition, cfgCyclomatic: number): void { - if (!def.complexity) return; - if (cfgCyclomatic <= 0) return; - def.complexity.cyclomatic = cfgCyclomatic; - const { loc, halstead } = def.complexity; - const volume = halstead ? halstead.volume : 0; - const commentRatio = loc && loc.loc > 0 ? loc.commentLines / loc.loc : 0; - def.complexity.maintainabilityIndex = computeMaintainabilityIndex( - volume, - cfgCyclomatic, - loc?.sloc ?? 0, - commentRatio, - ); -} - function storeComplexityResults(results: WalkResults, defs: Definition[], langId: string): void { const byLine = indexByLine((results.complexity || []) as ComplexityFuncResult[]); for (const def of defs) { @@ -555,6 +539,11 @@ function storeComplexityResults(results: WalkResults, defs: Definition[], langId } } +// Note: intentionally does not touch def.complexity.cyclomatic with the CFG's +// block/edge count — the CFG doesn't model short-circuit logical operators, +// optional chaining, or nested-function bodies the way the AST-derived +// cyclomatic (storeComplexityResults, above) correctly does, so overriding it +// silently corrupts the metric (issue #1743). Mirrors ast-analysis/engine.ts. function storeCfgResults(results: WalkResults, defs: Definition[]): void { const byLine = indexByLine((results.cfg || []) as CfgFuncResult[]); for (const def of defs) { @@ -566,9 +555,6 @@ function storeCfgResults(results: WalkResults, defs: Definition[]): void { const cfgResult = matchResultToDef(byLine.get(def.line), def.name); if (!cfgResult) continue; def.cfg = { blocks: cfgResult.blocks, edges: cfgResult.edges }; - if (cfgResult.cyclomatic != null) { - overrideCyclomaticFromCfg(def, cfgResult.cyclomatic); - } } } } diff --git a/tests/integration/issue-1743-incremental-cyclomatic-staleness.test.ts b/tests/integration/issue-1743-incremental-cyclomatic-staleness.test.ts new file mode 100644 index 000000000..85550cb3c --- /dev/null +++ b/tests/integration/issue-1743-incremental-cyclomatic-staleness.test.ts @@ -0,0 +1,273 @@ +/** + * Regression test for #1743: `codegraph build` (incremental mode) computed + * WRONG `cyclomatic` complexity for functions that were NOT edited, when they + * live in a file where a DIFFERENT function was edited elsewhere (shifting + * line numbers). `cognitive`, Halstead, `loc`/`sloc` all stayed correct — only + * `cyclomatic` (and the `maintainabilityIndex` derived from it) came out wrong. + * A full clean rebuild recomputed the correct values. + * + * Root cause: `storeCfgResults` / `storeNativeCfgResults` in + * `src/ast-analysis/engine.ts` (and a near-identical duplicate in + * `src/domain/wasm-worker-entry.ts`) OVERWROTE the correctly-computed, + * AST-derived `complexity.cyclomatic` with a CFG block/edge-count value + * (`edges - blocks + 2`, McCabe's formula applied to the control-flow graph). + * That override is wrong because the CFG builder does not model: + * - short-circuit logical operators (`&&`, `||`, `??`), + * - optional chaining (`?.`), + * - or nested function/closure bodies (a CFG stops at a nested function + * boundary, while the AST-based cyclomatic walk intentionally folds a + * closure's branches into its enclosing function, same as cognitive + * complexity does) — + * all of which the AST-derived cyclomatic (`computeFunctionComplexity` / + * `compute_all_metrics`, used directly and unconditionally by the native + * engine) correctly counts. Verified empirically against + * `src/extractors/javascript.ts`'s real `extractReturnTypeMapWalk` (whose + * entire body is a single nested `walk` closure): CFG blocks=3/edges=2 → + * override computed cyclomatic=1, while the correct AST-derived value is 26. + * + * This override ran whenever the JS `ast-analysis` pipeline processed a file + * — unconditionally for `--engine wasm`, and for `--engine native` whenever + * the Rust orchestrator was bypassed (e.g. `forceFullRebuild` triggered by an + * engine switch, schema/version/config change — see + * `checkEngineSchemaMismatch` in `domain/graph/builder/pipeline.ts`), which + * silently corrupts native-sourced complexity data too since the override + * doesn't care where `def.complexity`/`def.cfg` came from. This is why the + * bug was reported as "incremental only": the reporter's baseline "clean + * rebuild" happened to run through the native orchestrator's own fast path + * (which never applied this override) while the follow-up rebuild took the + * JS-pipeline path instead. + * + * Fixed by removing the CFG-derived cyclomatic override entirely — cyclomatic + * complexity is now always the single, correctly-computed AST-derived value, + * matching the native engine's (always-correct) behavior. CFG blocks/edges + * are still stored for `codegraph cfg` visualization; they just no longer + * feed back into `complexity.cyclomatic`. + * + * Strategy (mirrors #1738's incremental-vs-full-rebuild ground-truth + * pattern): build a multi-function fixture deliberately covering all three + * CFG blind spots (`&&`/`||`, `?.`, and a nested closure) in functions that + * are never edited, edit an EARLIER, unrelated function to shift every later + * line number, rebuild incrementally, then diff the untouched functions' + * complexity against a from-scratch full rebuild of the exact same final + * file content. + */ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { buildGraph } from '../../src/domain/graph/builder.js'; +import { complexityData } from '../../src/features/complexity.js'; +import { isNativeAvailable } from '../../src/infrastructure/native.js'; +import type { EngineMode } from '../../src/types.js'; + +const REL_FILE = 'src/lib/calc.js'; + +/** Functions that are NEVER edited across the whole scenario — the ones the + * bug corrupted. Each exercises a distinct CFG blind spot. */ +const UNTOUCHED_FUNCTIONS = ['processWithGuards', 'accessOptional', 'walkTreeOuter'] as const; + +function baseFixtureSource(): string { + return `function helperOne(x) { + return x + 1; +} + +function processWithGuards(a, b, c) { + let result = 0; + if (a && b) { + result += 1; + } else if (a || c) { + result += 2; + } + if (a && b && c) { + result += 3; + } + return result; +} + +function accessOptional(obj) { + if (obj?.a?.b) { + return obj.a.b.value; + } + return obj?.fallback ?? null; +} + +function walkTreeOuter(root) { + function visit(node) { + if (!node) return; + if (node.left) { + visit(node.left); + } else if (node.right) { + visit(node.right); + } + for (const child of node.children || []) { + if (child.active) { + visit(child); + } + } + } + visit(root); +} + +module.exports = { helperOne, processWithGuards, accessOptional, walkTreeOuter }; +`; +} + +/** The same fixture after `helperOne` (only) has been edited — adds several + * lines with no branches of its own, shifting every later function's line + * numbers without changing any of their own source text. */ +function editedFixtureSource(): string { + return baseFixtureSource().replace( + `function helperOne(x) { + return x + 1; +}`, + `function helperOne(x) { + // Several lines added here shift every function below down by a fixed + // amount, without touching their own source text at all. + console.log('helperOne called with', x); + console.log('some more logging'); + console.log('and even more logging, to shift line numbers meaningfully'); + return x + 1; +}`, + ); +} + +function writeFixture(root: string, source: string): void { + fs.mkdirSync(path.join(root, 'src', 'lib'), { recursive: true }); + fs.writeFileSync(path.join(root, REL_FILE), source); +} + +interface ComplexitySnapshot { + cyclomatic: number; + cognitive: number; + maintainabilityIndex: number; +} + +function snapshotComplexity(dbPath: string): Map { + const data = complexityData(dbPath, { file: REL_FILE, noTests: true, limit: 500 }) as { + functions: Array<{ + name: string; + cyclomatic: number; + cognitive: number; + maintainabilityIndex: number; + }>; + }; + const byName = new Map(); + for (const f of data.functions) { + byName.set(f.name, { + cyclomatic: f.cyclomatic, + cognitive: f.cognitive, + maintainabilityIndex: f.maintainabilityIndex, + }); + } + return byName; +} + +function runScenario(engine: EngineMode): void { + describe(`cyclomatic complexity after incremental rebuild (#1743) — ${engine}`, () => { + let projDir: string; + const tmpDirs: string[] = []; + const dbPath = () => path.join(projDir, '.codegraph', 'graph.db'); + + function mkTmp(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tmpDirs.push(dir); + return dir; + } + + beforeAll(async () => { + projDir = mkTmp(`cg-1743-${engine}-`); + writeFixture(projDir, baseFixtureSource()); + await buildGraph(projDir, { engine, incremental: false, skipRegistry: true }); + }, 60_000); + + afterAll(() => { + for (const dir of tmpDirs) fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('baseline full build reports non-trivial cyclomatic for every untouched function', () => { + const snap = snapshotComplexity(dbPath()); + // Sanity floor: guards against a future regression producing a + // different-but-still-wrong low value (e.g. 1) that happens to match + // the reference build by coincidence in the test below. + expect(snap.get('processWithGuards')?.cyclomatic ?? 0).toBeGreaterThanOrEqual(6); + expect(snap.get('accessOptional')?.cyclomatic ?? 0).toBeGreaterThanOrEqual(3); + expect(snap.get('walkTreeOuter')?.cyclomatic ?? 0).toBeGreaterThanOrEqual(5); + }); + + it('untouched functions keep their exact cyclomatic/cognitive/MI after an incremental ' + + 'rebuild that only edits an earlier, unrelated function (matches a from-scratch full rebuild)', async () => { + const before = snapshotComplexity(dbPath()); + + // Edit ONLY helperOne — shifts every later function's line numbers + // without touching their source text at all. + writeFixture(projDir, editedFixtureSource()); + await buildGraph(projDir, { engine, skipRegistry: true }); // incremental (default) + const incremental = snapshotComplexity(dbPath()); + + // Ground truth: an independent repo built in one full pass with the + // identical final file content (post-edit). + const refDir = mkTmp(`cg-1743-ref-${engine}-`); + writeFixture(refDir, editedFixtureSource()); + await buildGraph(refDir, { engine, incremental: false, skipRegistry: true }); + const reference = snapshotComplexity(path.join(refDir, '.codegraph', 'graph.db')); + + for (const name of UNTOUCHED_FUNCTIONS) { + const beforeSnap = before.get(name); + const incrSnap = incremental.get(name); + const refSnap = reference.get(name); + expect(incrSnap, `${name} missing from incremental build`).toBeDefined(); + expect(refSnap, `${name} missing from reference full rebuild`).toBeDefined(); + expect(beforeSnap, `${name} missing from pre-edit baseline`).toBeDefined(); + + expect( + incrSnap!.cyclomatic, + `${name}: cyclomatic complexity went stale/wrong after an incremental rebuild ` + + `that only shifted its line number (#1743) — incremental=${incrSnap!.cyclomatic}, ` + + `full-rebuild ground truth=${refSnap!.cyclomatic}`, + ).toBe(refSnap!.cyclomatic); + // The edit never touches these functions' own source, so cyclomatic + // must also be byte-for-byte identical to the pre-edit baseline. + expect(incrSnap!.cyclomatic).toBe(beforeSnap!.cyclomatic); + expect(incrSnap!.cognitive).toBe(refSnap!.cognitive); + expect(incrSnap!.maintainabilityIndex).toBe(refSnap!.maintainabilityIndex); + } + }, 60_000); + }); +} + +runScenario('wasm'); + +describe.skipIf(!isNativeAvailable())('native engine coverage', () => { + runScenario('native'); + + // Concrete, independently-verified trigger for the native engine: switching + // `--engine` on an existing graph.db sets `forceFullRebuild` (engine-mismatch + // guard in `checkEngineSchemaMismatch`), which bypasses the Rust orchestrator + // and routes the rebuild through the same JS `ast-analysis` pipeline WASM + // always uses — exercising the exact CFG-override bug even though `--engine + // native` is requested for the final rebuild. + it('switching engines on an existing graph.db does not corrupt cyclomatic on the ' + + 'follow-up native rebuild (forceFullRebuild bypasses the Rust orchestrator)', async () => { + const projDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-1743-engineswitch-')); + try { + writeFixture(projDir, baseFixtureSource()); + await buildGraph(projDir, { engine: 'native', incremental: false, skipRegistry: true }); + const dbPath = path.join(projDir, '.codegraph', 'graph.db'); + const before = snapshotComplexity(dbPath); + + // Switch to wasm (no file changes), then switch back to native. + await buildGraph(projDir, { engine: 'wasm', skipRegistry: true }); + await buildGraph(projDir, { engine: 'native', skipRegistry: true }); + const after = snapshotComplexity(dbPath); + + for (const name of UNTOUCHED_FUNCTIONS) { + expect( + after.get(name)?.cyclomatic, + `${name}: cyclomatic corrupted after an engine-switch round trip (#1743)`, + ).toBe(before.get(name)?.cyclomatic); + } + } finally { + fs.rmSync(projDir, { recursive: true, force: true }); + } + }, 60_000); +}); From ccf3c196881a17c7d3fc73b0bad524c009cf2527 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Sun, 5 Jul 2026 23:48:25 -0600 Subject: [PATCH 20/67] fix: backfill edges.technique for incrementally-inserted calls edges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full builds always tag directly-resolved calls edges technique='ts-native' (applied by both engines, not just native), either inline or via a post-insert backfill. Two incremental-rebuild paths left it NULL instead: - codegraph watch (rebuildFile/emitIncrementalCallEdges in builder/incremental.ts) never set technique at all when inserting a calls edge. Fixed with a scoped post-rebuild backfill, mirroring applyEdgeTechniquesAfterNativeInsert. - codegraph build's native-orchestrator incremental path (backfillEdgeTechniquesAfterNativeOrchestrator) scoped its backfill to only the directly-changed files reported by Rust, missing one-hop reverse dependents whose outgoing edges into a changed file are reconnected by Rust's own reverse-dep cascade and so also carry a fresh, untagged technique. Fixed by expanding the scope to include them. Rust itself never writes the technique column on any edge-insertion path (confirmed across crates/codegraph-core) — it has always been the JS-side backfill's job, so no native crate changes are needed. No README/CLAUDE.md/ROADMAP.md updates needed — bug fix only, no new architecture, commands, or language support (docs check acknowledged). Fixes #1744 Impact: 4 functions changed, 9 affected --- src/domain/graph/builder/incremental.ts | 69 +++++- .../builder/stages/native-orchestrator.ts | 49 +++- src/shared/constants.ts | 7 +- ...ue-1744-incremental-edge-technique.test.ts | 227 ++++++++++++++++++ 4 files changed, 343 insertions(+), 9 deletions(-) create mode 100644 tests/integration/issue-1744-incremental-edge-technique.test.ts diff --git a/src/domain/graph/builder/incremental.ts b/src/domain/graph/builder/incremental.ts index 9ef64d638..beb6ac388 100644 --- a/src/domain/graph/builder/incremental.ts +++ b/src/domain/graph/builder/incremental.ts @@ -11,7 +11,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { bulkNodeIdsByFile, purgeFileData } from '../../../db/index.js'; import { debug, warn } from '../../../infrastructure/logger.js'; -import { normalizePath } from '../../../shared/constants.js'; +import { normalizePath, TS_NATIVE_CONFIDENCE_FLOOR } from '../../../shared/constants.js'; import type { BetterSqlite3Database, EngineOpts, @@ -795,6 +795,66 @@ function buildCallEdges( return edgesAdded; } +// ── technique backfill (#1744) ────────────────────────────────────────── + +/** + * Backfill `technique = 'ts-native'` on `calls` edges just written by + * `buildCallEdges`/`emitIncrementalCallEdges` above, which insert edges via + * `stmts.insertEdge.run(...)` without ever setting `technique`, leaving it + * NULL. + * + * `'ts-native'` is not an engine marker — it is the resolution-technique + * label the full-build paths apply to every directly name/type-resolved + * `calls` edge, in both the WASM/JS pipeline (`emitDirectCallEdgesForCall` in + * stages/build-edges.ts) and the native pipeline (`buildCallEdgesNative`, + * same file), as opposed to `'points-to'` (alias/pts fallback) or `'cha'` / + * `'super-dispatch'` (virtual-dispatch expansion). `incremental.ts`'s call + * resolution (`resolveCallTargets` + the same-class/defineProperty + * fallbacks in `applyThisReceiverFallbacks`) implements only that same + * direct-resolution cascade — it has no pts or CHA/RTA post-pass of its own + * — so every edge it emits is the direct-resolution case and always + * belongs under `'ts-native'`, regardless of which engine parsed the file. + * + * Mirrors `applyEdgeTechniquesAfterNativeInsert` (full-build JS pipeline, + * stages/build-edges.ts) and `backfillEdgeTechniquesAfterNativeOrchestrator` + * (stages/native-orchestrator.ts): scope to the just-rebuilt files' source + * nodes, backfill NULL technique to 'ts-native', then lift any resulting + * ts-native edge below the confidence floor up to it — so a `calls` edge's + * `technique` (and, transitively, its confidence floor) no longer depends on + * whether it was last touched by a full build or a single-file watch rebuild. + * + * Scoped to `touchedFiles` (the rebuilt file + any reverse-dep cascade + * files), not a full-table scan. Chunked to stay within SQLite's + * SQLITE_LIMIT_VARIABLE_NUMBER (999 on older builds). + */ +function backfillIncrementalEdgeTechniques( + db: BetterSqlite3Database, + touchedFiles: readonly string[], +): void { + if (touchedFiles.length === 0) return; + const CHUNK_SIZE = 500; + const tx = db.transaction(() => { + for (let i = 0; i < touchedFiles.length; i += CHUNK_SIZE) { + const chunk = touchedFiles.slice(i, i + CHUNK_SIZE); + const placeholders = chunk.map(() => '?').join(','); + db.prepare( + `UPDATE edges SET technique = 'ts-native' + WHERE kind = 'calls' AND technique IS NULL + AND source_id IN (SELECT id FROM nodes WHERE file IN (${placeholders}))`, + ).run(...chunk); + // Lift resolved ts-native edges below the confidence floor for this + // chunk, matching the floor lift the full-build native paths apply. + db.prepare( + `UPDATE edges SET confidence = ? + WHERE kind = 'calls' AND technique = 'ts-native' + AND confidence > 0 AND confidence < ? + AND source_id IN (SELECT id FROM nodes WHERE file IN (${placeholders}))`, + ).run(TS_NATIVE_CONFIDENCE_FLOOR, TS_NATIVE_CONFIDENCE_FLOOR, ...chunk); + } + }); + tx(); +} + // ── Main entry point ──────────────────────────────────────────────────── /** Build the "this file was deleted" result returned by `rebuildFile`. */ @@ -1017,6 +1077,13 @@ export async function rebuildFile( cache, ); edgesAdded += cascadeEdges; + + // Backfill technique='ts-native' (and the confidence floor) for this + // rebuild's calls edges — buildCallEdges above inserts edges without a + // technique value, unlike a full rebuild of either engine, which always + // tags directly-resolved calls edges 'ts-native' (#1744). + backfillIncrementalEdgeTechniques(db, [relPath, ...reverseDeps]); + // Include pre-deletion edge counts from reverse deps so the net delta // (edgesAdded - edgesBefore) is correct even when the cascade re-inserts // their edges unchanged. diff --git a/src/domain/graph/builder/stages/native-orchestrator.ts b/src/domain/graph/builder/stages/native-orchestrator.ts index edb4fe986..236ecbedf 100644 --- a/src/domain/graph/builder/stages/native-orchestrator.ts +++ b/src/domain/graph/builder/stages/native-orchestrator.ts @@ -1834,6 +1834,38 @@ async function backfillNativeDroppedFiles( cleanupThisDispatchWasmTrees(wasmResults); } +/** + * One-hop reverse dependents of `files`: files with an edge whose TARGET is a + * node inside one of `files`. The Rust incremental pipeline's own reverse-dep + * cascade (`reconnect_reverse_dep_edges` in detect_changes.rs) re-creates + * edges FROM these files INTO the changed files whenever the changed files' + * nodes are purged + reinserted with new IDs — without ever writing + * `technique` — so `backfillEdgeTechniquesAfterNativeOrchestrator` must reach + * them too, not just `changedFiles` itself (#1744). Mirrors the one-hop + * `findReverseDeps` query in `builder/incremental.ts`. + * + * Chunked to stay within SQLite's SQLITE_LIMIT_VARIABLE_NUMBER (999 on older + * builds). + */ +function findOneHopReverseDepFiles(db: BetterSqlite3Database, files: readonly string[]): string[] { + const found = new Set(); + const CHUNK_SIZE = 500; + for (let i = 0; i < files.length; i += CHUNK_SIZE) { + const chunk = files.slice(i, i + CHUNK_SIZE); + const placeholders = chunk.map(() => '?').join(','); + const rows = db + .prepare( + `SELECT DISTINCT n_src.file AS file FROM edges e + JOIN nodes n_src ON e.source_id = n_src.id + JOIN nodes n_tgt ON e.target_id = n_tgt.id + WHERE n_tgt.file IN (${placeholders}) AND n_src.kind != 'directory'`, + ) + .all(...chunk) as Array<{ file: string }>; + for (const r of rows) found.add(r.file); + } + return [...found]; +} + /** * Backfill the `technique` column on `calls` edges written by the native Rust * orchestrator, which does not write the column itself. Also lifts any @@ -1842,8 +1874,9 @@ async function backfillNativeDroppedFiles( * reflected in the call-confidence metric. * * For full builds, all `calls` edges in the DB are new so a global UPDATE is - * correct. For incremental builds, only changed-file source nodes are updated - * to avoid overwriting previously-set technique values on unchanged edges. + * correct. For incremental builds, only changed-file source nodes — plus + * their one-hop reverse dependents (#1744) — are updated to avoid overwriting + * previously-set technique values on unchanged edges. */ function backfillEdgeTechniquesAfterNativeOrchestrator( db: BetterSqlite3Database, @@ -1868,12 +1901,18 @@ function backfillEdgeTechniquesAfterNativeOrchestrator( ).run(TS_NATIVE_CONFIDENCE_FLOOR, TS_NATIVE_CONFIDENCE_FLOOR); return; } - // Incremental: scope to source nodes whose file is one of the changed files. + // Incremental: scope to source nodes whose file is one of the changed files, + // plus one-hop reverse dependents — callers whose outgoing edges into a + // changed file were reconnected by Rust's own cascade and so also carry a + // fresh, untagged technique value (#1744). + const scopeFiles = [ + ...new Set([...changedFiles, ...findOneHopReverseDepFiles(db, changedFiles)]), + ]; // Chunk to stay within SQLite's SQLITE_LIMIT_VARIABLE_NUMBER (999 on older builds). const CHUNK_SIZE = 500; const tx = db.transaction(() => { - for (let i = 0; i < changedFiles.length; i += CHUNK_SIZE) { - const chunk = changedFiles.slice(i, i + CHUNK_SIZE); + for (let i = 0; i < scopeFiles.length; i += CHUNK_SIZE) { + const chunk = scopeFiles.slice(i, i + CHUNK_SIZE); const placeholders = chunk.map(() => '?').join(','); db.prepare( `UPDATE edges SET technique = 'ts-native' diff --git a/src/shared/constants.ts b/src/shared/constants.ts index bff1cab4c..8e75b1b7a 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -56,9 +56,10 @@ export const EXTENSIONS: ArrayCompatSet = withArrayCompat(new Set(SUPPOR * (confidence = 0.0) are intentionally excluded and must remain at 0.0 so * they stay below DEFAULT_MIN_CONFIDENCE and never surface in normal queries. * - * Used in `build-edges.ts` (in-memory + `applyEdgeTechniquesAfterNativeInsert`) - * and `native-orchestrator.ts` (`backfillEdgeTechniquesAfterNativeOrchestrator`). - * Centralised here so all three insertion paths apply the same value. + * Used in `build-edges.ts` (in-memory + `applyEdgeTechniquesAfterNativeInsert`), + * `native-orchestrator.ts` (`backfillEdgeTechniquesAfterNativeOrchestrator`), + * and `incremental.ts` (`backfillIncrementalEdgeTechniques`, #1744). + * Centralised here so all insertion paths apply the same value. */ export const TS_NATIVE_CONFIDENCE_FLOOR = 0.5; diff --git a/tests/integration/issue-1744-incremental-edge-technique.test.ts b/tests/integration/issue-1744-incremental-edge-technique.test.ts new file mode 100644 index 000000000..c3b69ee38 --- /dev/null +++ b/tests/integration/issue-1744-incremental-edge-technique.test.ts @@ -0,0 +1,227 @@ +/** + * Regression test for #1744: a `calls` edge's `technique` DB column differed + * depending on whether it was inserted via a full build or an incremental + * rebuild of the same final source state. + * + * Full builds always tag directly-resolved `calls` edges `technique = + * 'ts-native'` — a resolution-technique label applied by BOTH engines (not a + * native-engine marker): `emitDirectCallEdgesForCall` for the WASM/JS + * pipeline and `buildCallEdgesNative` for the native pipeline, both in + * `stages/build-edges.ts`. WASM/JS writes it inline via `batchInsertEdges`; + * native writes it via a post-insert backfill, since neither the native bulk + * `insertEdge` FFI nor the Rust orchestrator write `technique` at insert + * time. + * + * Two independent incremental-rebuild code paths had the same gap — the + * backfill never ran, or ran with an incomplete scope — both fixed here: + * + * 1. `codegraph watch` -> `rebuildFile` -> `buildCallEdges` -> + * `emitIncrementalCallEdges` in `src/domain/graph/builder/incremental.ts` + * inserted `calls` edges via `stmts.insertEdge.run(...)` without ever + * setting `technique`, for either engine. Fixed by + * `backfillIncrementalEdgeTechniques`, called unconditionally at the end + * of `rebuildFile`. + * + * 2. `codegraph build` (default incremental mode, native engine) -> + * `tryNativeOrchestrator`'s own backfill + * (`backfillEdgeTechniquesAfterNativeOrchestrator` in + * `stages/native-orchestrator.ts`) scoped its UPDATE to only the + * directly-changed files the Rust pipeline reports — missing one-hop + * reverse dependents (e.g. `callerA.js`/`callerB.js`) whose outgoing + * edges into the changed file are reconnected by Rust's own + * reverse-dep cascade (their target node IDs shifted when the changed + * file's nodes were purged + reinserted) and so also carry a fresh, + * untagged `technique`. Fixed by `findOneHopReverseDepFiles`, which + * expands the backfill's scope to include them. + * + * Fixture: callerA.js/callerB.js import and call `callee()` from callee.js. + * Editing callee.js (the file with *incoming* call edges, not a caller + * itself) is the scenario that exercises the reverse-dep cascade for gap (2) + * above — editing a caller file directly would already have worked even + * before this fix, since a changed file's own outgoing edges are always + * freshly (re)computed as part of its own changed-file processing. + */ +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 { getNodeId as getNodeIdQuery, initSchema, openDb } from '../../src/db/index.js'; +import { rebuildFile } from '../../src/domain/graph/builder/incremental.js'; +import { buildGraph } from '../../src/domain/graph/builder.js'; +import { isNativeAvailable } from '../../src/infrastructure/native.js'; +import type { EngineMode } from '../../src/types.js'; + +function writeFixture(dir: string, calleeMarker: string): void { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, 'callee.js'), + `export function callee() {\n ${calleeMarker}\n return 1;\n}\n`, + ); + fs.writeFileSync( + path.join(dir, 'callerA.js'), + `import { callee } from './callee.js';\nexport function callerA() {\n return callee();\n}\n`, + ); + fs.writeFileSync( + path.join(dir, 'callerB.js'), + `import { callee } from './callee.js';\nexport function callerB() {\n return callee();\n}\n`, + ); +} + +interface CallEdgeRow { + srcFile: string; + src: string; + tgt: string; + technique: string | null; + confidence: number; +} + +function readCallEdges(dbPath: string): CallEdgeRow[] { + const db = new Database(dbPath, { readonly: true }); + try { + return db + .prepare( + `SELECT n1.file AS srcFile, n1.name AS src, n2.name AS tgt, e.technique, e.confidence + 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.file, n1.name, n2.name`, + ) + .all() as CallEdgeRow[]; + } finally { + db.close(); + } +} + +function makeStmts(db: ReturnType) { + return { + insertNode: db.prepare( + 'INSERT OR IGNORE INTO nodes (name, kind, file, line, end_line) VALUES (?, ?, ?, ?, ?)', + ), + getNodeId: { + get: (name: string, kind: string, file: string, line: number) => { + const id = getNodeIdQuery(db, name, kind, file, line); + return id != null ? { id } : undefined; + }, + }, + insertEdge: db.prepare( + 'INSERT INTO edges (source_id, target_id, kind, confidence, dynamic) VALUES (?, ?, ?, ?, ?)', + ), + countNodes: db.prepare('SELECT COUNT(*) as c FROM nodes WHERE file = ?'), + countEdges: db.prepare( + 'SELECT COUNT(*) as c FROM edges WHERE source_id IN (SELECT id FROM nodes WHERE file = ?)', + ), + findNodeInFile: db.prepare( + "SELECT id, kind, file FROM nodes WHERE name = ? AND kind IN ('function', 'method', 'class', 'interface', 'type', 'struct', 'enum', 'trait', 'record', 'module', 'constant') AND file = ?", + ), + findNodeByName: db.prepare( + "SELECT id, file, kind FROM nodes WHERE name = ? AND kind IN ('function', 'method', 'class', 'interface', 'type', 'struct', 'enum', 'trait', 'record', 'module', 'constant')", + ), + listSymbols: db.prepare("SELECT name, kind, line FROM nodes WHERE file = ? AND kind != 'file'"), + upsertFileHash: db.prepare( + 'INSERT OR REPLACE INTO file_hashes (file, hash, mtime, size) VALUES (?, ?, ?, ?)', + ), + deleteFileHash: db.prepare('DELETE FROM file_hashes WHERE file = ?'), + }; +} + +// ── Suite 1: codegraph build --engine native (native orchestrator's own +// incremental path, stages/native-orchestrator.ts) ────────────────────── + +describe.skipIf(!isNativeAvailable())( + 'codegraph build --engine native: incremental rebuild technique matches full rebuild (#1744)', + () => { + let projDir: string; + let refDir: string; + + beforeAll(async () => { + projDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-1744-buildcli-')); + writeFixture(projDir, '// v1'); + await buildGraph(projDir, { engine: 'native', incremental: false, skipRegistry: true }); + + // Edit ONLY callee.js — callerA.js/callerB.js are reverse deps whose + // outgoing edges get reconnected by the Rust incremental cascade. + writeFixture(projDir, '// v2 edited'); + await buildGraph(projDir, { engine: 'native', skipRegistry: true }); // incremental (default) + + refDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-1744-buildcli-ref-')); + writeFixture(refDir, '// v2 edited'); + await buildGraph(refDir, { engine: 'native', incremental: false, skipRegistry: true }); + }, 60_000); + + afterAll(() => { + fs.rmSync(projDir, { recursive: true, force: true }); + fs.rmSync(refDir, { recursive: true, force: true }); + }); + + it('tags reverse-dependent calls edges technique=ts-native after an incremental rebuild, matching a full rebuild', () => { + const incremental = readCallEdges(path.join(projDir, '.codegraph', 'graph.db')); + const reference = readCallEdges(path.join(refDir, '.codegraph', 'graph.db')); + + expect(incremental.length).toBeGreaterThan(0); + expect(incremental).toEqual(reference); + for (const edge of incremental) { + expect( + edge.technique, + `${edge.srcFile}: ${edge.src} -> ${edge.tgt} should be technique='ts-native', not ${edge.technique}`, + ).toBe('ts-native'); + } + }); + }, +); + +// ── Suite 2: codegraph watch (rebuildFile, builder/incremental.ts) ───────── + +function runWatchScenario(engine: EngineMode): void { + describe(`codegraph watch (rebuildFile): incremental rebuild technique matches full rebuild (#1744) — ${engine}`, () => { + let projDir: string; + let refDir: string; + + beforeAll(async () => { + projDir = fs.mkdtempSync(path.join(os.tmpdir(), `cg-1744-watch-${engine}-`)); + writeFixture(projDir, '// v1'); + await buildGraph(projDir, { engine, incremental: false, skipRegistry: true }); + + // Edit ONLY callee.js via the watcher's single-file rebuild path. + writeFixture(projDir, '// v2 edited'); + const dbPath = path.join(projDir, '.codegraph', 'graph.db'); + const db = openDb(dbPath); + try { + initSchema(db); + const stmts = makeStmts(db); + await rebuildFile(db, projDir, path.join(projDir, 'callee.js'), stmts, { engine }, null); + } finally { + db.close(); + } + + refDir = fs.mkdtempSync(path.join(os.tmpdir(), `cg-1744-watch-ref-${engine}-`)); + writeFixture(refDir, '// v2 edited'); + await buildGraph(refDir, { engine, incremental: false, skipRegistry: true }); + }, 60_000); + + afterAll(() => { + fs.rmSync(projDir, { recursive: true, force: true }); + fs.rmSync(refDir, { recursive: true, force: true }); + }); + + it('tags reverse-dependent calls edges technique=ts-native after rebuildFile, matching a full rebuild', () => { + const incremental = readCallEdges(path.join(projDir, '.codegraph', 'graph.db')); + const reference = readCallEdges(path.join(refDir, '.codegraph', 'graph.db')); + + expect(incremental.length).toBeGreaterThan(0); + expect(incremental).toEqual(reference); + for (const edge of incremental) { + expect( + edge.technique, + `${edge.srcFile}: ${edge.src} -> ${edge.tgt} should be technique='ts-native', not ${edge.technique}`, + ).toBe('ts-native'); + } + }); + }); +} + +runWatchScenario('wasm'); +describe.skipIf(!isNativeAvailable())('native engine coverage', () => { + runWatchScenario('native'); +}); From d5b1162eb538a491d522abd3c0adae0c7aba0454 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Sun, 5 Jul 2026 23:52:45 -0600 Subject: [PATCH 21/67] fix: wrap remote embedding JSON parse failure in EngineError response.json() in embedRemote sat outside the try/catch guarding the fetch call, so a 200 OK response with a malformed/non-JSON body (e.g. an HTML error page from a misconfigured proxy) threw a raw uncaught SyntaxError instead of the descriptive EngineError used by every other failure mode in this function (timeout, network failure, non-2xx status, bad response shape, dimension mismatch). Fixes #1745 Impact: 1 functions changed, 9 affected --- src/domain/search/providers/remote.ts | 11 ++++++++++- tests/search/embedding-remote-provider.test.ts | 10 ++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/domain/search/providers/remote.ts b/src/domain/search/providers/remote.ts index d323cdabd..00ef79a8b 100644 --- a/src/domain/search/providers/remote.ts +++ b/src/domain/search/providers/remote.ts @@ -197,7 +197,16 @@ export async function embedRemote( batchNumber, ); - const json = (await response.json()) as OpenAIEmbeddingResponse; + let json: OpenAIEmbeddingResponse; + try { + json = (await response.json()) as OpenAIEmbeddingResponse; + } catch (err: unknown) { + throw new EngineError( + `Remote embedding endpoint ${url} returned a response that could not be parsed as JSON: ` + + `${err instanceof Error ? err.message : String(err)}`, + { cause: err instanceof Error ? err : undefined }, + ); + } const mapped = mapRemoteEmbeddingResponse(json, batch, url, dim); dim = mapped.dim; results.push(...mapped.vectors); diff --git a/tests/search/embedding-remote-provider.test.ts b/tests/search/embedding-remote-provider.test.ts index 43591cad6..a2decb3f5 100644 --- a/tests/search/embedding-remote-provider.test.ts +++ b/tests/search/embedding-remote-provider.test.ts @@ -154,6 +154,16 @@ describe('embedRemote', () => { ); }); + it('throws EngineError instead of a raw SyntaxError when the response body is not valid JSON', async () => { + fetchMock.mockResolvedValueOnce( + new Response('Bad Gateway', { status: 200 }), + ); + await expect(embedRemote(['a'], { baseUrl: 'http://x', model: 'm' })).rejects.toMatchObject({ + name: 'EngineError', + message: expect.stringContaining('could not be parsed as JSON'), + }); + }); + it('throws EngineError when the response shape does not match the input length', async () => { fetchMock.mockResolvedValueOnce( new Response(JSON.stringify({ data: [{ embedding: [1], index: 0 }] }), { status: 200 }), From 89710173ff45fcc02951ae70ed371aca94f80ffa Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Sun, 5 Jul 2026 23:56:24 -0600 Subject: [PATCH 22/67] refactor: extract shared platform-default-path helper in config.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getDefaultUserConfigPath() and resolveUserConfigPath() each implemented the same XDG_CONFIG_HOME / APPDATA / ~/.config three-way branch, so a future change to the priority order or path shape had to be applied in two places. Extract the branch into computePlatformDefaultConfigPath(), called by both; resolveUserConfigPath() still layers its own existsSync checks on top. Pure dedup — no behavior change. Fixes #1746 Impact: 3 functions changed, 38 affected --- src/infrastructure/config.ts | 57 +++++++++++++++++++----------------- 1 file changed, 30 insertions(+), 27 deletions(-) diff --git a/src/infrastructure/config.ts b/src/infrastructure/config.ts index 84b2eed35..05ba47601 100644 --- a/src/infrastructure/config.ts +++ b/src/infrastructure/config.ts @@ -278,6 +278,34 @@ const _globalConfigCache = new Map | null>(); // ── Global config file location ───────────────────────────────────────── +/** + * Compute the platform-specific default global-config path, ignoring + * CODEGRAPH_USER_CONFIG and file existence — callers handle those separately. + * Shared by getDefaultUserConfigPath() and resolveUserConfigPath() (previously + * duplicated verbatim between the two — issue #1746). + * + * Priority: + * 1. $XDG_CONFIG_HOME/codegraph/config.json + * 2. %APPDATA%\codegraph\config.json (Windows) + * 3. ~/.config/codegraph/config.json (fallback; also the Windows fallback + * when %APPDATA% is unset) + * + * @param home Home directory to use for the fallback branches. Defaults to + * os.homedir(); accepted as a parameter so a caller that already computed + * it (e.g. to reuse for a legacy-path check) doesn't pay for a second call. + */ +function computePlatformDefaultConfigPath(home: string = os.homedir()): string { + const xdgConfig = process.env.XDG_CONFIG_HOME; + if (xdgConfig) return path.join(xdgConfig, 'codegraph', 'config.json'); + if (process.platform === 'win32') { + const appdata = process.env.APPDATA; + return appdata + ? path.join(appdata, 'codegraph', 'config.json') + : path.join(home, '.config', 'codegraph', 'config.json'); + } + return path.join(home, '.config', 'codegraph', 'config.json'); +} + /** * Return the canonical path where a new global config file should be written. * @@ -294,17 +322,7 @@ const _globalConfigCache = new Map | null>(); export function getDefaultUserConfigPath(): string { const envPath = process.env.CODEGRAPH_USER_CONFIG; if (envPath) return envPath; - - const home = os.homedir(); - const xdgConfig = process.env.XDG_CONFIG_HOME; - if (xdgConfig) return path.join(xdgConfig, 'codegraph', 'config.json'); - if (process.platform === 'win32') { - const appdata = process.env.APPDATA; - return appdata - ? path.join(appdata, 'codegraph', 'config.json') - : path.join(home, '.config', 'codegraph', 'config.json'); - } - return path.join(home, '.config', 'codegraph', 'config.json'); + return computePlatformDefaultConfigPath(); } /** @@ -328,22 +346,7 @@ export function resolveUserConfigPath(): string | null { } const home = os.homedir(); - - // XDG_CONFIG_HOME takes priority on all platforms when explicitly set. - // Falls back to %APPDATA% on Windows, or ~/.config on Unix/macOS. - let platformDefault: string; - const xdgConfig = process.env.XDG_CONFIG_HOME; - if (xdgConfig) { - platformDefault = path.join(xdgConfig, 'codegraph', 'config.json'); - } else if (process.platform === 'win32') { - const appdata = process.env.APPDATA; - platformDefault = appdata - ? path.join(appdata, 'codegraph', 'config.json') - : path.join(home, '.config', 'codegraph', 'config.json'); - } else { - platformDefault = path.join(home, '.config', 'codegraph', 'config.json'); - } - + const platformDefault = computePlatformDefaultConfigPath(home); if (fs.existsSync(platformDefault)) return platformDefault; const legacyPath = path.join(home, '.codegraph', 'config.json'); From 056c5e42746958713129596aff1e4f41a9d8a83e Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Mon, 6 Jul 2026 00:12:57 -0600 Subject: [PATCH 23/67] refactor: decompose loadConfig and related high-effort functions in config.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract-method decomposition of the 5 functions in src/infrastructure/config.ts flagged for excessive Halstead effort (>15000) while staying within warn-only range on cognitive/cyclomatic/maxNesting/mi: loadConfig, loadConfigWithProvenance, resolveConsent, promptForConsentIfNeeded, and resolveSecrets. Each function's natural sub-steps (merge layers, consent-resolution stages, provenance-tracking stages, prompt gating/IO, secret-command validation/exec) are pulled into named private helpers, with the original function reduced to a thin orchestrator. No control flow, priority ordering, or error handling was changed — verified via the full test suite and manual sanity checks of config loading, global-config consent resolution, and apiKeyCommand secret resolution. Fixes #1747 Impact: 20 functions changed, 136 affected --- src/infrastructure/config.ts | 390 ++++++++++++++++++++++++----------- 1 file changed, 265 insertions(+), 125 deletions(-) diff --git a/src/infrastructure/config.ts b/src/infrastructure/config.ts index 05ba47601..315d221d5 100644 --- a/src/infrastructure/config.ts +++ b/src/infrastructure/config.ts @@ -467,35 +467,40 @@ function matchesAppliesTo(parsed: ParsedUserConfig | null, rootDir: string): boo } /** - * Resolve whether the global user config should be applied for a given repo. - * Implements the §4.1/§4.2 precedence chain from the spec. - * - * @param rootDir Absolute repo root. - * @param override Per-run override from CLI flags (_userConfigOverride). - * @param registryPath Optional registry path (for tests). + * §4.1 steps 1–3: resolve consent from an explicit per-run override + * (--no-user-config / --user-config). Returns undefined when no override is + * active, signaling the caller to fall through to §4.1 step 4 (the + * registry-based resolution in resolveConsentFromRegistry). */ -function resolveConsent( - rootDir: string, +function resolveConsentFromOverride( override: string | boolean | undefined, - registryPath: string = REGISTRY_PATH, -): ConsentResolutionResult { +): ConsentResolutionResult | undefined { // §4.1 step 1: --no-user-config if (override === false) { return { applied: false, globalPath: null, consentDecision: undefined }; } + if (override === undefined) return undefined; + // §4.1 steps 2–3: explicit path or bare --user-config - if (override !== undefined) { - const explicitPath = typeof override === 'string' ? override : resolveUserConfigPath(); - if (explicitPath && fs.existsSync(explicitPath)) { - return { applied: true, globalPath: explicitPath, consentDecision: undefined }; - } - if (typeof override === 'string') { - warn(`--user-config path "${override}" does not exist; skipping global layer.`); - } - return { applied: false, globalPath: null, consentDecision: undefined }; + const explicitPath = typeof override === 'string' ? override : resolveUserConfigPath(); + if (explicitPath && fs.existsSync(explicitPath)) { + return { applied: true, globalPath: explicitPath, consentDecision: undefined }; } + if (typeof override === 'string') { + warn(`--user-config path "${override}" does not exist; skipping global layer.`); + } + return { applied: false, globalPath: null, consentDecision: undefined }; +} +/** + * §4.1 step 4 + §4.2: resolve consent via the global config file location + * and the per-repo registry decision, once no per-run override is active. + */ +function resolveConsentFromRegistry( + rootDir: string, + registryPath: string, +): ConsentResolutionResult { // §4.1 step 4: resolve global file — if none, NOT applied const globalPath = resolveUserConfigPath(); if (!globalPath) { @@ -525,6 +530,22 @@ function resolveConsent( return { applied: false, globalPath, consentDecision: undefined }; } +/** + * Resolve whether the global user config should be applied for a given repo. + * Implements the §4.1/§4.2 precedence chain from the spec. + * + * @param rootDir Absolute repo root. + * @param override Per-run override from CLI flags (_userConfigOverride). + * @param registryPath Optional registry path (for tests). + */ +function resolveConsent( + rootDir: string, + override: string | boolean | undefined, + registryPath: string = REGISTRY_PATH, +): ConsentResolutionResult { + return resolveConsentFromOverride(override) ?? resolveConsentFromRegistry(rootDir, registryPath); +} + // Last applied global path and parsed data — exposed so pipeline.ts and // loadConfigWithProvenance can reuse the already-parsed file contents without a // second disk read (eliminating the TOCTOU window between loadConfig and callers). @@ -564,42 +585,42 @@ export function computeConfigHash(config: CodegraphConfig): string { // ── Interactive consent prompt ────────────────────────────────────────── /** - * When called from the build command, check whether we should prompt the user - * for global-config consent and, if so, prompt and persist the answer. - * - * Only fires when ALL of: - * - A global config file exists - * - The repo is undecided (no recorded consent) - * - Not matched by appliesTo globs - * - process.stdin.isTTY && process.stdout.isTTY - * - CI env is not set - * - No per-run --user-config / --no-user-config flag is active + * Gating logic for promptForConsentIfNeeded: determine whether the + * interactive consent prompt should be shown, returning the resolved + * global config path if so, or null if any no-prompt condition applies + * (per-run override active, no global file, already decided, appliesTo + * match, or a non-interactive/CI session). */ -export async function promptForConsentIfNeeded( - rootDir: string, - registryPath: string = REGISTRY_PATH, -): Promise { +function shouldPromptForGlobalConsent(rootDir: string, registryPath: string): string | null { // No-op if per-run override is active - if (_userConfigOverride !== undefined) return; + if (_userConfigOverride !== undefined) return null; const globalPath = resolveUserConfigPath(); - if (!globalPath) return; + if (!globalPath) return null; const consentDecision = getUserConfigConsent(rootDir, registryPath); - if (consentDecision !== undefined) return; // already decided + if (consentDecision !== undefined) return null; // already decided // Check appliesTo globs (dynamic consent — no prompt needed) const parsed = loadUserConfigFile(globalPath); - if (matchesAppliesTo(parsed, rootDir)) return; // covered by appliesTo + if (matchesAppliesTo(parsed, rootDir)) return null; // covered by appliesTo // Only prompt in fully interactive sessions - if (!process.stdin.isTTY || !process.stdout.isTTY) return; - if (process.env.CI) return; + if (!process.stdin.isTTY || !process.stdout.isTTY) return null; + if (process.env.CI) return null; + + return globalPath; +} +/** + * Show the interactive global-config consent prompt on stdin/stdout and + * return the user's trimmed, lower-cased answer. + */ +async function askGlobalConsentQuestion(globalPath: string, rootDir: string): Promise { const { createInterface } = await import('node:readline'); const rl = createInterface({ input: process.stdin, output: process.stdout }); - const answer = await new Promise((resolve) => { + return new Promise((resolve) => { rl.question( `\nA global codegraph config was found at ${globalPath}.\n` + `Apply settings not explicitly configured in this repo to ${path.resolve(rootDir)}? [y/N]\n` + @@ -611,6 +632,28 @@ export async function promptForConsentIfNeeded( }, ); }); +} + +/** + * When called from the build command, check whether we should prompt the user + * for global-config consent and, if so, prompt and persist the answer. + * + * Only fires when ALL of: + * - A global config file exists + * - The repo is undecided (no recorded consent) + * - Not matched by appliesTo globs + * - process.stdin.isTTY && process.stdout.isTTY + * - CI env is not set + * - No per-run --user-config / --no-user-config flag is active + */ +export async function promptForConsentIfNeeded( + rootDir: string, + registryPath: string = REGISTRY_PATH, +): Promise { + const globalPath = shouldPromptForGlobalConsent(rootDir, registryPath); + if (!globalPath) return; + + const answer = await askGlobalConsentQuestion(globalPath, rootDir); const decided = answer === 'y' || answer === 'yes' ? 'enabled' : 'disabled'; setUserConfigConsent(rootDir, decided, registryPath); @@ -627,6 +670,87 @@ export interface LoadConfigOpts { registryPath?: string; } +/** + * Determine the effective per-run user-config override: explicit opts win + * over the module-level override set by the CLI preAction hook. Shared by + * loadConfig and loadConfigWithProvenance. + */ +function resolveEffectiveOverride(opts: LoadConfigOpts | undefined): string | boolean | undefined { + return opts?.userConfig !== undefined ? opts.userConfig : _userConfigOverride; +} + +/** + * Build the config cache key for a given cwd + resolved consent. Cache key + * includes the applied global path so toggled consent is reflected. + */ +function buildConfigCacheKey(cwd: string, applied: boolean, globalPath: string | null): string { + return `${cwd}::${applied ? (globalPath ?? 'default') : 'none'}`; +} + +/** + * Look up `cacheKey` in the config cache. On a hit, also restores + * _lastAppliedGlobalConfig from the parallel global-config cache so + * loadConfigWithProvenance gets correct provenance on cache hits. Returns + * undefined on a miss (caller proceeds to rebuild the merged config). + */ +function getCachedConfig(cacheKey: string): CodegraphConfig | undefined { + const cached = _configCache.get(cacheKey); + if (!cached) return undefined; + _lastAppliedGlobalConfig = _globalConfigCache.get(cacheKey) ?? null; + return structuredClone(cached); +} + +/** + * Layer 1: merge the global user config into `merged`, if consent was + * granted and a global file is present. Updates the module-level + * _lastAppliedGlobalConfig as a side effect so pipeline.ts and + * loadConfigWithProvenance can reuse the already-parsed, sanitized global + * data without a second disk read (eliminates the TOCTOU window between + * loadConfig and its callers). + */ +function applyGlobalConfigLayer( + merged: Record, + applied: boolean, + globalPath: string | null, +): Record { + if (!applied || !globalPath) return merged; + const userFileData = loadUserConfigFile(globalPath); + if (!userFileData) return merged; + debug(`Applying global user config from ${globalPath}`); + const sanitized = sanitizeUserLayer(userFileData.globalConfig); + _lastAppliedGlobalConfig = sanitized; + merged = mergeConfig(merged, sanitized); + merged = applyExcludeTestsShorthand(merged, sanitized); + return merged; +} + +/** + * Layer 2: merge the first matching project config file (.codegraphrc.json + * or similar, in CONFIG_FILES priority order) into `merged`. + */ +function applyProjectConfigLayer( + merged: Record, + cwd: string, +): Record { + for (const name of CONFIG_FILES) { + const filePath = path.join(cwd, name); + if (fs.existsSync(filePath)) { + try { + const raw = fs.readFileSync(filePath, 'utf-8'); + const projectConfig = JSON.parse(raw) as Record; + debug(`Loaded project config from ${filePath}`); + merged = mergeConfig(merged, projectConfig); + merged = applyExcludeTestsShorthand(merged, projectConfig); + break; + } catch (err: unknown) { + if (err instanceof ConfigError) throw err; + debug(`Failed to parse config ${filePath}: ${toErrorMessage(err)}`); + } + } + } + return merged; +} + /** * Load project configuration from a .codegraphrc.json or similar file. * Returns merged config with defaults: defaults → global (if applied) → project → env → secrets. @@ -634,27 +758,21 @@ export interface LoadConfigOpts { */ export function loadConfig(cwd?: string, opts?: LoadConfigOpts): CodegraphConfig { cwd = path.resolve(cwd || process.cwd()); - - // Determine effective override: explicit opts win over module-level variable - const override = opts?.userConfig !== undefined ? opts.userConfig : _userConfigOverride; + const override = resolveEffectiveOverride(opts); // Resolve consent and global path const { applied, globalPath } = resolveConsent(cwd, override, opts?.registryPath); - // Cache key includes applied global path and override flag so toggled consent is reflected - const cacheKey = `${cwd}::${applied ? (globalPath ?? 'default') : 'none'}`; + const cacheKey = buildConfigCacheKey(cwd, applied, globalPath); // Always update _lastAppliedGlobalPath/_lastAppliedGlobalConfig before returning — // on a cache hit the previous call may have been for a different repo or different // opts, so stale values here would misbehave for programmatic callers making // multiple buildGraph calls in the same process. _lastAppliedGlobalPath = applied ? globalPath : null; _lastAppliedGlobalConfig = null; // updated below if a global file is loaded - const cached = _configCache.get(cacheKey); - if (cached) { - // Restore global config so loadConfigWithProvenance gets correct provenance on cache hits. - _lastAppliedGlobalConfig = _globalConfigCache.get(cacheKey) ?? null; - return structuredClone(cached); - } + + const cached = getCachedConfig(cacheKey); + if (cached) return cached; // ── Layer 0: DEFAULTS ───────────────────────────────────────────── // Deep-clone so later layers (mergeConfig / applyExcludeTestsShorthand / @@ -669,36 +787,10 @@ export function loadConfig(cwd?: string, opts?: LoadConfigOpts): CodegraphConfig let merged = structuredClone(DEFAULTS) as unknown as Record; // ── Layer 1: global (if applied) ────────────────────────────────── - if (applied && globalPath) { - const userFileData = loadUserConfigFile(globalPath); - if (userFileData) { - debug(`Applying global user config from ${globalPath}`); - const sanitized = sanitizeUserLayer(userFileData.globalConfig); - // Cache the sanitized global data so pipeline.ts and loadConfigWithProvenance - // can use it without a second disk read (eliminates TOCTOU window). - _lastAppliedGlobalConfig = sanitized; - merged = mergeConfig(merged, sanitized); - merged = applyExcludeTestsShorthand(merged, sanitized); - } - } + merged = applyGlobalConfigLayer(merged, applied, globalPath); // ── Layer 2: project ────────────────────────────────────────────── - for (const name of CONFIG_FILES) { - const filePath = path.join(cwd, name); - if (fs.existsSync(filePath)) { - try { - const raw = fs.readFileSync(filePath, 'utf-8'); - const projectConfig = JSON.parse(raw) as Record; - debug(`Loaded project config from ${filePath}`); - merged = mergeConfig(merged, projectConfig); - merged = applyExcludeTestsShorthand(merged, projectConfig); - break; - } catch (err: unknown) { - if (err instanceof ConfigError) throw err; - debug(`Failed to parse config ${filePath}: ${toErrorMessage(err)}`); - } - } - } + merged = applyProjectConfigLayer(merged, cwd); // ── Layers 3–4: env overrides + secret resolution ───────────────── const result = resolveSecrets(applyEnvOverrides(merged as unknown as CodegraphConfig)); @@ -718,42 +810,34 @@ export function clearConfigCache(): void { } /** - * Load config and return it together with per-key provenance information. - * Used by `codegraph config --explain`. - * - * Calls loadConfig first so _lastAppliedGlobalConfig is populated, then uses - * that cached data for the global-layer provenance — avoiding a second disk - * read and eliminating the TOCTOU window between the two reads. + * Layer 0 provenance: every top-level key starts attributed to 'default'. */ -export function loadConfigWithProvenance( - cwd?: string, - opts?: LoadConfigOpts, -): import('../types.js').ConfigWithProvenance { - cwd = path.resolve(cwd || process.cwd()); - const override = opts?.userConfig !== undefined ? opts.userConfig : _userConfigOverride; - const { applied, globalPath, consentDecision } = resolveConsent( - cwd, - override, - opts?.registryPath, - ); - - // Load (or return from cache) the merged config first — this also populates - // _lastAppliedGlobalConfig with the already-parsed and sanitized global layer. - const config = loadConfig(cwd, opts); - - // Build provenance by tracking which layer supplies each top-level key +function initDefaultProvenance(): Record { const provenance: Record = {}; - - // Layer 0: defaults — everything starts as 'default' for (const k of Object.keys(DEFAULTS)) provenance[k] = 'default'; + return provenance; +} - // Layer 1: global — reuse the data loadConfig already parsed (no second disk read) +/** + * Layer 1 provenance: attribute keys supplied by the global user config, + * reusing the data loadConfig already parsed into _lastAppliedGlobalConfig + * (no second disk read). + */ +function applyGlobalProvenance( + provenance: Record, + applied: boolean, + globalPath: string | null, +): void { const globalRaw = applied && globalPath ? _lastAppliedGlobalConfig : null; - if (globalRaw) { - for (const k of Object.keys(globalRaw)) provenance[k] = 'user'; - } + if (!globalRaw) return; + for (const k of Object.keys(globalRaw)) provenance[k] = 'user'; +} - // Layer 2: project +/** + * Layer 2 provenance: attribute keys supplied by the first matching project + * config file (mirrors applyProjectConfigLayer's file resolution order). + */ +function applyProjectProvenance(provenance: Record, cwd: string): void { for (const name of CONFIG_FILES) { const filePath = path.join(cwd, name); if (fs.existsSync(filePath)) { @@ -766,8 +850,13 @@ export function loadConfigWithProvenance( } } } +} - // Layer 3+: env overrides (LLM keys) +/** + * Layer 3+ provenance: mark `llm` as env-sourced if any CODEGRAPH_LLM_* + * override variable is set. + */ +function applyEnvProvenance(provenance: Record): void { const ENV_LLM_KEYS = [ 'CODEGRAPH_LLM_PROVIDER', 'CODEGRAPH_LLM_API_KEY', @@ -777,6 +866,37 @@ export function loadConfigWithProvenance( if (ENV_LLM_KEYS.some((k) => process.env[k] !== undefined)) { provenance.llm = 'env'; } +} + +/** + * Load config and return it together with per-key provenance information. + * Used by `codegraph config --explain`. + * + * Calls loadConfig first so _lastAppliedGlobalConfig is populated, then uses + * that cached data for the global-layer provenance — avoiding a second disk + * read and eliminating the TOCTOU window between the two reads. + */ +export function loadConfigWithProvenance( + cwd?: string, + opts?: LoadConfigOpts, +): import('../types.js').ConfigWithProvenance { + cwd = path.resolve(cwd || process.cwd()); + const override = resolveEffectiveOverride(opts); + const { applied, globalPath, consentDecision } = resolveConsent( + cwd, + override, + opts?.registryPath, + ); + + // Load (or return from cache) the merged config first — this also populates + // _lastAppliedGlobalConfig with the already-parsed and sanitized global layer. + const config = loadConfig(cwd, opts); + + // Build provenance by tracking which layer supplies each top-level key + const provenance = initDefaultProvenance(); + applyGlobalProvenance(provenance, applied, globalPath); + applyProjectProvenance(provenance, cwd); + applyEnvProvenance(provenance); return { config, provenance, appliedGlobalPath: applied ? globalPath : null, consentDecision }; } @@ -816,19 +936,28 @@ export function applyEnvOverrides(config: CodegraphConfig): CodegraphConfig { return config; } -export function resolveSecrets(config: CodegraphConfig): CodegraphConfig { - const cmd = config.llm.apiKeyCommand; - if (cmd == null) return config; - if (typeof cmd !== 'string') { - const actual = Array.isArray(cmd) ? 'array' : typeof cmd; - throw new ConfigError( - `llm.apiKeyCommand must be a string (received ${actual}). ` + - 'The command is split on whitespace and executed without a shell. ' + - 'Example: "apiKeyCommand": "op read op://vault/openai/api-key"', - ); - } - if (cmd.trim() === '') return config; +/** + * Guard against a malformed llm.apiKeyCommand value (e.g. an array or number + * from hand-edited JSON). Throws a ConfigError with a descriptive message; + * no-ops when `cmd` is already a string. + */ +function assertApiKeyCommandIsString(cmd: string): void { + if (typeof cmd === 'string') return; + const actual = Array.isArray(cmd) ? 'array' : typeof cmd; + throw new ConfigError( + `llm.apiKeyCommand must be a string (received ${actual}). ` + + 'The command is split on whitespace and executed without a shell. ' + + 'Example: "apiKeyCommand": "op read op://vault/openai/api-key"', + ); +} +/** + * Execute the configured apiKeyCommand (split on whitespace, no shell) and + * return its trimmed stdout, or null if the command produced no output. + * Failures are logged via warn() and swallowed — a broken secret command + * must not abort config loading. + */ +function runApiKeyCommand(cmd: string, config: CodegraphConfig): string | null { const parts = cmd.trim().split(/\s+/); const [executable, ...args] = parts; try { @@ -838,11 +967,22 @@ export function resolveSecrets(config: CodegraphConfig): CodegraphConfig { maxBuffer: config.llm.apiKeyCommandMaxBufferBytes, stdio: ['ignore', 'pipe', 'pipe'], }).trim(); - if (result) { - (config.llm as Record).apiKey = result; - } + return result || null; } catch (err: unknown) { warn(`apiKeyCommand failed: ${toErrorMessage(err)}`); + return null; + } +} + +export function resolveSecrets(config: CodegraphConfig): CodegraphConfig { + const cmd = config.llm.apiKeyCommand; + if (cmd == null) return config; + assertApiKeyCommandIsString(cmd); + if (cmd.trim() === '') return config; + + const result = runApiKeyCommand(cmd, config); + if (result) { + (config.llm as Record).apiKey = result; } return config; } From 07381713b70ada41532f68fde15c4475868701bf Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Mon, 6 Jul 2026 00:22:31 -0600 Subject: [PATCH 24/67] refactor: decompose findDbPath and openRepo in db/connection.ts (docs check acknowledged) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract-method refactor only, no behavior change. Both functions exceeded the halstead.effort FAIL threshold (>15000) and were flagged WARN on cognitive/cyclomatic complexity. findDbPath: extracted resolveCustomDbPath (--db directory/`.codegraph` handling), resolveDbSearchCeiling and resolveDbSearchStartDir (realpathSync normalization), and walkUpForDbPath (the parent-directory walk with git ceiling/no-ceiling stop conditions). openRepo: extracted wrapInjectedRepo (opts.repo validation), tryOpenRepoNative (native rusqlite attempt with busy/locked re-throw and fallback-on-failure), and openRepoSqliteFallback (better-sqlite3 fallback construction). halstead.effort: findDbPath 30574.33 -> 1710.82, openRepo 15118.12 -> 2055.6. No newly extracted helper exceeds any threshold. Internal refactor only — no public API, CLI, or language-support changes, so README/CLAUDE.md/ROADMAP do not need updates. Fixes #1748 Impact: 11 functions changed, 159 affected --- src/db/connection.ts | 184 +++++++++++++++++++++++++++---------------- 1 file changed, 116 insertions(+), 68 deletions(-) diff --git a/src/db/connection.ts b/src/db/connection.ts index 56f23978d..92cc69010 100644 --- a/src/db/connection.ts +++ b/src/db/connection.ts @@ -263,55 +263,67 @@ export function closeDbPairDeferred(pair: LockedDatabasePair): void { closeDbDeferred(pair.db); } -export function findDbPath(customPath?: string): string { - if (customPath) { - const resolved = path.resolve(customPath); - // When a directory is passed (e.g. --db /path/to/repo), locate the DB - // inside it — matching the layout that `build` creates. - try { - if (fs.statSync(resolved).isDirectory()) { - // If the caller passed the .codegraph directory itself (e.g. --db /repo/.codegraph), - // use it directly to avoid double-appending .codegraph/.codegraph/graph.db. - if (path.basename(resolved) === '.codegraph') { - return path.join(resolved, 'graph.db'); - } - return path.join(resolved, '.codegraph', 'graph.db'); +/** + * Resolve an explicit `--db` path: when it points at a directory, locate the + * DB inside it — matching the layout that `build` creates. + */ +function resolveCustomDbPath(customPath: string): string { + const resolved = path.resolve(customPath); + try { + if (fs.statSync(resolved).isDirectory()) { + // If the caller passed the .codegraph directory itself (e.g. --db /repo/.codegraph), + // use it directly to avoid double-appending .codegraph/.codegraph/graph.db. + if (path.basename(resolved) === '.codegraph') { + return path.join(resolved, 'graph.db'); } - } catch { - // Path doesn't exist yet — return as-is (e.g. a future custom DB path). + return path.join(resolved, '.codegraph', 'graph.db'); } - return resolved; + } catch { + // Path doesn't exist yet — return as-is (e.g. a future custom DB path). } - const rawCeiling = findRepoRoot(); - // Normalize ceiling with realpathSync to resolve 8.3 short names (Windows - // RUNNER~1 → runneradmin) and symlinks (macOS /var → /private/var). - // findRepoRoot already applies realpathSync internally, but the git output - // may still contain short names on some Windows CI environments. - let ceiling: string | null; - if (rawCeiling) { - try { - ceiling = fs.realpathSync(rawCeiling); - } catch (e) { - debug(`realpathSync failed for ceiling "${rawCeiling}": ${toErrorMessage(e)}`); - ceiling = rawCeiling; - } - } else { - ceiling = null; + return resolved; +} + +/** + * Normalize the git ceiling with realpathSync to resolve 8.3 short names + * (Windows RUNNER~1 → runneradmin) and symlinks (macOS /var → /private/var). + * findRepoRoot already applies realpathSync internally, but the git output + * may still contain short names on some Windows CI environments. + */ +function resolveDbSearchCeiling(rawCeiling: string | null): string | null { + if (!rawCeiling) return null; + try { + return fs.realpathSync(rawCeiling); + } catch (e) { + debug(`realpathSync failed for ceiling "${rawCeiling}": ${toErrorMessage(e)}`); + return rawCeiling; } - // Resolve symlinks (e.g. macOS /var → /private/var) so dir matches ceiling from git - let dir: string; +} + +/** Resolve symlinks in cwd (e.g. macOS /var → /private/var) so dir matches ceiling from git. */ +function resolveDbSearchStartDir(): string { try { - dir = fs.realpathSync(process.cwd()); + return fs.realpathSync(process.cwd()); } catch (e) { debug(`realpathSync failed for cwd: ${toErrorMessage(e)}`); - dir = process.cwd(); + return process.cwd(); } +} + +/** + * Walk up from `startDir` toward `ceiling` looking for an existing + * .codegraph/graph.db. Returns the found path, or null if the walk reaches + * the ceiling (or, when there's no ceiling, after checking only `startDir`) + * without finding one. + */ +function walkUpForDbPath(startDir: string, ceiling: string | null): string | null { + let dir = startDir; while (true) { const candidate = path.join(dir, '.codegraph', 'graph.db'); if (fs.existsSync(candidate)) return candidate; if (ceiling && isSameDirectory(dir, ceiling)) { debug(`findDbPath: stopped at git ceiling ${ceiling}`); - break; + return null; } // Outside a git repo, cwd is the first (and only) directory we'll check. // Walking past it risks attaching to a stale .codegraph/ in an unrelated @@ -319,12 +331,22 @@ export function findDbPath(customPath?: string): string { // or $HOME/.codegraph/ leaking into every scratch dir under $HOME. if (!ceiling) { debug(`findDbPath: no git ceiling, stopping at ${dir}`); - break; + return null; } const parent = path.dirname(dir); - if (parent === dir) break; + if (parent === dir) return null; dir = parent; } +} + +export function findDbPath(customPath?: string): string { + if (customPath) { + return resolveCustomDbPath(customPath); + } + const ceiling = resolveDbSearchCeiling(findRepoRoot()); + const startDir = resolveDbSearchStartDir(); + const found = walkUpForDbPath(startDir, ceiling); + if (found) return found; const base = ceiling || process.cwd(); return path.join(base, '.codegraph', 'graph.db'); } @@ -416,6 +438,58 @@ function openRepoNative(customDbPath?: string): { repo: Repository; close(): voi } } +/** Validate and wrap an injected `opts.repo` Repository instance (no DB opened). */ +function wrapInjectedRepo(repo: Repository): { repo: Repository; close(): void } { + if (!(repo instanceof Repository)) { + throw new TypeError( + `openRepo: opts.repo must be a Repository instance, got ${Object.prototype.toString.call(repo)}`, + ); + } + return { repo, close() {} }; +} + +/** + * Attempt the native rusqlite path (Phase 6.14) when the resolved engine + * allows it. Re-throws user-visible errors (DB not found, busy/locked) since + * falling back to better-sqlite3 wouldn't help; returns undefined for other + * native failures so the caller falls back to better-sqlite3. + */ +function tryOpenRepoNative( + customDbPath: string | undefined, + engine: 'native' | 'wasm' | 'auto', +): { repo: Repository; close(): void } | undefined { + if (engine === 'wasm' || !isNativeAvailable()) return undefined; + try { + return openRepoNative(customDbPath); + } catch (e) { + // Re-throw user-visible errors (e.g. DB not found) — only silently + // fall back for native-engine failures (e.g. incompatible native binary). + if (e instanceof DbError) throw e; + // Re-throw locking/busy errors — falling back to better-sqlite3 would + // hit the same contention (and potentially hang without busy_timeout). + const msg = toErrorMessage(e); + if (/\b(busy|locked|SQLITE_BUSY|SQLITE_LOCKED)\b/i.test(msg)) { + throw new DbError(`Database is busy (another process may be writing): ${msg}`, {}); + } + debug(`openRepo: native path failed, falling back to better-sqlite3: ${msg}`); + return undefined; + } +} + +/** Open the better-sqlite3 fallback repo used when the native path is unavailable or opted out. */ +function openRepoSqliteFallback( + customDbPath: string | undefined, + busyTimeoutMs: number, +): { repo: Repository; close(): void } { + const db = openReadonlyOrFail(customDbPath, busyTimeoutMs); + return { + repo: new SqliteRepository(db), + close() { + db.close(); + }, + }; +} + /** * Open a Repository from either an injected instance or a DB path. * @@ -429,43 +503,17 @@ export function openRepo( opts: { repo?: Repository; engine?: 'native' | 'wasm' | 'auto' } = {}, ): { repo: Repository; close(): void } { if (opts.repo != null) { - if (!(opts.repo instanceof Repository)) { - throw new TypeError( - `openRepo: opts.repo must be a Repository instance, got ${Object.prototype.toString.call(opts.repo)}`, - ); - } - return { repo: opts.repo, close() {} }; + return wrapInjectedRepo(opts.repo); } // Respect explicit engine selection: opts.engine > config.build.engine > auto. // This ensures --engine wasm and benchmark workers bypass the native path. const { engine, busyTimeoutMs } = resolveDbSettings(customDbPath, opts.engine); - // Try native rusqlite path first (Phase 6.14) - if (engine !== 'wasm' && isNativeAvailable()) { - try { - return openRepoNative(customDbPath); - } catch (e) { - // Re-throw user-visible errors (e.g. DB not found) — only silently - // fall back for native-engine failures (e.g. incompatible native binary). - if (e instanceof DbError) throw e; - // Re-throw locking/busy errors — falling back to better-sqlite3 would - // hit the same contention (and potentially hang without busy_timeout). - const msg = toErrorMessage(e); - if (/\b(busy|locked|SQLITE_BUSY|SQLITE_LOCKED)\b/i.test(msg)) { - throw new DbError(`Database is busy (another process may be writing): ${msg}`, {}); - } - debug(`openRepo: native path failed, falling back to better-sqlite3: ${msg}`); - } - } + const native = tryOpenRepoNative(customDbPath, engine); + if (native) return native; - const db = openReadonlyOrFail(customDbPath, busyTimeoutMs); - return { - repo: new SqliteRepository(db), - close() { - db.close(); - }, - }; + return openRepoSqliteFallback(customDbPath, busyTimeoutMs); } /** From 6f2423c52d27a9d8f13ca170dd474ccd5c9023f1 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Mon, 6 Jul 2026 00:28:15 -0600 Subject: [PATCH 25/67] refactor: dedupe busy/locked error detection into isBusyOrLockedError Extracts the /\b(busy|locked|SQLITE_BUSY|SQLITE_LOCKED)\b/i regex check into a single isBusyOrLockedError(msg) helper, replacing the identical inline check duplicated between openRepo's native path (tryOpenRepoNative) and openReadonlyWithNative's native path. No change to matching behavior or the busy_timeout value. DEFAULTS.db.busyTimeoutMs (this issue's other flagged cleanup) was already added and wired into openDb/openReadonlyOrFail by an earlier commit in this stack (8f23020d); this commit covers the remaining regex duplication only. docs check acknowledged: internal dedup-only refactor, no CLI surface, language support, or documented architecture/design decision changed. Fixes #1749 Impact: 3 functions changed, 27 affected --- src/db/connection.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/db/connection.ts b/src/db/connection.ts index 92cc69010..a4ec10d3a 100644 --- a/src/db/connection.ts +++ b/src/db/connection.ts @@ -438,6 +438,16 @@ function openRepoNative(customDbPath?: string): { repo: Repository; close(): voi } } +/** + * True when an error message indicates the SQLite database is busy or locked + * (SQLITE_BUSY/SQLITE_LOCKED). Shared by openRepo()'s and + * openReadonlyWithNative()'s native-path catch blocks so the two call sites + * can't drift. + */ +function isBusyOrLockedError(msg: string): boolean { + return /\b(busy|locked|SQLITE_BUSY|SQLITE_LOCKED)\b/i.test(msg); +} + /** Validate and wrap an injected `opts.repo` Repository instance (no DB opened). */ function wrapInjectedRepo(repo: Repository): { repo: Repository; close(): void } { if (!(repo instanceof Repository)) { @@ -468,7 +478,7 @@ function tryOpenRepoNative( // Re-throw locking/busy errors — falling back to better-sqlite3 would // hit the same contention (and potentially hang without busy_timeout). const msg = toErrorMessage(e); - if (/\b(busy|locked|SQLITE_BUSY|SQLITE_LOCKED)\b/i.test(msg)) { + if (isBusyOrLockedError(msg)) { throw new DbError(`Database is busy (another process may be writing): ${msg}`, {}); } debug(`openRepo: native path failed, falling back to better-sqlite3: ${msg}`); @@ -553,7 +563,7 @@ export function openReadonlyWithNative( nativeDb = native.NativeDatabase.openReadonly(dbPath); } catch (e) { const msg = toErrorMessage(e); - if (/\b(busy|locked|SQLITE_BUSY|SQLITE_LOCKED)\b/i.test(msg)) { + if (isBusyOrLockedError(msg)) { debug(`openReadonlyWithNative: native path busy, skipping native DB: ${msg}`); } else { debug(`openReadonlyWithNative: native path failed: ${msg}`); From 4c02378e895c0532e559b2070d60971e5791a45d Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Mon, 6 Jul 2026 00:31:33 -0600 Subject: [PATCH 26/67] fix: log statSync failures in findDbPath instead of silently swallowing them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The catch block in resolveCustomDbPath (part of the findDbPath flow) caught all fs.statSync errors — including unexpected ones like EACCES or symlink loops — indistinguishably from the expected "path doesn't exist yet" case, leaving no diagnostic trail. Add a debug() call matching this file's existing convention (18 other catch blocks already do this). Fixes #1750 Impact: 1 functions changed, 68 affected --- src/db/connection.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/db/connection.ts b/src/db/connection.ts index a4ec10d3a..8933c0628 100644 --- a/src/db/connection.ts +++ b/src/db/connection.ts @@ -278,8 +278,11 @@ function resolveCustomDbPath(customPath: string): string { } return path.join(resolved, '.codegraph', 'graph.db'); } - } catch { + } catch (e) { // Path doesn't exist yet — return as-is (e.g. a future custom DB path). + debug( + `findDbPath: statSync failed for ${resolved}, treating as non-existent: ${toErrorMessage(e)}`, + ); } return resolved; } From 0698e16bc58c71c24f42bca34804cd085edbb8df Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Mon, 6 Jul 2026 00:45:28 -0600 Subject: [PATCH 27/67] test: add direct unit coverage for closeDbPair/closeDbPairDeferred/closeDbDeferred MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit closeDbPair, closeDbPairDeferred, and closeDbDeferred in src/db/connection.ts had zero direct test coverage despite being core resource-lifecycle primitives in a fanIn-55 file — the same category of gap where the openReadonlyWithNative leak went undetected (see openReadonlyWithNative-leak.test.ts). Adds three describe blocks to tests/unit/db.test.ts, alongside the existing openDb/closeDb coverage: - closeDbPair: native handle closes before the better-sqlite3 handle; a native close failure doesn't prevent the better-sqlite3 close; works with no native handle present. - closeDbPairDeferred: native closes synchronously within the call, while the better-sqlite3 close is deferred via closeDbDeferred (including when the native close throws). - closeDbDeferred: the advisory lock releases synchronously (verified via a real lock file), the handle itself closes on the next tick, and flushDeferredClose() closes it synchronously when called first — with the originally scheduled callback correctly skipping a second close. Fixes #1751 --- tests/unit/db.test.ts | 180 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 179 insertions(+), 1 deletion(-) diff --git a/tests/unit/db.test.ts b/tests/unit/db.test.ts index 65628e2ea..abd5895f7 100644 --- a/tests/unit/db.test.ts +++ b/tests/unit/db.test.ts @@ -9,7 +9,7 @@ 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, vi } from 'vitest'; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; const execFileSyncSpy = vi.hoisted(() => vi.fn()); @@ -20,10 +20,16 @@ vi.mock('node:child_process', async (importOriginal) => { }); import { _resetRepoRootCache } from '../../src/db/connection.js'; +import type { LockedDatabase } from '../../src/db/index.js'; import { + acquireAdvisoryLock, closeDb, + closeDbDeferred, + closeDbPair, + closeDbPairDeferred, findDbPath, findRepoRoot, + flushDeferredClose, getBuildMeta, initSchema, MIGRATIONS, @@ -31,6 +37,7 @@ import { openReadonlyOrFail, setBuildMeta, } from '../../src/db/index.js'; +import type { NativeDatabase } from '../../src/types.js'; let tmpDir: string; @@ -126,6 +133,177 @@ describe('openDb', () => { }); }); +// ── closeDbPair / closeDbPairDeferred / closeDbDeferred (#1751) ──────────── +// These pair-close helpers only ever call `.close()` on whatever db/nativeDb +// handles they're given — they never open a DB themselves — so lightweight +// mock handles are enough to exercise close ordering and deferral timing. +// (Contrast with openReadonlyWithNative-leak.test.ts, which needs a real +// handle because that regression is about *opening*, not closing.) + +/** Resolves once the event loop has drained the `setImmediate` queue, i.e. + * after any close scheduled via `closeDbDeferred`'s own `setImmediate`. */ +function tick(): Promise { + return new Promise((resolve) => setImmediate(resolve)); +} + +function makeFakeDb(lockPath?: string): { + db: LockedDatabase; + close: ReturnType; +} { + const close = vi.fn(); + const db = { close, __lockPath: lockPath } as unknown as LockedDatabase; + return { db, close }; +} + +function makeFakeNativeDb(): { nativeDb: NativeDatabase; close: ReturnType } { + const close = vi.fn(); + const nativeDb = { close } as unknown as NativeDatabase; + return { nativeDb, close }; +} + +describe('closeDbPair', () => { + it('closes the native handle before the better-sqlite3 handle', () => { + const order: string[] = []; + const { db, close: dbClose } = makeFakeDb(); + const { nativeDb, close: nativeClose } = makeFakeNativeDb(); + dbClose.mockImplementation(() => order.push('db')); + nativeClose.mockImplementation(() => order.push('native')); + + closeDbPair({ db, nativeDb }); + + expect(order).toEqual(['native', 'db']); + }); + + it('still closes the better-sqlite3 handle when the native close throws', () => { + const { db, close: dbClose } = makeFakeDb(); + const { nativeDb, close: nativeClose } = makeFakeNativeDb(); + nativeClose.mockImplementation(() => { + throw new Error('native close boom'); + }); + + expect(() => closeDbPair({ db, nativeDb })).not.toThrow(); + + expect(nativeClose).toHaveBeenCalledTimes(1); + expect(dbClose).toHaveBeenCalledTimes(1); + }); + + it('closes the better-sqlite3 handle when there is no native handle', () => { + const { db, close: dbClose } = makeFakeDb(); + + closeDbPair({ db }); + + expect(dbClose).toHaveBeenCalledTimes(1); + }); +}); + +describe('closeDbPairDeferred', () => { + // Drain anything left in connection.ts's module-private deferred-close + // queue so state can't leak into a later test. + afterEach(async () => { + flushDeferredClose(); + await tick(); + }); + + it('closes the native handle immediately, synchronously within the call', () => { + const { db } = makeFakeDb(); + const { nativeDb, close: nativeClose } = makeFakeNativeDb(); + + closeDbPairDeferred({ db, nativeDb }); + + expect(nativeClose).toHaveBeenCalledTimes(1); + }); + + it('defers the better-sqlite3 close to the next tick instead of closing synchronously', async () => { + const { db, close: dbClose } = makeFakeDb(); + const { nativeDb } = makeFakeNativeDb(); + + closeDbPairDeferred({ db, nativeDb }); + expect(dbClose).not.toHaveBeenCalled(); + + await tick(); + + expect(dbClose).toHaveBeenCalledTimes(1); + }); + + it('still defers the better-sqlite3 close when the native close throws', async () => { + const { db, close: dbClose } = makeFakeDb(); + const { nativeDb, close: nativeClose } = makeFakeNativeDb(); + nativeClose.mockImplementation(() => { + throw new Error('native close boom'); + }); + + expect(() => closeDbPairDeferred({ db, nativeDb })).not.toThrow(); + expect(dbClose).not.toHaveBeenCalled(); + + await tick(); + + expect(dbClose).toHaveBeenCalledTimes(1); + }); +}); + +describe('closeDbDeferred', () => { + afterEach(async () => { + flushDeferredClose(); + await tick(); + }); + + it('releases the advisory lock synchronously while deferring the actual handle close', async () => { + const dbPath = path.join(tmpDir, 'deferred-close-lock.db'); + acquireAdvisoryLock(dbPath); + const lockPath = `${dbPath}.lock`; + expect(fs.existsSync(lockPath)).toBe(true); + + const { db, close } = makeFakeDb(lockPath); + + closeDbDeferred(db); + + // Lock release + clearing __lockPath happen synchronously, within the call. + expect(fs.existsSync(lockPath)).toBe(false); + expect(db.__lockPath).toBeUndefined(); + // The handle itself is not closed yet — that's deferred to the next tick. + expect(close).not.toHaveBeenCalled(); + + await tick(); + + expect(close).toHaveBeenCalledTimes(1); + }); + + it('closes the handle on the next tick when nothing flushes it early', async () => { + const { db, close } = makeFakeDb(); + + closeDbDeferred(db); + expect(close).not.toHaveBeenCalled(); + + await tick(); + + expect(close).toHaveBeenCalledTimes(1); + }); + + it('flushDeferredClose() closes the handle synchronously when called before the next tick', async () => { + const { db, close } = makeFakeDb(); + + closeDbDeferred(db); + expect(close).not.toHaveBeenCalled(); + + flushDeferredClose(); + expect(close).toHaveBeenCalledTimes(1); + + // The setImmediate callback scheduled by closeDbDeferred must notice the + // handle was already removed + closed by the flush, and must not close + // it a second time. + await tick(); + + expect(close).toHaveBeenCalledTimes(1); + }); + + it('does not attempt a lock release when the handle has no advisory lock path', () => { + const { db } = makeFakeDb(); + + expect(() => closeDbDeferred(db)).not.toThrow(); + expect(db.__lockPath).toBeUndefined(); + }); +}); + describe('findDbPath', () => { it('returns resolved custom path when provided', () => { const custom = path.join(tmpDir, 'custom.db'); From 4ac3b991d4b9460fde7bafb8699c4801bc2e71e1 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Mon, 6 Jul 2026 01:41:23 -0600 Subject: [PATCH 28/67] fix: correct blast-radius/fn-impact computation for line-shifted declarations on incremental rebuild Root cause: reconnectReverseDepEdges (build-edges.ts, WASM/JS engine) and its native mirror reconnect_reverse_dep_edges (crates/codegraph-core's detect_changes.rs) re-attach a reverse-dependency caller's edge to its purged-and-reinserted target using only (name, kind, file) plus "nearest to the old line" as a tiebreak. When a file contains multiple distinct symbols sharing the same name and kind -- e.g. several object-literal close() {} methods returned from different functions in the same file, a pattern this repo's own src/db/connection.ts uses four times -- nearest- line is not a reliable way to tell them apart: once unrelated code shifts the whole same-named group, an old reference line can end up numerically closer to a different sibling's new line than to its own, silently re-attaching the edge to the wrong symbol (and collapsing distinct edges together via INSERT OR IGNORE while leaving another candidate untargeted). A full rebuild is immune because it re-resolves every call from scratch using real call-site information, not line proximity. Root-caused by replaying this repo's own last 35 real commits as a sequence of incremental builds and diffing the result against a full rebuild of the identical final source: node tables came out byte- identical, but 5 reverse-dep callers ended up wired to close@line 433 instead of the correct close@line 580. Fixed by recording each target's 1-based ordinal rank (by line) among its same-(name,kind) siblings at save time, and using that ordinal -- not line proximity -- to re-select the correct candidate after purge+reinsert, falling back to nearest-line only when the sibling count itself changed since save (a genuinely ambiguous case). Changes: - src/domain/graph/builder/context.ts: extend savedReverseDepEdges with tgtOrdinal/tgtSiblingCount. - src/domain/graph/builder/stages/detect-changes.ts: compute each target's ordinal/sibling-count before purge (computeNodeOrdinals). - src/domain/graph/builder/stages/build-edges.ts: reconnect using the saved ordinal instead of nearest-line (pickReconnectTarget). - crates/codegraph-core/.../detect_changes.rs: identical fix on the native engine (compute_ordinals, pick_reconnect_target), plus Rust unit/integration tests covering the ordinal match, the sibling-count- changed fallback, and a full save/purge/reconnect round trip. Native engine: fixed in source and manually verified by full read- through (borrow-checker/type correctness), but NOT compiled or run via cargo test in this session -- the environment's disk ran critically low (shared machine, other concurrent sessions) and a from-scratch napi/ cargo build for this crate was not safe to attempt. The TS-side fix is complete, tested (full suite green), and independently verified via a controlled A/B swap against the pre-fix code reproducing the exact divergence this fix resolves. A separate, pre-existing bug was found and filed independently (optave/ops-codegraph-tool#1863): resolveByGlobal's receiver-less call resolution matches every same-named candidate clearing a loose directory-proximity confidence threshold and creates an edge to each of them, rather than picking the best match. That bug reproduces on a from-scratch full build too (not incremental-specific) and is out of scope for this fix. docs check acknowledged: internal correctness fix to incremental-build edge reconnection; no CLI surface, language support, or documented architecture/design decision changed. Fixes #1752 Impact: 5 functions changed, 12 affected --- .../graph/builder/stages/detect_changes.rs | 303 +++++++++++++++++- src/domain/graph/builder/context.ts | 10 + .../graph/builder/stages/build-edges.ts | 77 ++++- .../graph/builder/stages/detect-changes.ts | 50 +++ ...1752-reverse-dep-sibling-reconnect.test.ts | 235 ++++++++++++++ 5 files changed, 652 insertions(+), 23 deletions(-) create mode 100644 tests/integration/issue-1752-reverse-dep-sibling-reconnect.test.ts diff --git a/crates/codegraph-core/src/domain/graph/builder/stages/detect_changes.rs b/crates/codegraph-core/src/domain/graph/builder/stages/detect_changes.rs index f600ab191..907e6ab8a 100644 --- a/crates/codegraph-core/src/domain/graph/builder/stages/detect_changes.rs +++ b/crates/codegraph-core/src/domain/graph/builder/stages/detect_changes.rs @@ -369,6 +369,54 @@ pub struct SavedReverseDepEdge { pub edge_kind: String, pub confidence: f64, pub dynamic: i64, + /// 1-based rank of the target (by ascending line) among nodes sharing its + /// (name, kind) within `tgt_file`, computed at save time — see #1752. + pub tgt_ordinal: i64, + /// Size of that (name, kind) sibling group at save time. + pub tgt_sibling_count: i64, +} + +/// Computes each node's 1-based ordinal rank (by ascending line) among nodes +/// sharing its (name, kind) within `file`, plus the sibling-group size, +/// keyed by `(name, kind, line)`. +/// +/// A file can contain multiple distinct symbols with the identical name and +/// kind — e.g. several object-literal `close() {}` methods returned from +/// different functions in the same file. `(name, kind, file)` alone is not a +/// unique identity for such symbols, so `reconnect_reverse_dep_edges` cannot +/// safely tell them apart by nearest-line matching once unrelated code +/// shifts the candidates unevenly (#1752). The ordinal recorded here — the +/// target's rank among same-named siblings at save time — lets reconnection +/// map an old target to its new node correctly as long as the sibling count +/// is unchanged, regardless of how far the whole group has shifted. +fn compute_ordinals(conn: &Connection, file: &str) -> HashMap<(String, String, i64), (i64, i64)> { + let mut by_group: HashMap<(String, String), Vec> = HashMap::new(); + let mut result = HashMap::new(); + let mut stmt = match conn.prepare("SELECT name, kind, line FROM nodes WHERE file = ?1") { + Ok(s) => s, + Err(_) => return result, + }; + let rows = match stmt.query_map([file], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, i64>(2)?, + )) + }) { + Ok(r) => r, + Err(_) => return result, + }; + for row in rows.flatten() { + by_group.entry((row.0, row.1)).or_default().push(row.2); + } + for ((name, kind), mut lines) in by_group { + lines.sort_unstable(); + let sibling_count = lines.len() as i64; + for (idx, line) in lines.iter().enumerate() { + result.insert((name.clone(), kind.clone(), *line), (idx as i64 + 1, sibling_count)); + } + } + result } /// Save edges from reverse-dep files → changed files BEFORE purge so they @@ -403,6 +451,10 @@ pub fn save_reverse_dep_edges( }; for changed in changed_paths { + // Must be computed BEFORE this file's nodes are purged — captures the + // pre-purge sibling layout so reconnection can map old→new correctly + // even when several same-named/same-kind symbols exist in the file. + let ordinals = compute_ordinals(conn, changed); let rows = match stmt.query_map([changed], |row| { Ok(( row.get::<_, i64>(0)?, @@ -425,6 +477,10 @@ pub fn save_reverse_dep_edges( if changed_set.contains(row.8.as_str()) { continue; } + let (tgt_ordinal, tgt_sibling_count) = ordinals + .get(&(row.1.clone(), row.2.clone(), row.4)) + .copied() + .unwrap_or((1, 1)); saved.push(SavedReverseDepEdge { source_id: row.0, tgt_name: row.1, @@ -434,18 +490,56 @@ pub fn save_reverse_dep_edges( edge_kind: row.5, confidence: row.6, dynamic: row.7, + tgt_ordinal, + tgt_sibling_count, }); } } saved } +/// Picks the correct reconnect target among same-(name,kind,file) candidates +/// (sorted by ascending line). +/// +/// When only one candidate exists, it's an unambiguous match. When several +/// exist (e.g. multiple object-literal `close() {}` methods in one file) and +/// the sibling-group size is unchanged since save, the saved ordinal — the +/// target's rank by line among its siblings at save time — reliably +/// identifies the original target even though the whole group may have +/// shifted by an arbitrary number of lines. Falls back to nearest-line only +/// when the sibling count itself changed (a same-named sibling was added or +/// removed), since the ordinal mapping can no longer be trusted — see #1752. +fn pick_reconnect_target( + candidates: &[(i64, i64)], + tgt_ordinal: i64, + tgt_sibling_count: i64, + tgt_line: i64, +) -> Option { + if candidates.is_empty() { + return None; + } + if candidates.len() == 1 { + return Some(candidates[0].0); + } + if candidates.len() as i64 == tgt_sibling_count + && tgt_ordinal >= 1 + && (tgt_ordinal as usize) <= candidates.len() + { + return Some(candidates[(tgt_ordinal - 1) as usize].0); + } + candidates + .iter() + .min_by_key(|(_, line)| (line - tgt_line).abs()) + .map(|(id, _)| *id) +} + /// Reconnect saved reverse-dep edges to the new target node IDs. /// /// The source node ID is still valid (reverse-dep nodes were never purged). -/// The target was deleted and re-inserted with a new ID — look it up by -/// (name, kind, file) using nearest-line matching, and recreate the edge. -/// Mirrors `reconnectReverseDepEdges` in `build-edges.ts`. +/// The target was deleted and re-inserted with a new ID — look up all +/// (name, kind, file) candidates and pick the one matching the saved ordinal +/// (see `pick_reconnect_target`), then recreate the edge. Mirrors +/// `reconnectReverseDepEdges` in `build-edges.ts`. /// /// Returns (reconnected, dropped) counts. pub fn reconnect_reverse_dep_edges( @@ -463,9 +557,8 @@ pub fn reconnect_reverse_dep_edges( let mut reconnected = 0usize; let mut dropped = 0usize; { - let mut find_stmt = match tx.prepare( - "SELECT id FROM nodes WHERE name = ?1 AND kind = ?2 AND file = ?3 \ - ORDER BY ABS(line - ?4) LIMIT 1", + let mut candidates_stmt = match tx.prepare( + "SELECT id, line FROM nodes WHERE name = ?1 AND kind = ?2 AND file = ?3 ORDER BY line", ) { Ok(s) => s, Err(_) => return (0, 0), @@ -477,12 +570,41 @@ pub fn reconnect_reverse_dep_edges( Ok(s) => s, Err(_) => return (0, 0), }; + + // Cache candidate lists per (name, kind, file) group — many saved + // edges often share the same target (e.g. several callers of the + // same function), so this avoids re-querying per edge. + let mut candidates_cache: HashMap<(String, String, String), Vec<(i64, i64)>> = + HashMap::new(); + for s in saved { - match find_stmt.query_row( - rusqlite::params![&s.tgt_name, &s.tgt_kind, &s.tgt_file, s.tgt_line], - |row| row.get::<_, i64>(0), + let key = (s.tgt_name.clone(), s.tgt_kind.clone(), s.tgt_file.clone()); + let candidates = match candidates_cache.entry(key) { + std::collections::hash_map::Entry::Occupied(e) => e.into_mut(), + std::collections::hash_map::Entry::Vacant(e) => { + let mut rows: Vec<(i64, i64)> = Vec::new(); + if let Ok(mut rows_iter) = candidates_stmt + .query(rusqlite::params![&s.tgt_name, &s.tgt_kind, &s.tgt_file]) + { + while let Ok(Some(row)) = rows_iter.next() { + if let (Ok(id), Ok(line)) = + (row.get::<_, i64>(0), row.get::<_, i64>(1)) + { + rows.push((id, line)); + } + } + } + e.insert(rows) + } + }; + + match pick_reconnect_target( + candidates, + s.tgt_ordinal, + s.tgt_sibling_count, + s.tgt_line, ) { - Ok(new_id) => { + Some(new_id) => { // INSERT OR IGNORE silently swallows duplicate-row constraint // errors and returns Ok(0). Only count rows that actually // inserted so the diagnostic counter isn't inflated by no-ops. @@ -498,7 +620,7 @@ pub fn reconnect_reverse_dep_edges( Err(_) => dropped += 1, } } - Err(_) => { + None => { dropped += 1; } } @@ -828,4 +950,163 @@ mod tests { let removed = detect_removed_files(&existing, &all_files, "/project", None); assert_eq!(removed, vec!["src/deleted.ts"]); } + + // ── Reverse-dep edge reconnection (#1752) ─────────────────────────── + + #[test] + fn pick_reconnect_target_single_candidate_is_unambiguous() { + let candidates = vec![(42, 100)]; + assert_eq!(pick_reconnect_target(&candidates, 1, 1, 999), Some(42)); + } + + #[test] + fn pick_reconnect_target_no_candidates_returns_none() { + let candidates: Vec<(i64, i64)> = vec![]; + assert_eq!(pick_reconnect_target(&candidates, 1, 1, 100), None); + } + + #[test] + fn pick_reconnect_target_uses_ordinal_when_sibling_count_matches() { + // Four same-named/same-kind siblings (e.g. four `close() {}` methods), + // shifted down by an insertion elsewhere in the file. The 3rd-ranked + // sibling (originally closest to line 433 in the pre-shift layout) + // must still resolve to the 3rd-ranked sibling post-shift (id 30, + // line 580), NOT to whichever candidate is nearest to the stale old + // line 433 (which would wrongly pick id 10 / line 433... except that + // id no longer exists post-purge; the point is nearest-*new*-line + // to the OLD reference can pick the wrong post-shift sibling once the + // group shifts unevenly). + let candidates = vec![(10, 178), (20, 461), (30, 500), (40, 580)]; + // Saved ordinal=3 out of 4 siblings (matches count) → must pick the + // 3rd by line (id 30), regardless of how far tgt_line (the stale old + // reference, 433) now sits from any candidate. + let picked = pick_reconnect_target(&candidates, 3, 4, 433); + assert_eq!(picked, Some(30)); + } + + #[test] + fn pick_reconnect_target_falls_back_to_nearest_line_when_sibling_count_changed() { + // A sibling was added/removed since save — the ordinal mapping can no + // longer be trusted, so fall back to nearest-line (best effort, same + // as pre-#1752 behavior). + let candidates = vec![(10, 100), (20, 200), (30, 300)]; + // Saved sibling_count=2 but now there are 3 candidates → mismatch. + let picked = pick_reconnect_target(&candidates, 2, 2, 195); + assert_eq!(picked, Some(20)); // nearest to 195 is line 200 + } + + /// Minimal in-memory schema covering only the columns `save_reverse_dep_edges` + /// / `reconnect_reverse_dep_edges` touch — not the full production migration set. + fn test_conn() -> Connection { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch( + "CREATE TABLE nodes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + kind TEXT NOT NULL, + file TEXT NOT NULL, + line INTEGER, + UNIQUE(name, kind, file, line) + ); + CREATE TABLE edges ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source_id INTEGER NOT NULL, + target_id INTEGER NOT NULL, + kind TEXT NOT NULL, + confidence REAL DEFAULT 1.0, + dynamic INTEGER DEFAULT 0 + );", + ) + .unwrap(); + conn + } + + fn insert_node(conn: &Connection, name: &str, kind: &str, file: &str, line: i64) -> i64 { + conn.execute( + "INSERT INTO nodes (name, kind, file, line) VALUES (?1, ?2, ?3, ?4)", + rusqlite::params![name, kind, file, line], + ) + .unwrap(); + conn.last_insert_rowid() + } + + #[test] + fn compute_ordinals_ranks_same_name_kind_siblings_by_line() { + let conn = test_conn(); + insert_node(&conn, "close", "method", "src/db/connection.ts", 433); + insert_node(&conn, "close", "method", "src/db/connection.ts", 461); + insert_node(&conn, "close", "method", "src/db/connection.ts", 500); + insert_node(&conn, "close", "method", "src/db/connection.ts", 580); + // An unrelated, uniquely-named sibling must not pollute the group. + insert_node(&conn, "openDb", "function", "src/db/connection.ts", 161); + + let ordinals = compute_ordinals(&conn, "src/db/connection.ts"); + let key = |line: i64| ("close".to_string(), "method".to_string(), line); + assert_eq!(ordinals.get(&key(433)), Some(&(1, 4))); + assert_eq!(ordinals.get(&key(461)), Some(&(2, 4))); + assert_eq!(ordinals.get(&key(500)), Some(&(3, 4))); + assert_eq!(ordinals.get(&key(580)), Some(&(4, 4))); + assert_eq!( + ordinals.get(&("openDb".to_string(), "function".to_string(), 161)), + Some(&(1, 1)) + ); + } + + /// End-to-end reproduction of #1752: a reverse-dep caller's edge to the + /// 3rd of four same-named `close` siblings must survive a purge+reinsert + /// cycle that shifts all four down uniformly (inserting a new, unrelated + /// function above them — exactly the real repro: "insert N lines above + /// several existing functions"). A uniform shift is already enough to + /// break the old nearest-*old*-line heuristic: once the whole group moves + /// far enough, the saved reference line (500) ends up numerically closest + /// to the wrong (lowest) candidate's new line rather than its own — + /// mirroring the real bug's `close@433` vs `close@580` divergence found + /// by replaying this repo's actual commit history. + #[test] + fn reconnect_survives_uniform_shift_of_same_named_siblings() { + let conn = test_conn(); + let file = "src/db/connection.ts"; + + // Pre-edit layout: four `close` siblings. + insert_node(&conn, "close", "method", file, 433); + insert_node(&conn, "close", "method", file, 461); + let target_old_id = insert_node(&conn, "close", "method", file, 500); + insert_node(&conn, "close", "method", file, 580); + // External caller in a different (untouched) file. + let caller_id = insert_node(&conn, "triageData", "function", "src/features/triage.ts", 146); + conn.execute( + "INSERT INTO edges (source_id, target_id, kind, confidence, dynamic) VALUES (?1, ?2, 'calls', 0.5, 0)", + rusqlite::params![caller_id, target_old_id], + ) + .unwrap(); + + let saved = save_reverse_dep_edges(&conn, &[file.to_string()]); + assert_eq!(saved.len(), 1); + assert_eq!(saved[0].tgt_ordinal, 3); + assert_eq!(saved[0].tgt_sibling_count, 4); + + // Simulate purge_changed_files: delete the changed file's nodes/edges. + conn.execute("DELETE FROM edges WHERE target_id IN (SELECT id FROM nodes WHERE file = ?1)", [file]).unwrap(); + conn.execute("DELETE FROM nodes WHERE file = ?1", [file]).unwrap(); + + // Re-insert: a new function was added above all four, shifting every + // sibling down by the same delta (147 lines) — the exact shape of the + // real #1752 repro (insert one helper above several functions). + insert_node(&conn, "close", "method", file, 433 + 147); + insert_node(&conn, "close", "method", file, 461 + 147); + let target_new_id = insert_node(&conn, "close", "method", file, 500 + 147); + insert_node(&conn, "close", "method", file, 580 + 147); + + let (reconnected, dropped) = reconnect_reverse_dep_edges(&conn, &saved); + assert_eq!((reconnected, dropped), (1, 0)); + + let new_target: i64 = conn + .query_row( + "SELECT target_id FROM edges WHERE source_id = ?1", + [caller_id], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(new_target, target_new_id); + } } diff --git a/src/domain/graph/builder/context.ts b/src/domain/graph/builder/context.ts index 892e68101..94cf99f8a 100644 --- a/src/domain/graph/builder/context.ts +++ b/src/domain/graph/builder/context.ts @@ -87,6 +87,16 @@ export class PipelineContext { tgtKind: string; tgtFile: string; tgtLine: number; + /** + * 1-based rank of the target (by ascending line) among nodes sharing its + * (name, kind) within `tgtFile`, computed at save time. Lets + * `reconnectReverseDepEdges` map an old target to its correct new node + * even when multiple distinct symbols in the file share the same name + * and kind (e.g. several object-literal `close() {}` methods) — see #1752. + */ + tgtOrdinal: number; + /** Size of that (name, kind) sibling group at save time. */ + tgtSiblingCount: number; edgeKind: string; confidence: number; dynamic: number; diff --git a/src/domain/graph/builder/stages/build-edges.ts b/src/domain/graph/builder/stages/build-edges.ts index 33d8e100b..570121fde 100644 --- a/src/domain/graph/builder/stages/build-edges.ts +++ b/src/domain/graph/builder/stages/build-edges.ts @@ -1975,33 +1975,86 @@ function applyEdgeTechniquesAfterNativeInsert( // ── Reverse-dep edge reconnection (#932, #933) ───────────────────────── +/** + * Picks the correct reconnect target among same-(name,kind,file) candidates + * (sorted by ascending line). + * + * When only one candidate exists, it's an unambiguous match. When several + * exist (e.g. multiple object-literal `close() {}` methods in one file) and + * the sibling-group size is unchanged since save, the saved ordinal — the + * target's rank by line among its siblings at save time — reliably + * identifies the original target even though the whole group may have + * shifted by an arbitrary number of lines. Falls back to nearest-line only + * when the sibling count itself changed (a same-named sibling was added or + * removed), since the ordinal mapping can no longer be trusted — see #1752. + */ +function pickReconnectTarget( + candidates: Array<{ id: number; line: number }>, + tgtOrdinal: number, + tgtSiblingCount: number, + tgtLine: number, +): number | null { + if (candidates.length === 0) return null; + if (candidates.length === 1) return candidates[0]!.id; + if (candidates.length === tgtSiblingCount && tgtOrdinal >= 1 && tgtOrdinal <= candidates.length) { + return candidates[tgtOrdinal - 1]!.id; + } + let best = candidates[0]!; + let bestDist = Math.abs(best.line - tgtLine); + for (const c of candidates) { + const dist = Math.abs(c.line - tgtLine); + if (dist < bestDist) { + best = c; + bestDist = dist; + } + } + return best.id; +} + /** * Reconnect edges that were saved before changed-file purge. * * Each saved edge records: sourceId (still valid — reverse-dep nodes were not - * purged) and target attributes (name, kind, file, line). The target node was - * deleted and re-inserted with a new ID by insertNodes. We look up the new ID - * by (name, kind, file) and re-create the edge. + * purged) and target attributes (name, kind, file, line, ordinal, sibling + * count). The target node was deleted and re-inserted with a new ID by + * insertNodes. We look up all (name, kind, file) candidates and pick the one + * matching the saved ordinal (see `pickReconnectTarget`), then re-create the + * edge. */ function reconnectReverseDepEdges(ctx: PipelineContext): void { const { db } = ctx; - const findNodeStmt = db.prepare( - 'SELECT id FROM nodes WHERE name = ? AND kind = ? AND file = ? ORDER BY ABS(line - ?) LIMIT 1', + const candidatesStmt = db.prepare( + 'SELECT id, line FROM nodes WHERE name = ? AND kind = ? AND file = ? ORDER BY line', ); const reconnectedRows: EdgeRowTuple[] = []; let dropped = 0; + // Cache candidate lists per (name, kind, file) group — many saved edges + // often share the same target (e.g. several callers of the same + // function), so this avoids re-querying per edge. + const candidatesCache = new Map>(); + for (const saved of ctx.savedReverseDepEdges) { - const newTarget = findNodeStmt.get( - saved.tgtName, - saved.tgtKind, - saved.tgtFile, + const cacheKey = `${saved.tgtName}|${saved.tgtKind}|${saved.tgtFile}`; + let candidates = candidatesCache.get(cacheKey); + if (!candidates) { + candidates = candidatesStmt.all(saved.tgtName, saved.tgtKind, saved.tgtFile) as Array<{ + id: number; + line: number; + }>; + candidatesCache.set(cacheKey, candidates); + } + + const newId = pickReconnectTarget( + candidates, + saved.tgtOrdinal, + saved.tgtSiblingCount, saved.tgtLine, - ) as { id: number } | undefined; - if (newTarget) { + ); + if (newId != null) { reconnectedRows.push([ saved.sourceId, - newTarget.id, + newId, saved.edgeKind, saved.confidence, saved.dynamic, diff --git a/src/domain/graph/builder/stages/detect-changes.ts b/src/domain/graph/builder/stages/detect-changes.ts index c33f6c70c..f58155a9e 100644 --- a/src/domain/graph/builder/stages/detect-changes.ts +++ b/src/domain/graph/builder/stages/detect-changes.ts @@ -377,6 +377,47 @@ function findReverseDependencies( return reverseDeps; } +/** + * Computes each node's 1-based ordinal rank (by ascending line) among nodes + * sharing its (name, kind) within `file`, plus the sibling-group size, keyed + * by `name|kind|line`. + * + * A file can contain multiple distinct symbols with the identical name and + * kind — e.g. several object-literal `close() {}` methods returned from + * different functions in the same file. `(name, kind, file)` alone is not a + * unique identity for such symbols, so `reconnectReverseDepEdges` cannot + * safely tell them apart by nearest-line matching once unrelated code shifts + * the candidates unevenly (#1752). The ordinal recorded here — the target's + * rank among same-named siblings at save time — lets reconnection map an old + * target to its new node correctly as long as the sibling count is + * unchanged, regardless of how far the whole group has shifted. + */ +function computeNodeOrdinals( + db: BetterSqlite3Database, + file: string, +): Map { + const result = new Map(); + const rows = db.prepare('SELECT name, kind, line FROM nodes WHERE file = ?').all(file) as Array<{ + name: string; + kind: string; + line: number; + }>; + const groups = new Map(); + for (const row of rows) { + const groupKey = `${row.name}|${row.kind}`; + if (!groups.has(groupKey)) groups.set(groupKey, []); + groups.get(groupKey)!.push(row.line); + } + for (const [groupKey, lines] of groups) { + lines.sort((a, b) => a - b); + const siblingCount = lines.length; + lines.forEach((line, idx) => { + result.set(`${groupKey}|${line}`, { ordinal: idx + 1, siblingCount }); + }); + } + return result; +} + /** * Reconnects reverse-dep files to the changed files they depend on. * @@ -422,6 +463,10 @@ function addReverseDeps( WHERE n_tgt.file = ? AND n_src.file != n_tgt.file `); for (const changedPath of changePaths) { + // Must be computed BEFORE this file's nodes are purged — captures the + // pre-purge sibling layout so reconnection can map old→new correctly + // even when several same-named/same-kind symbols exist in the file. + const ordinals = computeNodeOrdinals(db, changedPath); for (const row of saveEdgesStmt.all(changedPath) as Array<{ source_id: number; tgt_name: string; @@ -438,12 +483,17 @@ function addReverseDeps( // Skip edges whose source is also being purged — buildEdges will // re-create them with correct new IDs. if (changePathSet.has(row.src_file)) continue; + const { ordinal, siblingCount } = ordinals.get( + `${row.tgt_name}|${row.tgt_kind}|${row.tgt_line}`, + ) ?? { ordinal: 1, siblingCount: 1 }; ctx.savedReverseDepEdges.push({ sourceId: row.source_id, tgtName: row.tgt_name, tgtKind: row.tgt_kind, tgtFile: row.tgt_file, tgtLine: row.tgt_line, + tgtOrdinal: ordinal, + tgtSiblingCount: siblingCount, edgeKind: row.edge_kind, confidence: row.confidence, dynamic: row.dynamic, diff --git a/tests/integration/issue-1752-reverse-dep-sibling-reconnect.test.ts b/tests/integration/issue-1752-reverse-dep-sibling-reconnect.test.ts new file mode 100644 index 000000000..6d9a2419a --- /dev/null +++ b/tests/integration/issue-1752-reverse-dep-sibling-reconnect.test.ts @@ -0,0 +1,235 @@ +/** + * Regression test for #1752: `codegraph check --staged --blast-radius` (and + * `codegraph fn-impact`) reported a DIFFERENT transitive-caller count for a + * function after an INCREMENTAL rebuild than after a from-scratch full + * rebuild of the identical final source — even though the function's own + * source text and call relationships never changed. A full rebuild always + * produced the correct, stable number. + * + * Root cause: `reconnectReverseDepEdges` (`build-edges.ts`, WASM/JS engine) + * and its native mirror `reconnect_reverse_dep_edges` + * (`crates/codegraph-core/.../detect_changes.rs`) re-attach a reverse-dep + * caller's edge to its purged-and-reinserted target using only + * `(name, kind, file)` plus "nearest to the old line" as a tiebreak. When a + * file contains MULTIPLE distinct symbols sharing the same name and kind — + * e.g. several object-literal `close() {}` methods returned from different + * functions in the same file, a common pattern for resource-handle wrappers + * (confirmed in this repo's own `src/db/connection.ts`, which has four such + * `close` methods, each destructured out by callers exactly like + * `const { repo, close } = openRepo(...)`) — nearest-line is not a reliable + * way to tell them apart: once unrelated code inserted elsewhere in the file + * shifts the whole same-named group, an old reference line can end up + * numerically closer to a DIFFERENT sibling's new line than to its own, + * silently re-attaching the edge to the wrong symbol (and, when several + * saved edges collide on the same wrong candidate, collapsing two distinct + * edges into one via INSERT OR IGNORE while another candidate is left with + * no edge at all). A full rebuild is immune because it re-resolves every + * call from scratch using real call-site information, not line proximity. + * + * Root-caused by replaying this repo's own last 35 real commits as a + * sequence of incremental builds and diffing the result against a full + * rebuild of the identical final source: node tables were byte-identical, + * but 5 reverse-dep callers ended up wired to `close@line 433` instead of + * the correct `close@line 580`. Reproduced in miniature here: a caller that + * destructures `close` off four candidate `open*()` functions resolves (via + * the global by-name fallback, confidence-gated by directory proximity — + * `resolveByGlobal` in `resolver/strategy.ts`) to all four same-named `close` + * siblings at once, exactly mirroring the real edge shape found in this + * repo's own graph.db. (That any-candidate-clearing-confidence resolution + * breadth is a separate, pre-existing concern — filed independently — this + * test only relies on the resulting edge *set* being stable across rebuilds, + * not on how many edges resolveByGlobal produces.) + * + * Fixed by recording each target's 1-based ordinal rank (by line) among its + * same-(name,kind) siblings at save time, and using that ordinal — not line + * proximity — to re-select the correct candidate after purge+reinsert, + * falling back to nearest-line only when the sibling count itself changed + * (a genuinely ambiguous case, e.g. a sibling added/removed since save). + * + * WASM engine only: the native mirror fix in `reconnect_reverse_dep_edges` + * (Rust) requires rebuilding the native addon to take effect. Covered + * instead by dedicated Rust unit/integration tests in `detect_changes.rs` + * (`pick_reconnect_target_*`, `compute_ordinals_*`, + * `reconnect_survives_uniform_shift_of_same_named_siblings`). + */ +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 REL_CONN_FILE = 'src/db/conn.ts'; +// Same directory DEPTH as the caller matters: call-target resolution for this +// fixture's unqualified `close()` call falls back to a directory-proximity- +// scored global name match (see module doc comment), which requires the +// caller and target to share a common grandparent directory — mirroring this +// repo's real `src/features/*.ts` -> `src/db/connection.ts` shape. +const REL_CALLER_FILE = 'src/features/caller.ts'; + +/** + * Four distinct functions, each returning an object with its OWN `close()` + * method — same name ("close") and kind ("method") repeated four times in + * one file, exactly the ambiguous shape that broke reverse-dep reconnection + * in #1752 (mirrors `src/db/connection.ts`'s four real `close` methods). + */ +function connSource(): string { + return `export function openA() { + return { + close() { + return 'A'; + }, + }; +} + +export function openB() { + return { + close() { + return 'B'; + }, + }; +} + +export function openC() { + return { + close() { + return 'C'; + }, + }; +} + +export function openD() { + return { + close() { + return 'D'; + }, + }; +} +`; +} + +/** + * The same fixture after a new, unrelated function has been inserted above + * ALL four `open*` functions — shifts every `close` sibling down by a fixed + * amount without touching any of their own source text. + */ +function editedConnSource(): string { + return `function helperPadding(x: number): number { + // Several lines added here shift every function below down by a fixed + // amount, without touching their own source text at all. + console.log('padding line 1'); + console.log('padding line 2'); + console.log('padding line 3'); + console.log('padding line 4'); + console.log('padding line 5'); + return x + 1; +} + +${connSource()}`; +} + +/** + * Reverse-dep file: never edited across the whole scenario. Destructures + * `close` off `openC()`'s return value — the exact pattern this repo's own + * `openRepo()` callers use (`const { repo, close } = openRepo(...)`). + */ +function callerSource(): string { + return `import { openC } from '../db/conn.js'; + +export function useC(): void { + const { close } = openC(); + close(); +} +`; +} + +function writeFixture(root: string, conn: string): void { + fs.mkdirSync(path.join(root, 'src', 'db'), { recursive: true }); + fs.mkdirSync(path.join(root, 'src', 'features'), { recursive: true }); + fs.writeFileSync(path.join(root, REL_CONN_FILE), conn); + fs.writeFileSync(path.join(root, REL_CALLER_FILE), callerSource()); +} + +/** Sorted list of target lines every `useC() -> close` edge currently points to. */ +function findUseCCloseTargetLines(dbPath: string): number[] { + const db = new Database(dbPath, { readonly: true }); + try { + const rows = db + .prepare( + `SELECT t.line AS line + FROM edges e + JOIN nodes s ON e.source_id = s.id + JOIN nodes t ON e.target_id = t.id + WHERE s.name = 'useC' AND t.name = 'close' AND t.kind = 'method' + AND e.kind = 'calls' + ORDER BY t.line`, + ) + .all() as Array<{ line: number }>; + return rows.map((r) => r.line); + } finally { + db.close(); + } +} + +describe('Issue #1752: reverse-dep call edges survive same-named-sibling line shifts (wasm)', () => { + let projDir: string; + const tmpDirs: string[] = []; + const dbPath = () => path.join(projDir, '.codegraph', 'graph.db'); + + function mkTmp(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tmpDirs.push(dir); + return dir; + } + + beforeAll(async () => { + projDir = mkTmp('cg-1752-wasm-'); + writeFixture(projDir, connSource()); + await buildGraph(projDir, { engine: 'wasm', incremental: false, skipRegistry: true }); + }, 60_000); + + afterAll(() => { + for (const dir of tmpDirs) fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('baseline: useC resolves at least one close() edge, including openC’s own', () => { + const lines = findUseCCloseTargetLines(dbPath()); + expect(lines.length).toBeGreaterThanOrEqual(1); + }); + + it('useC keeps the exact same set of close() target lines after an incremental rebuild ' + + 'that only inserts an unrelated function above all four open*/close() pairs ' + + '(matches a from-scratch full rebuild)', async () => { + // Edit conn.ts only — shifts every close() sibling's line number + // without touching any of their own source text. + fs.writeFileSync(path.join(projDir, REL_CONN_FILE), editedConnSource()); + await buildGraph(projDir, { engine: 'wasm', skipRegistry: true }); // incremental (default) + const incrementalLines = findUseCCloseTargetLines(dbPath()); + + // Ground truth: an independent repo built in one full pass with the + // identical final file content (post-edit). + const refDir = mkTmp('cg-1752-ref-wasm-'); + writeFixture(refDir, editedConnSource()); + await buildGraph(refDir, { engine: 'wasm', incremental: false, skipRegistry: true }); + const refDbPath = path.join(refDir, '.codegraph', 'graph.db'); + const referenceLines = findUseCCloseTargetLines(refDbPath); + + expect( + referenceLines.length, + 'reference full rebuild: useC -> close edges missing', + ).toBeGreaterThanOrEqual(1); + + expect( + incrementalLines, + 'useC’s close() call edges were reconnected to the wrong same-named siblings ' + + `after an incremental rebuild (#1752) — incremental target lines=[${incrementalLines}], ` + + `full-rebuild ground truth=[${referenceLines}]`, + ).toEqual(referenceLines); + + // The old nearest-line heuristic didn't just pick a wrong candidate — + // it could collapse two distinct saved edges onto the SAME new node + // (via INSERT OR IGNORE) while leaving another candidate un-targeted. + // Guard against that regression shape explicitly. + expect(new Set(incrementalLines).size).toBe(incrementalLines.length); + }, 60_000); +}); From 6767f09d9d11d4f8c67a41ea893ad7f4cd27461a Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Mon, 6 Jul 2026 04:08:11 -0600 Subject: [PATCH 29/67] feat: wire points-to solver max-iterations cap through DEFAULTS.analysis.pointsToMaxIterations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MAX_SOLVER_ITERATIONS was a hardcoded 50 in both the WASM points-to solver (points-to.ts) and the native Rust solver (build_edges.rs), duplicating but never reading DEFAULTS.analysis.pointsToMaxIterations. Threads a maxIterations parameter from the pipeline's resolved config through buildPointsToMap -> buildPointsToMapForFile -> buildCallEdgesJS/buildCallEdgesNative on the TS side, and through build_call_edges -> process_file -> build_file_context -> build_pts_map_for_file -> build_points_to_map on the Rust side, sourced from a new BuildConfig.analysis.points_to_max_iterations field deserialized from the JSON config payload already passed to the native engine. Default value (50) is unchanged when no override is configured. docs check acknowledged: no README/CLAUDE.md/ROADMAP.md updates needed — purely internal plumbing for an already-documented, already-accepted config key with no new CLI flags or user-facing surface. Fixes #1753 Impact: 7 functions changed, 6 affected --- .../src/domain/graph/builder/pipeline.rs | 14 +- .../graph/builder/stages/build_edges.rs | 137 +++++++++++--- .../src/infrastructure/config.rs | 43 +++++ .../graph/builder/stages/build-edges.ts | 21 ++- src/domain/graph/resolver/points-to.ts | 23 +-- src/infrastructure/config.ts | 21 ++- src/types.ts | 14 +- ...ssue-1753-points-to-max-iterations.test.ts | 178 ++++++++++++++++++ tests/unit/config.test.ts | 12 ++ tests/unit/points-to.test.ts | 60 ++++++ 10 files changed, 469 insertions(+), 54 deletions(-) create mode 100644 tests/integration/issue-1753-points-to-max-iterations.test.ts diff --git a/crates/codegraph-core/src/domain/graph/builder/pipeline.rs b/crates/codegraph-core/src/domain/graph/builder/pipeline.rs index 2d2471457..17810f123 100644 --- a/crates/codegraph-core/src/domain/graph/builder/pipeline.rs +++ b/crates/codegraph-core/src/domain/graph/builder/pipeline.rs @@ -578,7 +578,13 @@ pub fn run_pipeline( // Build call edges using existing Rust edge_builder (internal path) // For now, call edges are built via the existing napi-exported function's // internal logic. We load nodes from DB and pass to the edge builder. - build_and_insert_call_edges(conn, &file_symbols, &import_ctx, !change_result.is_full_build); + build_and_insert_call_edges( + conn, + &file_symbols, + &import_ctx, + !change_result.is_full_build, + config.analysis.points_to_max_iterations, + ); reconnect_saved_reverse_dep_edges(conn, &saved_reverse_dep_edges); @@ -1517,11 +1523,15 @@ fn insert_call_edge_rows(conn: &Connection, edges: &[crate::domain::graph::build } /// Full builds always load every node — there is no smaller set anyway. +/// +/// `max_iterations` caps the Phase 8.3 points-to solver's fixed-point loop — +/// forwarded from `config.analysis.points_to_max_iterations` (issue #1753). fn build_and_insert_call_edges( conn: &Connection, file_symbols: &BTreeMap, import_ctx: &ImportEdgeContext, is_incremental: bool, + max_iterations: u32, ) { use crate::domain::graph::builder::stages::build_edges::*; @@ -1619,7 +1629,7 @@ fn build_and_insert_call_edges( }); } - let computed_edges = build_call_edges(file_entries, all_nodes, builtin_receivers); + let computed_edges = build_call_edges(file_entries, all_nodes, builtin_receivers, max_iterations); insert_call_edge_rows(conn, &computed_edges); } diff --git a/crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs b/crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs index 8a48743ed..94b35cc06 100644 --- a/crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs +++ b/crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs @@ -182,11 +182,17 @@ impl<'a> EdgeContext<'a> { // ── Phase 8.3: points-to analysis ───────────────────────────────────────── -/// Maximum fixed-point iterations for the pts solver. -/// Mirrors `MAX_SOLVER_ITERATIONS` in `src/domain/graph/resolver/points-to.ts`. -/// TODO: wire through `CodegraphConfig.analysis.pointsToMaxIterations` once -/// config plumbing is in place (same pattern as `typePropagationDepth`). -const MAX_SOLVER_ITERATIONS: usize = 50; +/// Default maximum fixed-point iterations for the pts solver — mirrors +/// `MAX_SOLVER_ITERATIONS` in `src/domain/graph/resolver/points-to.ts` and +/// `DEFAULTS.analysis.pointsToMaxIterations` in `src/infrastructure/config.ts`. +/// `build_call_edges()` now receives the resolved value as an explicit +/// `max_iterations` parameter (threaded from `BuildConfig.analysis.points_to_max_iterations` +/// on the native-first pipeline path, or from `ctx.config.analysis.pointsToMaxIterations` +/// via the napi call on the JS-orchestrated per-stage path); production code no +/// longer references this constant directly, so it is `#[cfg(test)]`-gated — +/// it remains only as the fallback default used directly by unit tests below. +#[cfg(test)] +const MAX_SOLVER_ITERATIONS: u32 = 50; /// Per-file points-to binding inputs, borrowed from a `FileEdgeInput`. /// `fn_ref_bindings` must already include the `fn::this → ctx` conversions @@ -208,11 +214,16 @@ struct PtsBindings<'a> { /// Seeds every locally-defined callable and every imported name as pointing /// to itself, generates inclusion constraints (`pts(lhs) ⊇ pts(rhsKey)`) /// from every binding kind, then solves by fixed-point iteration. +/// +/// `max_iterations` caps the fixed-point loop below — resolved from +/// `CodegraphConfig.analysis.pointsToMaxIterations` by the caller (mirrors +/// the `maxIterations` parameter of the TS `buildPointsToMap`). fn build_points_to_map( bindings: &PtsBindings, def_names: &HashSet<&str>, imported_names: &HashMap<&str, &str>, definition_params: &HashMap<&str, Vec<&str>>, + max_iterations: u32, ) -> HashMap> { let mut pts: HashMap> = HashMap::new(); for name in def_names { @@ -346,7 +357,7 @@ fn build_points_to_map( } // Fixed-point iteration: propagate pts sets until no new information flows. - for _ in 0..MAX_SOLVER_ITERATIONS { + for _ in 0..max_iterations { let mut changed = false; for (lhs, rhs_key) in &constraints { let rhs_pts: Option> = pts.get(rhs_key.as_str()) @@ -445,17 +456,22 @@ fn emit_pts_alias_edges<'a>( /// /// Mirrors the algorithm in builder.js `buildEdges` transaction (call edges /// portion). Import edges are handled separately in JS. +/// +/// `max_iterations` caps the Phase 8.3 points-to solver's fixed-point loop — +/// callers pass `ctx.config.analysis.pointsToMaxIterations` (resolved from +/// `.codegraphrc.json`, defaulting to `DEFAULTS.analysis.pointsToMaxIterations`). #[napi] pub fn build_call_edges( files: Vec, all_nodes: Vec, builtin_receivers: Vec, + max_iterations: u32, ) -> Vec { let ctx = EdgeContext::new(&all_nodes, &builtin_receivers); let mut edges = Vec::new(); for file_input in &files { - process_file(&ctx, file_input, &all_nodes, &mut edges); + process_file(&ctx, file_input, &all_nodes, &mut edges, max_iterations); } edges @@ -512,6 +528,7 @@ fn build_type_map<'a>(file_input: &'a FileEdgeInput) -> HashMap<&'a str, (&'a st fn build_pts_map_for_file( file_input: &FileEdgeInput, imported_names: &HashMap<&str, &str>, + max_iterations: u32, ) -> Option>> { let raw_fn_ref: &[FnRefBinding] = file_input.fn_ref_bindings.as_deref().unwrap_or(&[]); let this_calls: &[ThisCallBinding] = file_input.this_call_bindings.as_deref().unwrap_or(&[]); @@ -568,13 +585,20 @@ fn build_pts_map_for_file( PtsBindings { fn_ref_bindings: &merged_fn_ref, ..bindings } }; - Some(build_points_to_map(&final_bindings, &def_names, imported_names, &definition_params)) + Some(build_points_to_map( + &final_bindings, + &def_names, + imported_names, + &definition_params, + max_iterations, + )) } /// Build all per-file lookup structures needed for edge emission. fn build_file_context<'a>( file_input: &'a FileEdgeInput, all_nodes: &'a [NodeInfo], + max_iterations: u32, ) -> FileContext<'a> { let rel_path = file_input.file.as_str(); let imported_names: HashMap<&str, &str> = file_input @@ -599,7 +623,7 @@ fn build_file_context<'a>( node_id, } }).collect(); - let pts_map = build_pts_map_for_file(file_input, &imported_names); + let pts_map = build_pts_map_for_file(file_input, &imported_names, max_iterations); let raw_fn_ref: &[FnRefBinding] = file_input.fn_ref_bindings.as_deref().unwrap_or(&[]); // Case (c) flat-key gate set: lhs names from the *raw* fnRefBindings only // (thisCall conversions are scoped keys and never flat-matched). @@ -732,8 +756,9 @@ fn process_file<'a>( file_input: &'a FileEdgeInput, all_nodes: &'a [NodeInfo], edges: &mut Vec, + max_iterations: u32, ) { - let fc = build_file_context(file_input, all_nodes); + let fc = build_file_context(file_input, all_nodes, max_iterations); // Phase 8.3: tracks pts-resolved edges separately from seen_edges so that a // subsequent direct call to the same caller→target pair can upgrade confidence @@ -2198,7 +2223,7 @@ mod call_edge_tests { vec![], )]; - let edges = build_call_edges(files, all_nodes, vec![]); + let edges = build_call_edges(files, all_nodes, vec![], MAX_SOLVER_ITERATIONS); let receiver_edge = edges.iter().find(|e| e.kind == "receiver"); assert!( @@ -2239,7 +2264,7 @@ mod call_edge_tests { // same-file kind="function" node as an import artifact and falls through. file.imported_names = vec![ImportedName { name: "Calculator".to_string(), file: "utils.js".to_string(), imported: None }]; - let edges = build_call_edges(vec![file], all_nodes, vec![]); + let edges = build_call_edges(vec![file], all_nodes, vec![], MAX_SOLVER_ITERATIONS); let receiver_edge = edges.iter().find(|e| e.kind == "receiver"); assert!( @@ -2273,7 +2298,7 @@ mod call_edge_tests { vec![], )]; - let edges = build_call_edges(files, all_nodes, vec![]); + let edges = build_call_edges(files, all_nodes, vec![], MAX_SOLVER_ITERATIONS); let receiver_edge = edges.iter().find(|e| e.kind == "receiver"); assert!( @@ -2301,7 +2326,7 @@ mod call_edge_tests { vec![type_map_entry("UserService.logger", "Logger", 1.0)], vec![], )]; - let edges = build_call_edges(files, all_nodes, vec![]); + let edges = build_call_edges(files, all_nodes, vec![], MAX_SOLVER_ITERATIONS); assert!( edges.iter().any(|e| e.kind == "calls" && e.source_id == 1 && e.target_id == 2), "expected calls edge UserService.create → Logger.error; got: {:?}", @@ -2325,7 +2350,7 @@ mod call_edge_tests { vec![type_map_entry("useRest::eerest", "E4", 0.85)], vec![], )]; - let edges = build_call_edges(files, all_nodes, vec![]); + let edges = build_call_edges(files, all_nodes, vec![], MAX_SOLVER_ITERATIONS); assert!( edges.iter().any(|e| e.kind == "calls" && e.source_id == 1 && e.target_id == 2), "expected calls edge useRest → E4.e4 via rest-param key; got: {:?}", @@ -2349,7 +2374,7 @@ mod call_edge_tests { vec![], vec![], )]; - let edges = build_call_edges(files, all_nodes, vec![]); + let edges = build_call_edges(files, all_nodes, vec![], MAX_SOLVER_ITERATIONS); assert!( !edges.iter().any(|e| e.kind == "calls" && e.source_id == 1 && e.target_id == 2), "bare call must not resolve to same-class sibling in a module-scoped language" @@ -2372,7 +2397,7 @@ mod call_edge_tests { vec![], vec![], )]; - let edges = build_call_edges(files, all_nodes, vec![]); + let edges = build_call_edges(files, all_nodes, vec![], MAX_SOLVER_ITERATIONS); assert!( edges.iter().any(|e| e.kind == "calls" && e.source_id == 1 && e.target_id == 2), "bare sibling call must resolve in a class-scoped language; got: {:?}", @@ -2397,7 +2422,7 @@ mod call_edge_tests { vec![], vec![], )]; - let edges = build_call_edges(files, all_nodes, vec![]); + let edges = build_call_edges(files, all_nodes, vec![], MAX_SOLVER_ITERATIONS); assert!( edges.iter().any(|e| e.kind == "calls" && e.source_id == 1 && e.target_id == 2), "expected Geo.Shape.describe → Shape.area via bare class segment; got: {:?}", @@ -2423,7 +2448,7 @@ mod call_edge_tests { vec![type_map_entry("calc", "Calculator", 0.85)], vec![], )]; - let edges = build_call_edges(files, all_nodes, vec![]); + let edges = build_call_edges(files, all_nodes, vec![], MAX_SOLVER_ITERATIONS); let re = edges.iter().find(|e| e.kind == "receiver").expect("receiver edge"); assert!( (re.confidence - 0.85).abs() < 1e-9, @@ -2450,7 +2475,7 @@ mod call_edge_tests { vec![], )]; - let edges = build_call_edges(files, all_nodes, vec![]); + let edges = build_call_edges(files, all_nodes, vec![], MAX_SOLVER_ITERATIONS); let receiver_edge = edges.iter().find(|e| e.kind == "receiver"); assert!(receiver_edge.is_some(), "expected receiver edge for direct class-name receiver"); @@ -2496,7 +2521,7 @@ mod call_edge_tests { arg_name: "target".to_string(), }]); - let edges = build_call_edges(vec![file], all_nodes, vec![]); + let edges = build_call_edges(vec![file], all_nodes, vec![], MAX_SOLVER_ITERATIONS); assert!( edges.iter().any(|e| e.source_id == 1 && e.target_id == 2 && e.kind == "calls"), @@ -2541,7 +2566,7 @@ mod call_edge_tests { this_arg: "handler".to_string(), }]); - let edges = build_call_edges(vec![file], all_nodes, vec![]); + let edges = build_call_edges(vec![file], all_nodes, vec![], MAX_SOLVER_ITERATIONS); assert!( edges.iter().any(|e| e.source_id == 1 && e.target_id == 2 && e.kind == "calls"), @@ -2587,7 +2612,7 @@ mod call_edge_tests { enclosing_func: "iterPlain".to_string(), }]); - let edges = build_call_edges(vec![file], all_nodes, vec![]); + let edges = build_call_edges(vec![file], all_nodes, vec![], MAX_SOLVER_ITERATIONS); for target in [1u32, 2u32] { assert!( @@ -2637,7 +2662,7 @@ mod call_edge_tests { value_name: "e4".to_string(), }]); - let edges = build_call_edges(vec![file], all_nodes, vec![]); + let edges = build_call_edges(vec![file], all_nodes, vec![], MAX_SOLVER_ITERATIONS); assert!( edges.iter().any(|e| e.source_id == 1 && e.target_id == 2 && e.kind == "calls"), @@ -2679,7 +2704,7 @@ mod call_edge_tests { start_index: 0, }]); - let edges = build_call_edges(vec![file], all_nodes, vec![]); + let edges = build_call_edges(vec![file], all_nodes, vec![], MAX_SOLVER_ITERATIONS); assert!( edges.iter().any(|e| e.source_id == 1 && e.target_id == 2 && e.kind == "calls"), @@ -2692,6 +2717,68 @@ mod call_edge_tests { edges.iter().map(|e| (e.source_id, e.target_id, &e.kind)).collect::>() ); } + + /// Regression for issue #1753: the points-to solver's fixed-point loop must + /// honor the caller-supplied `max_iterations` rather than a hardcoded value. + /// Mirrors the equivalent TS-side test in `tests/unit/points-to.test.ts`. + /// + /// Builds an 8-hop alias chain `a0=a1, a1=a2, ..., a6=a7, a7=handler` in this + /// exact (declaration) order. `build_points_to_map` processes constraints in + /// array order each pass, so a single hop propagates per iteration, moving + /// from the tail of the array backward to the front — resolving `a0` + /// requires exactly `chain_len` (8) iterations. + #[test] + fn max_iterations_caps_alias_chain_convergence() { + let chain_len: u32 = 8; + let mut fn_ref_bindings: Vec = (0..chain_len - 1) + .map(|i| FnRefBinding { + lhs: format!("a{i}"), + rhs: format!("a{}", i + 1), + rhs_receiver: None, + }) + .collect(); + fn_ref_bindings.push(FnRefBinding { + lhs: format!("a{}", chain_len - 1), + rhs: "handler".to_string(), + rhs_receiver: None, + }); + + let def_names: HashSet<&str> = ["handler"].into_iter().collect(); + let imported_names: HashMap<&str, &str> = HashMap::new(); + let definition_params: HashMap<&str, Vec<&str>> = HashMap::new(); + let bindings = PtsBindings { + fn_ref_bindings: &fn_ref_bindings, + param_bindings: &[], + array_elem_bindings: &[], + spread_arg_bindings: &[], + for_of_bindings: &[], + array_callback_bindings: &[], + object_rest_param_bindings: &[], + object_prop_bindings: &[], + }; + + // A cap well below the chain length must not converge for a0. + let pts_low = + build_points_to_map(&bindings, &def_names, &imported_names, &definition_params, 3); + assert!( + resolve_via_points_to("a0", &pts_low).is_empty(), + "expected a0 to NOT resolve with max_iterations=3 (chain needs {chain_len})" + ); + + // A cap at the chain length must fully converge for a0. + let pts_high = build_points_to_map( + &bindings, + &def_names, + &imported_names, + &definition_params, + chain_len, + ); + assert_eq!( + resolve_via_points_to("a0", &pts_high), + vec!["handler"], + "expected a0 to resolve to handler with max_iterations={chain_len}" + ); + } } #[cfg(test)] diff --git a/crates/codegraph-core/src/infrastructure/config.rs b/crates/codegraph-core/src/infrastructure/config.rs index 4dbb706c2..4392e9c72 100644 --- a/crates/codegraph-core/src/infrastructure/config.rs +++ b/crates/codegraph-core/src/infrastructure/config.rs @@ -32,6 +32,10 @@ pub struct BuildConfig { /// Config-level path aliases (merged with tsconfig aliases). #[serde(default)] pub aliases: std::collections::HashMap, + + /// Analysis-tuning settings (points-to solver, etc.). + #[serde(default)] + pub analysis: AnalysisConfig, } #[derive(Debug, Clone, Deserialize)] @@ -66,6 +70,31 @@ fn default_drift_threshold() -> f64 { 0.1 } +/// Subset of `CodegraphConfig.analysis` relevant to the build pipeline. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AnalysisConfig { + /// Maximum fixed-point iterations for the Phase 8.3 points-to solver. + /// Mirrors `DEFAULTS.analysis.pointsToMaxIterations` in + /// `src/infrastructure/config.ts` and `MAX_SOLVER_ITERATIONS` in + /// `src/domain/graph/builder/stages/build_edges.rs`. Threaded to + /// `build_call_edges()` via `build_and_insert_call_edges()`. + #[serde(default = "default_points_to_max_iterations")] + pub points_to_max_iterations: u32, +} + +impl Default for AnalysisConfig { + fn default() -> Self { + Self { + points_to_max_iterations: default_points_to_max_iterations(), + } + } +} + +fn default_points_to_max_iterations() -> u32 { + 50 +} + /// Build options passed from the JS caller. #[derive(Debug, Clone, Deserialize, Default)] #[serde(rename_all = "camelCase")] @@ -143,6 +172,8 @@ mod tests { assert!(config.include.is_empty()); assert!(config.exclude.is_empty()); assert!(config.build.incremental); + // Default mirrors DEFAULTS.analysis.pointsToMaxIterations in config.ts. + assert_eq!(config.analysis.points_to_max_iterations, 50); } #[test] @@ -166,6 +197,18 @@ mod tests { assert!(!config.build.incremental); assert_eq!(config.build.drift_threshold, 0.2); assert_eq!(config.aliases.get("@/").unwrap(), "src/"); + // analysis key omitted entirely — must still fall back to the default. + assert_eq!(config.analysis.points_to_max_iterations, 50); + } + + #[test] + fn deserialize_analysis_override() { + // Mirrors the shape the JS side serializes via JSON.stringify(ctx.config) + // when a repo sets a non-default `analysis.pointsToMaxIterations` in + // .codegraphrc.json (issue #1753). + let json = r#"{"analysis": {"pointsToMaxIterations": 5}}"#; + let config: BuildConfig = serde_json::from_str(json).unwrap(); + assert_eq!(config.analysis.points_to_max_iterations, 5); } #[test] diff --git a/src/domain/graph/builder/stages/build-edges.ts b/src/domain/graph/builder/stages/build-edges.ts index 570121fde..cfe33f891 100644 --- a/src/domain/graph/builder/stages/build-edges.ts +++ b/src/domain/graph/builder/stages/build-edges.ts @@ -638,9 +638,12 @@ function buildCallEdgesNative( nativeFiles.push(buildNativeFileEntry(ctx, relPath, fileNodeRow.id, symbols, rootDir)); } - const nativeEdges = native.buildCallEdges(nativeFiles, allNodes, [ - ...BUILTIN_RECEIVERS, - ]) as NativeEdge[]; + const nativeEdges = native.buildCallEdges( + nativeFiles, + allNodes, + [...BUILTIN_RECEIVERS], + ctx.config.analysis.pointsToMaxIterations, + ) as NativeEdge[]; for (const e of nativeEdges) { allEdgeRows.push([ e.sourceId, @@ -929,7 +932,11 @@ function buildCallEdgesJS( } const seenCallEdges = new Set(); - const ptsMap = buildPointsToMapForFile(symbols, importedNames); + const ptsMap = buildPointsToMapForFile( + symbols, + importedNames, + ctx.config.analysis.pointsToMaxIterations, + ); // Build the import-artifact name set: importedNames plus CJS require bindings. // Used only by resolveReceiverEdge to distinguish local definitions from CJS // import shadows — does NOT affect call-target resolution or DB edges (#1661). @@ -1059,10 +1066,15 @@ function makeContextLookup(ctx: PipelineContext, getNodeIdStmt: NodeIdStmt): Cal * (`const Svc = MyService`) is an uncommon pattern that would require tracking * `new`-expression flows separately from the alias chain. That is left to Phase * 8.2 call-assignment propagation, which already handles constructor assignments. + * + * @param maxIterations - fixed-point solver iteration cap, forwarded to + * `buildPointsToMap` (resolved from `ctx.config.analysis.pointsToMaxIterations` + * by the caller, which already holds the pipeline's resolved config). */ function buildPointsToMapForFile( symbols: ExtractorOutput, importedNames: Map, + maxIterations: number, ): PointsToMap | null { const hasThisCallBindings = !!symbols.thisCallBindings?.length; if ( @@ -1108,6 +1120,7 @@ function buildPointsToMapForFile( symbols.arrayCallbackBindings, symbols.objectRestParamBindings, symbols.objectPropBindings, + maxIterations, ); } diff --git a/src/domain/graph/resolver/points-to.ts b/src/domain/graph/resolver/points-to.ts index 5573c53ab..ec9274719 100644 --- a/src/domain/graph/resolver/points-to.ts +++ b/src/domain/graph/resolver/points-to.ts @@ -19,6 +19,7 @@ * that build-edges.ts already builds per file is the cross-module link — if * a variable aliases an imported name, resolveCallTargets follows it). */ +import { DEFAULTS } from '../../../infrastructure/config.js'; import type { ArrayCallbackBinding, ArrayElemBinding, @@ -32,14 +33,6 @@ import type { export type PointsToMap = Map>; -/** - * Maximum fixed-point iterations before bailing out (prevents divergence). - * Mirrors `DEFAULTS.analysis.pointsToMaxIterations` in config.ts. - * TODO(Phase 8.3): thread config through buildPointsToMap so this can be tuned - * per-repo via `.codegraphrc.json` (tracked alongside typePropagationDepth). - */ -const MAX_SOLVER_ITERATIONS = 50; - /** * Seed the pts map from locally-defined functions, imported names, and * fnRefBindings (direct assignment aliases: `const fn = handler`). @@ -365,15 +358,16 @@ function appendAdvancedConstraints( /** * Run the fixed-point solver: propagate pts sets through constraints until - * no new information flows (or MAX_SOLVER_ITERATIONS is reached). + * no new information flows (or `maxIterations` is reached). * * Mutates `pts` in place. */ function buildCallSiteTypeMap( pts: PointsToMap, constraints: ReadonlyArray<{ lhs: string; rhsKey: string }>, + maxIterations: number, ): void { - for (let iter = 0; iter < MAX_SOLVER_ITERATIONS; iter++) { + for (let iter = 0; iter < maxIterations; iter++) { let changed = false; for (const { lhs, rhsKey } of constraints) { const rhsPts = pts.get(rhsKey); @@ -410,6 +404,12 @@ function buildCallSiteTypeMap( * @param spreadArgBindings - spread-argument bindings (Phase 8.3e) * @param forOfBindings - for-of iteration variable bindings (Phase 8.3e) * @param arrayCallbackBindings - Array.from/callback bindings (Phase 8.3e) + * @param maxIterations - fixed-point iteration cap before bailing out (prevents + * divergence). Defaults to `DEFAULTS.analysis.pointsToMaxIterations`; + * callers that already hold a resolved `CodegraphConfig` (e.g. + * `buildPointsToMapForFile` in `stages/build-edges.ts`) pass the + * user-configured value through explicitly. Mirrored by + * `MAX_SOLVER_ITERATIONS` in the native Rust solver (`stages/build_edges.rs`). */ export function buildPointsToMap( fnRefBindings: readonly FnRefBinding[], @@ -423,6 +423,7 @@ export function buildPointsToMap( arrayCallbackBindings?: readonly ArrayCallbackBinding[], objectRestParamBindings?: readonly ObjectRestParamBinding[], objectPropBindings?: readonly ObjectPropBinding[], + maxIterations: number = DEFAULTS.analysis.pointsToMaxIterations, ): PointsToMap { const { pts, constraints } = buildThisAssignmentMap( fnRefBindings, @@ -447,7 +448,7 @@ export function buildPointsToMap( if (constraints.length === 0) return pts; - buildCallSiteTypeMap(pts, constraints); + buildCallSiteTypeMap(pts, constraints, maxIterations); return pts; } diff --git a/src/infrastructure/config.ts b/src/infrastructure/config.ts index 315d221d5..46e0dd791 100644 --- a/src/infrastructure/config.ts +++ b/src/infrastructure/config.ts @@ -149,11 +149,10 @@ export const DEFAULTS = deepFreeze({ briefImporterDepth: 5, briefHighRiskCallers: 10, briefMediumRiskCallers: 3, - // TODO(Phase 8.3): wire these into the points-to solver and type-propagation path - // once config is threaded through to extractSymbols / buildPointsToMap. Currently - // controlled by hardcoded constants in src/extractors/javascript.ts - // (MAX_PROPAGATION_DEPTH, PROPAGATION_HOP_PENALTY, INFERRED_RETURN_TYPE_CONFIDENCE) and in - // src/domain/graph/resolver/points-to.ts (MAX_SOLVER_ITERATIONS). + // TODO(Phase 8.3): wire these into the type-propagation path once config is + // threaded through to extractSymbols. Currently controlled by hardcoded + // constants in src/extractors/javascript.ts (MAX_PROPAGATION_DEPTH, + // PROPAGATION_HOP_PENALTY, INFERRED_RETURN_TYPE_CONFIDENCE). typePropagationDepth: 3, /** * Confidence score assigned to a return type inferred from `return new Constructor()` @@ -164,10 +163,14 @@ export const DEFAULTS = deepFreeze({ typeInferenceConfidence: 0.85, /** * Maximum fixed-point iterations for the Phase 8.3 points-to solver. - * @reserved — currently not wired to either the WASM solver - * (`MAX_SOLVER_ITERATIONS` in `points-to.ts`) or the native Rust solver - * (`MAX_SOLVER_ITERATIONS` in `stages/build_edges.rs`), both of which use the - * same hardcoded value of 50. See the TODO comment above. + * Wired as the default `maxIterations` parameter of `buildPointsToMap()` + * in `src/domain/graph/resolver/points-to.ts`. The build pipeline + * (`buildCallEdgesJS` in `stages/build-edges.ts`, which already holds a + * resolved `ctx.config`) passes the value through explicitly to the WASM + * solver, and to the native Rust solver (`MAX_SOLVER_ITERATIONS` in + * `stages/build_edges.rs`) via `native.buildCallEdges()` on the per-stage + * path or the `BuildConfig` JSON payload on the native-first path — keeping + * both engines in sync. */ pointsToMaxIterations: 50, }, diff --git a/src/types.ts b/src/types.ts index 1d41d4e02..954b9fb5a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1490,8 +1490,11 @@ export interface CodegraphConfig { typePropagationDepth: number; /** * Maximum fixed-point iterations for the Phase 8.3 points-to solver. - * @reserved — currently not wired to either solver; both use a hardcoded - * constant of 50. See TODO in `src/infrastructure/config.ts`. + * Wired as the default `maxIterations` parameter of `buildPointsToMap()` + * in `src/domain/graph/resolver/points-to.ts`. The build pipeline (which + * already holds a resolved config) passes this value through explicitly + * to both the WASM solver and, via `native.buildCallEdges()` / the + * `BuildConfig` JSON payload, the native Rust solver in `stages/build_edges.rs`. */ pointsToMaxIterations: number; /** @@ -2276,7 +2279,12 @@ export interface NativeAddon { assignments: Array<{ node: string; community: number }>; modularity: number; }; - buildCallEdges(files: unknown[], nodes: unknown[], builtinReceivers: string[]): unknown[]; + buildCallEdges( + files: unknown[], + nodes: unknown[], + builtinReceivers: string[], + maxIterations: number, + ): unknown[]; buildImportEdges?( files: unknown[], resolvedImports: unknown[], diff --git a/tests/integration/issue-1753-points-to-max-iterations.test.ts b/tests/integration/issue-1753-points-to-max-iterations.test.ts new file mode 100644 index 000000000..c740a83e8 --- /dev/null +++ b/tests/integration/issue-1753-points-to-max-iterations.test.ts @@ -0,0 +1,178 @@ +/** + * Regression test for issue #1753 — `pointsToMaxIterations` config threading. + * + * `MAX_SOLVER_ITERATIONS` in the Phase 8.3 points-to solver used to be a + * hardcoded constant (50) in both `src/domain/graph/resolver/points-to.ts` + * (WASM) and `crates/codegraph-core/.../build_edges.rs` (native), duplicating + * — but never reading from — `DEFAULTS.analysis.pointsToMaxIterations` in + * `src/infrastructure/config.ts`. + * + * This suite builds an 8-hop function-alias chain + * (`a0=a1, a1=a2, ..., a6=a7, a7=handler`) that the fixed-point solver needs + * exactly 8 iterations to fully resolve (see the equivalent unit tests in + * `tests/unit/points-to.test.ts` and the Rust `max_iterations_caps_alias_chain_convergence` + * test for the derivation). A `.codegraphrc.json` setting + * `analysis.pointsToMaxIterations` below that depth must suppress the + * resulting call edge on BOTH engines; the default (50) must resolve it. + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import Database from 'better-sqlite3'; +import { afterAll, describe, expect, it } from 'vitest'; +import { buildGraph } from '../../src/domain/graph/builder.js'; +import { isNativeAvailable } from '../../src/infrastructure/native.js'; + +const hasNative = isNativeAvailable(); +const requireParity = !!process.env.CODEGRAPH_PARITY; +const itNativeOrSkip = requireParity || hasNative ? it : it.skip; + +// 8-hop alias chain: a0 requires exactly 8 fixed-point iterations to resolve +// to `handler` (one hop propagates per solver iteration — see file header). +const CHAIN_LENGTH = 8; + +const HANDLER_JS = ` +export function handler(item) { + return item * 2; +} +`.trimStart(); + +function buildConsumerSource(): string { + const lines = [ + "import { handler } from './handler.js';", + '', + 'export function processItems(items) {', + ]; + for (let i = 0; i < CHAIN_LENGTH - 1; i++) { + lines.push(` const a${i} = a${i + 1};`); + } + lines.push(` const a${CHAIN_LENGTH - 1} = handler;`); + lines.push(' return items.map(a0);'); + lines.push('}'); + return `${lines.join('\n')}\n`; +} + +const CONSUMER_JS = buildConsumerSource(); + +const dirsToClean: string[] = []; + +function writeFixture(dir: string, maxIterations?: number): void { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'handler.js'), HANDLER_JS); + fs.writeFileSync(path.join(dir, 'consumer.js'), CONSUMER_JS); + if (maxIterations !== undefined) { + fs.writeFileSync( + path.join(dir, '.codegraphrc.json'), + JSON.stringify({ analysis: { pointsToMaxIterations: maxIterations } }), + ); + } +} + +function readCallEdges(dbPath: string): Array<{ source: string; target: string }> { + const db = new Database(dbPath, { readonly: true }); + const rows = db + .prepare(` + SELECT n1.name AS source, n2.name AS target + 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<{ source: string; target: string }>; + db.close(); + return rows; +} + +function hasProcessItemsToHandlerEdge(dbPath: string): boolean { + return readCallEdges(dbPath).some((e) => e.source === 'processItems' && e.target === 'handler'); +} + +afterAll(() => { + for (const dir of dirsToClean) { + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { + /* ignore */ + } + } +}); + +async function buildFixture(engine: 'wasm' | 'native', maxIterations?: number): Promise { + const label = maxIterations === undefined ? 'default' : `cap${maxIterations}`; + const tmpBase = fs.mkdtempSync( + path.join(os.tmpdir(), `codegraph-pts-max-iter-${engine}-${label}-`), + ); + dirsToClean.push(tmpBase); + writeFixture(tmpBase, maxIterations); + await buildGraph(tmpBase, { engine, incremental: false, skipRegistry: true }); + return tmpBase; +} + +describe('Phase 8.3 pts: pointsToMaxIterations config threading (WASM)', () => { + it('resolves the 8-hop alias chain with the default cap (50)', async () => { + const dir = await buildFixture('wasm'); + expect(hasProcessItemsToHandlerEdge(path.join(dir, '.codegraph', 'graph.db'))).toBe(true); + }, 60_000); + + it('suppresses the alias-chain edge when .codegraphrc.json caps below the required depth', async () => { + const dir = await buildFixture('wasm', 3); + expect(hasProcessItemsToHandlerEdge(path.join(dir, '.codegraph', 'graph.db'))).toBe(false); + }, 60_000); + + it('resolves the alias-chain edge when .codegraphrc.json raises the cap to meet the required depth', async () => { + const dir = await buildFixture('wasm', CHAIN_LENGTH); + expect(hasProcessItemsToHandlerEdge(path.join(dir, '.codegraph', 'graph.db'))).toBe(true); + }, 60_000); +}); + +describe('Phase 8.3 pts: pointsToMaxIterations config threading (native)', () => { + itNativeOrSkip( + 'resolves the 8-hop alias chain with the default cap (50)', + async () => { + const dir = await buildFixture('native'); + expect(hasProcessItemsToHandlerEdge(path.join(dir, '.codegraph', 'graph.db'))).toBe(true); + }, + 60_000, + ); + + itNativeOrSkip( + 'suppresses the alias-chain edge when .codegraphrc.json caps below the required depth', + async () => { + const dir = await buildFixture('native', 3); + expect(hasProcessItemsToHandlerEdge(path.join(dir, '.codegraph', 'graph.db'))).toBe(false); + }, + 60_000, + ); +}); + +describe('Phase 8.3 pts: pointsToMaxIterations — engine parity', () => { + itNativeOrSkip( + 'both engines agree the chain resolves under the default cap', + async () => { + const wasmDir = await buildFixture('wasm'); + const nativeDir = await buildFixture('native'); + const wasmEdges = readCallEdges(path.join(wasmDir, '.codegraph', 'graph.db')); + const nativeEdges = readCallEdges(path.join(nativeDir, '.codegraph', 'graph.db')); + expect(nativeEdges).toEqual(wasmEdges); + expect(hasProcessItemsToHandlerEdge(path.join(wasmDir, '.codegraph', 'graph.db'))).toBe(true); + }, + 60_000, + ); + + itNativeOrSkip( + 'both engines agree the chain is suppressed under a below-depth override', + async () => { + const wasmDir = await buildFixture('wasm', 3); + const nativeDir = await buildFixture('native', 3); + const wasmEdges = readCallEdges(path.join(wasmDir, '.codegraph', 'graph.db')); + const nativeEdges = readCallEdges(path.join(nativeDir, '.codegraph', 'graph.db')); + expect(nativeEdges).toEqual(wasmEdges); + expect(hasProcessItemsToHandlerEdge(path.join(wasmDir, '.codegraph', 'graph.db'))).toBe( + false, + ); + }, + 60_000, + ); +}); diff --git a/tests/unit/config.test.ts b/tests/unit/config.test.ts index 14122333c..71cb22b4f 100644 --- a/tests/unit/config.test.ts +++ b/tests/unit/config.test.ts @@ -272,6 +272,18 @@ describe('loadConfig', () => { expect(config.analysis.impactDepth).toBe(3); expect(config.analysis.auditDepth).toBe(3); }); + + it('loads a pointsToMaxIterations override from config (issue #1753)', () => { + const dir = fs.mkdtempSync(path.join(tmpDir, 'pts-max-iter-')); + fs.writeFileSync( + path.join(dir, '.codegraphrc.json'), + JSON.stringify({ analysis: { pointsToMaxIterations: 5 } }), + ); + const config = loadConfig(dir); + expect(config.analysis.pointsToMaxIterations).toBe(5); + // Sibling defaults preserved + expect(config.analysis.typePropagationDepth).toBe(3); + }); }); describe('mergeConfig', () => { diff --git a/tests/unit/points-to.test.ts b/tests/unit/points-to.test.ts index b9dac077d..e32b2df71 100644 --- a/tests/unit/points-to.test.ts +++ b/tests/unit/points-to.test.ts @@ -417,3 +417,63 @@ describe('buildPointsToMap — object-rest parameter dispatch (Phase 8.3f)', () } }); }); + +describe('buildPointsToMap — maxIterations cap (issue #1753)', () => { + // Builds an 8-hop alias chain a0=a1, a1=a2, ..., a6=a7, a7=handler, in this + // exact declaration order. buildCallSiteTypeMap processes constraints in + // array order on every pass, so only one hop propagates per iteration, + // moving from the tail of the array backward to the front — resolving a0 + // requires exactly CHAIN_LENGTH (8) iterations. + const CHAIN_LENGTH = 8; + function buildChainBindings(): Array<{ lhs: string; rhs: string }> { + const fnRefBindings: Array<{ lhs: string; rhs: string }> = []; + for (let i = 0; i < CHAIN_LENGTH - 1; i++) { + fnRefBindings.push({ lhs: `a${i}`, rhs: `a${i + 1}` }); + } + fnRefBindings.push({ lhs: `a${CHAIN_LENGTH - 1}`, rhs: 'handler' }); + return fnRefBindings; + } + + it('does not converge the chain when maxIterations is below the required depth', () => { + const pts = buildPointsToMap( + buildChainBindings(), + new Set(['handler']), + NO_IMPORTS, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + 3, // well below CHAIN_LENGTH (8) + ); + expect(resolveViaPointsTo('a0', pts)).toEqual([]); + }); + + it('fully converges the chain when maxIterations meets the required depth', () => { + const pts = buildPointsToMap( + buildChainBindings(), + new Set(['handler']), + NO_IMPORTS, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + CHAIN_LENGTH, + ); + expect(resolveViaPointsTo('a0', pts)).toEqual(['handler']); + }); + + it('defaults to DEFAULTS.analysis.pointsToMaxIterations (50) when maxIterations is omitted', () => { + // The 8-hop chain converges comfortably under the default cap, confirming + // default behavior is unchanged now that maxIterations is configurable. + const pts = buildPointsToMap(buildChainBindings(), new Set(['handler']), NO_IMPORTS); + expect(resolveViaPointsTo('a0', pts)).toEqual(['handler']); + }); +}); From 5f6f30a5047977c4242da8736e99728740c37a32 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Mon, 6 Jul 2026 04:25:26 -0600 Subject: [PATCH 30/67] refactor: route console.log calls in domain/search through logger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit generator.ts's embedding-progress messages now go through logger.info() (unconditionally visible, matches the info()-based "Reusing previously- stored embedding model" message already used in cli/commands/embed.ts) instead of console.log/stdout. semantic.ts's dimension-mismatch message is a genuine warning (same severity class as the file's existing warnOnSimilarQueries), so both of its console.log lines are merged into a single warn() call, following the fix already applied to prepare.ts. cli-formatter.ts is the actual data-output layer for `codegraph search` (including the --json contract), directly analogous to presentation/queries-cli/ — CLI display wrappers for query functions that already live in presentation/ and call console.log directly. No other domain/ file has precedent for presentation code living there, so it moves to presentation/search.ts rather than being kept as a domain-layer exception. Only two import sites needed updating (cli/commands/search.ts, the domain/search/index.ts barrel) plus one test import. docs check acknowledged: internal logging-layer refactor + file move, no new feature/language/CLI/architecture surface — README.md, CLAUDE.md, and ROADMAP.md do not need updates. Fixes #1754 Impact: 2 functions changed, 9 affected --- src/cli/commands/search.ts | 2 +- src/domain/search/generator.ts | 10 +++++----- src/domain/search/index.ts | 1 - src/domain/search/search/semantic.ts | 6 +++--- .../search/cli-formatter.ts => presentation/search.ts} | 10 +++++----- tests/search/embedder-search.test.ts | 2 +- 6 files changed, 15 insertions(+), 16 deletions(-) rename src/{domain/search/search/cli-formatter.ts => presentation/search.ts} (93%) diff --git a/src/cli/commands/search.ts b/src/cli/commands/search.ts index 1650879af..8a349a2bb 100644 --- a/src/cli/commands/search.ts +++ b/src/cli/commands/search.ts @@ -1,5 +1,5 @@ import { collectFile } from '../../db/query-builder.js'; -import { search } from '../../domain/search/index.js'; +import { search } from '../../presentation/search.js'; import type { CommandDefinition } from '../types.js'; export const command: CommandDefinition = { diff --git a/src/domain/search/generator.ts b/src/domain/search/generator.ts index d9b47cf0a..1af2ec61e 100644 --- a/src/domain/search/generator.ts +++ b/src/domain/search/generator.ts @@ -1,7 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { closeDb, findDbPath, getBuildMeta, openDb } from '../../db/index.js'; -import { warn } from '../../infrastructure/logger.js'; +import { info, warn } from '../../infrastructure/logger.js'; import { DbError } from '../../shared/errors.js'; import type { BetterSqlite3Database, NodeRow } from '../../types.js'; import { embed, getModelConfig } from './models.js'; @@ -242,7 +242,7 @@ export async function buildEmbeddings( const byFile = loadNodesByFile(db); const nodeCount = [...byFile.values()].reduce((acc, list) => acc + list.length, 0); - console.log(`Building embeddings for ${nodeCount} symbols (strategy: ${strategy})...`); + info(`Building embeddings for ${nodeCount} symbols (strategy: ${strategy})...`); let contextWindow: number; let displayName: string; @@ -275,7 +275,7 @@ export async function buildEmbeddings( ); } - console.log( + info( `Embedding ${prepared.texts.length} symbols${options.remote ? ` via remote provider (${displayName})` : ''}...`, ); const { vectors, dim } = options.remote @@ -289,8 +289,8 @@ export async function buildEmbeddings( const provider = options.remote ? 'openai' : null; persistEmbeddings(db, prepared, vectors as Float32Array[], dim, displayName, strategy, provider); - console.log( - `\nStored ${vectors.length} embeddings (${dim}d, ${displayName}, strategy: ${strategy}) in graph.db`, + info( + `Stored ${vectors.length} embeddings (${dim}d, ${displayName}, strategy: ${strategy}) in graph.db`, ); closeDb(db); } diff --git a/src/domain/search/index.ts b/src/domain/search/index.ts index 6b7c355c6..a4f8bf682 100644 --- a/src/domain/search/index.ts +++ b/src/domain/search/index.ts @@ -10,7 +10,6 @@ export type { ModelConfig } from './models.js'; export { DEFAULT_MODEL, disposeModel, EMBEDDING_STRATEGIES, embed, MODELS } from './models.js'; export type { RemoteEmbeddingOptions } from './providers/remote.js'; export { embedRemote, resolveRemoteEmbeddingOptions } from './providers/remote.js'; -export { search } from './search/cli-formatter.js'; export { hybridSearchData } from './search/hybrid.js'; export { ftsSearchData } from './search/keyword.js'; export { multiSearchData, searchData } from './search/semantic.js'; diff --git a/src/domain/search/search/semantic.ts b/src/domain/search/search/semantic.ts index 07f8f4bb2..17c0b5e82 100644 --- a/src/domain/search/search/semantic.ts +++ b/src/domain/search/search/semantic.ts @@ -72,10 +72,10 @@ function rowVector(row: StoredRow): Float32Array { /** Warn when stored embeddings and the query model use different dimensions. */ function checkDimensionMismatch(storedDim: number | null, dim: number): boolean { if (storedDim && dim !== storedDim) { - console.log( - `Warning: query model dimension (${dim}) doesn't match stored embeddings (${storedDim}).`, + warn( + `Query model dimension (${dim}) doesn't match stored embeddings (${storedDim}). ` + + 'Re-run `codegraph embed` with the same model, or use --model to match.', ); - console.log(` Re-run \`codegraph embed\` with the same model, or use --model to match.`); return true; } return false; diff --git a/src/domain/search/search/cli-formatter.ts b/src/presentation/search.ts similarity index 93% rename from src/domain/search/search/cli-formatter.ts rename to src/presentation/search.ts index 44eeb4fb5..1f835fffe 100644 --- a/src/domain/search/search/cli-formatter.ts +++ b/src/presentation/search.ts @@ -1,8 +1,8 @@ -import { warn } from '../../../infrastructure/logger.js'; -import { hybridSearchData } from './hybrid.js'; -import { ftsSearchData } from './keyword.js'; -import type { SemanticSearchOpts } from './semantic.js'; -import { multiSearchData, searchData } from './semantic.js'; +import { hybridSearchData } from '../domain/search/search/hybrid.js'; +import { ftsSearchData } from '../domain/search/search/keyword.js'; +import type { SemanticSearchOpts } from '../domain/search/search/semantic.js'; +import { multiSearchData, searchData } from '../domain/search/search/semantic.js'; +import { warn } from '../infrastructure/logger.js'; interface SearchOpts extends SemanticSearchOpts { mode?: 'hybrid' | 'semantic' | 'keyword'; diff --git a/tests/search/embedder-search.test.ts b/tests/search/embedder-search.test.ts index 38582fd3f..d9924bbea 100644 --- a/tests/search/embedder-search.test.ts +++ b/tests/search/embedder-search.test.ts @@ -36,9 +36,9 @@ import { ftsSearchData, hybridSearchData, multiSearchData, - search, searchData, } from '../../src/domain/search/index.js'; +import { search } from '../../src/presentation/search.js'; // ─── Helpers ─────────────────────────────────────────────────────────── From b4793180d27dcd5033f58e3e3e4946983c7524d7 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Mon, 6 Jul 2026 04:48:30 -0600 Subject: [PATCH 31/67] refactor: reduce cyclomatic complexity of computeDeltaModularityDirected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit computeDeltaModularityDirected exceeded the cyclomatic threshold (11 vs warn=10) from four repeated "newC < arr.length ? fget(arr, newC) [|| 0] : 0" bounds-checked reads. Two of the four (inFromNew/outToNew, plus the oldC siblings inFromOld/outToOld) included a `|| 0` fallback; the other two (totalInStrengthNew/totalOutStrengthNew, plus totalInStrengthOld/ totalOutStrengthOld) did not. Traced the asymmetry to its root: all six arrays involved are dense, zero-initialized Float64Arrays populated purely by +=/-= over edge weights and node strengths that are scrubbed of NaN/undefined at adapter.ts's ingestion point (`+linkWeight(attrs) || 0`). No code path can put NaN or a sparse "hole" into any of them, so `|| 0` is a no-op today for both array families. The `|| 0` presence instead traces back to which arrays already had a getter-based public API: getNeighborEdgeWeightToCommunity/ getOutEdgeWeightToCommunity/getInEdgeWeightFromCommunity always bake in `|| 0`, and this function hand-inlined that same convention for the edge-weight arrays it reads directly, but never established an equivalent convention for the community-strength-total arrays (which is itself inconsistently applied elsewhere in this file, e.g. computeDeltaCPM's sizeOld vs. sizeNew on the same communityTotalSize array) — confirmed by diffing against the original vendored ngraph.leiden source, where this exact asymmetry already existed unchanged since the initial vendoring commit. Since the two families are provably equivalent here, extracted a single shared `fgetOrZero(arr, i)` helper (bounds check + `|| 0`) into typed-array-helpers.ts and applied it uniformly to all eight reads (newC/oldC x edge-weight x strength-total), documenting why the fallback is currently redundant but retained for defense-in-depth and consistency. Split the two extracted read-groups into computeDirectedEdgeWeightTerms/ computeDirectedStrengthTerms helper functions. cyclomatic 11 -> 3, cognitive 10 -> 2 for computeDeltaModularityDirected; new helpers are cyclomatic 1 (computeDirectedEdgeWeightTerms/ computeDirectedStrengthTerms) and 3 (fgetOrZero) — none exceed threshold. Verified behavior preservation two ways: - Direct detectClusters(graph, { directed: true }) comparison across 12 seed/resolution/file-vs-function-level combinations on this repo's own dependency graph (701 file nodes / 8833 function nodes) between the pre-refactor and post-refactor build: byte-for-byte identical quality() and community assignments. - codegraph communities -T --json (undirected path) before/after, controlling for native-vs-JS engine selection: byte-for-byte identical. Added unit tests for fgetOrZero's bounds/zero/NaN-squashing contract, plus two exact-value regression tests pinning computeDeltaModularityDirected's quality()/assignment output for the existing directed-modularity fixtures, so a future accidental re-divergence of the `|| 0` handling would be caught. Fixes #1755 docs check acknowledged Impact: 4 functions changed, 7 affected --- src/graph/algorithms/leiden/partition.ts | 61 +++++++++++--- .../algorithms/leiden/typed-array-helpers.ts | 27 +++++++ tests/graph/algorithms/leiden.test.ts | 80 +++++++++++++++++++ 3 files changed, 155 insertions(+), 13 deletions(-) diff --git a/src/graph/algorithms/leiden/partition.ts b/src/graph/algorithms/leiden/partition.ts index ad2464f5c..69fd92086 100644 --- a/src/graph/algorithms/leiden/partition.ts +++ b/src/graph/algorithms/leiden/partition.ts @@ -9,7 +9,7 @@ import type { GraphAdapter } from './adapter.js'; import { accumulateInternalEdgeWeights, accumulateNodeAggregates } from './aggregate-helpers.js'; -import { fget, iget, u8get } from './typed-array-helpers.js'; +import { fget, fgetOrZero, iget, u8get } from './typed-array-helpers.js'; export interface CompactOptions { keepOldOrder?: boolean; @@ -260,6 +260,46 @@ function computeDeltaModularityUndirected( return gain_remove + gain_add; } +/** + * Directed delta-modularity edge-weight terms: in/out edge weight from `v` to + * `newC`/`oldC`, read via the shared bounds-checked `fgetOrZero` (see its + * doc comment for why `|| 0` is safe/redundant here but retained anyway). + */ +function computeDirectedEdgeWeightTerms( + s: PartitionState, + newC: number, + oldC: number, +): { inFromNew: number; outToNew: number; inFromOld: number; outToOld: number } { + return { + inFromNew: fgetOrZero(s.inEdgeWeightFromCommunity, newC), + outToNew: fgetOrZero(s.outEdgeWeightToCommunity, newC), + inFromOld: fgetOrZero(s.inEdgeWeightFromCommunity, oldC), + outToOld: fgetOrZero(s.outEdgeWeightToCommunity, oldC), + }; +} + +/** + * Directed delta-modularity community-strength terms: total in/out strength + * of `newC`/`oldC`, read via the shared bounds-checked `fgetOrZero`. + */ +function computeDirectedStrengthTerms( + s: PartitionState, + newC: number, + oldC: number, +): { + totalInStrengthNew: number; + totalOutStrengthNew: number; + totalInStrengthOld: number; + totalOutStrengthOld: number; +} { + return { + totalInStrengthNew: fgetOrZero(s.communityTotalInStrength, newC), + totalOutStrengthNew: fgetOrZero(s.communityTotalOutStrength, newC), + totalInStrengthOld: fgetOrZero(s.communityTotalInStrength, oldC), + totalOutStrengthOld: fgetOrZero(s.communityTotalOutStrength, oldC), + }; +} + function computeDeltaModularityDirected( s: PartitionState, v: number, @@ -271,18 +311,13 @@ function computeDeltaModularityDirected( const totalEdgeWeight: number = s.graph.totalWeight; const strengthOutV: number = fget(s.graph.strengthOut, v); const strengthInV: number = fget(s.graph.strengthIn, v); - const inFromNew: number = - newC < s.inEdgeWeightFromCommunity.length ? fget(s.inEdgeWeightFromCommunity, newC) || 0 : 0; - const outToNew: number = - newC < s.outEdgeWeightToCommunity.length ? fget(s.outEdgeWeightToCommunity, newC) || 0 : 0; - const inFromOld: number = fget(s.inEdgeWeightFromCommunity, oldC) || 0; - const outToOld: number = fget(s.outEdgeWeightToCommunity, oldC) || 0; - const totalInStrengthNew: number = - newC < s.communityTotalInStrength.length ? fget(s.communityTotalInStrength, newC) : 0; - const totalOutStrengthNew: number = - newC < s.communityTotalOutStrength.length ? fget(s.communityTotalOutStrength, newC) : 0; - const totalInStrengthOld: number = fget(s.communityTotalInStrength, oldC); - const totalOutStrengthOld: number = fget(s.communityTotalOutStrength, oldC); + const { inFromNew, outToNew, inFromOld, outToOld } = computeDirectedEdgeWeightTerms( + s, + newC, + oldC, + ); + const { totalInStrengthNew, totalOutStrengthNew, totalInStrengthOld, totalOutStrengthOld } = + computeDirectedStrengthTerms(s, newC, oldC); // Self-loop correction + constant term (see modularity.ts diffModularityDirected) const selfW: number = fget(s.graph.selfLoop, v) || 0; const deltaInternal: number = diff --git a/src/graph/algorithms/leiden/typed-array-helpers.ts b/src/graph/algorithms/leiden/typed-array-helpers.ts index ce3ef58a4..698446bc9 100644 --- a/src/graph/algorithms/leiden/typed-array-helpers.ts +++ b/src/graph/algorithms/leiden/typed-array-helpers.ts @@ -22,6 +22,33 @@ export function u8get(a: Uint8Array, i: number): number { return a[i] as number; } +/** + * Bounds-checked community-accumulator read: `i < a.length ? (fget(a, i) || 0) : 0`. + * + * Community ids are sometimes probed before the community itself has been + * grown into a given per-community accumulator array (e.g. a brand-new + * `newC` about to receive its first member) — the bounds check treats that + * as a not-yet-existing community contributing zero, matching + * `getNeighborEdgeWeightToCommunity`/`getOutEdgeWeightToCommunity`/ + * `getInEdgeWeightFromCommunity` in partition.ts, which every other reader + * of these arrays already goes through. + * + * The `|| 0` is not reachable today: every array this is used with in this + * codebase (communityTotalStrength/In/Out, neighborEdgeWeightToCommunity, + * outEdgeWeightToCommunity, inEdgeWeightFromCommunity) is a dense, + * zero-initialized Float64Array populated purely by +=/-= over edge weights + * and node strengths that are scrubbed of NaN/undefined at + * `makeGraphAdapter` construction time (`+linkWeight(attrs) || 0` / + * `+nodeSize(attrs) || 0` in adapter.ts) — so a bare bounds-checked `fget` + * would return the identical value. Kept for defense-in-depth against a + * future change to that invariant, and so callers reading either array + * family use one consistent, already-safe accessor instead of + * hand-rolling the same ternary+`||` per call site (see issue #1755). + */ +export function fgetOrZero(a: Float64Array, i: number): number { + return i < a.length ? fget(a, i) || 0 : 0; +} + /** In-place compound addition: `a[i] += v`, safe under noUncheckedIndexedAccess. */ export function taAdd(a: Float64Array, i: number, v: number): void { a[i] = fget(a, i) + v; diff --git a/tests/graph/algorithms/leiden.test.ts b/tests/graph/algorithms/leiden.test.ts index b02a077c6..37f22e263 100644 --- a/tests/graph/algorithms/leiden.test.ts +++ b/tests/graph/algorithms/leiden.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { detectClusters } from '../../../src/graph/algorithms/leiden/index.js'; +import { fgetOrZero } from '../../../src/graph/algorithms/leiden/typed-array-helpers.js'; import { CodeGraph } from '../../../src/graph/model.js'; // ─── Helpers ────────────────────────────────────────────────────────── @@ -651,3 +652,82 @@ describe('community connectivity', () => { } }); }); + +// ─── fgetOrZero (typed-array-helpers) ────────────────────────────────── +// +// Shared bounds-checked accessor extracted from computeDeltaModularityDirected +// (issue #1755) to unify the "newC < arr.length ? fget(arr, newC) [|| 0] : 0" +// pattern that previously appeared with an inconsistent `|| 0` across the +// edge-weight-to-community arrays vs. the community-strength-total arrays. + +describe('fgetOrZero (typed-array-helpers)', () => { + it('returns the stored value for an in-bounds index', () => { + const a = new Float64Array([1, 2, 3]); + expect(fgetOrZero(a, 1)).toBe(2); + }); + + it('returns 0 for an index at or beyond the array length (not-yet-existing community)', () => { + const a = new Float64Array([1, 2, 3]); + expect(fgetOrZero(a, 3)).toBe(0); + expect(fgetOrZero(a, 100)).toBe(0); + }); + + it('returns a plain 0 for a legitimate zero entry, same as a bare bounds-checked read', () => { + const a = new Float64Array([0, 5]); + expect(fgetOrZero(a, 0)).toBe(0); + }); + + it('squashes a stray NaN to 0 (defense-in-depth guard; not reachable via current callers)', () => { + const a = new Float64Array([Number.NaN, 5]); + expect(Number.isNaN(fgetOrZero(a, 0))).toBe(false); + expect(fgetOrZero(a, 0)).toBe(0); + }); +}); + +// ─── Directed modularity delta — exact regression values ─────────────── +// +// Pins the exact quality()/community-assignment output of the two existing +// "directed modularity" / "directed self-loops" fixtures above, computed +// through computeDeltaModularityDirected's fgetOrZero-based reads (issue +// #1755). These exact values were verified byte-for-byte identical against +// the pre-refactor implementation (four independent bounds-check-and-`||0` +// expressions) on this same seed. A future change that re-diverges the +// `|| 0` handling between the edge-weight and community-strength array +// families (or otherwise perturbs the delta-modularity formula) would shift +// these floating-point values and fail this test. + +describe('directed modularity delta — exact regression', () => { + it('matches the pinned quality/assignment for the two-triangle directed graph', () => { + const g = new CodeGraph(); + const A = ['0', '1', '2']; + const B = ['3', '4', '5']; + for (const id of [...A, ...B]) g.addNode(id); + for (let i = 0; i < A.length; i++) + for (let j = 0; j < A.length; j++) if (i !== j) g.addEdge(A[i], A[j]); + for (let i = 0; i < B.length; i++) + for (let j = 0; j < B.length; j++) if (i !== j) g.addEdge(B[i], B[j]); + g.addEdge('2', '3'); + + const clusters = detectClusters(g, { directed: true, randomSeed: 2 }); + expect(clusters.quality()).toBe(0.42603550295857995); + expect([...A, ...B].map((id) => clusters.getClass(id))).toEqual([1, 1, 1, 0, 0, 0]); + }); + + it('matches the pinned quality/assignment for the directed graph with self-loops', () => { + const g = new CodeGraph(); + const A = ['0', '1', '2']; + const B = ['3', '4', '5']; + for (const id of [...A, ...B]) g.addNode(id); + for (let i = 0; i < A.length; i++) + for (let j = 0; j < A.length; j++) if (i !== j) g.addEdge(A[i], A[j]); + for (let i = 0; i < B.length; i++) + for (let j = 0; j < B.length; j++) if (i !== j) g.addEdge(B[i], B[j]); + g.addEdge('2', '3'); + g.addEdge('0', '0', { weight: 3 }); + g.addEdge('3', '3', { weight: 3 }); + + const clusters = detectClusters(g, { directed: true, randomSeed: 2 }); + expect(clusters.quality()).toBe(0.44875346260387805); + expect([...A, ...B].map((id) => clusters.getClass(id))).toEqual([1, 1, 1, 0, 0, 0]); + }); +}); From c39ac409e5f896ba2024e298b9aa5daf4be9acdf Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Mon, 6 Jul 2026 05:01:25 -0600 Subject: [PATCH 32/67] refactor: unify impact-level rendering format between audit and fn-impact commands `renderAuditFunction` (presentation/audit.ts) and `printFnImpactLevels` (presentation/queries-cli/impact.ts) both rendered the same `Record` transitive-caller-levels shape (produced by the shared `bfsTransitiveCallers` BFS) but with two different, independently-maintained text formats. Extract a single `renderImpactLevels(levels, opts)` helper into a new presentation/impact-levels.ts module and adopt it in both call sites, using the richer icon+truncation format from fn-impact as the canonical one (per issue #1756's recommendation). `opts.emptyMessage` lets audit.ts suppress the "No callers found." line, since its "Impact: N transitive dependent(s)" line already conveys a zero count and none of its other subsections (Calls/Called by/Tests) print an explicit empty message either. Investigated downstream dependents before making this change: - No test asserts on audit.ts's exact text output; tests/integration/audit.test.ts only exercises the auditData data layer, and all CLI-level audit/fn-impact tests (tests/integration/cli.test.ts, tests/unit/queries-unit.test.ts) use --json. - No skill or hook in .claude/ parses this text; every `codegraph audit` invocation across .claude/skills/ already passes --json. - docs/examples/CLI.md and MCP.md do document an audit output example, but it was already stale relative to the current implementation independent of this change (wrong header/complexity/impact wording, single-line "Calls:" list) -- filed as optave/ops-codegraph-tool#1873 rather than fixing inline. - README.md/CLAUDE.md/ROADMAP.md contain no example output text for audit's impact-level rendering (checked directly) -- docs check acknowledged. BEHAVIOR CHANGE: `codegraph audit`'s impact-level text output now matches `codegraph fn-impact`'s icon+truncation format (per-level header with count, `^ :` per entry, truncated at 20 with "... and N more") instead of the old plain `Level {n}: name1, name2, ...` comma list. --json/--ndjson output is unaffected. Fixes #1756 Impact: 9 functions changed, 6 affected --- src/presentation/audit.ts | 9 ++-- src/presentation/impact-levels.ts | 60 ++++++++++++++++++++++++++ src/presentation/queries-cli/impact.ts | 17 +------- 3 files changed, 67 insertions(+), 19 deletions(-) create mode 100644 src/presentation/impact-levels.ts diff --git a/src/presentation/audit.ts b/src/presentation/audit.ts index 9a350a6e7..2670b7696 100644 --- a/src/presentation/audit.ts +++ b/src/presentation/audit.ts @@ -2,6 +2,7 @@ import { kindIcon } from '../domain/queries.js'; import { auditData } from '../features/audit.js'; import { outputResult } from '../infrastructure/result-formatter.js'; import type { AuditFunctionEntry, AuditResult, CodegraphConfig } from '../types.js'; +import { renderImpactLevels } from './impact-levels.js'; interface AuditOpts { json?: boolean; @@ -62,12 +63,12 @@ function renderThresholdBreaches(fn: AuditFunctionEntry): void { } } -/** Render the transitive-dependent impact summary, one line per BFS level. */ +/** Render the transitive-dependent impact summary, one block per BFS level. */ function renderImpactSection(fn: AuditFunctionEntry): void { console.log(`\n Impact: ${fn.impact.totalDependents} transitive dependent(s)`); - for (const [level, nodes] of Object.entries(fn.impact.levels)) { - console.log(` Level ${level}: ${nodes.map((n) => n.name).join(', ')}`); - } + // No "0 found" message here -- the count above already conveys it, matching this + // file's other sections (e.g. renderCallRefs), which print nothing when empty. + renderImpactLevels(fn.impact.levels, { emptyMessage: null }); } /** Render a labeled list of call references (used for both "Calls" and "Called by"). */ diff --git a/src/presentation/impact-levels.ts b/src/presentation/impact-levels.ts new file mode 100644 index 000000000..ce8262ce7 --- /dev/null +++ b/src/presentation/impact-levels.ts @@ -0,0 +1,60 @@ +/** + * Shared renderer for transitive-caller "impact levels" — the `{ level -> entries[] }` + * map produced by `bfsTransitiveCallers` (see domain/analysis/fn-impact.js). + * + * Both `codegraph audit` (presentation/audit.js) and `codegraph fn-impact` + * (presentation/queries-cli/impact.js) render this same shape and must stay + * visually identical — this module is the single source of truth for that format. + */ + +import { kindIcon } from '../domain/queries.js'; + +/** A single transitive-caller entry within one impact-level bucket. */ +export interface ImpactLevelRef { + name: string; + kind: string; + file: string; + line: number; +} + +export interface RenderImpactLevelsOpts { + /** + * Message printed when there are no levels at all. Pass `null` to print nothing + * (e.g. when the caller already reports a "0" count immediately above). + * Default: " No callers found." + */ + emptyMessage?: string | null; + /** Max entries shown per level before truncating with "... and N more". Default: 20. */ + limit?: number; +} + +/** + * Render a transitive-caller level map as indented, icon-prefixed bullet lists — + * one block per BFS level, with deeper levels indented further: + * + * -- Level 1 (2 functions): + * ^ f caller src/a.ts:10 + * ^ m Caller.method src/b.ts:20 + * ---- Level 2 (1 functions): + * ^ f transitiveCaller src/c.ts:5 + */ +export function renderImpactLevels( + levels: Record, + opts: RenderImpactLevelsOpts = {}, +): void { + const { emptyMessage = ' No callers found.', limit = 20 } = opts; + + if (Object.keys(levels).length === 0) { + if (emptyMessage) console.log(emptyMessage); + return; + } + + for (const [level, fns] of Object.entries(levels).sort((a, b) => Number(a[0]) - Number(b[0]))) { + const l = parseInt(level, 10); + console.log(` ${'--'.repeat(l)} Level ${level} (${fns.length} functions):`); + for (const f of fns.slice(0, limit)) { + console.log(` ${' '.repeat(l)}^ ${kindIcon(f.kind)} ${f.name} ${f.file}:${f.line}`); + } + if (fns.length > limit) console.log(` ... and ${fns.length - limit} more`); + } +} diff --git a/src/presentation/queries-cli/impact.ts b/src/presentation/queries-cli/impact.ts index 94b1fa64d..16cc0fe7a 100644 --- a/src/presentation/queries-cli/impact.ts +++ b/src/presentation/queries-cli/impact.ts @@ -8,6 +8,7 @@ import { kindIcon, } from '../../domain/queries.js'; import { outputResult } from '../../infrastructure/result-formatter.js'; +import { renderImpactLevels } from '../impact-levels.js'; interface SymbolRef { kind: string; @@ -228,20 +229,6 @@ export function impactAnalysis(file: string, customDbPath: string, opts: OutputO console.log(`\n Total: ${data.totalDependents} files transitively depend on "${file}"\n`); } -function printFnImpactLevels(levels: Record): void { - if (Object.keys(levels).length === 0) { - console.log(` No callers found.`); - return; - } - for (const [level, fns] of Object.entries(levels).sort((a, b) => Number(a[0]) - Number(b[0]))) { - const l = parseInt(level, 10); - console.log(` ${'--'.repeat(l)} Level ${level} (${fns.length} functions):`); - for (const f of fns.slice(0, 20)) - console.log(` ${' '.repeat(l)}^ ${kindIcon(f.kind)} ${f.name} ${f.file}:${f.line}`); - if (fns.length > 20) console.log(` ... and ${fns.length - 20} more`); - } -} - export function fnImpact(name: string, customDbPath: string, opts: OutputOpts = {}): void { const data = fnImpactData(name, customDbPath, opts) as unknown as FnImpactData; if (outputResult(data as unknown as Record, 'results', opts)) return; @@ -253,7 +240,7 @@ export function fnImpact(name: string, customDbPath: string, opts: OutputOpts = for (const r of data.results) { console.log(`\nFunction impact: ${kindIcon(r.kind)} ${r.name} -- ${r.file}:${r.line}\n`); - printFnImpactLevels(r.levels); + renderImpactLevels(r.levels); console.log(`\n Total: ${r.totalDependents} functions transitively depend on ${r.name}\n`); } } From 067674fcc8284db0722afa5cfaf6fed0ef14c92a Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Mon, 6 Jul 2026 05:12:49 -0600 Subject: [PATCH 33/67] refactor: dedupe computeSavings via pct helper and persist partial token-benchmark results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit computeSavings reimplemented the percentage-reduction formula inline instead of using the pct helper already defined for computeAggregate. Both call sites now share pct — verified byte-identical output across normal, zero-denominator, negative-savings, and null-guard cases. main()'s per-issue loop only serialized results to stdout after the full loop completed, so a crash partway through (another issue throwing, or the optional --perf benchmarks failing) discarded every already-computed result. The loop now overwrites token-benchmark.partial.json after each issue; the file is removed once the full run succeeds and the final JSON has been printed. Fixes #1757 Impact: 5 functions changed, 17 affected --- .gitignore | 1 + scripts/token-benchmark.ts | 56 +++++++++++++++++++++++++++++--------- 2 files changed, 44 insertions(+), 13 deletions(-) diff --git a/.gitignore b/.gitignore index 74b27941c..d8e9f3c1a 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ crates/codegraph-core/*.node .claude/worktrees/ generated/DEPENDENCIES.md generated/DEPENDENCIES.json +token-benchmark.partial.json artifacts/ pkg/ target/ diff --git a/scripts/token-benchmark.ts b/scripts/token-benchmark.ts index 7e66cb6b8..87d4c9698 100644 --- a/scripts/token-benchmark.ts +++ b/scripts/token-benchmark.ts @@ -81,6 +81,11 @@ function round2(n) { return Math.round(n * 100) / 100; } +/** Percentage reduction from `a` to `b` (e.g. token/cost savings). Returns 0 when `a <= 0`. */ +function pct(a, b) { + return a > 0 ? Math.round(((a - b) / a) * 100) : 0; +} + function git(args, cwd) { return execFileSync('git', args, { cwd, stdio: 'pipe', encoding: 'utf8' }).trim(); } @@ -423,19 +428,9 @@ function medianForRuns(runs) { /** Token + cost savings (% reduction) between two median objects. */ function computeSavings(baselineMedian, codegraphMedian) { if (!baselineMedian || !codegraphMedian || baselineMedian.inputTokens <= 0) return null; - const tokenSavings = - ((baselineMedian.inputTokens - codegraphMedian.inputTokens) / - baselineMedian.inputTokens) * - 100; - const costSavings = - baselineMedian.totalCostUsd > 0 - ? ((baselineMedian.totalCostUsd - codegraphMedian.totalCostUsd) / - baselineMedian.totalCostUsd) * - 100 - : 0; return { - inputTokensPct: Math.round(tokenSavings), - costPct: Math.round(costSavings), + inputTokensPct: pct(baselineMedian.inputTokens, codegraphMedian.inputTokens), + costPct: pct(baselineMedian.totalCostUsd, codegraphMedian.totalCostUsd), }; } @@ -484,7 +479,6 @@ function computeAggregate(results) { const totalCodegraphTokens = sum((r) => r.codegraph.median.inputTokens); const totalBaselineCost = sum((r) => r.baseline.median.totalCostUsd); const totalCodegraphCost = sum((r) => r.codegraph.median.totalCostUsd); - const pct = (a, b) => (a > 0 ? Math.round(((a - b) / a) * 100) : 0); return { savings: { @@ -502,6 +496,36 @@ function computeAggregate(results) { // ── Main ────────────────────────────────────────────────────────────────── +/** Where in-progress results are (re)written after each issue completes. */ +const PARTIAL_RESULTS_PATH = path.join(root, 'token-benchmark.partial.json'); + +/** + * Overwrite the partial-results snapshot with the results collected so far. + * Called after each issue completes so a later crash (another issue + * throwing, or `runPerfBenchmarks` failing) doesn't discard the + * already-computed results for the issues that already finished (#1757). + */ +function writePartialResults(results, totalIssues) { + const partialOutput = { + version: benchVersion, + date: new Date().toISOString().slice(0, 10), + model: MODEL, + runsPerIssue: RUNS, + maxTurns: MAX_TURNS, + maxBudgetUsd: MAX_BUDGET, + partial: true, + completedIssues: results.length, + totalIssues, + issues: results, + aggregate: computeAggregate(results), + perfBenchmarks: null, + }; + fs.writeFileSync(PARTIAL_RESULTS_PATH, JSON.stringify(partialOutput, null, 2)); + console.error( + ` Partial results written to ${PARTIAL_RESULTS_PATH} (${results.length}/${totalIssues} issues)`, + ); +} + async function main() { // Resolve Next.js directory const nextjsDir = flags['nextjs-dir'] @@ -522,6 +546,7 @@ async function main() { const results = []; for (const issue of selectedIssues) { results.push(await runIssueExperiment(issue, nextjsDir)); + writePartialResults(results, selectedIssues.length); } const aggregate = computeAggregate(results); @@ -549,6 +574,11 @@ async function main() { }; console.log(JSON.stringify(output, null, 2)); + + // Full run (including perf benchmarks, if requested) succeeded — the + // complete results are now in the stdout output above, so the partial + // snapshot is no longer needed. + fs.rmSync(PARTIAL_RESULTS_PATH, { force: true }); } main().catch((err) => { From f6807b4aa2ab82a8b10e2c789362b98c4f08239a Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Mon, 6 Jul 2026 05:40:03 -0600 Subject: [PATCH 34/67] fix: add main.rs driver to rust dynamic tracer fixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rust fixture had no main.rs, so trace_rust() failed immediately trying to inject `mod trace_support;` into a nonexistent file. This silently skipped the rust same-file recall assertion in tracer-validation.test.ts as "toolchain not available" even when cargo was installed, masking the fact that the rust dynamic tracer had never actually been exercised. Adds main.rs exercising the full models/repository/service/validator call chain through build_service()/add_user()/get_user()/remove_user() plus a direct_repo_access() helper, and documents the edges it introduces in expected-edges.json (mirroring how the swift/dart/zig fixtures already catalog every call sourced from their own main driver) — precision stays at 100%, recall is 58.3% against a larger, more complete manifest (24 edges vs. 14). Getting the tracer to actually run past the missing file surfaced three more bugs in the same trace_rust() code path that had never been reached before, since compilation was never previously attempted: - The dump_trace() injection used a GNU-only inline `i\text` sed form that BSD sed (macOS) rejects; switched to the portable `i\` + newline form. - The impl-block context regex captured the trait name instead of the concrete type for `impl Trait for Type` blocks, mislabeling e.g. EmailValidator.validate as Validator.validate in the trace output. - trace_support.rs's trace_call() held an immutable borrow from t.stack.last() across mutations of t.seen/t.edges, which doesn't compile under the borrow checker; clones the needed fields out first instead. The fixture itself also never compiled as a real crate: User didn't derive Clone (needed by find_by_id's .cloned()), and service.rs called Repository trait methods on self.repo without importing the trait. Both fixed. Verified end-to-end: native-tracer.sh now compiles and runs, producing 23/24 expected edges (the only miss, User.display_name, is legitimately unreachable since the repo stub's save() never persists into its HashMap). tracer-validation.test.ts's rust case now actually runs (rather than skipping) at 100% same-file recall (7/7) against the 50% threshold. Filed #1876 for the pre-existing static-resolution gap this also confirmed (receiver-typed/trait-dispatch calls through locally-typed variables aren't resolved by either engine). Fixes #1759 Impact: 1 functions changed, 1 affected Impact: 3 functions changed, 2 affected --- .../fixtures/rust/expected-edges.json | 70 +++++++++++++++++++ .../resolution/fixtures/rust/main.rs | 35 ++++++++++ .../resolution/fixtures/rust/models.rs | 1 + .../resolution/fixtures/rust/service.rs | 2 +- .../resolution/tracer/native-tracer.sh | 25 +++++-- 5 files changed, 126 insertions(+), 7 deletions(-) create mode 100644 tests/benchmarks/resolution/fixtures/rust/main.rs diff --git a/tests/benchmarks/resolution/fixtures/rust/expected-edges.json b/tests/benchmarks/resolution/fixtures/rust/expected-edges.json index 00acd907f..85c07ce4e 100644 --- a/tests/benchmarks/resolution/fixtures/rust/expected-edges.json +++ b/tests/benchmarks/resolution/fixtures/rust/expected-edges.json @@ -100,6 +100,76 @@ "kind": "calls", "mode": "same-file", "notes": "Same-file associated function call (UserService::new)" + }, + { + "source": { "name": "main", "file": "main.rs" }, + "target": { "name": "build_service", "file": "service.rs" }, + "kind": "calls", + "mode": "module-function", + "notes": "Cross-module function call via use crate::service::build_service" + }, + { + "source": { "name": "main", "file": "main.rs" }, + "target": { "name": "UserService.add_user", "file": "service.rs" }, + "kind": "calls", + "mode": "receiver-typed", + "notes": "service.add_user() — service is typed as UserService (returned by build_service)" + }, + { + "source": { "name": "main", "file": "main.rs" }, + "target": { "name": "UserService.get_user", "file": "service.rs" }, + "kind": "calls", + "mode": "receiver-typed", + "notes": "service.get_user() — service is typed as UserService (returned by build_service)" + }, + { + "source": { "name": "main", "file": "main.rs" }, + "target": { "name": "User.display_name", "file": "models.rs" }, + "kind": "calls", + "mode": "receiver-typed", + "notes": "user.display_name() — user is typed as User (returned by UserService::get_user)" + }, + { + "source": { "name": "main", "file": "main.rs" }, + "target": { "name": "UserService.remove_user", "file": "service.rs" }, + "kind": "calls", + "mode": "receiver-typed", + "notes": "service.remove_user() — service is typed as UserService (returned by build_service)" + }, + { + "source": { "name": "main", "file": "main.rs" }, + "target": { "name": "direct_repo_access", "file": "main.rs" }, + "kind": "calls", + "mode": "same-file", + "notes": "Same-file private function call" + }, + { + "source": { "name": "direct_repo_access", "file": "main.rs" }, + "target": { "name": "UserRepository.new", "file": "repository.rs" }, + "kind": "calls", + "mode": "constructor", + "notes": "UserRepository::new() — cross-module associated function call via use crate::repository::UserRepository" + }, + { + "source": { "name": "direct_repo_access", "file": "main.rs" }, + "target": { "name": "create_user", "file": "models.rs" }, + "kind": "calls", + "mode": "module-function", + "notes": "Cross-module function call via use crate::models::create_user" + }, + { + "source": { "name": "direct_repo_access", "file": "main.rs" }, + "target": { "name": "validate_all", "file": "validator.rs" }, + "kind": "calls", + "mode": "module-function", + "notes": "Cross-module function call via use crate::validator::validate_all" + }, + { + "source": { "name": "direct_repo_access", "file": "main.rs" }, + "target": { "name": "UserRepository.save", "file": "repository.rs" }, + "kind": "calls", + "mode": "receiver-typed", + "notes": "repo.save() — repo is typed as UserRepository" } ] } diff --git a/tests/benchmarks/resolution/fixtures/rust/main.rs b/tests/benchmarks/resolution/fixtures/rust/main.rs new file mode 100644 index 000000000..3bfe7fdd7 --- /dev/null +++ b/tests/benchmarks/resolution/fixtures/rust/main.rs @@ -0,0 +1,35 @@ +mod models; +mod repository; +mod service; +mod validator; + +use crate::models::{create_user, Repository}; +use crate::repository::UserRepository; +use crate::service::build_service; +use crate::validator::validate_all; + +fn main() { + let service = build_service(); + + match service.add_user(1, "Alice", "alice@example.com") { + Ok(()) => println!("Added user 1"), + Err(e) => println!("Failed to add user: {}", e), + } + + if let Some(user) = service.get_user(1) { + println!("Found: {}", user.display_name()); + } + + let removed = service.remove_user(1); + println!("Removed: {}", removed); + + direct_repo_access(); +} + +fn direct_repo_access() { + let repo = UserRepository::new(); + let user = create_user(2, "Bob", "bob@example.com"); + if validate_all(&user).is_ok() { + let _ = repo.save(&user); + } +} diff --git a/tests/benchmarks/resolution/fixtures/rust/models.rs b/tests/benchmarks/resolution/fixtures/rust/models.rs index 9bd189a6f..a2b81d453 100644 --- a/tests/benchmarks/resolution/fixtures/rust/models.rs +++ b/tests/benchmarks/resolution/fixtures/rust/models.rs @@ -1,3 +1,4 @@ +#[derive(Clone)] pub struct User { pub id: u64, pub name: String, diff --git a/tests/benchmarks/resolution/fixtures/rust/service.rs b/tests/benchmarks/resolution/fixtures/rust/service.rs index 53942d58a..8c82be6e2 100644 --- a/tests/benchmarks/resolution/fixtures/rust/service.rs +++ b/tests/benchmarks/resolution/fixtures/rust/service.rs @@ -1,4 +1,4 @@ -use crate::models::{create_user, User}; +use crate::models::{create_user, Repository, User}; use crate::repository::{create_repository, UserRepository}; use crate::validator::validate_all; diff --git a/tests/benchmarks/resolution/tracer/native-tracer.sh b/tests/benchmarks/resolution/tracer/native-tracer.sh index 602b5268e..e83d3276d 100644 --- a/tests/benchmarks/resolution/tracer/native-tracer.sh +++ b/tests/benchmarks/resolution/tracer/native-tracer.sh @@ -385,13 +385,18 @@ thread_local! { pub fn trace_call(name: &str, file: &str) -> TraceGuard { TRACER.with(|t| { let mut t = t.borrow_mut(); - if let Some(caller) = t.stack.last() { - let key = format!("{}@{}->{}@{}", caller.name, caller.file, name, file); + // Clone the caller's name/file out (rather than holding the &Frame + // borrow from t.stack.last() across the mutations below) so the + // subsequent t.seen / t.edges mutable accesses don't overlap with an + // outstanding immutable borrow of t. + let caller = t.stack.last().map(|f| (f.name.clone(), f.file.clone())); + if let Some((caller_name, caller_file)) = caller { + let key = format!("{}@{}->{}@{}", caller_name, caller_file, name, file); if !t.seen.contains(&key) { t.seen.insert(key); t.edges.push(Edge { - source_name: caller.name.clone(), - source_file: caller.file.clone(), + source_name: caller_name, + source_file: caller_file, target_name: name.to_string(), target_file: file.to_string(), }); @@ -432,18 +437,26 @@ RSTRACE # Inject trace_call into every fn body, tracking impl blocks for qualnames. # Rust's Drop-guard RAII pattern means only entry needs injecting — the # guard's Drop impl fires trace_support's exit hook automatically. + # The context regex's leading optional group absorbs "Trait for " so group + # 2 always lands on the concrete type — plain `impl Type {` and trait impls + # `impl Trait for Type {` both must qualname as the type, not the trait + # (methods are defined per-type, and expected-edges.json's trait-dispatch + # entries name the implementing type, e.g. EmailValidator.validate). inject_trace_calls \ "$TMP_DIR/src/*.rs" \ "trace_support.rs" \ - '^impl[[:space:]]+([A-Za-z_][A-Za-z0-9_]*)' 1 \ + '^impl[[:space:]]+([A-Za-z_][A-Za-z0-9_]*[[:space:]]+for[[:space:]]+)?([A-Za-z_][A-Za-z0-9_]*)' 2 \ 'fn[[:space:]]+([a-z_][a-z0-9_]*)' 1 \ '' \ raii \ ' let _tg = crate::trace_support::trace_call("%s", "%s");' # Inject dump_trace() at end of main() + # The i\text form (rather than a GNU-only inline i\text) is required + # for BSD sed (macOS) to accept this — see #1759. sedi '/^fn main/,/^\}/ { - /^\}/ i\ crate::trace_support::dump_trace(); + /^\}/ i\ + crate::trace_support::dump_trace(); }' "$TMP_DIR/src/main.rs" # Redirect eprintln/println in fixture code to stderr to keep stdout clean for JSON From 1c07a54cd4da03ed4a47548861ebf5b26c7f20dd Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Mon, 6 Jul 2026 06:02:46 -0600 Subject: [PATCH 35/67] fix: scope diff file-header detection to between-hunk positions parseDiffOutput tested every diff line against the file-header regexes unconditionally, including lines inside an active hunk body. A removed line whose original content starts with "-- " (e.g. a Markdown horizontal rule) becomes a "--- " line once diff-prefixed and was misdetected as a source-file header, silently dropped, and desynced the old-line cursor for the rest of the hunk. Symmetrically, an added line starting with "++ b/" becomes a "+++ b/..." line and was misdetected as a new-file header, flushing the in-progress run under a phantom file key and misattributing every later line in the hunk to it. DiffLineTracker now derives an insideHunk() state from the old/new line-count bounds declared by the most recent hunk header, and parseDiffOutput only attempts file-header matching while that is false: before a file's first hunk, or once the previous hunk's declared counts are fully consumed. Real diffs never have hunk-header counts that lie about their body length, so this makes header detection position-aware without weakening it for any real diff shape. No user-facing behavior, commands, or architecture changed. docs check acknowledged. Fixes #1761 Impact: 4 functions changed, 5 affected --- src/features/check.ts | 116 +++++++++++++++++++++----------- tests/integration/check.test.ts | 76 +++++++++++++++++++++ 2 files changed, 154 insertions(+), 38 deletions(-) diff --git a/src/features/check.ts b/src/features/check.ts index 95a172e16..ca12c29c6 100644 --- a/src/features/check.ts +++ b/src/features/check.ts @@ -74,6 +74,9 @@ function isDevNullTargetLine(line: string): boolean { class DiffLineTracker { private oldLineCursor = 0; private newLineCursor = 0; + /** End-exclusive old/new bounds declared by the current hunk's `@@ -a,b +c,d @@` header — see `insideHunk`. */ + private oldHunkEnd = 0; + private newHunkEnd = 0; private removedRunStart: number | null = null; private removedRunEnd: number | null = null; private addedRunStart: number | null = null; @@ -89,19 +92,40 @@ class DiffLineTracker { */ private pendingRemovedText: string[] = []; - startHunk(oldStart: number, newStart: number): void { + startHunk(oldStart: number, oldCount: number, newStart: number, newCount: number): void { this.oldLineCursor = oldStart; this.newLineCursor = newStart; + this.oldHunkEnd = oldStart + oldCount; + this.newHunkEnd = newStart + newCount; this.pendingRemovedText = []; } + /** + * True while either cursor is still short of the bounds declared by the + * most recent hunk header — i.e. while a hunk body is still being walked. + * False before the first hunk header of a file is seen, and again once + * both sides' declared line counts have been fully consumed. + * + * `parseDiffOutput` only attempts `--- `/`+++ b/` file-header matching + * while this is false. A hunk body line whose own content starts with + * `-- `/`++ ` (e.g. removing a Markdown horizontal rule `-- foo`) becomes + * `--- foo`/`+++ foo` once diff-prefixed — indistinguishable from a real + * file header by content alone — so position, not pattern, is what must + * disambiguate it. See issue #1761. + */ + insideHunk(): boolean { + return this.oldLineCursor < this.oldHunkEnd || this.newLineCursor < this.newHunkEnd; + } + /** * Consumes one hunk body line, advancing the old- and new-side cursors as - * needed. Lines beginning with `--- `/`+++ ` (source-file headers) are - * already filtered out by the caller before a line ever reaches here, so a - * leading `-`/`+` unambiguously marks a removed/added line — even one - * whose own content starts with dashes or pluses (e.g. removing a line of - * literal text `-- foo`). + * needed. The caller only attempts `--- `/`+++ ` file-header matching while + * `insideHunk()` is false, so a body line that happens to start with one of + * those prefixes (the diff-prefixed form of a removed/added line whose own + * content starts with dashes or pluses, e.g. literal text `-- foo`) still + * reaches here as ordinary content instead of being misdetected as a + * header. A leading `-`/`+` therefore unambiguously marks a removed/added + * line regardless of what follows it. */ consume( line: string, @@ -197,44 +221,60 @@ export function parseDiffOutput(diffOutput: string): ParsedDiff { const tracker = new DiffLineTracker(); for (const line of diffOutput.split('\n')) { - if (isDevNullSourceLine(line)) { - prevIsDevNull = true; - continue; - } - if (isSourceFileHeaderLine(line)) { - prevIsDevNull = false; - continue; - } - const newFile = extractNewFileName(line); - if (newFile) { - if (currentFile) tracker.flush(currentFile, oldRanges, changedRanges, changedEdits); - currentFile = newFile; - if (!changedRanges.has(currentFile)) changedRanges.set(currentFile, []); - if (!oldRanges.has(currentFile)) oldRanges.set(currentFile, []); - if (!changedEdits.has(currentFile)) changedEdits.set(currentFile, []); - if (prevIsDevNull) newFiles.add(currentFile); - prevIsDevNull = false; - continue; - } - if (isDevNullTargetLine(line)) { - // `+++ /dev/null` (file deletion) is not `b/`-prefixed, so - // extractNewFileName returned null above and this line would otherwise - // fall through to tracker.consume and be misread as an added source - // line under whichever file preceded this one in the diff. Flush and - // clear the file context instead — the deleted file's hunk body that - // follows has no corresponding DB entry to check against anyway (its - // nodes are purged from the graph), so there is nothing to track here. - if (currentFile) tracker.flush(currentFile, oldRanges, changedRanges, changedEdits); - currentFile = null; - prevIsDevNull = false; - continue; + // File-header lines (`--- `/`+++ b/`/`+++ /dev/null`) only ever appear + // between hunks — before the first hunk of a file, or once the previous + // hunk's declared old/new line counts are fully consumed. Gating this + // whole block on `!insideHunk()` keeps a hunk-body line whose own + // content starts with `-- `/`++ ` (e.g. a Markdown horizontal rule) from + // being misdetected as a file header purely because of a text + // coincidence — position, not pattern, decides. See issue #1761. + if (!tracker.insideHunk()) { + if (isDevNullSourceLine(line)) { + prevIsDevNull = true; + continue; + } + if (isSourceFileHeaderLine(line)) { + prevIsDevNull = false; + continue; + } + const newFile = extractNewFileName(line); + if (newFile) { + if (currentFile) tracker.flush(currentFile, oldRanges, changedRanges, changedEdits); + currentFile = newFile; + if (!changedRanges.has(currentFile)) changedRanges.set(currentFile, []); + if (!oldRanges.has(currentFile)) oldRanges.set(currentFile, []); + if (!changedEdits.has(currentFile)) changedEdits.set(currentFile, []); + if (prevIsDevNull) newFiles.add(currentFile); + prevIsDevNull = false; + continue; + } + if (isDevNullTargetLine(line)) { + // `+++ /dev/null` (file deletion) is not `b/`-prefixed, so + // extractNewFileName returned null above and this line would otherwise + // fall through to tracker.consume and be misread as an added source + // line under whichever file preceded this one in the diff. Flush and + // clear the file context instead — the deleted file's hunk body that + // follows has no corresponding DB entry to check against anyway (its + // nodes are purged from the graph), so there is nothing to track here. + if (currentFile) tracker.flush(currentFile, oldRanges, changedRanges, changedEdits); + currentFile = null; + prevIsDevNull = false; + continue; + } } if (!currentFile) continue; const hunkMatch = line.match(HUNK_RE); if (hunkMatch) { tracker.flush(currentFile, oldRanges, changedRanges, changedEdits); - tracker.startHunk(parseInt(hunkMatch[1]!, 10), parseInt(hunkMatch[3]!, 10)); + const oldCount = hunkMatch[2] === undefined ? 1 : parseInt(hunkMatch[2], 10); + const newCount = hunkMatch[4] === undefined ? 1 : parseInt(hunkMatch[4], 10); + tracker.startHunk( + parseInt(hunkMatch[1]!, 10), + oldCount, + parseInt(hunkMatch[3]!, 10), + newCount, + ); continue; } diff --git a/tests/integration/check.test.ts b/tests/integration/check.test.ts index ccb750d3a..5b9bd9061 100644 --- a/tests/integration/check.test.ts +++ b/tests/integration/check.test.ts @@ -211,13 +211,26 @@ describe('parseDiffOutput', () => { }); test('handles multiple files', () => { + // Each hunk header's declared old/new line counts must be backed by a + // matching number of actual body lines — parseDiffOutput is + // position-aware (issue #1761) and treats a hunk as still "open" until + // its declared counts are consumed, so a header with no body would + // swallow the next file's `--- `/`+++ ` lines as hunk content instead of + // recognizing them. const diff = [ '--- a/src/a.js', '+++ b/src/a.js', '@@ -5,2 +5,3 @@', + ' context a', + '-old a', + '+new a', + '+extra a', '--- a/src/b.js', '+++ b/src/b.js', '@@ -10,1 +10,2 @@', + '-old b', + '+new b', + '+extra b', ].join('\n'); const { changedRanges } = parseDiffOutput(diff); @@ -295,6 +308,69 @@ describe('parseDiffOutput', () => { { start: 1, end: 2, addedText: ['line B', 'line B2'], removedText: ['line A', 'line A2'] }, ]); }); + + // ─── hunk-scoped file-header detection (issue #1761) ─────────────────── + + test('a removed line whose content starts with "-- " is treated as hunk content, not a file header', () => { + // A removed line whose original text is `-- horizontal rule` becomes the + // diff line `--- horizontal rule` once prefixed with the single-`-` + // removal marker — three dashes plus a space, indistinguishable from a + // `--- a/file` source header by content alone (confirmed against real + // `git diff` output on a fixture with this exact content). Without + // position-aware parsing this line is misdetected as a file header and + // silently dropped, desyncing the old-line cursor for the rest of the + // hunk. + const diff = [ + '--- a/src/oldfile.md', + '+++ b/src/oldfile.md', + '@@ -10,4 +10,2 @@', + '-line A', + '--- horizontal rule', + ' context unchanged', + '-line D', + '+replacement', + ].join('\n'); + + const { oldRanges, changedRanges } = parseDiffOutput(diff); + // Both removed lines (old lines 10-11) form one contiguous run, and the + // trailing removed line correctly lands at old line 13 — after the + // untouched context line at 12 — rather than shifted down by one. + expect(oldRanges.get('src/oldfile.md')).toEqual([ + { start: 10, end: 11 }, + { start: 13, end: 13 }, + ]); + expect(changedRanges.get('src/oldfile.md')).toEqual([{ start: 11, end: 11 }]); + }); + + test('an added line whose content starts with "++ b/" is treated as hunk content, not a new-file header', () => { + // Symmetric case: an added line whose original text is + // `++ b/some-file-path` becomes the diff line `+++ b/some-file-path` + // once prefixed with the single-`+` addition marker — indistinguishable + // from a `+++ b/` new-file header by content alone (confirmed + // against real `git diff` output on a fixture with this exact content). + // Without position-aware parsing this line is misdetected as the start + // of a new file section, flushing the in-progress run under a phantom + // file key derived from the line's own content and misattributing every + // line that follows it in the hunk. + const diff = [ + '--- a/src/real-file.md', + '+++ b/src/real-file.md', + '@@ -1,2 +1,4 @@', + ' context unchanged', + '-old line', + '+line A', + '+++ b/some-file-path', + '+line D', + ].join('\n'); + + const { changedRanges, oldRanges } = parseDiffOutput(diff); + // All three added lines (new lines 2-4) form one contiguous run under + // the real file, and no phantom "some-file-path" entry is created. + expect(changedRanges.get('src/real-file.md')).toEqual([{ start: 2, end: 4 }]); + expect(oldRanges.get('src/real-file.md')).toEqual([{ start: 2, end: 2 }]); + expect(changedRanges.has('some-file-path')).toBe(false); + expect(oldRanges.has('some-file-path')).toBe(false); + }); }); // ─── checkNoNewCycles ───────────────────────────────────────────────── From bf82aa2b881921454577c4b153bc13d89f492e6d Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Mon, 6 Jul 2026 06:11:06 -0600 Subject: [PATCH 36/67] fix: update titan-grind's dead-symbol script for current roles --json object shape codegraph roles --role dead --json has always returned { count, summary, symbols } (never a bare array, confirmed back to the command's original commit) but three Node one-liners in titan-grind's SKILL.md assumed a bare array, calling .length/.reduce()/.filter() directly on the parsed JSON. Steps 0.12 and 4 threw "items.reduce is not a function"; the Step 2c symbol-level duplicate scan silently swallowed the same TypeError in a try/catch and always emitted an empty candidate list. Read .count and .summary directly (summary is already the per-role breakdown) and read .symbols for the duplicate-scan candidate list. Fixes #1762 --- .claude/skills/titan-grind/SKILL.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.claude/skills/titan-grind/SKILL.md b/.claude/skills/titan-grind/SKILL.md index 56e79513b..f6605c53e 100644 --- a/.claude/skills/titan-grind/SKILL.md +++ b/.claude/skills/titan-grind/SKILL.md @@ -109,8 +109,9 @@ Forge shapes the metal. Grind smooths the rough edges. Your goal: find helpers t 12. **Capture dead-symbol baseline** (only if `grind.deadSymbolBaseline` is null): ```bash - codegraph roles --role dead -T --json | node -e "const d=[];process.stdin.on('data',c=>d.push(c));process.stdin.on('end',()=>{const items=JSON.parse(Buffer.concat(d));console.log(JSON.stringify({total:items.length,byRole:items.reduce((a,i)=>{a[i.role]=(a[i.role]||0)+1;return a},{})}));})" + codegraph roles --role dead -T --json | node -e "const d=[];process.stdin.on('data',c=>d.push(c));process.stdin.on('end',()=>{const data=JSON.parse(Buffer.concat(d));console.log(JSON.stringify({total:data.count,byRole:data.summary}));})" ``` + `codegraph roles --json` returns `{ count, summary, symbols }` (not a bare array) — `summary` is already the per-role breakdown (e.g. `dead-leaf`, `dead-entry`, `dead-ffi`, `dead-unresolved`), so no manual reduce is needed. Store the total in `grind.deadSymbolBaseline`. Write `titan-state.json` immediately. 13. **Drift detection.** Compare `titan-state.json → mainSHA` against current origin/main: @@ -304,8 +305,8 @@ const tokens = helperName .slice(0, 3); const d=[];process.stdin.on('data',c=>d.push(c)); process.stdin.on('end',()=>{ try { - const items=JSON.parse(Buffer.concat(d)); - const candidates = items.filter(i => + const data=JSON.parse(Buffer.concat(d)); + const candidates = (data.symbols || []).filter(i => i.name !== helperName && tokens.some(t => i.name.toLowerCase().includes(t)) ); @@ -579,7 +580,7 @@ After all targets in the phase are processed: ```bash codegraph build -codegraph roles --role dead -T --json | node -e "const d=[];process.stdin.on('data',c=>d.push(c));process.stdin.on('end',()=>{const items=JSON.parse(Buffer.concat(d));console.log(JSON.stringify({total:items.length,byRole:items.reduce((a,i)=>{a[i.role]=(a[i.role]||0)+1;return a},{})}));})" +codegraph roles --role dead -T --json | node -e "const d=[];process.stdin.on('data',c=>d.push(c));process.stdin.on('end',()=>{const data=JSON.parse(Buffer.concat(d));console.log(JSON.stringify({total:data.count,byRole:data.summary}));})" ``` Store in `grind.deadSymbolCurrent`. Write `titan-state.json`. From cd02d273044d92a64988d44957173c01f061d92f Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Mon, 6 Jul 2026 06:44:14 -0600 Subject: [PATCH 37/67] fix: thread configured busyTimeoutMs through remaining read-only query call sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the config-driven db.busyTimeoutMs wiring (started in #1748/#1749 for openDb()/openReadonlyOrFail() and the call sites that already held a loaded config) to the ~30 ad-hoc read-only query call sites across features/*, domain/analysis/*, and domain/search/* that previously called openReadonlyOrFail() directly and silently fell back to DEFAULTS.db.busyTimeoutMs. Adds resolveBusyTimeoutMs() to src/db/connection.ts (exported via db/index.ts), sharing rootDir derivation with resolveDbSettings() via a new deriveRootDirFromDbPath() helper. For call sites that never loaded config in their path, this new call is threaded in directly. Fixing withReadonlyDb() in domain/analysis/query-helpers.ts centrally also covers its callers (exports.ts, dependencies.ts, context.ts) for free. For call sites that already loaded config but did so AFTER opening the DB (diffImpactData, checkData, complexityData, manifestoData, moduleBoundariesData), reordered config-load-before-db-open, mirroring the precedent already established by resolveDbSettings()/openReadonlyWithNative() (see the phase-15 gauntlet handle-leak fix). Verified this reorder is safe: loadConfig() only throws ConfigError for one narrow case (a non-string llm.apiKeyCommand), unrelated to db.busyTimeoutMs and independent of whether the DB exists — no existing test pins the previous error ordering for any of these call sites. Made every new config.db.busyTimeoutMs access optional-chained (config.db?.busyTimeoutMs) after this surfaced a real crash in tests/integration/complexity.test.ts, which mocks loadConfig() to return a partial object without a db key. Verified end-to-end against a real built graph with a custom .codegraphrc.json (db.busyTimeoutMs override) via the CLI (owners, check, structure --modules, map, audit, complexity, dataflow, search, co-change, ast, flow, cfg, roles, where, children), confirming the configured value reaches the PRAGMA busy_timeout call and the default still applies with no config override. Added tests/unit/busy-timeout-query-sites.test.ts covering resolveBusyTimeoutMs directly plus a representative sample of call sites from each category (ownersData, cfgData, manifestoData, hybridSearchData, withReadonlyDb), spying on Database.prototype.pragma to assert the configured value is actually applied. Filed two follow-ups discovered while completing this wiring pass, both intentionally out of scope here: - #1881: several functions (manifestoData, hybridSearchData, moduleBoundariesData, diffImpactData, complexityData, auditData) resolve their config via process.cwd() rather than the resolved --db path, unlike resolveDbSettings()/checkData() — a pre-existing inconsistency, not introduced by this change, that affects cross-repo --db usage for several config fields (not just busyTimeoutMs). - #1882: the Rust native DB layer (NativeDatabase::open_readonly/ open_read_write in crates/codegraph-core/src/db/connection.rs) still hardcodes PRAGMA busy_timeout = 5000. Investigated and confirmed this does NOT require giving Rust config-loading capability (the JS side already resolves the value) — it requires threading an already-resolved number across the napi FFI boundary plus a native rebuild across platforms, which is a heavier-weight change than this wiring pass. Refs #1763 (TS read-only call-site wiring is complete; the Rust portion of that issue's scope is deferred to #1882, so this does not close it). docs check acknowledged: internal config wiring only, no new config keys or CLI flags — db.busyTimeoutMs was already documented by the original phase-7 commit that introduced it. Impact: 36 functions changed, 131 affected --- src/cli/commands/embed.ts | 4 +- src/cli/shared/open-graph.ts | 4 +- src/db/connection.ts | 35 ++++- src/db/index.ts | 1 + src/domain/analysis/diff-impact.ts | 13 +- src/domain/analysis/module-map.ts | 9 +- src/domain/analysis/query-helpers.ts | 9 +- src/domain/analysis/roles.ts | 6 +- src/domain/analysis/symbol-lookup.ts | 9 +- src/domain/search/search/hybrid.ts | 7 +- src/domain/search/search/keyword.ts | 7 +- src/domain/search/search/prepare.ts | 7 +- src/features/ast.ts | 4 +- src/features/audit.ts | 7 +- src/features/cfg.ts | 3 +- src/features/check.ts | 18 ++- src/features/cochange.ts | 13 +- src/features/complexity-query.ts | 11 +- src/features/dataflow.ts | 11 +- src/features/flow.ts | 6 +- src/features/manifesto.ts | 13 +- src/features/owners.ts | 4 +- src/features/structure-query.ts | 22 ++- src/shared/generators.ts | 17 ++- tests/unit/busy-timeout-query-sites.test.ts | 143 ++++++++++++++++++++ 25 files changed, 317 insertions(+), 66 deletions(-) create mode 100644 tests/unit/busy-timeout-query-sites.test.ts diff --git a/src/cli/commands/embed.ts b/src/cli/commands/embed.ts index 0dd94c101..7ad46a10c 100644 --- a/src/cli/commands/embed.ts +++ b/src/cli/commands/embed.ts @@ -1,5 +1,5 @@ import path from 'node:path'; -import { openReadonlyOrFail } from '../../db/index.js'; +import { openReadonlyOrFail, resolveBusyTimeoutMs } from '../../db/index.js'; import { getEmbeddingMeta } from '../../db/repository/embeddings.js'; import { buildEmbeddings, @@ -13,7 +13,7 @@ import type { CommandDefinition } from '../types.js'; function resolveStickyModel(dbPath: string | undefined): string | null { try { - const db = openReadonlyOrFail(dbPath); + const db = openReadonlyOrFail(dbPath, resolveBusyTimeoutMs(dbPath)); try { const storedName = getEmbeddingMeta(db, 'model'); if (!storedName) return null; diff --git a/src/cli/shared/open-graph.ts b/src/cli/shared/open-graph.ts index b2ef5dfd6..48f702deb 100644 --- a/src/cli/shared/open-graph.ts +++ b/src/cli/shared/open-graph.ts @@ -1,4 +1,4 @@ -import { openReadonlyOrFail } from '../../db/index.js'; +import { openReadonlyOrFail, resolveBusyTimeoutMs } from '../../db/index.js'; import type { BetterSqlite3Database } from '../../types.js'; /** @@ -8,6 +8,6 @@ export function openGraph(opts: { db?: string } = {}): { db: BetterSqlite3Database; close: () => void; } { - const db = openReadonlyOrFail(opts.db); + const db = openReadonlyOrFail(opts.db, resolveBusyTimeoutMs(opts.db)); return { db, close: () => db.close() }; } diff --git a/src/db/connection.ts b/src/db/connection.ts index 8933c0628..7efadbfe4 100644 --- a/src/db/connection.ts +++ b/src/db/connection.ts @@ -386,6 +386,18 @@ interface ResolvedDbSettings { busyTimeoutMs: number; } +/** + * Derive the project rootDir from a possibly-custom DB path, for loadConfig(). + * Using findDbPath (not path.resolve(customDbPath)) ensures directory inputs like + * --db /path/to/repo are normalised to .codegraph/graph.db before we strip two levels. + * Convention: resolvedDbPath = /.codegraph/graph.db + * Shared by resolveDbSettings() and resolveBusyTimeoutMs() so rootDir derivation can't drift. + */ +function deriveRootDirFromDbPath(customDbPath: string | undefined): string | undefined { + const resolvedDbPath = customDbPath ? findDbPath(customDbPath) : undefined; + return resolvedDbPath ? path.dirname(path.dirname(resolvedDbPath)) : undefined; +} + /** * Resolve the effective engine for DB access (explicit opts.engine > config.build.engine > * 'auto') alongside config.db.busyTimeoutMs, in a single loadConfig() call. @@ -400,12 +412,7 @@ function resolveDbSettings( customDbPath: string | undefined, engineOpt: 'native' | 'wasm' | 'auto' | undefined, ): ResolvedDbSettings { - // Using findDbPath (not path.resolve(customDbPath)) ensures directory inputs like - // --db /path/to/repo are normalised to .codegraph/graph.db before we strip two levels. - // Convention: resolvedDbPath = /.codegraph/graph.db - const resolvedDbPath = customDbPath ? findDbPath(customDbPath) : undefined; - const rootDir = resolvedDbPath ? path.dirname(path.dirname(resolvedDbPath)) : undefined; - const config = loadConfig(rootDir); + const config = loadConfig(deriveRootDirFromDbPath(customDbPath)); // config.build.engine is already populated from CODEGRAPH_ENGINE env by applyEnvOverrides, // so this covers both the env-var path and the .codegraphrc.json config-file path. return { @@ -414,6 +421,22 @@ function resolveDbSettings( }; } +/** + * Resolve config.db.busyTimeoutMs alone, for the ad-hoc read-only query call + * sites (features/*, domain/analysis/*, domain/search/*) that call + * openReadonlyOrFail() directly and don't need engine selection. Shares + * rootDir derivation with resolveDbSettings() so the two can't drift. + * + * MUST be called before opening any DB handle, for the same reason as + * resolveDbSettings(): loadConfig can throw (e.g. ConfigError via + * resolveSecrets on a malformed llm.apiKeyCommand config), and an + * already-open handle at that point would never be closed. + */ +export function resolveBusyTimeoutMs(customDbPath?: string): number { + const config = loadConfig(deriveRootDirFromDbPath(customDbPath)); + return config.db?.busyTimeoutMs ?? DEFAULTS.db.busyTimeoutMs; +} + /** Open a NativeRepository via rusqlite, throwing DbError if the DB file is missing. */ function openRepoNative(customDbPath?: string): { repo: Repository; close(): void } { const dbPath = findDbPath(customDbPath); diff --git a/src/db/index.ts b/src/db/index.ts index f08a37677..517bd6493 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -15,6 +15,7 @@ export { openReadonlyWithNative, openRepo, releaseAdvisoryLock, + resolveBusyTimeoutMs, } from './connection.js'; export { getBuildMeta, initSchema, MIGRATIONS, setBuildMeta } from './migrations.js'; export { diff --git a/src/domain/analysis/diff-impact.ts b/src/domain/analysis/diff-impact.ts index e16622cef..f5e116bba 100644 --- a/src/domain/analysis/diff-impact.ts +++ b/src/domain/analysis/diff-impact.ts @@ -6,7 +6,7 @@ import { cachedStmt } from '../../db/repository/cached-stmt.js'; import { evaluateBoundaries } from '../../features/boundaries.js'; import { coChangeForFiles } from '../../features/cochange.js'; import { ownersForFiles } from '../../features/owners.js'; -import { loadConfig } from '../../infrastructure/config.js'; +import { DEFAULTS, loadConfig } from '../../infrastructure/config.js'; import { debug } from '../../infrastructure/logger.js'; import { isTestFile } from '../../infrastructure/test-filter.js'; import { paginateResult } from '../../shared/paginate.js'; @@ -269,10 +269,17 @@ export function diffImpactData( config?: any; } = {}, ) { - const db = openReadonlyOrFail(customDbPath); + // Resolve config before opening the DB so config.db.busyTimeoutMs can be + // threaded through to openReadonlyOrFail() (mirrors resolveDbSettings()'s + // ordering in db/connection.ts — loadConfig can throw, and an already-open + // handle at that point would never be closed). + const config = opts.config || loadConfig(); + const db = openReadonlyOrFail( + customDbPath, + config.db?.busyTimeoutMs ?? DEFAULTS.db.busyTimeoutMs, + ); try { const noTests = opts.noTests || false; - const config = opts.config || loadConfig(); const maxDepth = opts.depth || config.analysis?.impactDepth || 3; const dbPath = findDbPath(customDbPath); diff --git a/src/domain/analysis/module-map.ts b/src/domain/analysis/module-map.ts index d1194bd1c..1d46b658c 100644 --- a/src/domain/analysis/module-map.ts +++ b/src/domain/analysis/module-map.ts @@ -1,5 +1,10 @@ import path from 'node:path'; -import { openReadonlyOrFail, openReadonlyWithNative, testFilterSQL } from '../../db/index.js'; +import { + openReadonlyOrFail, + openReadonlyWithNative, + resolveBusyTimeoutMs, + testFilterSQL, +} from '../../db/index.js'; import { loadConfig } from '../../infrastructure/config.js'; import { debug } from '../../infrastructure/logger.js'; import { isTestFile } from '../../infrastructure/test-filter.js'; @@ -307,7 +312,7 @@ function getComplexitySummary(db: BetterSqlite3Database, testFilter: string) { // --------------------------------------------------------------------------- export function moduleMapData(customDbPath: string, limit = 20, opts: { noTests?: boolean } = {}) { - const db = openReadonlyOrFail(customDbPath); + const db = openReadonlyOrFail(customDbPath, resolveBusyTimeoutMs(customDbPath)); try { const noTests = opts.noTests || false; diff --git a/src/domain/analysis/query-helpers.ts b/src/domain/analysis/query-helpers.ts index 293db0a5b..20c0cd3b0 100644 --- a/src/domain/analysis/query-helpers.ts +++ b/src/domain/analysis/query-helpers.ts @@ -1,4 +1,9 @@ -import { openReadonlyOrFail, openRepo, type Repository } from '../../db/index.js'; +import { + openReadonlyOrFail, + openRepo, + type Repository, + resolveBusyTimeoutMs, +} from '../../db/index.js'; import { loadConfig } from '../../infrastructure/config.js'; import type { BetterSqlite3Database, CodegraphConfig } from '../../types.js'; @@ -11,7 +16,7 @@ export function withReadonlyDb( customDbPath: string | undefined, fn: (db: BetterSqlite3Database) => T, ): T { - const db = openReadonlyOrFail(customDbPath); + const db = openReadonlyOrFail(customDbPath, resolveBusyTimeoutMs(customDbPath)); try { return fn(db); } finally { diff --git a/src/domain/analysis/roles.ts b/src/domain/analysis/roles.ts index 15ca183d1..602af92b3 100644 --- a/src/domain/analysis/roles.ts +++ b/src/domain/analysis/roles.ts @@ -1,4 +1,4 @@ -import { openReadonlyOrFail } from '../../db/index.js'; +import { openReadonlyOrFail, resolveBusyTimeoutMs } from '../../db/index.js'; import { buildFileConditionSQL } from '../../db/query-builder.js'; import { isTestFile } from '../../infrastructure/test-filter.js'; import { DEAD_ROLE_PREFIX } from '../../shared/kinds.js'; @@ -13,7 +13,7 @@ export interface DynamicCallCount { /** Return a count of flagged dynamic call sink edges, grouped by kind. */ export function dynamicCallsData(customDbPath: string): DynamicCallCount[] { - const db = openReadonlyOrFail(customDbPath); + const db = openReadonlyOrFail(customDbPath, resolveBusyTimeoutMs(customDbPath)); try { return db .prepare( @@ -39,7 +39,7 @@ export function rolesData( offset?: number; } = {}, ) { - const db = openReadonlyOrFail(customDbPath); + const db = openReadonlyOrFail(customDbPath, resolveBusyTimeoutMs(customDbPath)); try { const noTests = opts.noTests || false; const filterRole = opts.role || null; diff --git a/src/domain/analysis/symbol-lookup.ts b/src/domain/analysis/symbol-lookup.ts index 90a39934e..fc281e02f 100644 --- a/src/domain/analysis/symbol-lookup.ts +++ b/src/domain/analysis/symbol-lookup.ts @@ -13,6 +13,7 @@ import { listFunctionNodes, openReadonlyOrFail, Repository, + resolveBusyTimeoutMs, } from '../../db/index.js'; import { debug } from '../../infrastructure/logger.js'; import { isTestFile } from '../../infrastructure/test-filter.js'; @@ -95,7 +96,7 @@ export function queryNameData( customDbPath: string, opts: { noTests?: boolean; limit?: number; offset?: number } = {}, ) { - const db = openReadonlyOrFail(customDbPath); + const db = openReadonlyOrFail(customDbPath, resolveBusyTimeoutMs(customDbPath)); try { const noTests = opts.noTests || false; let nodes = db.prepare(`SELECT * FROM nodes WHERE name LIKE ?`).all(`%${name}%`) as NodeRow[]; @@ -195,7 +196,7 @@ export function whereData( customDbPath: string, opts: { noTests?: boolean; file?: boolean; limit?: number; offset?: number } = {}, ) { - const db = openReadonlyOrFail(customDbPath); + const db = openReadonlyOrFail(customDbPath, resolveBusyTimeoutMs(customDbPath)); try { const noTests = opts.noTests || false; const fileMode = opts.file || false; @@ -219,7 +220,7 @@ export function listFunctionsData( offset?: number; } = {}, ) { - const db = openReadonlyOrFail(customDbPath); + const db = openReadonlyOrFail(customDbPath, resolveBusyTimeoutMs(customDbPath)); try { const noTests = opts.noTests || false; @@ -241,7 +242,7 @@ export function childrenData( customDbPath: string, opts: { noTests?: boolean; file?: string; kind?: string; limit?: number; offset?: number } = {}, ) { - const db = openReadonlyOrFail(customDbPath); + const db = openReadonlyOrFail(customDbPath, resolveBusyTimeoutMs(customDbPath)); try { const noTests = opts.noTests || false; diff --git a/src/domain/search/search/hybrid.ts b/src/domain/search/search/hybrid.ts index bf6406c6c..0a2420b14 100644 --- a/src/domain/search/search/hybrid.ts +++ b/src/domain/search/search/hybrid.ts @@ -1,5 +1,5 @@ import { openReadonlyOrFail } from '../../../db/index.js'; -import { loadConfig } from '../../../infrastructure/config.js'; +import { DEFAULTS, loadConfig } from '../../../infrastructure/config.js'; import type { BetterSqlite3Database, CodegraphConfig } from '../../../types.js'; import { hasFtsIndex } from '../stores/fts5.js'; import { ftsSearchData } from './keyword.js'; @@ -184,7 +184,10 @@ export async function hybridSearchData( const k = opts.rrfK ?? searchCfg.rrfK ?? 60; const topK = (opts.limit ?? searchCfg.topK ?? 15) * 5; - const checkDb = openReadonlyOrFail(customDbPath) as BetterSqlite3Database; + const checkDb = openReadonlyOrFail( + customDbPath, + config.db?.busyTimeoutMs ?? DEFAULTS.db.busyTimeoutMs, + ) as BetterSqlite3Database; const ftsAvailable = hasFtsIndex(checkDb); checkDb.close(); if (!ftsAvailable) return null; diff --git a/src/domain/search/search/keyword.ts b/src/domain/search/search/keyword.ts index 66eda0acf..f9d5fe139 100644 --- a/src/domain/search/search/keyword.ts +++ b/src/domain/search/search/keyword.ts @@ -1,4 +1,4 @@ -import { openReadonlyOrFail } from '../../../db/index.js'; +import { openReadonlyOrFail, resolveBusyTimeoutMs } from '../../../db/index.js'; import { buildFileConditionSQL } from '../../../db/query-builder.js'; import type { BetterSqlite3Database } from '../../../types.js'; import { normalizeSymbol } from '../../queries.js'; @@ -41,7 +41,10 @@ export function ftsSearchData( ): FtsSearchResult | null { const limit = opts.limit || 15; - const db = openReadonlyOrFail(customDbPath) as BetterSqlite3Database; + const db = openReadonlyOrFail( + customDbPath, + resolveBusyTimeoutMs(customDbPath), + ) as BetterSqlite3Database; try { if (!hasFtsIndex(db)) { diff --git a/src/domain/search/search/prepare.ts b/src/domain/search/search/prepare.ts index 13cea6cb4..63bf90071 100644 --- a/src/domain/search/search/prepare.ts +++ b/src/domain/search/search/prepare.ts @@ -1,4 +1,4 @@ -import { openReadonlyOrFail } from '../../../db/index.js'; +import { openReadonlyOrFail, resolveBusyTimeoutMs } from '../../../db/index.js'; import { buildFileConditionSQL } from '../../../db/query-builder.js'; import { getEmbeddingCount, getEmbeddingMeta } from '../../../db/repository/embeddings.js'; import { info } from '../../../infrastructure/logger.js'; @@ -43,7 +43,10 @@ export function prepareSearch( customDbPath: string | undefined, opts: PrepareSearchOpts = {}, ): PreparedSearch | null { - const db = openReadonlyOrFail(customDbPath) as BetterSqlite3Database; + const db = openReadonlyOrFail( + customDbPath, + resolveBusyTimeoutMs(customDbPath), + ) as BetterSqlite3Database; try { const count = getEmbeddingCount(db); diff --git a/src/features/ast.ts b/src/features/ast.ts index d227b4bcc..f90f0fbcb 100644 --- a/src/features/ast.ts +++ b/src/features/ast.ts @@ -8,7 +8,7 @@ import { import { buildExtensionSet } from '../ast-analysis/shared.js'; import { walkWithVisitors } from '../ast-analysis/visitor.js'; import { createAstStoreVisitor } from '../ast-analysis/visitors/ast-store-visitor.js'; -import { bulkNodeIdsByFile, openReadonlyOrFail } from '../db/index.js'; +import { bulkNodeIdsByFile, openReadonlyOrFail, resolveBusyTimeoutMs } from '../db/index.js'; import { buildFileConditionSQL } from '../db/query-builder.js'; import { debug } from '../infrastructure/logger.js'; import { outputResult } from '../infrastructure/result-formatter.js'; @@ -304,7 +304,7 @@ export function astQueryData( limit: number; }; } { - const db = openReadonlyOrFail(customDbPath); + const db = openReadonlyOrFail(customDbPath, resolveBusyTimeoutMs(customDbPath)); const { kind, file, noTests, limit, offset } = opts; let where = 'WHERE 1=1'; diff --git a/src/features/audit.ts b/src/features/audit.ts index f5dc5bddf..64a55b462 100644 --- a/src/features/audit.ts +++ b/src/features/audit.ts @@ -3,7 +3,7 @@ import { openReadonlyOrFail } from '../db/index.js'; import { normalizeFileFilter } from '../db/query-builder.js'; import { bfsTransitiveCallers } from '../domain/analysis/impact.js'; import { explainData } from '../domain/queries.js'; -import { loadConfig } from '../infrastructure/config.js'; +import { DEFAULTS, loadConfig } from '../infrastructure/config.js'; import { debug } from '../infrastructure/logger.js'; import { isTestFile } from '../infrastructure/test-filter.js'; import { toErrorMessage } from '../shared/errors.js'; @@ -170,7 +170,10 @@ export function auditData( } // 2. Open DB for enrichment - const db = openReadonlyOrFail(customDbPath); + const db = openReadonlyOrFail( + customDbPath, + config.db?.busyTimeoutMs ?? DEFAULTS.db.busyTimeoutMs, + ); const thresholds = resolveThresholds(customDbPath, opts.config); let functions: AuditFunctionEntry[]; diff --git a/src/features/cfg.ts b/src/features/cfg.ts index a1a036166..48f1a7c42 100644 --- a/src/features/cfg.ts +++ b/src/features/cfg.ts @@ -15,6 +15,7 @@ import { getFunctionNodeId, hasCfgTables, openReadonlyOrFail, + resolveBusyTimeoutMs, } from '../db/index.js'; import { debug, info } from '../infrastructure/logger.js'; import { paginateResult } from '../shared/paginate.js'; @@ -609,7 +610,7 @@ export function cfgData( customDbPath: string | undefined, opts: CfgOpts = {}, ): CfgDataResult { - const db = openReadonlyOrFail(customDbPath); + const db = openReadonlyOrFail(customDbPath, resolveBusyTimeoutMs(customDbPath)); try { const noTests = opts.noTests || false; diff --git a/src/features/check.ts b/src/features/check.ts index ca12c29c6..a7f638f17 100644 --- a/src/features/check.ts +++ b/src/features/check.ts @@ -4,7 +4,7 @@ import path from 'node:path'; import { findDbPath, openReadonlyOrFail } from '../db/index.js'; import { bfsTransitiveCallers } from '../domain/analysis/impact.js'; import { findCycles } from '../domain/graph/cycles.js'; -import { loadConfig } from '../infrastructure/config.js'; +import { DEFAULTS, loadConfig } from '../infrastructure/config.js'; import { isTestFile } from '../infrastructure/test-filter.js'; import type { BetterSqlite3Database, CodegraphConfig } from '../types.js'; import { matchOwners, parseCodeowners } from './owners.js'; @@ -718,15 +718,23 @@ function makeEmptyCheck(): CheckResult { } export function checkData(customDbPath: string | undefined, opts: CheckOpts = {}): CheckResult { - const db = openReadonlyOrFail(customDbPath); + // Resolve repoRoot + config before opening the DB so config.db.busyTimeoutMs + // can be threaded through to openReadonlyOrFail() (mirrors resolveDbSettings()'s + // ordering in db/connection.ts — loadConfig can throw, and an already-open + // handle at that point would never be closed). repoRoot only depends on + // findDbPath(), not on the DB actually existing, so this reorder is safe. + const dbPath = findDbPath(customDbPath); + const repoRoot = path.resolve(path.dirname(dbPath), '..'); + const config = opts.config || loadConfig(repoRoot); + const db = openReadonlyOrFail( + customDbPath, + config.db?.busyTimeoutMs ?? DEFAULTS.db.busyTimeoutMs, + ); try { - const dbPath = findDbPath(customDbPath); - const repoRoot = path.resolve(path.dirname(dbPath), '..'); const noTests = opts.noTests || false; const maxDepth = opts.depth || 3; - const config = opts.config || loadConfig(repoRoot); const flags = resolveCheckFlags(opts, config); const gitRoot = findGitRoot(repoRoot); diff --git a/src/features/cochange.ts b/src/features/cochange.ts index 80ee0194d..f6b990cff 100644 --- a/src/features/cochange.ts +++ b/src/features/cochange.ts @@ -8,7 +8,14 @@ import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; -import { closeDb, findDbPath, initSchema, openDb, openReadonlyOrFail } from '../db/index.js'; +import { + closeDb, + findDbPath, + initSchema, + openDb, + openReadonlyOrFail, + resolveBusyTimeoutMs, +} from '../db/index.js'; import { DEFAULTS } from '../infrastructure/config.js'; import { debug, warn } from '../infrastructure/logger.js'; import { isTestFile } from '../infrastructure/test-filter.js'; @@ -407,7 +414,7 @@ export function coChangeData( customDbPath?: string, opts: { limit?: number; minJaccard?: number; noTests?: boolean; offset?: number } = {}, ): Record { - const db = openReadonlyOrFail(customDbPath); + const db = openReadonlyOrFail(customDbPath, resolveBusyTimeoutMs(customDbPath)); const limit = opts.limit || 20; const minJaccard = opts.minJaccard ?? DEFAULTS.coChange.minJaccard; const noTests = opts.noTests || false; @@ -479,7 +486,7 @@ export function coChangeTopData( customDbPath?: string, opts: { limit?: number; minJaccard?: number; noTests?: boolean; offset?: number } = {}, ): Record { - const db = openReadonlyOrFail(customDbPath); + const db = openReadonlyOrFail(customDbPath, resolveBusyTimeoutMs(customDbPath)); const limit = opts.limit || 20; const minJaccard = opts.minJaccard ?? DEFAULTS.coChange.minJaccard; const noTests = opts.noTests || false; diff --git a/src/features/complexity-query.ts b/src/features/complexity-query.ts index d1ba86a3a..135721b11 100644 --- a/src/features/complexity-query.ts +++ b/src/features/complexity-query.ts @@ -285,6 +285,7 @@ function resolveComplexityQueryOptions(opts: ComplexityQueryOpts): { noTests: boolean; aboveThreshold: boolean; thresholds: any; + busyTimeoutMs: number; } { const config = opts.config || loadConfig(process.cwd()); return { @@ -292,6 +293,7 @@ function resolveComplexityQueryOptions(opts: ComplexityQueryOpts): { noTests: opts.noTests || false, aboveThreshold: opts.aboveThreshold || false, thresholds: config.manifesto?.rules || DEFAULTS.manifesto.rules, + busyTimeoutMs: config.db?.busyTimeoutMs ?? DEFAULTS.db.busyTimeoutMs, }; } @@ -320,10 +322,13 @@ export function complexityData( customDbPath?: string, opts: ComplexityQueryOpts = {}, ): Record { - const db = openReadonlyOrFail(customDbPath); + // Resolve config (and thus busyTimeoutMs) before opening the DB — mirrors + // resolveDbSettings()'s ordering in db/connection.ts, since loadConfig can + // throw and an already-open handle at that point would never be closed. + const { sort, noTests, aboveThreshold, thresholds, busyTimeoutMs } = + resolveComplexityQueryOptions(opts); + const db = openReadonlyOrFail(customDbPath, busyTimeoutMs); try { - const { sort, noTests, aboveThreshold, thresholds } = resolveComplexityQueryOptions(opts); - const { where, params } = buildComplexityWhere({ noTests, target: opts.target || null, diff --git a/src/features/dataflow.ts b/src/features/dataflow.ts index 4f231492b..931a8370d 100644 --- a/src/features/dataflow.ts +++ b/src/features/dataflow.ts @@ -18,7 +18,12 @@ import { } from '../ast-analysis/shared.js'; import { walkWithVisitors } from '../ast-analysis/visitor.js'; import { createDataflowVisitor } from '../ast-analysis/visitors/dataflow-visitor.js'; -import { hasDataflowTable, openReadonlyOrFail, openReadonlyWithNative } from '../db/index.js'; +import { + hasDataflowTable, + openReadonlyOrFail, + openReadonlyWithNative, + resolveBusyTimeoutMs, +} from '../db/index.js'; import { ALL_SYMBOL_KINDS, normalizeSymbol } from '../domain/queries.js'; import { debug, info } from '../infrastructure/logger.js'; import { isTestFile } from '../infrastructure/test-filter.js'; @@ -1531,7 +1536,7 @@ export function dataflowPathData( offset?: number; } = {}, ): Record { - const db = openReadonlyOrFail(customDbPath); + const db = openReadonlyOrFail(customDbPath, resolveBusyTimeoutMs(customDbPath)); try { const noTests = opts.noTests || false; const maxDepth = opts.maxDepth || 10; @@ -1632,7 +1637,7 @@ export function dataflowImpactData( offset?: number; } = {}, ): Record { - const db = openReadonlyOrFail(customDbPath); + const db = openReadonlyOrFail(customDbPath, resolveBusyTimeoutMs(customDbPath)); try { const maxDepth = opts.depth || 5; const noTests = opts.noTests || false; diff --git a/src/features/flow.ts b/src/features/flow.ts index e2a4f1f21..910787658 100644 --- a/src/features/flow.ts +++ b/src/features/flow.ts @@ -5,7 +5,7 @@ * framework entry points (routes, commands, events) through their call chains. */ -import { openReadonlyOrFail } from '../db/index.js'; +import { openReadonlyOrFail, resolveBusyTimeoutMs } from '../db/index.js'; import { CORE_SYMBOL_KINDS, findMatchingNodes } from '../domain/queries.js'; import { isTestFile } from '../infrastructure/test-filter.js'; import { toSymbolRef } from '../shared/normalize.js'; @@ -33,7 +33,7 @@ export function listEntryPointsData( dbPath?: string, opts: { noTests?: boolean; limit?: number; offset?: number } = {}, ): Record { - const db = openReadonlyOrFail(dbPath); + const db = openReadonlyOrFail(dbPath, resolveBusyTimeoutMs(dbPath)); try { const noTests = opts.noTests || false; @@ -255,7 +255,7 @@ export function flowData( offset?: number; } = {}, ): Record { - const db = openReadonlyOrFail(dbPath); + const db = openReadonlyOrFail(dbPath, resolveBusyTimeoutMs(dbPath)); try { const maxDepth = opts.depth || 10; const noTests = opts.noTests || false; diff --git a/src/features/manifesto.ts b/src/features/manifesto.ts index 68c22126c..bf6c97a2d 100644 --- a/src/features/manifesto.ts +++ b/src/features/manifesto.ts @@ -1,7 +1,7 @@ import { openReadonlyOrFail } from '../db/index.js'; import { buildFileConditionSQL } from '../db/query-builder.js'; import { findCycles } from '../domain/graph/cycles.js'; -import { loadConfig } from '../infrastructure/config.js'; +import { DEFAULTS, loadConfig } from '../infrastructure/config.js'; import { debug } from '../infrastructure/logger.js'; import { paginateResult } from '../shared/paginate.js'; import type { BetterSqlite3Database, CodegraphConfig, ThresholdRule } from '../types.js'; @@ -469,10 +469,17 @@ export function manifestoData( customDbPath?: string, opts: ManifestoOpts = {}, ): Record { - const db = openReadonlyOrFail(customDbPath); + // Resolve config before opening the DB so config.db.busyTimeoutMs can be + // threaded through to openReadonlyOrFail() (mirrors resolveDbSettings()'s + // ordering in db/connection.ts — loadConfig can throw, and an already-open + // handle at that point would never be closed). + const config = opts.config || loadConfig(process.cwd()); + const db = openReadonlyOrFail( + customDbPath, + config.db?.busyTimeoutMs ?? DEFAULTS.db.busyTimeoutMs, + ); try { - const config = opts.config || loadConfig(process.cwd()); const rules = resolveRules( config.manifesto?.rules as unknown as Record>, ); diff --git a/src/features/owners.ts b/src/features/owners.ts index 93d1259f6..68e5084f5 100644 --- a/src/features/owners.ts +++ b/src/features/owners.ts @@ -1,6 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; -import { findDbPath, openReadonlyOrFail } from '../db/index.js'; +import { findDbPath, openReadonlyOrFail, resolveBusyTimeoutMs } from '../db/index.js'; import { normalizeFileFilter } from '../db/query-builder.js'; import { isTestFile } from '../infrastructure/test-filter.js'; @@ -320,7 +320,7 @@ function buildOwnersSummary( } export function ownersData(customDbPath?: string, opts: OwnersDataOpts = {}): OwnersDataResult { - const db = openReadonlyOrFail(customDbPath); + const db = openReadonlyOrFail(customDbPath, resolveBusyTimeoutMs(customDbPath)); try { const dbPath = findDbPath(customDbPath); const repoRoot = path.resolve(path.dirname(dbPath), '..'); diff --git a/src/features/structure-query.ts b/src/features/structure-query.ts index 033a00c99..80ea92481 100644 --- a/src/features/structure-query.ts +++ b/src/features/structure-query.ts @@ -7,8 +7,13 @@ * role classification). */ -import { openReadonlyOrFail, openReadonlyWithNative, testFilterSQL } from '../db/index.js'; -import { loadConfig } from '../infrastructure/config.js'; +import { + openReadonlyOrFail, + openReadonlyWithNative, + resolveBusyTimeoutMs, + testFilterSQL, +} from '../db/index.js'; +import { DEFAULTS, loadConfig } from '../infrastructure/config.js'; import { isTestFile } from '../infrastructure/test-filter.js'; import { normalizePath } from '../shared/constants.js'; import { paginateResult } from '../shared/paginate.js'; @@ -138,7 +143,7 @@ export function structureData( suppressed?: number; warning?: string; } { - const db = openReadonlyOrFail(customDbPath); + const db = openReadonlyOrFail(customDbPath, resolveBusyTimeoutMs(customDbPath)); try { const rawDir = opts.directory || null; const filterDir = rawDir && normalizePath(rawDir) !== '.' ? rawDir : null; @@ -376,9 +381,16 @@ export function moduleBoundariesData( }[]; count: number; } { - const db = openReadonlyOrFail(customDbPath); + // Resolve config before opening the DB so config.db.busyTimeoutMs can be + // threaded through to openReadonlyOrFail() (mirrors resolveDbSettings()'s + // ordering in db/connection.ts — loadConfig can throw, and an already-open + // handle at that point would never be closed). + const config = opts.config || loadConfig(); + const db = openReadonlyOrFail( + customDbPath, + config.db?.busyTimeoutMs ?? DEFAULTS.db.busyTimeoutMs, + ); try { - const config = opts.config || loadConfig(); const threshold = opts.threshold ?? (config as unknown as { structure?: { cohesionThreshold?: number } }).structure diff --git a/src/shared/generators.ts b/src/shared/generators.ts index acfbfc91e..fe8839535 100644 --- a/src/shared/generators.ts +++ b/src/shared/generators.ts @@ -1,4 +1,4 @@ -import { iterateFunctionNodes, openReadonlyOrFail } from '../db/index.js'; +import { iterateFunctionNodes, openReadonlyOrFail, resolveBusyTimeoutMs } from '../db/index.js'; import { buildFileConditionSQL } from '../db/query-builder.js'; import { isTestFile } from '../infrastructure/test-filter.js'; import type { BetterSqlite3Database, NodeRow } from '../types.js'; @@ -26,7 +26,10 @@ export function* iterListFunctions( customDbPath?: string, opts: IterListOpts = {}, ): Generator { - const db = openReadonlyOrFail(customDbPath) as BetterSqlite3Database; + const db = openReadonlyOrFail( + customDbPath, + resolveBusyTimeoutMs(customDbPath), + ) as BetterSqlite3Database; try { const noTests = opts.noTests || false; @@ -68,7 +71,10 @@ interface IterRolesOpts { * Generator: stream role-classified symbols one-by-one. */ export function* iterRoles(customDbPath?: string, opts: IterRolesOpts = {}): Generator { - const db = openReadonlyOrFail(customDbPath) as BetterSqlite3Database; + const db = openReadonlyOrFail( + customDbPath, + resolveBusyTimeoutMs(customDbPath), + ) as BetterSqlite3Database; try { const noTests = opts.noTests || false; const conditions = ['role IS NOT NULL']; @@ -133,7 +139,10 @@ export function* iterWhere( customDbPath?: string, opts: IterWhereOpts = {}, ): Generator { - const db = openReadonlyOrFail(customDbPath) as BetterSqlite3Database; + const db = openReadonlyOrFail( + customDbPath, + resolveBusyTimeoutMs(customDbPath), + ) as BetterSqlite3Database; try { const noTests = opts.noTests || false; const placeholders = ALL_SYMBOL_KINDS.map(() => '?').join(', '); diff --git a/tests/unit/busy-timeout-query-sites.test.ts b/tests/unit/busy-timeout-query-sites.test.ts new file mode 100644 index 000000000..159ae6e1e --- /dev/null +++ b/tests/unit/busy-timeout-query-sites.test.ts @@ -0,0 +1,143 @@ +/** + * Regression tests for issue #1763: the ad-hoc read-only query call sites in + * features/*, domain/analysis/*, and domain/search/* must thread the + * user-configured `db.busyTimeoutMs` through to their `openReadonlyOrFail()` + * call, instead of silently falling back to the DEFAULTS.db.busyTimeoutMs + * hardcoded default. + * + * Covers the shared `resolveBusyTimeoutMs()` helper directly, plus a + * representative sample of call sites from each category identified while + * fixing the issue: + * - category (c), never loaded config before this fix: ownersData, cfgData + * - category (b), loaded config AFTER opening the db (now reordered): manifestoData + * - category (a), already loaded config before opening the db: hybridSearchData + * - the shared withReadonlyDb() wrapper (also covers exports/dependencies/context) + */ +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, vi } from 'vitest'; +import { closeDb, initSchema, openDb, resolveBusyTimeoutMs } from '../../src/db/index.js'; +import { withReadonlyDb } from '../../src/domain/analysis/query-helpers.js'; +import { hybridSearchData } from '../../src/domain/search/search/hybrid.js'; +import { cfgData } from '../../src/features/cfg.js'; +import { manifestoData } from '../../src/features/manifesto.js'; +import { ownersData } from '../../src/features/owners.js'; +import { DEFAULTS } from '../../src/infrastructure/config.js'; + +const CUSTOM_BUSY_TIMEOUT_MS = 42424; + +let tmpDir: string; +let dbPath: string; + +/** Capture every `busy_timeout = N` pragma issued against a real Database instance. */ +function captureBusyTimeoutPragmas(): { calls: string[]; restore: () => void } { + const original = Database.prototype.pragma; + const calls: string[] = []; + const spy = vi.spyOn(Database.prototype, 'pragma').mockImplementation(function ( + this: unknown, + sql: string, + ...rest: unknown[] + ) { + if (typeof sql === 'string' && sql.startsWith('busy_timeout')) calls.push(sql); + return original.apply(this, [sql, ...rest] as Parameters); + }); + return { calls, restore: () => spy.mockRestore() }; +} + +beforeAll(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-busy-threading-')); + dbPath = path.join(tmpDir, '.codegraph', 'graph.db'); + const db = openDb(dbPath); + initSchema(db); + closeDb(db); + fs.writeFileSync( + path.join(tmpDir, '.codegraphrc.json'), + JSON.stringify({ db: { busyTimeoutMs: CUSTOM_BUSY_TIMEOUT_MS } }), + ); +}); + +afterAll(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe('resolveBusyTimeoutMs', () => { + it('reads db.busyTimeoutMs from the project config nearest the resolved DB path', () => { + expect(resolveBusyTimeoutMs(dbPath)).toBe(CUSTOM_BUSY_TIMEOUT_MS); + }); + + it('falls back to DEFAULTS.db.busyTimeoutMs when no project config sets it', () => { + const bareDir = fs.mkdtempSync(path.join(tmpDir, 'bare-')); + const bareDbPath = path.join(bareDir, '.codegraph', 'graph.db'); + const db = openDb(bareDbPath); + initSchema(db); + closeDb(db); + + expect(resolveBusyTimeoutMs(bareDbPath)).toBe(DEFAULTS.db.busyTimeoutMs); + expect(resolveBusyTimeoutMs(bareDbPath)).not.toBe(CUSTOM_BUSY_TIMEOUT_MS); + }); +}); + +describe('configured busyTimeoutMs reaches PRAGMA busy_timeout at ad-hoc read-only call sites', () => { + it('ownersData (never loaded config before this fix) applies the configured busy_timeout', () => { + const capture = captureBusyTimeoutPragmas(); + try { + ownersData(dbPath); + } finally { + capture.restore(); + } + expect(capture.calls).toContain(`busy_timeout = ${CUSTOM_BUSY_TIMEOUT_MS}`); + }); + + it('cfgData (never loaded config before this fix) applies the configured busy_timeout', () => { + const capture = captureBusyTimeoutPragmas(); + try { + cfgData('nonexistent-symbol', dbPath); + } finally { + capture.restore(); + } + expect(capture.calls).toContain(`busy_timeout = ${CUSTOM_BUSY_TIMEOUT_MS}`); + }); + + it('manifestoData (config load reordered to before the db opens) applies the configured busy_timeout', () => { + // manifestoData resolves its config via loadConfig(process.cwd()) — a + // pre-existing, cwd-based resolution this fix intentionally preserves + // (only the *timing* of the call moved, not what it resolves) — so the + // project config must be visible from process.cwd() here, not from dbPath. + const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(tmpDir); + const capture = captureBusyTimeoutPragmas(); + try { + manifestoData(dbPath); + } finally { + capture.restore(); + cwdSpy.mockRestore(); + } + expect(capture.calls).toContain(`busy_timeout = ${CUSTOM_BUSY_TIMEOUT_MS}`); + }); + + it('hybridSearchData (already loaded config before the db opens) applies the configured busy_timeout', async () => { + // Same pre-existing cwd-based config resolution as manifestoData (bare + // loadConfig() defaults to process.cwd()) — preserved, not introduced, by + // this fix. + const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(tmpDir); + const capture = captureBusyTimeoutPragmas(); + try { + await hybridSearchData('nonexistent-query', dbPath); + } finally { + capture.restore(); + cwdSpy.mockRestore(); + } + expect(capture.calls).toContain(`busy_timeout = ${CUSTOM_BUSY_TIMEOUT_MS}`); + }); + + it('withReadonlyDb (shared helper backing exports/dependencies/context) applies the configured busy_timeout', () => { + const capture = captureBusyTimeoutPragmas(); + try { + withReadonlyDb(dbPath, (db) => db.prepare('SELECT 1').get()); + } finally { + capture.restore(); + } + expect(capture.calls).toContain(`busy_timeout = ${CUSTOM_BUSY_TIMEOUT_MS}`); + }); +}); From 980b5dc46cd590c9b83b82e6ff1d81975290f91f Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Mon, 6 Jul 2026 07:10:42 -0600 Subject: [PATCH 38/67] fix: resolve computed string-literal keys in object-literal extraction (docs check acknowledged) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit extractObjectLiteralFunctions's pair branch only stripped quotes for plain string-typed keys, so a computed string-literal key like { ['foo']: () => {} } fell through to the raw bracket/quote text, emitting a definition named obj.['foo'] instead of obj.foo. This broke resolution of obj.foo() call sites. Extract the computed_property_name-unwrapping logic already used by resolveMethodDefinitionName into a shared resolveComputedKeyName helper and reuse it in the pair branch, skipping the pair (like the adjacent method_definition branch already does) when the computed key isn't a resolvable string literal (e.g. [Symbol.iterator]). Mirrors the same fix in the Rust native engine: extract_object_literal_functions (const) and match_js_objlit_qualified_method_defs (let/var) both had the identical gap, now routed through shared resolve_computed_key_name / resolve_pair_key_name helpers alongside resolve_method_def_name. No README/CLAUDE.md/ROADMAP.md changes needed — internal extractor bug fix, no new language, command, or architecture surface. Fixes #1764 Impact: 3 functions changed, 17 affected --- .../src/extractors/javascript.rs | 129 ++++++++++++++---- src/extractors/javascript.ts | 30 +++- tests/parsers/javascript.test.ts | 56 ++++++++ 3 files changed, 181 insertions(+), 34 deletions(-) diff --git a/crates/codegraph-core/src/extractors/javascript.rs b/crates/codegraph-core/src/extractors/javascript.rs index bad64596c..d273edb2e 100644 --- a/crates/codegraph-core/src/extractors/javascript.rs +++ b/crates/codegraph-core/src/extractors/javascript.rs @@ -512,12 +512,10 @@ fn extract_object_literal_functions( "pair" => { let Some(key_n) = child.child_by_field_name("key") else { continue }; let Some(val_n) = child.child_by_field_name("value") else { continue }; - let key = if key_n.kind() == "string" { - extract_string_fragment(&key_n, source).map(|s| s.to_string()) - } else { - Some(node_text(&key_n, source).to_string()) - }; - let Some(key) = key else { continue }; + // Use resolve_pair_key_name to strip brackets from computed string keys + // (e.g. ['foo'] → "foo") and skip non-string computed keys ([Symbol.iterator]), + // mirroring resolve_method_def_name below. + let Some(key) = resolve_pair_key_name(&key_n, source) else { continue }; let qualified = format!("{}.{}", var_name, key); match val_n.kind() { "arrow_function" | "function_expression" | "function" => { @@ -722,12 +720,10 @@ fn match_js_objlit_qualified_method_defs( if !matches!(val_n.kind(), "arrow_function" | "function_expression" | "function") { continue; } - let key = if key_n.kind() == "string" { - extract_string_fragment(&key_n, source).map(|s| s.to_string()) - } else { - Some(node_text(&key_n, source).to_string()) - }; - let Some(key) = key else { continue }; + // Use resolve_pair_key_name to strip brackets from computed string keys + // (e.g. ['foo'] → "foo") and skip non-string computed keys ([Symbol.iterator]), + // mirroring the const path in extract_object_literal_functions above. + let Some(key) = resolve_pair_key_name(&key_n, source) else { continue }; let qualified = format!("{}.{}", var_name, key); symbols.definitions.push(Definition { name: qualified, @@ -1140,6 +1136,27 @@ fn handle_class_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) { } } +/// Unwrap a `computed_property_name` node (e.g. `['foo']`) to its inner string-literal text +/// with quotes stripped, or `None` when the computed key isn't a plain string literal (e.g. +/// `[Symbol.iterator]`, `[x]`) — there's no statically resolvable name in that case. +fn resolve_computed_key_name(computed_node: &Node, source: &[u8]) -> Option { + // child(0)='[', child(1)=inner expression, child(2)=']' + let inner = computed_node.child(1)?; + match inner.kind() { + "string" => { + let s = extract_string_fragment(&inner, source).unwrap_or(""); + if s.is_empty() { return None; } + Some(s.to_string()) + } + "string_fragment" => { + let s = node_text(&inner, source); + if s.is_empty() { return None; } + Some(s.to_string()) + } + _ => None, // non-string computed key — skip + } +} + /// Extract the plain method name from a `method_definition` node. /// /// For computed property names (`['methodName']`), strips brackets and quotes from @@ -1149,26 +1166,27 @@ fn handle_class_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) { fn resolve_method_def_name(node: &Node, source: &[u8]) -> Option { let name_node = node.child_by_field_name("name")?; if name_node.kind() == "computed_property_name" { - // child(0)='[', child(1)=string literal, child(2)=']' - let inner = name_node.child(1)?; - match inner.kind() { - "string" => { - let s = extract_string_fragment(&inner, source).unwrap_or(""); - if s.is_empty() { return None; } - Some(s.to_string()) - } - "string_fragment" => { - let s = node_text(&inner, source); - if s.is_empty() { return None; } - Some(s.to_string()) - } - _ => None, // non-string computed key — skip - } + resolve_computed_key_name(&name_node, source) } else { Some(node_text(&name_node, source).to_string()) } } +/// Resolve an object-literal `pair` node's key field to its plain string form. +/// +/// Mirrors `resolve_method_def_name`'s computed-key handling so `{ ['foo']: () => {} }` and +/// `{ ['foo']() {} }` resolve identically: quoted string keys have their quotes stripped, +/// computed string-literal keys (`['foo']`) are unwrapped, and non-string computed keys +/// (e.g. `[Symbol.iterator]`) return `None` (no resolvable name — caller skips the pair) +/// rather than falling back to the raw bracket/quote source text. +fn resolve_pair_key_name(key_n: &Node, source: &[u8]) -> Option { + match key_n.kind() { + "string" => extract_string_fragment(key_n, source).map(|s| s.to_string()), + "computed_property_name" => resolve_computed_key_name(key_n, source), + _ => Some(node_text(key_n, source).to_string()), + } +} + fn handle_method_def(node: &Node, source: &[u8], symbols: &mut FileSymbols) { if let Some(method_name) = resolve_method_def_name(node, source) { let method_name = method_name.as_str(); @@ -4902,6 +4920,63 @@ mod tests { assert_eq!(tm_f.unwrap().type_name, "f"); } + /// Issue #1764: a computed string-literal pair key (`['foo']: () => {}`) must resolve to + /// the plain qualified name `obj.foo`, not the raw bracket/quote text `obj.['foo']` — the + /// same unwrapping `resolve_method_def_name` already applies to method_definition keys. + #[test] + fn computed_string_literal_pair_key_resolves_to_plain_name() { + let s = parse_js( + "const obj = {\n\ + ['foo']: () => { return 1; },\n\ + bar: () => { return 2; },\n\ + };\n\ + obj.foo();\n\ + obj.bar();", + ); + let names: Vec<_> = s.definitions.iter().map(|d| d.name.as_str()).collect(); + assert!(names.contains(&"obj.foo"), "expected 'obj.foo'; got: {:?}", names); + assert!(names.contains(&"obj.bar"), "expected 'obj.bar'; got: {:?}", names); + assert!( + !names.iter().any(|n| n.contains('[')), + "no definition name should retain the bracketed/quoted form; got: {:?}", + names + ); + } + + /// Issue #1764: a non-string computed pair key (`[Symbol.iterator]: () => {}`) has no + /// statically resolvable name — the pair must be skipped entirely, mirroring + /// method_definition's existing precedent for the same computed-key shape. + #[test] + fn non_string_computed_pair_key_is_skipped() { + let s = parse_js( + "const obj = {\n\ + [Symbol.iterator]: () => { return 1; },\n\ + bar: () => { return 2; },\n\ + };", + ); + let names: Vec<_> = s.definitions.iter().map(|d| d.name.as_str()).collect(); + assert!( + !names.iter().any(|n| n.contains("iterator")), + "non-string computed key must not produce a definition; got: {:?}", + names + ); + assert!(names.contains(&"obj.bar"), "expected 'obj.bar'; got: {:?}", names); + } + + /// Issue #1764: the same computed-key unwrapping must apply to `let`/`var` object literals + /// (match_js_objlit_qualified_method_defs), not just `const` (extract_object_literal_functions). + #[test] + fn computed_string_literal_pair_key_resolves_for_let_and_var() { + let s = parse_js( + "let obj2 = { ['computedLet']: () => {}, plain: () => {} };\n\ + var obj3 = { ['computedVar']: () => {} };", + ); + let names: Vec<_> = s.definitions.iter().map(|d| d.name.as_str()).collect(); + assert!(names.contains(&"obj2.computedLet"), "expected 'obj2.computedLet'; got: {:?}", names); + assert!(names.contains(&"obj2.plain"), "expected 'obj2.plain'; got: {:?}", names); + assert!(names.contains(&"obj3.computedVar"), "expected 'obj3.computedVar'; got: {:?}", names); + } + /// Issue #1551: `let` and `var` object-literal declarations must seed composite typeMap keys /// just like `const` declarations. Regression test for the parity gap where native bailed /// early for non-`const` declarations in the object-literal typeMap walk. diff --git a/src/extractors/javascript.ts b/src/extractors/javascript.ts index ad7f09bcd..097c1f127 100644 --- a/src/extractors/javascript.ts +++ b/src/extractors/javascript.ts @@ -1296,8 +1296,15 @@ function extractObjectLiteralFunctions( const keyNode = child.childForFieldName('key'); const valueNode = child.childForFieldName('value'); if (!keyNode || !valueNode) continue; + // Computed string-literal keys (e.g. `['foo']: fn`) are unwrapped the same way as + // method_definition's name field; non-string computed keys (e.g. `[Symbol.iterator]: fn`) + // resolve to '' and are skipped below, mirroring the method_definition branch. const keyName = - keyNode.type === 'string' ? keyNode.text.replace(/^['"]|['"]$/g, '') : keyNode.text; + keyNode.type === 'string' + ? keyNode.text.replace(/^['"]|['"]$/g, '') + : keyNode.type === 'computed_property_name' + ? resolveComputedKeyName(keyNode) + : keyNode.text; if (!keyName) continue; if ( valueNode.type === 'arrow_function' || @@ -2073,20 +2080,29 @@ function pushFnDeclContext(funcStack: string[], node: TreeSitterNode): boolean { } /** - * Resolve the raw method name from a method_definition's name field, unwrapping - * computed_property_name string literals (e.g. `['foo']() {}` -> 'foo'). Returns '' - * for non-string computed keys (no resolvable name). + * Unwrap a `computed_property_name` node (e.g. `['foo']`) to its inner string-literal text + * with quotes stripped, or '' when the computed key isn't a plain string literal (e.g. + * `[Symbol.iterator]`, `[x]`) — there's no statically resolvable name in that case. */ -function resolveMethodDefinitionName(nameNode: TreeSitterNode): string { - if (nameNode.type !== 'computed_property_name') return nameNode.text; +function resolveComputedKeyName(nameNode: TreeSitterNode): string { const inner = nameNode.child(1); if (!inner || (inner.type !== 'string' && inner.type !== 'string_fragment')) { - // Non-string computed key — skip adding to funcStack (no resolvable name). + // Non-string computed key — no resolvable name. return ''; } return inner.text.replace(/^['"]|['"]$/g, ''); } +/** + * Resolve the raw method name from a method_definition's name field, unwrapping + * computed_property_name string literals (e.g. `['foo']() {}` -> 'foo'). Returns '' + * for non-string computed keys (no resolvable name). + */ +function resolveMethodDefinitionName(nameNode: TreeSitterNode): string { + if (nameNode.type !== 'computed_property_name') return nameNode.text; + return resolveComputedKeyName(nameNode); +} + /** * Push node onto funcStack for a method_definition, qualified with the enclosing class * name so the PTS key matches callerName from findCaller (which uses diff --git a/tests/parsers/javascript.test.ts b/tests/parsers/javascript.test.ts index 14b36008d..c785eb02c 100644 --- a/tests/parsers/javascript.test.ts +++ b/tests/parsers/javascript.test.ts @@ -1527,6 +1527,62 @@ describe('JavaScript parser', () => { }); }); + describe('computed pair key extraction (#1764)', () => { + it('extracts a computed string-literal pair key as a plain qualified name', () => { + // `{ ['foo']: () => {} }` — computed_property_name wrapping a string literal must be + // unwrapped the same way as method_definition's name field (resolveComputedKeyName), + // not left as the raw bracket/quote text `obj.['foo']`. + const symbols = parseJS( + `const obj = { ['foo']: () => { return 1; }, bar: () => { return 2; } };`, + ); + expect(symbols.definitions).toContainEqual( + expect.objectContaining({ name: 'obj.foo', kind: 'function' }), + ); + expect(symbols.definitions).toContainEqual( + expect.objectContaining({ name: 'obj.bar', kind: 'function' }), + ); + }); + + it('does not use the bracketed/quoted form in the stored pair definition name', () => { + const symbols = parseJS(`const obj = { ['foo']: () => {} };`); + const def = symbols.definitions.find((d) => d.name.includes('[')); + expect(def).toBeUndefined(); + }); + + it('skips a non-string computed pair key (Symbol.iterator) instead of emitting garbage', () => { + // Mirrors method_definition's precedent ('does not extract non-string computed key'): + // there's no statically resolvable name, so the pair is skipped entirely rather than + // falling back to raw source text like `obj.[Symbol.iterator]`. + const symbols = parseJS(`const obj = { [Symbol.iterator]: () => {}, bar: () => {} };`); + const def = symbols.definitions.find((d) => d.name.includes('iterator')); + expect(def).toBeUndefined(); + expect(symbols.definitions).toContainEqual( + expect.objectContaining({ name: 'obj.bar', kind: 'function' }), + ); + }); + + it('skips a variable computed pair key instead of emitting garbage', () => { + const symbols = parseJS(`const key = 'foo'; const obj = { [key]: () => {}, bar: () => {} };`); + expect(symbols.definitions).not.toContainEqual( + expect.objectContaining({ name: expect.stringContaining('[key]') }), + ); + expect(symbols.definitions).not.toContainEqual(expect.objectContaining({ name: 'obj.key' })); + expect(symbols.definitions).toContainEqual( + expect.objectContaining({ name: 'obj.bar', kind: 'function' }), + ); + }); + + it('extracts a computed string-literal pair key for let/var object literals', () => { + const symbols = parseJS(`let x15 = { ['computedLet']: () => {}, plain: () => {} };`); + expect(symbols.definitions).toContainEqual( + expect.objectContaining({ name: 'x15.computedLet', kind: 'function' }), + ); + expect(symbols.definitions).toContainEqual( + expect.objectContaining({ name: 'x15.plain', kind: 'function' }), + ); + }); + }); + describe('class expression inside function extraction (#1471)', () => { it('extracts named class expression returned from a function', () => { const symbols = parseJS( From 4a873a3c0c989c49c1ed06449e3fb74c3c5cd57d Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Mon, 6 Jul 2026 07:27:37 -0600 Subject: [PATCH 39/67] fix: add same-class bare-call fallback to incremental rebuild path (docs check acknowledged) incremental.ts's applyThisReceiverFallbacks (renamed applyCallFallbacks) only implemented the this.method() and Object.defineProperty fallbacks, missing the same-class bare-call fallback that the full-build path has (resolveSameClassBareCallFallback in stages/build-edges.ts). For class-scoped languages (non-JS/TS), a bare call with no receiver now retries qualified as . in the incremental path too, guarded by isModuleScopedLanguage exactly as the full-build path is. Reuses the already-shared resolveSameClassQualifiedMethod from call-resolver.ts rather than duplicating its lookup logic. Fixes #1765 Impact: 2 functions changed, 6 affected --- src/domain/graph/builder/incremental.ts | 34 +++- ...65-incremental-same-class-barecall.test.ts | 180 ++++++++++++++++++ 2 files changed, 208 insertions(+), 6 deletions(-) create mode 100644 tests/integration/issue-1765-incremental-same-class-barecall.test.ts diff --git a/src/domain/graph/builder/incremental.ts b/src/domain/graph/builder/incremental.ts index beb6ac388..d78c4bfcc 100644 --- a/src/domain/graph/builder/incremental.ts +++ b/src/domain/graph/builder/incremental.ts @@ -25,6 +25,7 @@ import { computeConfidence, resolveImportPath } from '../resolve.js'; import { type CallNodeLookup, findCaller, + isModuleScopedLanguage, resolveCallTargets, resolveReceiverEdge, resolveSameClassQualifiedMethod, @@ -652,10 +653,23 @@ function resolveDefinePropertyTarget( } /** - * Apply `this`-receiver fallback resolution strategies for a single call site - * when the primary resolveCallTargets pass returned no targets. + * Apply fallback resolution strategies for a single call site when the + * primary resolveCallTargets pass returned no targets. + * + * Runs in order: + * 1. Same-class `this.method()` fallback. + * 2. Same-class bare-call fallback for non-JS/TS class-scoped languages + * (e.g. C# static sibling calls: `IsValidEmail()` inside + * `Validators.ValidateUser` resolves to `Validators.IsValidEmail`). + * 3. Object.defineProperty accessor fallback (this-calls inside getter/setter). + * + * Mirrors the same-class fallback strategies in `resolveFallbackTargets` + * (stages/build-edges.ts, full-build path). The Kotlin-reflection + * pre-qualify and reflection-keyExpr fallbacks are intentionally not + * mirrored here — that broader points-to/CHA/dynamic-sink gap between the + * two paths is tracked separately (#1815/#1852), not by this function. */ -function applyThisReceiverFallbacks( +function applyCallFallbacks( call: { name: string; receiver?: string | null }, callerName: string | null, relPath: string, @@ -672,7 +686,15 @@ function applyThisReceiverFallbacks( if (s1.length > 0) return s1; } - // Strategy 2: Object.defineProperty accessor fallback. + // Strategy 2: same-class bare-call fallback. Skipped for JS/TS, where a + // bare call is module-scoped, not class-scoped (mirrors + // resolveSameClassBareCallFallback in stages/build-edges.ts). + if (!call.receiver && callerName != null && !isModuleScopedLanguage(relPath)) { + const s2 = resolveSameClassQualifiedMethod(call.name, callerName, relPath, lookup); + if (s2.length > 0) return s2; + } + + // Strategy 3: Object.defineProperty accessor fallback. if (call.receiver === 'this' && callerName != null && definePropertyReceivers) { return resolveDefinePropertyTarget( call.name, @@ -769,7 +791,7 @@ function buildCallEdges( importedOriginalNames, ); - const targets = applyThisReceiverFallbacks( + const targets = applyCallFallbacks( call, caller.callerName, relPath, @@ -810,7 +832,7 @@ function buildCallEdges( * same file), as opposed to `'points-to'` (alias/pts fallback) or `'cha'` / * `'super-dispatch'` (virtual-dispatch expansion). `incremental.ts`'s call * resolution (`resolveCallTargets` + the same-class/defineProperty - * fallbacks in `applyThisReceiverFallbacks`) implements only that same + * fallbacks in `applyCallFallbacks`) implements only that same * direct-resolution cascade — it has no pts or CHA/RTA post-pass of its own * — so every edge it emits is the direct-resolution case and always * belongs under `'ts-native'`, regardless of which engine parsed the file. diff --git a/tests/integration/issue-1765-incremental-same-class-barecall.test.ts b/tests/integration/issue-1765-incremental-same-class-barecall.test.ts new file mode 100644 index 000000000..9cd7dec6b --- /dev/null +++ b/tests/integration/issue-1765-incremental-same-class-barecall.test.ts @@ -0,0 +1,180 @@ +/** + * Regression test for #1765: incremental single-file rebuild (`codegraph + * watch` -> `rebuildFile` in `src/domain/graph/builder/incremental.ts`) was + * missing the same-class bare-call fallback that the full-build path has + * (`resolveSameClassBareCallFallback` in `stages/build-edges.ts`). + * + * For class-scoped languages (non-JS/TS), a bare call with no receiver that + * fails resolution retries qualified as `.` — this is + * how e.g. C# static sibling calls (`IsValidEmail()` inside + * `Validators.ValidateUser` -> `Validators.IsValidEmail`) get resolved. + * `incremental.ts`'s `applyThisReceiverFallbacks` only implemented the + * `this.method()` and Object.defineProperty fallbacks, so a bare-call sibling + * edge resolved on a full build could go missing after a watch-mode + * single-file rebuild of the same file. + * + * Mirrors the full-build-vs-incremental-rebuild comparison pattern from + * `issue-1744-incremental-edge-technique.test.ts`. + */ +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 { getNodeId as getNodeIdQuery, initSchema, openDb } from '../../src/db/index.js'; +import { rebuildFile } from '../../src/domain/graph/builder/incremental.js'; +import { buildGraph } from '../../src/domain/graph/builder.js'; +import { isNativeAvailable } from '../../src/infrastructure/native.js'; +import type { EngineMode } from '../../src/types.js'; + +function writeFixture(dir: string, marker: string): void { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, 'Validators.cs'), + `namespace Demo; +public static class Validators { + // ${marker} + public static bool IsValidEmail(string email) { + return email.Contains("@"); + } + public static bool IsValidName(string name) { + return name.Length >= 2; + } + public static bool ValidateUser(string email, string name) { + return IsValidEmail(email) && IsValidName(name); + } +} +`, + ); +} + +interface CallEdgeRow { + src: string; + tgt: string; + kind: string; +} + +function readCallEdges(dbPath: string): CallEdgeRow[] { + const db = new Database(dbPath, { readonly: true }); + try { + return db + .prepare( + `SELECT n1.name AS src, n2.name AS tgt, e.kind + 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 CallEdgeRow[]; + } finally { + db.close(); + } +} + +function makeStmts(db: ReturnType) { + return { + insertNode: db.prepare( + 'INSERT OR IGNORE INTO nodes (name, kind, file, line, end_line) VALUES (?, ?, ?, ?, ?)', + ), + getNodeId: { + get: (name: string, kind: string, file: string, line: number) => { + const id = getNodeIdQuery(db, name, kind, file, line); + return id != null ? { id } : undefined; + }, + }, + insertEdge: db.prepare( + 'INSERT INTO edges (source_id, target_id, kind, confidence, dynamic) VALUES (?, ?, ?, ?, ?)', + ), + countNodes: db.prepare('SELECT COUNT(*) as c FROM nodes WHERE file = ?'), + countEdges: db.prepare( + 'SELECT COUNT(*) as c FROM edges WHERE source_id IN (SELECT id FROM nodes WHERE file = ?)', + ), + findNodeInFile: db.prepare( + "SELECT id, kind, file FROM nodes WHERE name = ? AND kind IN ('function', 'method', 'class', 'interface', 'type', 'struct', 'enum', 'trait', 'record', 'module', 'constant') AND file = ?", + ), + findNodeByName: db.prepare( + "SELECT id, file, kind FROM nodes WHERE name = ? AND kind IN ('function', 'method', 'class', 'interface', 'type', 'struct', 'enum', 'trait', 'record', 'module', 'constant')", + ), + listSymbols: db.prepare("SELECT name, kind, line FROM nodes WHERE file = ? AND kind != 'file'"), + upsertFileHash: db.prepare( + 'INSERT OR REPLACE INTO file_hashes (file, hash, mtime, size) VALUES (?, ?, ?, ?)', + ), + deleteFileHash: db.prepare('DELETE FROM file_hashes WHERE file = ?'), + }; +} + +function runWatchScenario(engine: EngineMode): void { + describe(`codegraph watch (rebuildFile): same-class bare-call fallback matches full rebuild (#1765) — ${engine}`, () => { + let projDir: string; + let refDir: string; + + beforeAll(async () => { + projDir = fs.mkdtempSync(path.join(os.tmpdir(), `cg-1765-watch-${engine}-`)); + writeFixture(projDir, 'v1'); + await buildGraph(projDir, { engine, incremental: false, skipRegistry: true }); + + // Edit Validators.cs, then rebuild ONLY it via the watcher's + // single-file incremental path (the exact code path under test). + writeFixture(projDir, 'v2 edited'); + const dbPath = path.join(projDir, '.codegraph', 'graph.db'); + const db = openDb(dbPath); + try { + initSchema(db); + const stmts = makeStmts(db); + await rebuildFile( + db, + projDir, + path.join(projDir, 'Validators.cs'), + stmts, + { engine }, + null, + ); + } finally { + db.close(); + } + + refDir = fs.mkdtempSync(path.join(os.tmpdir(), `cg-1765-watch-ref-${engine}-`)); + writeFixture(refDir, 'v2 edited'); + await buildGraph(refDir, { engine, incremental: false, skipRegistry: true }); + }, 60_000); + + afterAll(() => { + fs.rmSync(projDir, { recursive: true, force: true }); + fs.rmSync(refDir, { recursive: true, force: true }); + }); + + it('resolves the same-class bare-call sibling edges after rebuildFile, matching a full rebuild', () => { + const incremental = readCallEdges(path.join(projDir, '.codegraph', 'graph.db')); + const reference = readCallEdges(path.join(refDir, '.codegraph', 'graph.db')); + + expect(reference).toContainEqual({ + src: 'Validators.ValidateUser', + tgt: 'Validators.IsValidEmail', + kind: 'calls', + }); + expect(reference).toContainEqual({ + src: 'Validators.ValidateUser', + tgt: 'Validators.IsValidName', + kind: 'calls', + }); + + expect(incremental).toEqual(reference); + expect(incremental).toContainEqual({ + src: 'Validators.ValidateUser', + tgt: 'Validators.IsValidEmail', + kind: 'calls', + }); + expect(incremental).toContainEqual({ + src: 'Validators.ValidateUser', + tgt: 'Validators.IsValidName', + kind: 'calls', + }); + }); + }); +} + +runWatchScenario('wasm'); +describe.skipIf(!isNativeAvailable())('native engine coverage', () => { + runWatchScenario('native'); +}); From 8f3348a999cd6d639c04c4f789bef4e1a001c68a Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Mon, 6 Jul 2026 07:58:24 -0600 Subject: [PATCH 40/67] fix: unify Object.defineProperty accessor fallback kind-filtering between build paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The full-build path's Object.defineProperty accessor fallback (resolveDefinePropertyAccessorFallback in stages/build-edges.ts, and its native-engine post-pass buildDefinePropertyPostPass) and the incremental path's (applyCallFallbacks in incremental.ts) diverged in their final same-file fallback tier: full-build returned any same-file node named call.name regardless of kind, while incremental filtered to function/method kinds only. A getter/setter registered via Object.defineProperty always dispatches to callable code, and every resolved target is unconditionally emitted as a 'calls' edge downstream — so the incremental path's kind filter is the correct behavior. Extracted the shared logic into resolveDefinePropertyAccessorTarget in call-resolver.ts (applying the kind filter) and updated all three call sites - the full-build fallback, its native-engine post-pass, and the incremental fallback - to use it, removing the divergent duplicated implementations. docs check acknowledged: internal call-resolution bug fix/refactor, no new feature/language/architecture/command surface to document in README/CLAUDE.md/ROADMAP.md. Fixes #1766 Impact: 4 functions changed, 14 affected --- src/domain/graph/builder/call-resolver.ts | 44 ++++ src/domain/graph/builder/incremental.ts | 43 +--- .../graph/builder/stages/build-edges.ts | 57 +++--- ...-defineproperty-kind-filter-parity.test.ts | 192 ++++++++++++++++++ tests/unit/call-resolver.test.ts | 100 +++++++++ 5 files changed, 365 insertions(+), 71 deletions(-) create mode 100644 tests/integration/issue-1766-defineproperty-kind-filter-parity.test.ts diff --git a/src/domain/graph/builder/call-resolver.ts b/src/domain/graph/builder/call-resolver.ts index 68a5a2589..dde9fb234 100644 --- a/src/domain/graph/builder/call-resolver.ts +++ b/src/domain/graph/builder/call-resolver.ts @@ -14,6 +14,7 @@ import { isModuleScopedLanguage, resolveByGlobal, resolveByReceiver, + unwrapTypeEntry, } from '../resolver/strategy.js'; // ── Public interface ───────────────────────────────────────────────────── @@ -60,6 +61,49 @@ export function resolveSameClassQualifiedMethod( .filter((n) => n.kind === 'method'); } +/** + * Shared by both the full-build (build-edges.ts, including its native-engine + * post-pass) and incremental (incremental.ts) `Object.defineProperty` accessor + * fallback: when a function is registered as a getter/setter via + * `Object.defineProperty(obj, "bar", { get: getter })`, calls to `this.X()` + * inside `getter` resolve against `obj` (this === obj when the accessor is + * invoked). + * + * `definePropertyReceivers` maps the getter/setter's own name (`callerName`) + * to the receiver variable name (`obj`). Resolution: + * 1. Look up `obj`'s type in the typeMap and try the qualified `Type.X` + * method in the same file. + * 2. Otherwise, fall back to any same-file definition named `X` — handles + * plain object literals where the method isn't qualified (e.g. + * `const obj = { baz() {} }` defines `baz` directly). + * + * The fallback tier (2) is restricted to `function`/`method` kinds: a + * getter/setter's implementation is always callable code, so an unfiltered + * lookup could otherwise match an unrelated same-named class or variable in + * the same file (issue #1766). Tier (1) is intentionally left unfiltered, + * matching its pre-existing behaviour on all three call sites. + */ +export function resolveDefinePropertyAccessorTarget( + callName: string, + callerName: string, + relPath: string, + typeMap: Map, + lookup: CallNodeLookup, + definePropertyReceivers: ReadonlyMap, +): Array<{ id: number; file: string; kind?: string }> { + const receiverVarName = definePropertyReceivers.get(callerName); + if (!receiverVarName) return []; + + const typeName = unwrapTypeEntry(typeMap.get(receiverVarName)); + if (typeName) { + const qualified = lookup.byNameAndFile(`${typeName}.${callName}`, relPath); + if (qualified.length > 0) return [...qualified]; + } + return lookup + .byNameAndFile(callName, relPath) + .filter((n) => n.kind === 'function' || n.kind === 'method'); +} + // ── Shared resolution functions ────────────────────────────────────────── /** diff --git a/src/domain/graph/builder/incremental.ts b/src/domain/graph/builder/incremental.ts index d78c4bfcc..5f3492837 100644 --- a/src/domain/graph/builder/incremental.ts +++ b/src/domain/graph/builder/incremental.ts @@ -27,6 +27,7 @@ import { findCaller, isModuleScopedLanguage, resolveCallTargets, + resolveDefinePropertyAccessorTarget, resolveReceiverEdge, resolveSameClassQualifiedMethod, } from './call-resolver.js'; @@ -616,42 +617,6 @@ function buildIncrementalTypeMap(symbols: ExtractorOutput): Map return typeMap; } -/** - * Strategy 2 — Object.defineProperty accessor fallback. - * When a function is registered as a getter/setter via - * `Object.defineProperty(obj, "bar", { get: getter })`, calls to `this.X()` - * inside `getter` resolve against `obj`. Looks up the receiver var in the - * typeMap for its type, then falls back to any same-file definition named - * `callName` with function or method kind. - */ -function resolveDefinePropertyTarget( - callName: string, - callerName: string, - relPath: string, - typeMap: Map, - lookup: CallNodeLookup, - definePropertyReceivers: Map, -): Array<{ id: number; file: string; kind?: string }> { - const receiverVarName = definePropertyReceivers.get(callerName); - if (!receiverVarName) return []; - - const typeEntry = typeMap.get(receiverVarName); - const typeName = typeEntry - ? typeof typeEntry === 'string' - ? typeEntry - : (typeEntry as { type?: string }).type - : null; - if (typeName) { - const qualified = lookup.byNameAndFile(`${typeName}.${callName}`, relPath); - if (qualified.length > 0) return [...qualified]; - } - // Narrow to function/method kinds only to avoid matching unrelated - // variables or classes that share a name in the same file. - return lookup - .byNameAndFile(callName, relPath) - .filter((n) => n.kind === 'function' || n.kind === 'method'); -} - /** * Apply fallback resolution strategies for a single call site when the * primary resolveCallTargets pass returned no targets. @@ -694,9 +659,11 @@ function applyCallFallbacks( if (s2.length > 0) return s2; } - // Strategy 3: Object.defineProperty accessor fallback. + // Strategy 3: Object.defineProperty accessor fallback. Shared with the + // full-build path (stages/build-edges.ts) via call-resolver.ts so both + // paths apply the same function/method kind filter (issue #1766). if (call.receiver === 'this' && callerName != null && definePropertyReceivers) { - return resolveDefinePropertyTarget( + return resolveDefinePropertyAccessorTarget( call.name, callerName, relPath, diff --git a/src/domain/graph/builder/stages/build-edges.ts b/src/domain/graph/builder/stages/build-edges.ts index cfe33f891..89b28c467 100644 --- a/src/domain/graph/builder/stages/build-edges.ts +++ b/src/domain/graph/builder/stages/build-edges.ts @@ -43,6 +43,7 @@ import { findCaller, isModuleScopedLanguage, resolveCallTargets, + resolveDefinePropertyAccessorTarget, resolveReceiverEdge, resolveSameClassQualifiedMethod, } from '../call-resolver.js'; @@ -720,22 +721,16 @@ function buildDefinePropertyPostPass( ); if (directTargets.length > 0) continue; - // Resolve via receiver type - let targets: ReadonlyArray<{ id: number; file: string }> = []; - 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}`; - targets = lookup.byNameAndFile(qualifiedName, relPath); - } - // Same-file fallback for plain object-literal methods - if (targets.length === 0) { - targets = lookup.byNameAndFile(call.name, relPath); - } + // Resolve via receiver type, restricted to function/method kinds + // (shared with the WASM-path fallback and incremental.ts — issue #1766). + const targets = resolveDefinePropertyAccessorTarget( + call.name, + caller.callerName, + relPath, + typeMap as Map, + lookup, + definePropertyReceivers, + ); for (const t of targets) { const edgeKey = `${caller.id}|${t.id}`; @@ -1267,6 +1262,11 @@ function resolveReflectionKeyExprFallback( * nothing, try treating `obj` as the receiver and look up `obj.X` in the * typeMap, or fall back to a same-file lookup of any definition named X * that belongs to the object literal or its type. + * + * Checks applicability (this-receiver + known caller + a receiver map to + * consult) then delegates the actual resolution to the shared + * `resolveDefinePropertyAccessorTarget` (call-resolver.ts), which is also + * used by the native-engine post-pass below and by incremental.ts. */ function resolveDefinePropertyAccessorFallback( call: Call, @@ -1277,23 +1277,14 @@ function resolveDefinePropertyAccessorFallback( definePropertyReceivers: Map | undefined, ): Array<{ id: number; file: string; kind?: string }> { if (call.receiver !== 'this' || callerName == null || !definePropertyReceivers) return []; - const receiverVarName = definePropertyReceivers.get(callerName); - if (!receiverVarName) return []; - - const typeName = unwrapTypeEntry(typeMap.get(receiverVarName)); - if (typeName) { - const qualified = lookup.byNameAndFile(`${typeName}.${call.name}`, relPath); - if (qualified.length > 0) return [...qualified]; - } - // If still no targets, search for any definition named `call.name` in - // the same file — handles plain object literals where the method isn't - // qualified (e.g. `const obj = { baz() {} }` defines `baz` directly). - // Note: this is intentionally broad — it matches any same-file definition - // with the called name, not just members of the receiver object. This is - // the same behaviour used by the native post-pass path (buildDefinePropertyPostPass). - const sameFile = lookup.byNameAndFile(call.name, relPath); - if (sameFile.length > 0) return [...sameFile]; - return []; + return resolveDefinePropertyAccessorTarget( + call.name, + callerName, + relPath, + typeMap as Map, + lookup, + definePropertyReceivers, + ); } /** diff --git a/tests/integration/issue-1766-defineproperty-kind-filter-parity.test.ts b/tests/integration/issue-1766-defineproperty-kind-filter-parity.test.ts new file mode 100644 index 000000000..e429f6a79 --- /dev/null +++ b/tests/integration/issue-1766-defineproperty-kind-filter-parity.test.ts @@ -0,0 +1,192 @@ +/** + * Regression test for #1766: the full-build `Object.defineProperty` accessor + * fallback (`resolveDefinePropertyAccessorFallback` in + * `stages/build-edges.ts`, plus its native-engine post-pass + * `buildDefinePropertyPostPass`) and the incremental rebuild path's fallback + * (`applyCallFallbacks` in `incremental.ts`) used to diverge in their final + * same-file fallback tier: full-build returned ANY same-file node named + * `call.name` (unfiltered by kind — could match an unrelated class or + * variable), while incremental filtered to `function`/`method` kinds only. + * + * Both paths now share a single implementation + * (`resolveDefinePropertyAccessorTarget` in `call-resolver.ts`), so a full + * build and an incremental single-file rebuild of the same source must + * produce identical `calls` edges for this pattern. + * + * See `tests/unit/call-resolver.test.ts` ("resolveDefinePropertyAccessorTarget + * — kind filter parity (#1766)") for direct unit coverage of the kind filter + * itself (an unrelated same-named class/variable in the same file must never + * win over the actual function/method). That divergence is only observable + * by calling the fallback function directly: in a full pipeline run, a + * same-file name collision on the *bare* call name is normally already + * resolved — correctly or not, see #1888 (a separate, pre-existing, + * already-shared primary-resolution bug, confirmed identical on both + * engines) — by `resolveCallTargets`'s own unqualified lookup before this + * fallback tier is ever reached. This integration test instead locks in + * end-to-end parity for the overall accessor-fallback feature across + * full-build vs incremental after the consolidation. (Native-engine full + * builds reach this via a different mechanism than the WASM path — Rust's + * own composite-pts-key resolution, not the `definePropertyReceivers` path + * this issue is about — since #1887 tracks that the native orchestrator's + * fast path never runs the JS `definePropertyReceivers` post-pass at all.) + * + * Mirrors the full-build-vs-incremental-rebuild comparison pattern from + * `issue-1765-incremental-same-class-barecall.test.ts`. + */ +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 { getNodeId as getNodeIdQuery, initSchema, openDb } from '../../src/db/index.js'; +import { rebuildFile } from '../../src/domain/graph/builder/incremental.js'; +import { buildGraph } from '../../src/domain/graph/builder.js'; +import { isNativeAvailable } from '../../src/infrastructure/native.js'; +import type { EngineMode } from '../../src/types.js'; + +function writeFixture(dir: string, marker: string): void { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, 'accessor.js'), + `// ${marker} +// Object.defineProperty accessor this-dispatch: getter is registered as a +// get accessor for accessorTarget, so \`this\` inside getter refers to +// accessorTarget. this.baz() -> accessorTarget.baz -> baz. +function baz() { + return 42; +} + +const accessorTarget = { baz }; + +function getter() { + this.baz(); +} + +Object.defineProperty(accessorTarget, 'computed', { get: getter }); + +export function useAccessor() { + return accessorTarget.computed; +} +`, + ); +} + +interface CallEdgeRow { + src: string; + srcKind: string; + tgt: string; + tgtKind: string; + kind: string; +} + +function readCallEdges(dbPath: string): CallEdgeRow[] { + const db = new Database(dbPath, { readonly: true }); + try { + return db + .prepare( + `SELECT n1.name AS src, n1.kind AS srcKind, n2.name AS tgt, n2.kind AS tgtKind, e.kind + 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 CallEdgeRow[]; + } finally { + db.close(); + } +} + +function makeStmts(db: ReturnType) { + return { + insertNode: db.prepare( + 'INSERT OR IGNORE INTO nodes (name, kind, file, line, end_line) VALUES (?, ?, ?, ?, ?)', + ), + getNodeId: { + get: (name: string, kind: string, file: string, line: number) => { + const id = getNodeIdQuery(db, name, kind, file, line); + return id != null ? { id } : undefined; + }, + }, + insertEdge: db.prepare( + 'INSERT INTO edges (source_id, target_id, kind, confidence, dynamic) VALUES (?, ?, ?, ?, ?)', + ), + countNodes: db.prepare('SELECT COUNT(*) as c FROM nodes WHERE file = ?'), + countEdges: db.prepare( + 'SELECT COUNT(*) as c FROM edges WHERE source_id IN (SELECT id FROM nodes WHERE file = ?)', + ), + findNodeInFile: db.prepare( + "SELECT id, kind, file FROM nodes WHERE name = ? AND kind IN ('function', 'method', 'class', 'interface', 'type', 'struct', 'enum', 'trait', 'record', 'module', 'constant') AND file = ?", + ), + findNodeByName: db.prepare( + "SELECT id, file, kind FROM nodes WHERE name = ? AND kind IN ('function', 'method', 'class', 'interface', 'type', 'struct', 'enum', 'trait', 'record', 'module', 'constant')", + ), + listSymbols: db.prepare("SELECT name, kind, line FROM nodes WHERE file = ? AND kind != 'file'"), + upsertFileHash: db.prepare( + 'INSERT OR REPLACE INTO file_hashes (file, hash, mtime, size) VALUES (?, ?, ?, ?)', + ), + deleteFileHash: db.prepare('DELETE FROM file_hashes WHERE file = ?'), + }; +} + +function runWatchScenario(engine: EngineMode): void { + describe(`codegraph watch (rebuildFile): Object.defineProperty accessor fallback matches full rebuild (#1766) — ${engine}`, () => { + let projDir: string; + let refDir: string; + + beforeAll(async () => { + projDir = fs.mkdtempSync(path.join(os.tmpdir(), `cg-1766-watch-${engine}-`)); + writeFixture(projDir, 'v1'); + await buildGraph(projDir, { engine, incremental: false, skipRegistry: true }); + + // Edit accessor.js, then rebuild ONLY it via the watcher's single-file + // incremental path (the exact code path under test). + writeFixture(projDir, 'v2 edited'); + const dbPath = path.join(projDir, '.codegraph', 'graph.db'); + const db = openDb(dbPath); + try { + initSchema(db); + const stmts = makeStmts(db); + await rebuildFile(db, projDir, path.join(projDir, 'accessor.js'), stmts, { engine }, null); + } finally { + db.close(); + } + + refDir = fs.mkdtempSync(path.join(os.tmpdir(), `cg-1766-watch-ref-${engine}-`)); + writeFixture(refDir, 'v2 edited'); + await buildGraph(refDir, { engine, incremental: false, skipRegistry: true }); + }, 60_000); + + afterAll(() => { + fs.rmSync(projDir, { recursive: true, force: true }); + fs.rmSync(refDir, { recursive: true, force: true }); + }); + + it('resolves the accessor this-dispatch edge to the function, matching a full rebuild', () => { + const incremental = readCallEdges(path.join(projDir, '.codegraph', 'graph.db')); + const reference = readCallEdges(path.join(refDir, '.codegraph', 'graph.db')); + + expect(reference).toContainEqual({ + src: 'getter', + srcKind: 'function', + tgt: 'baz', + tgtKind: 'function', + kind: 'calls', + }); + + expect(incremental).toEqual(reference); + expect(incremental).toContainEqual({ + src: 'getter', + srcKind: 'function', + tgt: 'baz', + tgtKind: 'function', + kind: 'calls', + }); + }); + }); +} + +runWatchScenario('wasm'); +describe.skipIf(!isNativeAvailable())('native engine coverage', () => { + runWatchScenario('native'); +}); diff --git a/tests/unit/call-resolver.test.ts b/tests/unit/call-resolver.test.ts index 2c4d5daa9..a50a1045c 100644 --- a/tests/unit/call-resolver.test.ts +++ b/tests/unit/call-resolver.test.ts @@ -18,6 +18,7 @@ import { describe, expect, it } from 'vitest'; import type { CallNodeLookup } from '../../src/domain/graph/builder/call-resolver.js'; import { resolveByMethodOrGlobal, + resolveDefinePropertyAccessorTarget, resolveReceiverEdge, } from '../../src/domain/graph/builder/call-resolver.js'; @@ -288,3 +289,102 @@ describe('resolveReceiverEdge — local function constructor blocks global class expect(result?.receiverId).toBe(2); }); }); + +// ── resolveDefinePropertyAccessorTarget ─────────────────────────────────── + +/** + * Regression tests for #1766: the full-build path's `Object.defineProperty` + * accessor fallback (`resolveDefinePropertyAccessorFallback` in + * stages/build-edges.ts, plus its native-engine post-pass) and the incremental + * path's (`applyCallFallbacks` in incremental.ts) used to diverge in their + * final same-file fallback tier: full-build returned ANY same-file node named + * `call.name` (unfiltered by kind), while incremental filtered to + * function/method kinds only. Both paths now share this single function. + * + * A getter/setter registered via Object.defineProperty always dispatches to + * callable code — never a class or variable — so an unrelated same-named + * class or variable declared in the same file must never win. (In a full + * pipeline run this same-file collision is normally already caught upstream + * by resolveCallTargets's own unqualified lookup before this fallback is + * ever reached; these tests exercise the fallback directly to pin down its + * standalone kind-filtering contract regardless of caller.) + */ +describe('resolveDefinePropertyAccessorTarget — kind filter parity (#1766)', () => { + const receivers = new Map([['getter', 'obj']]); + + it('resolves to the function, ignoring an unrelated same-named class in the same file', () => { + const fn = { id: 1, file: 'a.js', kind: 'function' }; + const unrelatedClass = { id: 2, file: 'a.js', kind: 'class' }; + const lookup = makeReceiverLookup({ 'bar:a.js': [unrelatedClass, fn] }, {}); + const result = resolveDefinePropertyAccessorTarget( + 'bar', + 'getter', + 'a.js', + new Map(), // no typeMap entry for 'obj' — plain object literal, not a typed instance + lookup, + receivers, + ); + expect(result).toEqual([fn]); + }); + + it('resolves to a method, ignoring an unrelated same-named variable in the same file', () => { + const method = { id: 3, file: 'a.js', kind: 'method' }; + const unrelatedVar = { id: 4, file: 'a.js', kind: 'variable' }; + const lookup = makeReceiverLookup({ 'bar:a.js': [unrelatedVar, method] }, {}); + const result = resolveDefinePropertyAccessorTarget( + 'bar', + 'getter', + 'a.js', + new Map(), + lookup, + receivers, + ); + expect(result).toEqual([method]); + }); + + it('returns nothing when only a same-named class/variable exists (no function/method)', () => { + const unrelatedClass = { id: 5, file: 'a.js', kind: 'class' }; + const lookup = makeReceiverLookup({ 'bar:a.js': [unrelatedClass] }, {}); + const result = resolveDefinePropertyAccessorTarget( + 'bar', + 'getter', + 'a.js', + new Map(), + lookup, + receivers, + ); + expect(result).toEqual([]); + }); + + it('returns [] when callerName has no entry in definePropertyReceivers', () => { + const fn = { id: 6, file: 'a.js', kind: 'function' }; + const lookup = makeReceiverLookup({ 'bar:a.js': [fn] }, {}); + const result = resolveDefinePropertyAccessorTarget( + 'bar', + 'unregisteredGetter', + 'a.js', + new Map(), + lookup, + receivers, + ); + expect(result).toEqual([]); + }); + + it('prefers the typeName-qualified method when the receiver has a resolvable type', () => { + const qualifiedMethod = { id: 7, file: 'a.js', kind: 'method' }; + const bareFn = { id: 8, file: 'a.js', kind: 'function' }; + const lookup = makeReceiverLookup( + { 'Registry.bar:a.js': [qualifiedMethod], 'bar:a.js': [bareFn] }, + {}, + ); + const result = resolveDefinePropertyAccessorTarget( + 'bar', + 'getter', + 'a.js', + new Map([['obj', 'Registry']]), + lookup, + receivers, + ); + expect(result).toEqual([qualifiedMethod]); + }); +}); From acf804c960fd1e39ed5f594dfef7efbca34a9b8a Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Mon, 6 Jul 2026 08:11:35 -0600 Subject: [PATCH 41/67] refactor: share chunked statement-cache primitive between builder and structure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit batchUpdateRoles in features/structure.ts re-implemented the same chunk-size-keyed prepared-statement cache already used by getNodeStmt/getEdgeStmt/getExportStmt in domain/graph/builder/helpers.ts (check cache by chunk size, prepare + cache on miss) — the SQL and cache scope differed, but the caching mechanics were identical. Extracted the shared mechanics into src/shared/chunked-stmt-cache.ts: getOrCreateChunkStmt (flat Map) and its per-database wrapper getOrCreatePerDbChunkStmt (WeakMap>). builder/helpers.ts's three statement getters now delegate to getOrCreatePerDbChunkStmt against their existing WeakMap caches (same persisted-across-calls semantics as before); batchUpdateRoles now calls getOrCreateChunkStmt against a Map created fresh per call (same per-call-only lifetime as before). No SQL, chunking, or caching-scope changes on either side — one implementation instead of two copies of the same shape. Placed the primitive in shared/ rather than exporting it from builder/helpers.ts: nothing outside domain/graph/builder/ currently imports from that directory directly — index.ts, cli/commands/build.ts, and features/branch-compare.ts all go through the domain/graph/builder.ts barrel, which only re-exports the builder's public API (buildGraph, collectFiles, etc). Adding a generic, domain-agnostic statement-cache helper to that barrel's surface for a features/ consumer would be the first such exception; shared/ already hosts this class of cross-cutting, domain-free utility (paginate.ts, globs.ts, sleep.ts). docs check acknowledged: pure internal dedup refactor, no new feature, language, command, or architecture surface to document. Fixes #1767 Impact: 6 functions changed, 26 affected --- src/domain/graph/builder/helpers.ts | 32 ++-------------- src/features/structure.ts | 15 ++++---- src/shared/chunked-stmt-cache.ts | 58 +++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 36 deletions(-) create mode 100644 src/shared/chunked-stmt-cache.ts diff --git a/src/domain/graph/builder/helpers.ts b/src/domain/graph/builder/helpers.ts index 16dda21f5..2232e488e 100644 --- a/src/domain/graph/builder/helpers.ts +++ b/src/domain/graph/builder/helpers.ts @@ -8,6 +8,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { purgeFilesData } from '../../../db/index.js'; import { debug, warn } from '../../../infrastructure/logger.js'; +import { getOrCreatePerDbChunkStmt } from '../../../shared/chunked-stmt-cache.js'; import { buildIgnoreSet, EXTENSIONS, normalizePath } from '../../../shared/constants.js'; import { toErrorMessage } from '../../../shared/errors.js'; import { compileGlobs, globToRegex, matchesAny } from '../../../shared/globs.js'; @@ -357,33 +358,8 @@ const BATCH_CHUNK = 500; const nodeStmtCache = new WeakMap>(); const edgeStmtCache = new WeakMap>(); -/** - * Get (or lazily prepare + cache) a multi-value INSERT statement for a given - * chunk size, keyed per-database. Shared by getNodeStmt/getEdgeStmt, which - * previously duplicated this exact WeakMap> - * cache-getter shape — only the SQL text differed. - */ -function getOrCreateBatchStmt( - cache: WeakMap>, - db: BetterSqlite3Database, - chunkSize: number, - buildSql: (chunkSize: number) => string, -): SqliteStatement { - let perDb = cache.get(db); - if (!perDb) { - perDb = new Map(); - cache.set(db, perDb); - } - let stmt = perDb.get(chunkSize); - if (!stmt) { - stmt = db.prepare(buildSql(chunkSize)); - perDb.set(chunkSize, stmt); - } - return stmt; -} - function getNodeStmt(db: BetterSqlite3Database, chunkSize: number): SqliteStatement { - return getOrCreateBatchStmt(nodeStmtCache, db, chunkSize, (n) => { + return getOrCreatePerDbChunkStmt(nodeStmtCache, db, chunkSize, (n) => { const ph = '(?,?,?,?,?,?,?,?,?)'; return ( 'INSERT OR IGNORE INTO nodes (name,kind,file,line,end_line,parent_id,qualified_name,scope,visibility) VALUES ' + @@ -393,7 +369,7 @@ function getNodeStmt(db: BetterSqlite3Database, chunkSize: number): SqliteStatem } function getEdgeStmt(db: BetterSqlite3Database, chunkSize: number): SqliteStatement { - return getOrCreateBatchStmt(edgeStmtCache, db, chunkSize, (n) => { + return getOrCreatePerDbChunkStmt(edgeStmtCache, db, chunkSize, (n) => { const ph = '(?,?,?,?,?,?,?)'; return ( 'INSERT INTO edges (source_id,target_id,kind,confidence,dynamic,technique,dynamic_kind) VALUES ' + @@ -451,7 +427,7 @@ export function batchInsertEdges(db: BetterSqlite3Database, rows: unknown[][]): const exportStmtCache = new WeakMap>(); function getExportStmt(db: BetterSqlite3Database, chunkSize: number): SqliteStatement { - return getOrCreateBatchStmt(exportStmtCache, db, chunkSize, (n) => { + return getOrCreatePerDbChunkStmt(exportStmtCache, db, chunkSize, (n) => { const conditions = Array.from( { length: n }, () => '(name = ? AND kind = ? AND file = ? AND line = ?)', diff --git a/src/features/structure.ts b/src/features/structure.ts index 885b1102d..83a27b618 100644 --- a/src/features/structure.ts +++ b/src/features/structure.ts @@ -1,8 +1,9 @@ import path from 'node:path'; import { getBuildMeta, getNodeId, setBuildMeta, testFilterSQL } from '../db/index.js'; import { debug } from '../infrastructure/logger.js'; +import { getOrCreateChunkStmt } from '../shared/chunked-stmt-cache.js'; import { getAncestorDirs, normalizePath } from '../shared/constants.js'; -import type { BetterSqlite3Database } from '../types.js'; +import type { BetterSqlite3Database, SqliteStatement as DbSqliteStatement } from '../types.js'; // ─── Build-time helpers ─────────────────────────────────────────────── @@ -538,19 +539,17 @@ function batchUpdateRoles( resetFn: () => void, ): void { const ROLE_CHUNK = 500; - const roleStmtCache = new Map(); + const roleStmtCache = new Map(); db.transaction(() => { resetFn(); for (const [role, ids] of idsByRole) { for (let i = 0; i < ids.length; i += ROLE_CHUNK) { const end = Math.min(i + ROLE_CHUNK, ids.length); const chunkSize = end - i; - let stmt = roleStmtCache.get(chunkSize); - if (!stmt) { - const placeholders = Array.from({ length: chunkSize }, () => '?').join(','); - stmt = db.prepare(`UPDATE nodes SET role = ? WHERE id IN (${placeholders})`); - roleStmtCache.set(chunkSize, stmt); - } + const stmt = getOrCreateChunkStmt(roleStmtCache, db, chunkSize, (n) => { + const placeholders = Array.from({ length: n }, () => '?').join(','); + return `UPDATE nodes SET role = ? WHERE id IN (${placeholders})`; + }); const vals: unknown[] = [role]; for (let j = i; j < end; j++) vals.push(ids[j]); stmt.run(...vals); diff --git a/src/shared/chunked-stmt-cache.ts b/src/shared/chunked-stmt-cache.ts new file mode 100644 index 000000000..0c9c0bea1 --- /dev/null +++ b/src/shared/chunked-stmt-cache.ts @@ -0,0 +1,58 @@ +/** + * Chunk-size-keyed prepared-statement cache. + * + * Many call sites build a multi-value SQL statement (`INSERT ... VALUES + * (?,?,?),(?,?,?),...` or `UPDATE ... WHERE id IN (?,?,?)`) sized to a batch + * of rows, then re-run that exact statement for every batch of the same size. + * Recompiling the statement per batch is wasteful — this module memoizes the + * prepared statement by chunk size so it is built once per distinct size. + * + * Extracted from a duplicate between the node/edge/export batch-insert + * helpers in `domain/graph/builder/helpers.ts` and the node-role batch-update + * in `features/structure.ts`, which each re-implemented this exact + * check-cache/prepare/cache-set shape independently. + */ +import type { BetterSqlite3Database, SqliteStatement } from '../types.js'; + +/** + * Get (or lazily prepare + cache) a SQL statement for a given chunk size, + * memoized in `cache`. `buildSql` is only invoked on a cache miss. + */ +export function getOrCreateChunkStmt( + cache: Map>, + db: BetterSqlite3Database, + chunkSize: number, + buildSql: (chunkSize: number) => string, +): SqliteStatement { + let stmt = cache.get(chunkSize); + if (!stmt) { + stmt = db.prepare(buildSql(chunkSize)); + cache.set(chunkSize, stmt); + } + return stmt; +} + +/** + * Per-database variant of {@link getOrCreateChunkStmt}: resolves (or lazily + * creates) the chunk-size cache scoped to `db` inside `dbCache`, then + * delegates to it. + * + * Use this when the cache must persist across many calls (e.g. repeated + * batch inserts over the lifetime of a build) and needs to be keyed per + * database instance — so independent connections (such as isolated test + * databases) never collide, and cached statements are released once `db` is + * garbage collected. + */ +export function getOrCreatePerDbChunkStmt( + dbCache: WeakMap>>, + db: BetterSqlite3Database, + chunkSize: number, + buildSql: (chunkSize: number) => string, +): SqliteStatement { + let perDb = dbCache.get(db); + if (!perDb) { + perDb = new Map(); + dbCache.set(db, perDb); + } + return getOrCreateChunkStmt(perDb, db, chunkSize, buildSql); +} From 986c851bf30f815d16af6a29d1fc20be219173ea Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Mon, 6 Jul 2026 08:20:11 -0600 Subject: [PATCH 42/67] refactor: cache prepared statements in applyEdgeTechniquesAfterNativeInsert chunk loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The technique/confidence backfill loop in applyEdgeTechniquesAfterNativeInsert re-prepared its two UPDATE statements on every chunk iteration instead of caching by chunk size. Apply the shared getOrCreatePerDbChunkStmt primitive (src/shared/chunked-stmt-cache.ts) with a persistent per-db WeakMap cache, matching the batchInsertEdges/batchInsertNodes caches in builder/helpers.ts — this function can run twice within a single buildEdges() call against the same db (once from insertNativeBulkEdges, once from reconnectReverseDepEdges), so a fresh per-call cache would still miss across that second invocation. Pure performance refactor: SQL text and behavior are unchanged. Fixes #1768 Impact: 1 functions changed, 4 affected --- .../graph/builder/stages/build-edges.ts | 41 +++++++++++++++---- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/src/domain/graph/builder/stages/build-edges.ts b/src/domain/graph/builder/stages/build-edges.ts index 89b28c467..a67814c00 100644 --- a/src/domain/graph/builder/stages/build-edges.ts +++ b/src/domain/graph/builder/stages/build-edges.ts @@ -11,6 +11,7 @@ import { setTypeMapEntry } from '../../../../extractors/helpers.js'; import { PROPAGATION_HOP_PENALTY } from '../../../../extractors/javascript.js'; import { debug } from '../../../../infrastructure/logger.js'; import { loadNative } from '../../../../infrastructure/native.js'; +import { getOrCreatePerDbChunkStmt } from '../../../../shared/chunked-stmt-cache.js'; import { TS_NATIVE_CONFIDENCE_FLOOR } from '../../../../shared/constants.js'; import type { ArrayCallbackBinding, @@ -30,6 +31,7 @@ import type { ObjectRestParamBinding, ParamBinding, SpreadArgBinding, + SqliteStatement, ThisCallBinding, TypeMapEntry, } from '../../../../types.js'; @@ -1911,6 +1913,19 @@ function buildClassHierarchyEdges( // ── Native bulk-insert technique back-fill ────────────────────────────── +// Chunk-size-keyed statement caches for the technique/confidence backfill +// UPDATEs below, scoped per db like the batchInsertEdges/batchInsertNodes +// caches in builder/helpers.ts. Persisted across calls (not just loop +// iterations) because applyEdgeTechniquesAfterNativeInsert can run twice +// within a single buildEdges() invocation against the same db — once from +// insertNativeBulkEdges, once from reconnectReverseDepEdges — so caching +// per-call would still recompile on the second call (#1768). +const techniqueBackfillStmtCache = new WeakMap< + BetterSqlite3Database, + Map +>(); +const confidenceFloorStmtCache = new WeakMap>(); + /** * After native bulkInsertEdges (which does not write the technique column), * apply technique values from the in-memory row array back to the DB, and lift @@ -1947,17 +1962,27 @@ function applyEdgeTechniquesAfterNativeInsert( } for (let i = 0; i < sourceIds.length; i += CHUNK_SIZE) { const chunk = sourceIds.slice(i, i + CHUNK_SIZE); - const placeholders = chunk.map(() => '?').join(','); - db.prepare( - `UPDATE edges SET technique = 'ts-native' WHERE kind = 'calls' AND technique IS NULL AND source_id IN (${placeholders})`, - ).run(...chunk); + const chunkSize = chunk.length; + const techniqueStmt = getOrCreatePerDbChunkStmt( + techniqueBackfillStmtCache, + db, + chunkSize, + (n) => + `UPDATE edges SET technique = 'ts-native' WHERE kind = 'calls' AND technique IS NULL AND source_id IN (${Array.from({ length: n }, () => '?').join(',')})`, + ); + techniqueStmt.run(...chunk); // Lift resolved ts-native edges below the confidence floor for this chunk. - db.prepare( - `UPDATE edges SET confidence = ? + const confidenceStmt = getOrCreatePerDbChunkStmt( + confidenceFloorStmtCache, + db, + chunkSize, + (n) => + `UPDATE edges SET confidence = ? WHERE kind = 'calls' AND technique = 'ts-native' AND confidence > 0 AND confidence < ? - AND source_id IN (${placeholders})`, - ).run(TS_NATIVE_CONFIDENCE_FLOOR, TS_NATIVE_CONFIDENCE_FLOOR, ...chunk); + AND source_id IN (${Array.from({ length: n }, () => '?').join(',')})`, + ); + confidenceStmt.run(TS_NATIVE_CONFIDENCE_FLOOR, TS_NATIVE_CONFIDENCE_FLOOR, ...chunk); } // Back-fill dynamic_kind for flagged sink edges emitted by the native engine. // Native bulkInsertEdges uses INSERT OR IGNORE and does not write dynamic_kind, so From 71e91747d7832e79b73733228216c6e37ceef29b Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Mon, 6 Jul 2026 09:01:09 -0600 Subject: [PATCH 43/67] fix: replace fixed-depth directory-proximity check with symmetric distance (docs check acknowledged) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit computeConfidence (and its Rust mirror compute_confidence) scored call-edge resolution confidence using a fixed-depth check: same-directory, or dirname(dirname(caller)) === dirname(dirname(target)) for a "sibling directory" tier. That equality only holds when both files sit at the same depth, so a file in a subdirectory calling a method on a class declared in its direct parent directory (e.g. graph/algorithms/bfs.ts calling a method on the CodeGraph class in graph/model.ts) was scored as maximally distant (0.3) even though the receiver's type had already been correctly resolved via typeMap (from the parameter's `graph: CodeGraph` type annotation). That score fell below the 0.5 threshold used by resolveViaTypedMethod, silently dropping the call edge. Replaced both fixed-depth checks with a symmetric, depth-independent directory-tree distance (hops to the nearest common ancestor), preserving the existing same-directory (0.7) and sibling (0.5) tiers exactly and adding a new tier for direct parent/child directory nesting (0.6). Verified via the resolution-benchmark suite before/after the fix: all 42 language fixtures produce byte-identical precision/recall/TP/FP/FN numbers (aggregate recall 63.8%, 355/556 edges) — no regression. Fixes #1769 Impact: 3 functions changed, 26 affected --- .../src/domain/graph/resolve.rs | 138 +++++++++++++++--- src/domain/graph/resolve.ts | 43 +++++- ...769-parent-dir-receiver-typed-call.test.ts | 128 ++++++++++++++++ tests/unit/resolve.test.ts | 66 +++++++++ 4 files changed, 353 insertions(+), 22 deletions(-) create mode 100644 tests/integration/issue-1769-parent-dir-receiver-typed-call.test.ts diff --git a/crates/codegraph-core/src/domain/graph/resolve.rs b/crates/codegraph-core/src/domain/graph/resolve.rs index 87ed47ffd..36d3ec8ec 100644 --- a/crates/codegraph-core/src/domain/graph/resolve.rs +++ b/crates/codegraph-core/src/domain/graph/resolve.rs @@ -247,8 +247,42 @@ fn resolve_import_path_inner( relativize_to_root(&resolved.display().to_string().replace('\\', "/"), root_dir) } +/// All ancestor directories of `dir`, starting with `dir` itself, walking up to the root. +fn ancestor_chain(dir: &str) -> Vec { + let mut chain = vec![dir.to_string()]; + let mut cur = dir.to_string(); + while let Some(parent) = Path::new(&cur).parent() { + let parent_str = parent.display().to_string(); + chain.push(parent_str.clone()); + cur = parent_str; + } + chain +} + +/// Directory-tree distance between two directories: hops up from `a` to the +/// nearest ancestor shared with `b`, plus hops down from there to `b`. +/// +/// Symmetric and depth-independent — unlike a fixed-depth equality check +/// (e.g. comparing the parent-of-parent of `a` to the parent-of-parent of +/// `b`, as `compute_confidence` used to), this correctly scores both sibling +/// directories (common parent) and direct ancestor/descendant directories +/// (one nested inside the other) regardless of how deep either path is. The +/// fixed-depth check only matched when both files sat at the *same* depth, +/// so e.g. a file in `graph/algorithms/*.rs` calling a method declared in +/// the shallower `graph/model.rs` was scored as maximally distant (issue #1769). +fn directory_distance(a: &str, b: &str) -> usize { + let chain_a = ancestor_chain(a); + let chain_b = ancestor_chain(b); + for (i, dir_a) in chain_a.iter().enumerate() { + if let Some(j) = chain_b.iter().position(|dir_b| dir_b == dir_a) { + return i + j; + } + } + usize::MAX +} + /// Compute proximity-based confidence for call resolution. -/// Mirrors `computeConfidence()` in builder.js. +/// Mirrors `computeConfidence()` in resolve.ts. pub fn compute_confidence( caller_file: &str, target_file: &str, @@ -275,24 +309,12 @@ pub fn compute_confidence( .map(|p| p.display().to_string()) .unwrap_or_default(); - if caller_dir == target_dir { - return 0.7; - } - - let caller_parent = Path::new(&caller_dir) - .parent() - .map(|p| p.display().to_string()) - .unwrap_or_default(); - let target_parent = Path::new(&target_dir) - .parent() - .map(|p| p.display().to_string()) - .unwrap_or_default(); - - if caller_parent == target_parent { - return 0.5; + match directory_distance(&caller_dir, &target_dir) { + 0 => 0.7, // same directory + 1 => 0.6, // direct parent/child directory + 2 => 0.5, // sibling directories, or a grandparent/grandchild pair + _ => 0.3, } - - 0.3 } /// Batch resolve multiple imports (parallelized with rayon). @@ -430,4 +452,84 @@ mod tests { ); assert_eq!(result, "src/utils.ts"); } + + // Regression tests for #1769: a fixed-depth "grandparent equality" check + // used to compare the parent of `caller_dir` to the parent of `target_dir`, + // which only matched when both files sat at the *same* depth. A file in a + // subdirectory calling a method declared in its direct parent directory + // (e.g. `graph/algorithms/bfs.rs` calling `graph/model.rs`) was scored as + // maximally distant (0.3) purely because the two files were nested at + // different depths — well below the 0.5 threshold used by the call-edge + // resolver's typed-method lookup, silently dropping the call edge. + + #[test] + fn compute_confidence_scores_parent_child_dirs_above_resolver_threshold() { + let conf = compute_confidence("src/graph/algorithms/bfs.rs", "src/graph/model.rs", None); + assert!(conf >= 0.5, "expected >= 0.5, got {conf}"); + } + + #[test] + fn compute_confidence_is_symmetric_for_parent_child_dirs() { + let caller_deeper = + compute_confidence("src/graph/algorithms/bfs.rs", "src/graph/model.rs", None); + let target_deeper = + compute_confidence("src/graph/model.rs", "src/graph/algorithms/bfs.rs", None); + assert_eq!(caller_deeper, target_deeper); + } + + #[test] + fn compute_confidence_ranks_parent_child_between_same_dir_and_sibling() { + let same_dir = compute_confidence("src/graph/a.rs", "src/graph/b.rs", None); + let parent_child = + compute_confidence("src/graph/algorithms/bfs.rs", "src/graph/model.rs", None); + // True siblings: both one level below `src`, at equal depth. + let sibling = compute_confidence("src/graph/a.rs", "src/features/b.rs", None); + assert!(same_dir > parent_child); + assert!(parent_child > sibling); + } + + #[test] + fn compute_confidence_scores_two_level_nesting_at_or_above_sibling_tier() { + // the graph/algorithms/leiden/*.rs -> graph/model.rs shape from #1769. + let conf = compute_confidence( + "src/graph/algorithms/leiden/cpm.rs", + "src/graph/model.rs", + None, + ); + assert!(conf >= 0.5, "expected >= 0.5, got {conf}"); + } + + #[test] + fn compute_confidence_still_scores_unrelated_deep_files_as_distant() { + let conf = compute_confidence( + "src/graph/algorithms/leiden/cpm.rs", + "src/mcp/server.rs", + None, + ); + assert!(conf < 0.5, "expected < 0.5, got {conf}"); + } + + #[test] + fn directory_distance_same_dir_is_zero() { + assert_eq!(directory_distance("src/graph", "src/graph"), 0); + } + + #[test] + fn directory_distance_direct_parent_child_is_one() { + assert_eq!(directory_distance("src/graph/algorithms", "src/graph"), 1); + assert_eq!(directory_distance("src/graph", "src/graph/algorithms"), 1); + } + + #[test] + fn directory_distance_siblings_is_two() { + // Both dirs are one level below `src` — true siblings at equal depth. + assert_eq!(directory_distance("src/graph", "src/features"), 2); + } + + #[test] + fn directory_distance_unequal_depth_non_siblings_is_three() { + // `algorithms` is nested inside `graph`, which is a sibling of `features` — + // not a direct sibling pair despite sharing the `src` ancestor. + assert_eq!(directory_distance("src/graph/algorithms", "src/features"), 3); + } } diff --git a/src/domain/graph/resolve.ts b/src/domain/graph/resolve.ts index 3d5268cad..3dd05fe36 100644 --- a/src/domain/graph/resolve.ts +++ b/src/domain/graph/resolve.ts @@ -461,6 +461,41 @@ function resolveImportPathJS( return normalizePath(path.relative(rootDir, resolved)); } +/** All ancestor directories of `dir`, starting with `dir` itself, walking up to the root. */ +function ancestorChain(dir: string): string[] { + const chain = [dir]; + let cur = dir; + for (;;) { + const parent = path.dirname(cur); + if (parent === cur) return chain; // reached root ('.', '/', or a drive root) + chain.push(parent); + cur = parent; + } +} + +/** + * Directory-tree distance between two directories: hops up from `a` to the + * nearest ancestor shared with `b`, plus hops down from there to `b`. + * + * Symmetric and depth-independent — unlike a fixed-depth equality check + * (e.g. comparing `dirname(dirname(a))` to `dirname(dirname(b))`, as this + * function used to), this correctly scores both sibling directories (common + * parent) and direct ancestor/descendant directories (one nested inside the + * other) regardless of how deep either path is. The fixed-depth check only + * matched when both files sat at the *same* depth, so e.g. a file in + * `graph/algorithms/*.ts` calling a method declared in the shallower + * `graph/model.ts` was scored as maximally distant (issue #1769). + */ +function directoryDistance(a: string, b: string): number { + const chainA = ancestorChain(a); + const chainB = ancestorChain(b); + for (let i = 0; i < chainA.length; i++) { + const j = chainB.indexOf(chainA[i]!); + if (j !== -1) return i + j; + } + return Infinity; +} + function computeConfidenceJS( callerFile: string, targetFile: string, @@ -471,10 +506,10 @@ function computeConfidenceJS( if (importedFrom === targetFile) return 1.0; // Workspace-resolved imports get high confidence even across package boundaries if (importedFrom && _workspaceResolvedPaths.has(importedFrom)) return 0.95; - if (path.dirname(callerFile) === path.dirname(targetFile)) return 0.7; - const callerParent = path.dirname(path.dirname(callerFile)); - const targetParent = path.dirname(path.dirname(targetFile)); - if (callerParent === targetParent) return 0.5; + const dist = directoryDistance(path.dirname(callerFile), path.dirname(targetFile)); + if (dist === 0) return 0.7; // same directory + if (dist === 1) return 0.6; // direct parent/child directory + if (dist === 2) return 0.5; // sibling directories, or a grandparent/grandchild pair return 0.3; } diff --git a/tests/integration/issue-1769-parent-dir-receiver-typed-call.test.ts b/tests/integration/issue-1769-parent-dir-receiver-typed-call.test.ts new file mode 100644 index 000000000..ef7d03e34 --- /dev/null +++ b/tests/integration/issue-1769-parent-dir-receiver-typed-call.test.ts @@ -0,0 +1,128 @@ +/** + * Regression test for #1769: a function parameter typed with a class name, + * declared in a *different, less-deeply-nested directory*, must still + * resolve method calls on that parameter back to the class's declaration. + * + * Root cause: `computeConfidenceJS` (and its Rust mirror `compute_confidence`) + * scored directory proximity using a fixed-depth check — comparing the + * parent of the caller's directory to the parent of the target's directory. + * That check only matched when both files sat at the *same* depth, so a + * subdirectory file (e.g. `graph/algorithms/bfs.ts`) calling a method on a + * class declared in its direct parent directory (`graph/model.ts`) was + * scored as maximally distant (0.3) — well below the 0.5 threshold used by + * the call-edge resolver's typed-method lookup (`resolveViaTypedMethod` in + * src/domain/graph/resolver/strategy.ts), so the call edge was silently + * dropped even though the parameter's type annotation correctly resolved + * `foo: Foo` to `typeMap['foo'] = 'Foo'`. + * + * Setup mirrors the real-world shape from src/graph/algorithms/bfs.ts calling + * src/graph/model.ts: + * - model.ts (parent directory): `export class Foo { bar(x): number {...} }` + * - algorithms/consumer.ts (child directory): a function whose PARAMETER is + * typed `foo: Foo` (via type annotation, not `new Foo()` construction) + * calls `foo.bar(x)`. + * + * Expected: a `calls` edge from `useFoo` to `Foo.bar`, in both engines. + */ + +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 TSCONFIG = JSON.stringify({ + compilerOptions: { + target: 'es2022', + module: 'esnext', + moduleResolution: 'bundler', + strict: true, + }, + include: ['**/*.ts'], +}); + +const MODEL_TS = ` +export class Foo { + bar(x: number): number { + return x + 1; + } +} +`; + +// Nested one directory deeper than model.ts — mirrors graph/algorithms/bfs.ts +// receiving a `graph: CodeGraph` parameter from graph/model.ts. +const CONSUMER_TS = ` +import type { Foo } from '../model.js'; + +export function useFoo(foo: Foo, x: number): number { + return foo.bar(x); +} +`; + +let tmpWasm: string; +let tmpNative: string; + +function writeFixture(dir: string): void { + fs.writeFileSync(path.join(dir, 'tsconfig.json'), TSCONFIG); + // Both files live under a shared `graph/` directory — NOT at the fixture + // root — so their relative-path depths mirror the real src/graph/model.ts + // vs src/graph/algorithms/bfs.ts shape. Placing model.ts directly at the + // fixture root would make `path.dirname()` hit its "." fixed point at the + // same recursion depth for both files, which accidentally masks the #1769 + // bug (the old fixed-depth check happens to coincide at the filesystem + // root regardless of the asymmetry it's supposed to detect). + fs.mkdirSync(path.join(dir, 'graph', 'algorithms'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'graph', 'model.ts'), MODEL_TS); + fs.writeFileSync(path.join(dir, 'graph', 'algorithms', 'consumer.ts'), CONSUMER_TS); +} + +beforeAll(async () => { + tmpWasm = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-1769-wasm-')); + writeFixture(tmpWasm); + + tmpNative = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-1769-native-')); + writeFixture(tmpNative); + + await Promise.all([ + buildGraph(tmpWasm, { incremental: false, skipRegistry: true, engine: 'wasm' }), + buildGraph(tmpNative, { incremental: false, skipRegistry: true, engine: 'native' }), + ]); +}); + +afterAll(() => { + fs.rmSync(tmpWasm, { recursive: true, force: true }); + fs.rmSync(tmpNative, { recursive: true, force: true }); +}); + +function hasCallEdge(dbPath: string, sourceName: string, targetName: string): boolean { + const db = new Database(dbPath, { readonly: true }); + try { + const row = db + .prepare( + `SELECT 1 + 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' AND n1.name = ? AND n2.name = ?`, + ) + .get(sourceName, targetName); + return row !== undefined; + } finally { + db.close(); + } +} + +describe('parameter-typed receiver call to a parent-directory class (#1769)', () => { + it('WASM: resolves foo.bar(x) to Foo.bar across a child->parent directory boundary', () => { + expect(hasCallEdge(path.join(tmpWasm, '.codegraph', 'graph.db'), 'useFoo', 'Foo.bar')).toBe( + true, + ); + }); + + it('Native: resolves foo.bar(x) to Foo.bar across a child->parent directory boundary', () => { + expect(hasCallEdge(path.join(tmpNative, '.codegraph', 'graph.db'), 'useFoo', 'Foo.bar')).toBe( + true, + ); + }); +}); diff --git a/tests/unit/resolve.test.ts b/tests/unit/resolve.test.ts index 340d1c944..7ad38e4d4 100644 --- a/tests/unit/resolve.test.ts +++ b/tests/unit/resolve.test.ts @@ -184,6 +184,72 @@ describe('computeConfidenceJS', () => { expect(sameDir).toBeGreaterThan(siblingParent); expect(siblingParent).toBeGreaterThan(distant); }); + + // Regression tests for #1769: a fixed-depth "grandparent equality" check used + // to compare `dirname(dirname(callerFile))` to `dirname(dirname(targetFile))`, + // which only matched when both files sat at the *same* depth. A file in a + // subdirectory calling a method declared in its direct parent directory + // (e.g. `graph/algorithms/bfs.ts` calling `graph/model.ts`) was scored as + // maximally distant (0.3) purely because the two files were nested at + // different depths — well below the 0.5 threshold used by the call-edge + // resolver's typed-method lookup, silently dropping the call edge. + describe('directory-nesting distance (#1769)', () => { + it('scores a direct parent/child directory pair above the 0.5 resolver threshold', () => { + // caller one level deeper than target — the exact bfs.ts -> model.ts shape. + const conf = computeConfidenceJS( + 'src/graph/algorithms/bfs.ts', + 'src/graph/model.ts', + undefined, + ); + expect(conf).toBeGreaterThanOrEqual(0.5); + }); + + it('is symmetric: target one level deeper than caller scores the same as the reverse', () => { + const callerDeeper = computeConfidenceJS( + 'src/graph/algorithms/bfs.ts', + 'src/graph/model.ts', + undefined, + ); + const targetDeeper = computeConfidenceJS( + 'src/graph/model.ts', + 'src/graph/algorithms/bfs.ts', + undefined, + ); + expect(targetDeeper).toBe(callerDeeper); + }); + + it('ranks direct parent/child nesting strictly between same-directory and sibling-directory', () => { + const sameDir = computeConfidenceJS('src/graph/a.ts', 'src/graph/b.ts', undefined); + const parentChild = computeConfidenceJS( + 'src/graph/algorithms/bfs.ts', + 'src/graph/model.ts', + undefined, + ); + // True siblings: both one level below `src`, at equal depth. + const sibling = computeConfidenceJS('src/graph/a.ts', 'src/features/b.ts', undefined); + expect(sameDir).toBeGreaterThan(parentChild); + expect(parentChild).toBeGreaterThan(sibling); + }); + + it('scores a two-level-deep subdirectory calling into its grandparent at or above the sibling tier', () => { + // the graph/algorithms/leiden/*.ts -> graph/model.ts shape from #1769. + const conf = computeConfidenceJS( + 'src/graph/algorithms/leiden/cpm.ts', + 'src/graph/model.ts', + undefined, + ); + expect(conf).toBeGreaterThanOrEqual(0.5); + }); + + it('still scores unrelated deeply-nested files as distant', () => { + const conf = computeConfidenceJS( + 'src/graph/algorithms/leiden/cpm.ts', + 'src/mcp/server.ts', + undefined, + ); + expect(conf).toBeLessThan(0.5); + }); + }); }); // ─── computeConfidence (public API, dispatches to native or JS) ───── From d2935ccaae7f9ea97d3a0de037d24c1b1d465da3 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Mon, 6 Jul 2026 09:21:38 -0600 Subject: [PATCH 44/67] refactor: remove unreachable Partition delta-computation interface methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit optimiser.ts's computeQualityGain (the only caller of CPM/modularity delta computation in the Leiden hot loop) calls the standalone diffCPM/diffModularity functions directly — it never calls partition.deltaCPM(...), partition.deltaModularityDirected(...), or partition.deltaModularityUndirected(...). Confirmed via repo-wide grep that these three Partition interface methods, plus getCandidateCommunityCount, have zero callers in src/, tests/, or scripts, and that Partition/makePartition are not part of the public API (src/index.ts). Verified dead empirically, not just statically: replaced each of the four functions' bodies with a throw and ran the full test suite (3549 tests) — all passed, proving no code path anywhere reaches them. This also revealed that the "directed modularity delta — exact regression" tests added by #1755 (pinning computeDeltaModularityDirected's assumed output) actually exercise a different function — diffModularityDirected in modularity.ts, reached via computeQualityGain — since detectClusters never calls the Partition interface method. Corrected that test's comment accordingly instead of leaving it pointing at a deleted function. Removed: - The four dead Partition interface methods (deltaCPM, deltaModularityDirected, deltaModularityUndirected, getCandidateCommunityCount) and their backing implementations (computeDeltaCPM, computeDeltaModularityDirected, computeDeltaModularityUndirected, and the CPM/directed-term helper functions extracted in #1755/phase-22 that existed solely to serve them). - fgetOrZero (typed-array-helpers.ts), which becomes fully unreachable once computeDeltaModularityDirected (its only caller) is gone — it was extracted in #1755 specifically for that function. Removed its direct unit-test block along with it. Chose removal over wiring computeQualityGain to the Partition methods (deduplicating diffCPM/diffModularity vs. computeDeltaCPM/computeDeltaModularityDirected/ computeDeltaModularityUndirected) because the two implementations read through different data-access layers (raw PartitionState typed arrays vs. the PartitionView getter interface) — swapping which one runs in the Leiden hot loop would need rigorous before/after numerical verification on real data to rule out subtle divergence, on a file with a track record of exactly that kind of bug (#1734, #1755). Removal of genuinely unreachable code carries none of that risk. Verified no behavioral change: built this repo's own dependency graph (920 files / 19285 function-level nodes) and ran detectClusters directly (bypassing louvainCommunities' native-Rust preference to exercise the changed JS path) at file- and function-level granularity, both modularity and CPM quality functions, 3 resolutions x 3 random seeds (36 combinations) — before/after membership + quality() output is byte-for-byte identical (matching SHA-256). Also confirmed `codegraph communities -T --json` and `npm run typecheck`/ `npm run lint`/`npm test` (216 files, 3545 passed, 30 skipped, 2 todo) are unaffected. Filed #1895 to track codegraph's own dead-code detector blind spot (an object-literal-property-value reference counts as liveness without checking whether the resulting property is ever invoked) — out of scope to fix here. diff-impact --staged: 1 function changed (makePartition) -> 4 transitive callers across 2 files, all of which only use surviving Partition methods. Fixes #1770 docs check acknowledged Impact: 1 functions changed, 4 affected --- src/graph/algorithms/leiden/partition.ts | 169 +----------------- .../algorithms/leiden/typed-array-helpers.ts | 27 --- tests/graph/algorithms/leiden.test.ts | 49 +---- 3 files changed, 10 insertions(+), 235 deletions(-) diff --git a/src/graph/algorithms/leiden/partition.ts b/src/graph/algorithms/leiden/partition.ts index 69fd92086..54cc6e11f 100644 --- a/src/graph/algorithms/leiden/partition.ts +++ b/src/graph/algorithms/leiden/partition.ts @@ -9,7 +9,7 @@ import type { GraphAdapter } from './adapter.js'; import { accumulateInternalEdgeWeights, accumulateNodeAggregates } from './aggregate-helpers.js'; -import { fget, fgetOrZero, iget, u8get } from './typed-array-helpers.js'; +import { fget, iget, u8get } from './typed-array-helpers.js'; export interface CompactOptions { keepOldOrder?: boolean; @@ -29,14 +29,10 @@ export interface Partition { resizeCommunities(newCount: number): void; initializeAggregates(): void; accumulateNeighborCommunityEdgeWeights(v: number): number; - getCandidateCommunityCount(): number; getCandidateCommunityAt(i: number): number; getNeighborEdgeWeightToCommunity(c: number): number; getOutEdgeWeightToCommunity(c: number): number; getInEdgeWeightFromCommunity(c: number): number; - deltaModularityUndirected(v: number, newC: number, gamma?: number): number; - deltaModularityDirected(v: number, newC: number, gamma?: number): number; - deltaCPM(v: number, newC: number, gamma?: number): number; moveNodeToCommunity(v: number, newC: number): boolean; compactCommunityIds(opts?: CompactOptions): void; getCommunityMembers(): number[][]; @@ -229,163 +225,6 @@ function accumulateNeighborWeights(s: PartitionState, v: number): number { return s.candidateCommunityCount; } -/* ------------------------------------------------------------------ */ -/* Extracted: modularity delta computations */ -/* ------------------------------------------------------------------ */ - -function computeDeltaModularityUndirected( - s: PartitionState, - v: number, - newC: number, - gamma: number = 1.0, -): number { - const oldC: number = iget(s.nodeCommunity, v); - if (newC === oldC) return 0; - const twoM: number = s.graph.totalWeight; - const strengthV: number = fget(s.graph.strengthOut, v); - const weightToNew: number = - newC < s.neighborEdgeWeightToCommunity.length - ? fget(s.neighborEdgeWeightToCommunity, newC) || 0 - : 0; - const weightToOld: number = fget(s.neighborEdgeWeightToCommunity, oldC) || 0; - const totalStrengthNew: number = - newC < s.communityTotalStrength.length ? fget(s.communityTotalStrength, newC) : 0; - const totalStrengthOld: number = fget(s.communityTotalStrength, oldC); - const gain_remove: number = -( - weightToOld / twoM - - (gamma * (strengthV * totalStrengthOld)) / (twoM * twoM) - ); - const gain_add: number = - weightToNew / twoM - (gamma * (strengthV * totalStrengthNew)) / (twoM * twoM); - return gain_remove + gain_add; -} - -/** - * Directed delta-modularity edge-weight terms: in/out edge weight from `v` to - * `newC`/`oldC`, read via the shared bounds-checked `fgetOrZero` (see its - * doc comment for why `|| 0` is safe/redundant here but retained anyway). - */ -function computeDirectedEdgeWeightTerms( - s: PartitionState, - newC: number, - oldC: number, -): { inFromNew: number; outToNew: number; inFromOld: number; outToOld: number } { - return { - inFromNew: fgetOrZero(s.inEdgeWeightFromCommunity, newC), - outToNew: fgetOrZero(s.outEdgeWeightToCommunity, newC), - inFromOld: fgetOrZero(s.inEdgeWeightFromCommunity, oldC), - outToOld: fgetOrZero(s.outEdgeWeightToCommunity, oldC), - }; -} - -/** - * Directed delta-modularity community-strength terms: total in/out strength - * of `newC`/`oldC`, read via the shared bounds-checked `fgetOrZero`. - */ -function computeDirectedStrengthTerms( - s: PartitionState, - newC: number, - oldC: number, -): { - totalInStrengthNew: number; - totalOutStrengthNew: number; - totalInStrengthOld: number; - totalOutStrengthOld: number; -} { - return { - totalInStrengthNew: fgetOrZero(s.communityTotalInStrength, newC), - totalOutStrengthNew: fgetOrZero(s.communityTotalOutStrength, newC), - totalInStrengthOld: fgetOrZero(s.communityTotalInStrength, oldC), - totalOutStrengthOld: fgetOrZero(s.communityTotalOutStrength, oldC), - }; -} - -function computeDeltaModularityDirected( - s: PartitionState, - v: number, - newC: number, - gamma: number = 1.0, -): number { - const oldC: number = iget(s.nodeCommunity, v); - if (newC === oldC) return 0; - const totalEdgeWeight: number = s.graph.totalWeight; - const strengthOutV: number = fget(s.graph.strengthOut, v); - const strengthInV: number = fget(s.graph.strengthIn, v); - const { inFromNew, outToNew, inFromOld, outToOld } = computeDirectedEdgeWeightTerms( - s, - newC, - oldC, - ); - const { totalInStrengthNew, totalOutStrengthNew, totalInStrengthOld, totalOutStrengthOld } = - computeDirectedStrengthTerms(s, newC, oldC); - // Self-loop correction + constant term (see modularity.ts diffModularityDirected) - const selfW: number = fget(s.graph.selfLoop, v) || 0; - const deltaInternal: number = - (inFromNew + outToNew - inFromOld - outToOld + 2 * selfW) / totalEdgeWeight; - const deltaExpected: number = - (gamma * - (strengthOutV * (totalInStrengthNew - totalInStrengthOld) + - strengthInV * (totalOutStrengthNew - totalOutStrengthOld) + - 2 * strengthOutV * strengthInV)) / - (totalEdgeWeight * totalEdgeWeight); - return deltaInternal - deltaExpected; -} - -/** computeCpmEdgeWeights — directed branch: in+out weight, plus self-loop correction. */ -function computeCpmEdgeWeightsDirected( - s: PartitionState, - v: number, - oldC: number, - newC: number, -): { wOld: number; wNew: number; selfCorrection: number } { - const wOld: number = - (fget(s.outEdgeWeightToCommunity, oldC) || 0) + (fget(s.inEdgeWeightFromCommunity, oldC) || 0); - const wNew: number = - newC < s.outEdgeWeightToCommunity.length - ? (fget(s.outEdgeWeightToCommunity, newC) || 0) + - (fget(s.inEdgeWeightFromCommunity, newC) || 0) - : 0; - // Self-loop correction (see cpm.ts diffCPM) - const selfCorrection: number = 2 * (fget(s.graph.selfLoop, v) || 0); - return { wOld, wNew, selfCorrection }; -} - -/** computeCpmEdgeWeights — undirected branch: single neighbor-weight-to-community value. */ -function computeCpmEdgeWeightsUndirected( - s: PartitionState, - oldC: number, - newC: number, -): { wOld: number; wNew: number; selfCorrection: number } { - const wOld: number = fget(s.neighborEdgeWeightToCommunity, oldC) || 0; - const wNew: number = - newC < s.neighborEdgeWeightToCommunity.length - ? fget(s.neighborEdgeWeightToCommunity, newC) || 0 - : 0; - return { wOld, wNew, selfCorrection: 0 }; -} - -/** Directed/undirected edge-weight-to-community split used by computeDeltaCPM. */ -function computeCpmEdgeWeights( - s: PartitionState, - v: number, - oldC: number, - newC: number, -): { wOld: number; wNew: number; selfCorrection: number } { - return s.graph.directed - ? computeCpmEdgeWeightsDirected(s, v, oldC, newC) - : computeCpmEdgeWeightsUndirected(s, oldC, newC); -} - -function computeDeltaCPM(s: PartitionState, v: number, newC: number, gamma: number = 1.0): number { - const oldC: number = iget(s.nodeCommunity, v); - if (newC === oldC) return 0; - const { wOld: w_old, wNew: w_new, selfCorrection } = computeCpmEdgeWeights(s, v, oldC, newC); - const nodeSz: number = fget(s.graph.size, v) || 1; - const sizeOld: number = fget(s.communityTotalSize, oldC) || 0; - const sizeNew: number = newC < s.communityTotalSize.length ? fget(s.communityTotalSize, newC) : 0; - return w_new - w_old + selfCorrection - gamma * nodeSz * (sizeNew - sizeOld + nodeSz); -} - /* ------------------------------------------------------------------ */ /* Extracted: node move */ /* ------------------------------------------------------------------ */ @@ -594,17 +433,11 @@ export function makePartition(graph: GraphAdapter, options: MakePartitionOptions }, initializeAggregates: () => initAggregates(s), accumulateNeighborCommunityEdgeWeights: (v: number) => accumulateNeighborWeights(s, v), - getCandidateCommunityCount: (): number => s.candidateCommunityCount, getCandidateCommunityAt: (i: number): number => iget(s.candidateCommunities, i), getNeighborEdgeWeightToCommunity: (c: number): number => fget(s.neighborEdgeWeightToCommunity, c) || 0, getOutEdgeWeightToCommunity: (c: number): number => fget(s.outEdgeWeightToCommunity, c) || 0, getInEdgeWeightFromCommunity: (c: number): number => fget(s.inEdgeWeightFromCommunity, c) || 0, - deltaModularityUndirected: (v: number, newC: number, gamma?: number) => - computeDeltaModularityUndirected(s, v, newC, gamma), - deltaModularityDirected: (v: number, newC: number, gamma?: number) => - computeDeltaModularityDirected(s, v, newC, gamma), - deltaCPM: (v: number, newC: number, gamma?: number) => computeDeltaCPM(s, v, newC, gamma), moveNodeToCommunity: (v: number, newC: number) => moveNode(s, v, newC), compactCommunityIds: (opts?: CompactOptions) => compactIds(s, opts), getCommunityMembers(): number[][] { diff --git a/src/graph/algorithms/leiden/typed-array-helpers.ts b/src/graph/algorithms/leiden/typed-array-helpers.ts index 698446bc9..ce3ef58a4 100644 --- a/src/graph/algorithms/leiden/typed-array-helpers.ts +++ b/src/graph/algorithms/leiden/typed-array-helpers.ts @@ -22,33 +22,6 @@ export function u8get(a: Uint8Array, i: number): number { return a[i] as number; } -/** - * Bounds-checked community-accumulator read: `i < a.length ? (fget(a, i) || 0) : 0`. - * - * Community ids are sometimes probed before the community itself has been - * grown into a given per-community accumulator array (e.g. a brand-new - * `newC` about to receive its first member) — the bounds check treats that - * as a not-yet-existing community contributing zero, matching - * `getNeighborEdgeWeightToCommunity`/`getOutEdgeWeightToCommunity`/ - * `getInEdgeWeightFromCommunity` in partition.ts, which every other reader - * of these arrays already goes through. - * - * The `|| 0` is not reachable today: every array this is used with in this - * codebase (communityTotalStrength/In/Out, neighborEdgeWeightToCommunity, - * outEdgeWeightToCommunity, inEdgeWeightFromCommunity) is a dense, - * zero-initialized Float64Array populated purely by +=/-= over edge weights - * and node strengths that are scrubbed of NaN/undefined at - * `makeGraphAdapter` construction time (`+linkWeight(attrs) || 0` / - * `+nodeSize(attrs) || 0` in adapter.ts) — so a bare bounds-checked `fget` - * would return the identical value. Kept for defense-in-depth against a - * future change to that invariant, and so callers reading either array - * family use one consistent, already-safe accessor instead of - * hand-rolling the same ternary+`||` per call site (see issue #1755). - */ -export function fgetOrZero(a: Float64Array, i: number): number { - return i < a.length ? fget(a, i) || 0 : 0; -} - /** In-place compound addition: `a[i] += v`, safe under noUncheckedIndexedAccess. */ export function taAdd(a: Float64Array, i: number, v: number): void { a[i] = fget(a, i) + v; diff --git a/tests/graph/algorithms/leiden.test.ts b/tests/graph/algorithms/leiden.test.ts index 37f22e263..69aa6e2e3 100644 --- a/tests/graph/algorithms/leiden.test.ts +++ b/tests/graph/algorithms/leiden.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from 'vitest'; import { detectClusters } from '../../../src/graph/algorithms/leiden/index.js'; -import { fgetOrZero } from '../../../src/graph/algorithms/leiden/typed-array-helpers.js'; import { CodeGraph } from '../../../src/graph/model.js'; // ─── Helpers ────────────────────────────────────────────────────────── @@ -653,48 +652,18 @@ describe('community connectivity', () => { }); }); -// ─── fgetOrZero (typed-array-helpers) ────────────────────────────────── -// -// Shared bounds-checked accessor extracted from computeDeltaModularityDirected -// (issue #1755) to unify the "newC < arr.length ? fget(arr, newC) [|| 0] : 0" -// pattern that previously appeared with an inconsistent `|| 0` across the -// edge-weight-to-community arrays vs. the community-strength-total arrays. - -describe('fgetOrZero (typed-array-helpers)', () => { - it('returns the stored value for an in-bounds index', () => { - const a = new Float64Array([1, 2, 3]); - expect(fgetOrZero(a, 1)).toBe(2); - }); - - it('returns 0 for an index at or beyond the array length (not-yet-existing community)', () => { - const a = new Float64Array([1, 2, 3]); - expect(fgetOrZero(a, 3)).toBe(0); - expect(fgetOrZero(a, 100)).toBe(0); - }); - - it('returns a plain 0 for a legitimate zero entry, same as a bare bounds-checked read', () => { - const a = new Float64Array([0, 5]); - expect(fgetOrZero(a, 0)).toBe(0); - }); - - it('squashes a stray NaN to 0 (defense-in-depth guard; not reachable via current callers)', () => { - const a = new Float64Array([Number.NaN, 5]); - expect(Number.isNaN(fgetOrZero(a, 0))).toBe(false); - expect(fgetOrZero(a, 0)).toBe(0); - }); -}); - // ─── Directed modularity delta — exact regression values ─────────────── // // Pins the exact quality()/community-assignment output of the two existing -// "directed modularity" / "directed self-loops" fixtures above, computed -// through computeDeltaModularityDirected's fgetOrZero-based reads (issue -// #1755). These exact values were verified byte-for-byte identical against -// the pre-refactor implementation (four independent bounds-check-and-`||0` -// expressions) on this same seed. A future change that re-diverges the -// `|| 0` handling between the edge-weight and community-strength array -// families (or otherwise perturbs the delta-modularity formula) would shift -// these floating-point values and fail this test. +// "directed modularity" / "directed self-loops" fixtures above. These go +// through the live directed-modularity delta path — diffModularityDirected +// in modularity.ts, invoked via computeQualityGain (optimiser.ts) during the +// Leiden local-move and refinement phases — not Partition's deltaModularityDirected +// interface method, which issue #1770 found to be unreachable dead code (no +// caller ever invokes partition.deltaModularityDirected(...)) and removed. +// A future change that perturbs diffModularityDirected's formula or its +// bounds-checked reads would shift these floating-point values and fail +// this test. describe('directed modularity delta — exact regression', () => { it('matches the pinned quality/assignment for the two-triangle directed graph', () => { From 651518638d26c38c1654b11feecbd8d177792477 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Mon, 6 Jul 2026 10:05:22 -0600 Subject: [PATCH 45/67] fix: emit reference edges for function identifiers used as object-literal values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codegraph roles --role dead inconsistently flagged dispatch-table handler functions (`{ matches, resolve: someFunction }`-style arrays) as dead, depending on an unrelated, incidental property of the referenced function: whether it happened to call another tracked symbol internally (fanOut > 0). Root cause: codegraph created no edge at all for a bare function identifier used as an object-literal property VALUE. The role classifier's `node.kind === 'function' && node.fanOut > 0` heuristic in classifyUnreferencedNode incidentally rescued whichever handlers happened to have nonzero fanOut, leaving the rest misclassified dead-unresolved — a false positive that risks deleting live dispatch-table handlers during dead-code cleanup. Fix: both engines now extract a dynamic `calls` edge (dynamicKind / dynamic_kind = 'value-ref') for every bare-identifier object-literal property value and shorthand property (`{ resolve: fn }` / `{ fn }`), attributed to the enclosing scope via the existing findCaller machinery (falls back to the widest enclosing constant/variable binding for top-level dispatch tables, same as any other call). Resolution is restricted to function/method-kind targets only, so plain data references (`{ name: SOME_CONSTANT }`) are silently dropped rather than fabricating nonsensical edges to constants. Reuses the existing `calls` edge kind + dynamic flag (per ADR-002's "no new edge kind" precedent, and the existing extractCallbackReferenceCalls mechanism from #1741) rather than inventing a new edge kind — fan-in/fan-out (scoped to `kind IN ('calls','imports-type')` / `kind = 'calls'`) already account for it with zero changes to structure.ts/roles.rs's median computations. The `fanOut > 0` heuristic in classifyUnreferencedNode is kept, narrowed by comment: it's no longer load-bearing for the object-literal case but remains the only rescue for sibling value-reference patterns not yet extracted as edges (logical-or fallback defaults, ternary defaults, array-of-functions elements). Applied to both engines: - WASM/TS: src/extractors/javascript.ts (collectObjectLiteralValueRefCall, wired into the shared runCollectorWalk used by both the walk and query extraction paths), src/domain/graph/builder/stages/build-edges.ts (resolveFallbackTargets kind filter), src/domain/graph/builder/incremental.ts (mirrored filter for the incremental/watch-mode path), src/types.ts (new 'value-ref' DynamicKind variant). - Native: crates/codegraph-core/src/extractors/javascript.rs (handle_object_literal_pair_value_ref / handle_object_literal_shorthand_value_ref, wired into match_js_node), crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs (matching kind filter in process_file). Verified against the exact repro (this repo's own PARAM_NODE_HANDLERS dispatch table in src/ast-analysis/visitor-utils.ts): all 5 handlers now show totalDependents=1 and role=core on both engines, with zero dead-flagged symbols in that file. Resolution-benchmark precision/recall is unchanged across all 34 language fixtures (javascript 100/100, typescript 95.7/93.6, aggregate 63.8% recall, identical before and after). Fixes #1771 docs check acknowledged — internal edge-emission/resolver bug fix, no new commands, languages, or architecture to document. Impact: 9 functions changed, 19 affected --- .../graph/builder/stages/build_edges.rs | 11 + .../src/extractors/javascript.rs | 139 +++++++++++ .../src/graph/classifiers/roles.rs | 11 + .../decisions/002-dynamic-call-resolution.md | 11 +- src/domain/graph/builder/incremental.ts | 9 +- .../graph/builder/stages/build-edges.ts | 16 ++ src/extractors/javascript.ts | 54 +++++ src/graph/classifiers/roles.ts | 11 + src/types.ts | 3 +- ...ssue-1771-dispatch-table-value-ref.test.ts | 228 ++++++++++++++++++ tests/parsers/javascript.test.ts | 69 ++++++ 11 files changed, 559 insertions(+), 3 deletions(-) create mode 100644 tests/integration/issue-1771-dispatch-table-value-ref.test.ts diff --git a/crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs b/crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs index 94b35cc06..01a4fbaed 100644 --- a/crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs +++ b/crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs @@ -785,6 +785,17 @@ fn process_file<'a>( let mut targets = resolve_call_targets( ctx, call, fc.rel_path, imported_from, &fc.type_map, caller_name, imported_original_name, ); + // #1771: object-literal property-value references resolve against + // function/method-kind targets only — a bare identifier there is as + // likely to be a plain data reference (`{ name: SOME_CONSTANT }`) as + // a function reference, so drop any non-callable match rather than + // fabricating a "calls" edge to a constant/class/etc. Applied once + // here (after all resolve_call_targets tiers), mirroring the + // `dynamicKind === 'value-ref'` filter in resolveFallbackTargets + // (stages/build-edges.ts). + if call.dynamic_kind.as_deref() == Some("value-ref") { + targets.retain(|t| t.kind == "function" || t.kind == "method"); + } sort_targets_by_confidence(&mut targets, fc.rel_path, imported_from); emit_call_edges(&targets, caller_id, is_dynamic, fc.rel_path, imported_from, &mut seen_edges, &mut pts_edge_map, edges); diff --git a/crates/codegraph-core/src/extractors/javascript.rs b/crates/codegraph-core/src/extractors/javascript.rs index d273edb2e..2168a30d6 100644 --- a/crates/codegraph-core/src/extractors/javascript.rs +++ b/crates/codegraph-core/src/extractors/javascript.rs @@ -1075,6 +1075,12 @@ fn match_js_node(node: &Node, source: &[u8], symbols: &mut FileSymbols, _depth: "import_statement" => handle_import_stmt(node, source, symbols), "export_statement" => handle_export_stmt(node, source, symbols), "expression_statement" => handle_expr_stmt(node, source, symbols), + // #1771: dispatch-table-style object-literal property values + // (`{ resolve: someFunction }` / shorthand `{ someFunction }`). + "pair" => handle_object_literal_pair_value_ref(node, source, &mut symbols.calls), + "shorthand_property_identifier" => { + handle_object_literal_shorthand_value_ref(node, source, &mut symbols.calls) + } _ => {} } } @@ -2440,6 +2446,62 @@ fn extract_callback_reference_calls(call_node: &Node, source: &[u8], calls: &mut } } +/// Collect a dynamic value-ref `Call` for an object-literal `pair` node whose +/// value is a bare identifier — e.g. `{ resolve: someFunction }`, the +/// "dispatch table" pattern (`{ matches, resolve }`-style handler arrays, +/// issue #1771). Restricted to plain `identifier` values: call expressions, +/// member expressions, and inline function/arrow values are handled by their +/// own extraction paths (regular call resolution, `seed_objlit_type_map_entries` +/// / `match_js_objlit_qualified_method_defs`) and must not be double-counted here. +/// +/// Emitted unconditionally for every bare-identifier property value in the +/// file — `dynamic_kind: "value-ref"` is resolved downstream (build_edges.rs) +/// against function/method-kind targets ONLY, so plain data references +/// (`{ name: SOME_CONSTANT }`) naturally fail to resolve into an edge rather +/// than needing a structural allowlist gate here. +/// +/// Mirrors `collectObjectLiteralValueRefCall` in `src/extractors/javascript.ts`. +fn handle_object_literal_pair_value_ref(node: &Node, source: &[u8], calls: &mut Vec) { + let Some(value_n) = node.child_by_field_name("value") else { return }; + if value_n.kind() != "identifier" { + return; + } + let text = node_text(&value_n, source); + if JS_BUILTIN_GLOBALS.contains(&text) { + return; + } + calls.push(Call { + name: text.to_string(), + line: start_line(&value_n), + dynamic: Some(true), + dynamic_kind: Some("value-ref".to_string()), + ..Default::default() + }); +} + +/// Collect a dynamic value-ref `Call` for an object-literal shorthand property +/// (`{ someFunction }`) — semantically identical to `{ someFunction: someFunction }`. +/// `shorthand_property_identifier` only appears inside object-literal +/// EXPRESSIONS in this grammar (destructuring patterns use the distinct +/// `shorthand_property_identifier_pattern` kind), so this can't misfire on +/// destructuring targets. +/// +/// Mirrors the walk path's `shorthand_property_identifier` handling in +/// `src/extractors/javascript.ts`'s `runCollectorWalk` (issue #1771). +fn handle_object_literal_shorthand_value_ref(node: &Node, source: &[u8], calls: &mut Vec) { + let text = node_text(node, source); + if JS_BUILTIN_GLOBALS.contains(&text) { + return; + } + calls.push(Call { + name: text.to_string(), + line: start_line(node), + dynamic: Some(true), + dynamic_kind: Some("value-ref".to_string()), + ..Default::default() + }); +} + fn extract_destructured_bindings( pattern: &Node, source: &[u8], @@ -4171,6 +4233,83 @@ mod tests { assert!(dynamic_calls.is_empty()); } + // ── #1771: object-literal value-ref extraction ────────────────────────── + + #[test] + fn extracts_value_ref_call_for_object_literal_property() { + let s = parse_js("const table = { resolve: resolveWrapperParam };"); + let value_refs: Vec<_> = s + .calls + .iter() + .filter(|c| c.dynamic_kind.as_deref() == Some("value-ref")) + .collect(); + assert!(value_refs.iter().any(|c| c.name == "resolveWrapperParam")); + assert!(value_refs.iter().all(|c| c.dynamic == Some(true))); + } + + #[test] + fn extracts_value_ref_calls_for_every_handler_in_dispatch_table_array() { + // Mirrors this repo's own PARAM_NODE_HANDLERS pattern (issue #1771). + let s = parse_js( + "const HANDLERS = [\n\ + { matches: isA, resolve: resolveA },\n\ + { matches: isB, resolve: resolveB },\n\ + ];", + ); + let names: Vec<&str> = s + .calls + .iter() + .filter(|c| c.dynamic_kind.as_deref() == Some("value-ref")) + .map(|c| c.name.as_str()) + .collect(); + for expected in ["isA", "resolveA", "isB", "resolveB"] { + assert!(names.contains(&expected), "missing value-ref call for {}", expected); + } + } + + #[test] + fn extracts_value_ref_call_for_shorthand_property() { + let s = parse_js("const table = { someFunction };"); + let value_refs: Vec<_> = s + .calls + .iter() + .filter(|c| c.dynamic_kind.as_deref() == Some("value-ref")) + .collect(); + assert!(value_refs.iter().any(|c| c.name == "someFunction")); + } + + #[test] + fn no_value_ref_call_for_call_expression_value() { + let s = parse_js("const table = { resolve: someFunction() };"); + assert!(s.calls.iter().all(|c| c.dynamic_kind.as_deref() != Some("value-ref"))); + } + + #[test] + fn no_value_ref_call_for_member_expression_value() { + let s = parse_js("const table = { resolve: obj.someFunction };"); + assert!(s.calls.iter().all(|c| c.dynamic_kind.as_deref() != Some("value-ref"))); + } + + #[test] + fn no_value_ref_call_for_inline_function_value() { + let s = parse_js("const table = { resolve: () => {}, other: function () {} };"); + assert!(s.calls.iter().all(|c| c.dynamic_kind.as_deref() != Some("value-ref"))); + } + + #[test] + fn no_value_ref_call_for_literal_or_data_shaped_values() { + let s = parse_js( + "const config = { name: 'literal', count: 42, active: true, empty: null, list: [1, 2] };", + ); + assert!(s.calls.iter().all(|c| c.dynamic_kind.as_deref() != Some("value-ref"))); + } + + #[test] + fn no_value_ref_call_for_builtin_globals() { + let s = parse_js("const table = { log: console, Ctor: Object };"); + assert!(s.calls.iter().all(|c| c.dynamic_kind.as_deref() != Some("value-ref"))); + } + #[test] fn no_duplicate_call_for_call_expression_arg() { let s = parse_js("router.use(checkPermissions(['admin']));"); diff --git a/crates/codegraph-core/src/graph/classifiers/roles.rs b/crates/codegraph-core/src/graph/classifiers/roles.rs index 1b45f03bc..b4572e93d 100644 --- a/crates/codegraph-core/src/graph/classifiers/roles.rs +++ b/crates/codegraph-core/src/graph/classifiers/roles.rs @@ -237,6 +237,17 @@ fn classify_node( // value references, not call sites, so no call edge is produced. We // require `fan_out > 0` as evidence that the function is non-trivial // (i.e. it calls something), ruling out truly inert dead helpers. + // + // NOTE (#1771): this used to also be the only thing rescuing functions + // referenced as object-literal property values (dispatch tables, e.g. + // `{ resolve: someFunction }`) — and only by coincidence, for whichever + // of those functions happened to have fan_out > 0 themselves. That + // pattern now gets a real `calls` edge (dynamic_kind "value-ref") at + // extraction time, so it no longer depends on this heuristic. Kept + // here as a fallback for value-reference shapes that still produce no + // edge at all — the logical-or default above, and others (ternary + // defaults, array-of-functions elements, default parameter values) + // that aren't extracted as edges yet. if kind == "function" && fan_out > 0 { return "leaf"; } diff --git a/docs/architecture/decisions/002-dynamic-call-resolution.md b/docs/architecture/decisions/002-dynamic-call-resolution.md index 0e70c3422..5159f7b99 100644 --- a/docs/architecture/decisions/002-dynamic-call-resolution.md +++ b/docs/architecture/decisions/002-dynamic-call-resolution.md @@ -76,11 +76,20 @@ export type DynamicKind = | 'computed-key' // obj[k]() — resolvable iff k is a const literal, else flag | 'reflection' // .call/.apply/.bind, getattr, Method.invoke, $obj->$m() | 'eval' // eval(), new Function() — undecidable - | 'unresolved-dynamic'; // detected dynamic call we cannot resolve + | 'unresolved-dynamic' // detected dynamic call we cannot resolve + | 'value-ref'; // bare identifier used as an object-literal property value + // (dispatch tables, e.g. `{ resolve: someFn }`) — resolvable + // against function/method-kind targets only (#1771) ``` `dynamic?: boolean` is kept to avoid churning every `call.dynamic ? 1 : 0` site. +`value-ref` is Track A (resolvable) but deliberately **not** added to the flag-only +sink-edge set: when the identifier doesn't resolve to a function/method (e.g. a +plain data reference like `{ name: SOME_CONSTANT }`), that's the common case, not +an undecidable dynamic call site — so it's silently dropped rather than flagged, +unlike `eval`/`computed-key`/`unresolved-dynamic`. + ### Sink edges reuse `kind='calls'`, not a new edge kind A new `EdgeKind` would ripple through every edge-kind switch, role classifier, exporter, MCP tool, and the viewer — high blast radius. Instead: DB migration adds `dynamic_kind TEXT` column to `edges`; sink edges use `kind='calls'`, `dynamic=1`, `dynamic_kind=`, `confidence=0.0`. Confidence below `DEFAULT_MIN_CONFIDENCE=0.5` means they never pollute normal queries or exports but remain queryable when explicitly requested. diff --git a/src/domain/graph/builder/incremental.ts b/src/domain/graph/builder/incremental.ts index 5f3492837..6c5b1e7cc 100644 --- a/src/domain/graph/builder/incremental.ts +++ b/src/domain/graph/builder/incremental.ts @@ -758,7 +758,7 @@ function buildCallEdges( importedOriginalNames, ); - const targets = applyCallFallbacks( + let targets = applyCallFallbacks( call, caller.callerName, relPath, @@ -768,6 +768,13 @@ function buildCallEdges( initialTargets, ); + // #1771: object-literal property-value references resolve against + // function/method-kind targets only — mirrors the same filter in + // resolveFallbackTargets (stages/build-edges.ts, full-build path). + if (call.dynamicKind === 'value-ref') { + targets = targets.filter((t) => t.kind === 'function' || t.kind === 'method'); + } + edgesAdded += emitIncrementalCallEdges( call, caller, diff --git a/src/domain/graph/builder/stages/build-edges.ts b/src/domain/graph/builder/stages/build-edges.ts index a67814c00..03ab97c63 100644 --- a/src/domain/graph/builder/stages/build-edges.ts +++ b/src/domain/graph/builder/stages/build-edges.ts @@ -1368,6 +1368,22 @@ function resolveFallbackTargets( if (qualified.length > 0) targets = qualified; } + // #1771: object-literal property-value references (`{ resolve: someFn }`) + // resolve against function/method-kind targets only — a bare identifier + // there is as likely to be a plain data reference (`{ name: SOME_CONSTANT }`) + // as a function, so drop any non-callable match rather than fabricating a + // "calls" edge to a constant/class/etc. Applied once here, after every + // fallback tier above, so it covers whichever tier produced the match. + if (call.dynamicKind === 'value-ref') { + // `targets` is typed without `kind` when it flows straight through from + // resolveCallTargets (call-resolver.ts's declared return type omits it), + // but every underlying CallNodeLookup method actually populates it — the + // same gap the preQualifiedTargets cast above already works around. + targets = (targets as ReadonlyArray<{ id: number; file: string; kind?: string }>).filter( + (t) => t.kind === 'function' || t.kind === 'method', + ); + } + return { targets, importedFrom }; } diff --git a/src/extractors/javascript.ts b/src/extractors/javascript.ts index 097c1f127..f780290b2 100644 --- a/src/extractors/javascript.ts +++ b/src/extractors/javascript.ts @@ -459,6 +459,7 @@ function extractSymbolsQuery(tree: TreeSitterTree, query: TreeSitterQuery): Extr objectPropBindings, newExpressions, definePropertyReceivers, + valueRefCalls: calls, imports, calls, thisCallBindings, @@ -884,6 +885,7 @@ function extractSymbolsWalk(tree: TreeSitterTree): ExtractorOutput { objectPropBindings: ctx.objectPropBindings!, newExpressions, definePropertyReceivers, + valueRefCalls: ctx.calls, funcPropDefs: ctx.definitions, }); ctx.newExpressions = newExpressions; @@ -3138,6 +3140,32 @@ function collectObjectPropBindings(node: TreeSitterNode, bindings: ObjectPropBin } } +/** + * Collect a dynamic value-ref `Call` for an object-literal `pair` node whose + * value is a bare identifier — e.g. `{ resolve: someFunction }`, the + * "dispatch table" pattern (`{ matches, resolve }`-style handler arrays, + * issue #1771). Restricted to plain `identifier` values: call expressions, + * member expressions, and inline function/arrow values are handled by their + * own extraction paths (regular call resolution, `extractObjectLiteralFunctions`) + * and must not be double-counted here. + * + * Emitted unconditionally for every bare-identifier property value in the + * file — `dynamicKind: 'value-ref'` is resolved downstream (build-edges.ts / + * incremental.ts) against function/method-kind targets ONLY, so plain data + * references (`{ name: SOME_CONSTANT }`) naturally fail to resolve into an + * edge rather than needing a structural allowlist gate here. + */ +function collectObjectLiteralValueRefCall(pairNode: TreeSitterNode, calls: Call[]): void { + const valueNode = pairNode.childForFieldName('value'); + if (valueNode?.type !== 'identifier' || BUILTIN_GLOBALS.has(valueNode.text)) return; + calls.push({ + name: valueNode.text, + line: nodeStartLine(valueNode), + dynamic: true, + dynamicKind: 'value-ref', + }); +} + function extractReceiverName(objNode: TreeSitterNode | null): string | undefined { if (!objNode) return undefined; const t = objNode.type; @@ -3687,6 +3715,15 @@ function collectThisCallAndBindings( * (the walk path's walkJavaScriptNode covers those node types itself). * - `funcPropDefs` — walk path only (the query path captures `fn.method = …` * assignments via the `assign_left`/`assign_right` query pattern). + * + * `valueRefCalls` is REQUIRED (unlike `calls`) — both paths route + * object-literal value-ref extraction through this single field, since + * neither `walkJavaScriptNode` (walk path) nor the compiled query patterns + * (query path) visit `pair`/`shorthand_property_identifier` nodes on their + * own (#1771). Both callers pass their own `calls` array here; it's a + * separate field from the optional `calls` above purely so this collector + * isn't accidentally gated off by the walk path's "don't double-collect + * call_expression" omission. */ interface CollectorWalkTargets { definitions: Definition[]; @@ -3696,6 +3733,7 @@ interface CollectorWalkTargets { objectPropBindings: ObjectPropBinding[]; newExpressions: string[]; definePropertyReceivers: Map; + valueRefCalls: Call[]; imports?: Import[]; calls?: Call[]; thisCallBindings?: ThisCallBinding[]; @@ -3774,6 +3812,22 @@ function runCollectorWalk(rootNode: TreeSitterNode, targets: CollectorWalkTarget case 'class_static_block': if (targets.classMemberDefs) handleStaticBlock(node, targets.classMemberDefs); break; + case 'pair': + // #1771: dispatch-table-style object-literal property values, e.g. + // `{ resolve: someFunction }`. + collectObjectLiteralValueRefCall(node, targets.valueRefCalls); + break; + case 'shorthand_property_identifier': + // #1771: shorthand form of the same pattern, e.g. `{ someFunction }`. + if (!BUILTIN_GLOBALS.has(node.text)) { + targets.valueRefCalls.push({ + name: node.text, + line: nodeStartLine(node), + dynamic: true, + dynamicKind: 'value-ref', + }); + } + break; } for (let i = 0; i < node.childCount; i++) { walk(node.child(i)!, depth + 1, childInDynamicImport); diff --git a/src/graph/classifiers/roles.ts b/src/graph/classifiers/roles.ts index fe278ff31..632037aa2 100644 --- a/src/graph/classifiers/roles.ts +++ b/src/graph/classifiers/roles.ts @@ -219,6 +219,17 @@ function classifyUnreferencedNode(node: RoleClassificationNode): Role { // value references, not call sites, so no call edge is produced. We // require `fanOut > 0` as evidence that the function is non-trivial // (i.e. it calls something), ruling out truly inert dead helpers. + // + // NOTE (#1771): this used to also be the only thing rescuing functions + // referenced as object-literal property values (dispatch tables, e.g. + // `{ resolve: someFunction }`) — and only by coincidence, for whichever + // of those functions happened to have fanOut > 0 themselves. That + // pattern now gets a real `calls` edge (dynamicKind 'value-ref') at + // extraction time, so it no longer depends on this heuristic. Kept + // here as a fallback for value-reference shapes that still produce no + // edge at all — the logical-or default above, and others (ternary + // defaults, array-of-functions elements, default parameter values) + // that aren't extracted as edges yet. return 'leaf'; } } diff --git a/src/types.ts b/src/types.ts index 954b9fb5a..c352bb0b5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -487,7 +487,8 @@ export type DynamicKind = | 'computed-key' // obj[k]() — potentially resolvable via pts; else flagged | 'reflection' // .call/.apply/.bind / Reflect.* / callable-ref — resolved when target is in codebase; sink edge emitted if unresolved | 'eval' // eval() / new Function() — undecidable; always flagged - | 'unresolved-dynamic'; // any other detected dynamic pattern; flagged + | 'unresolved-dynamic' // any other detected dynamic pattern; flagged + | 'value-ref'; // bare identifier used as an object-literal property value (dispatch-table pattern, e.g. `{ resolve: someFn }`) — resolved only against function/method-kind targets; unresolved (e.g. plain data references) are dropped silently, NOT flagged /** A function/method call detected by an extractor. */ export interface Call { diff --git a/tests/integration/issue-1771-dispatch-table-value-ref.test.ts b/tests/integration/issue-1771-dispatch-table-value-ref.test.ts new file mode 100644 index 000000000..5a92bd3cc --- /dev/null +++ b/tests/integration/issue-1771-dispatch-table-value-ref.test.ts @@ -0,0 +1,228 @@ +/** + * Integration test for #1771: dispatch-table function references + * (`{ matches, resolve }`-style handler arrays) inconsistently flagged + * dead-unresolved depending on an unrelated, incidental property of the + * referenced function (whether it happens to call another tracked symbol + * internally, giving it fanOut > 0). + * + * Root cause: codegraph created no edge at all for a bare function + * identifier used as an object-literal property VALUE (e.g. + * `{ resolve: someFunction }`) — only a `fanOut > 0` heuristic in the role + * classifier incidentally rescued whichever handlers happened to call + * another tracked symbol internally. + * + * Fix: both engines now emit a real `calls` edge (dynamic=1, + * dynamicKind/dynamic_kind = 'value-ref') from the enclosing scope to the + * referenced function/method, so fan-in reflects the reference directly and + * role classification no longer depends on the referenced function's own + * unrelated fanOut. + */ + +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'; +import { isNativeAvailable } from '../../src/infrastructure/native.js'; + +// Mirrors this repo's own src/ast-analysis/visitor-utils.ts PARAM_NODE_HANDLERS +// dispatch table: an array of `{ matches, resolve }` objects where `resolve` +// is a bare function identifier dispatched at runtime via `handler.resolve(...)`. +// `resolveB` deliberately calls another tracked function (`helper`) so it has +// fanOut > 0 — the exact "coincidental rescue" condition from the bug report — +// while `resolveA`/`resolveC` do not, so they exercise the actually-fixed path. +const FIXTURE = { + 'dispatch.js': ` +function isA(node) { return node.type === 'a'; } +function resolveA(node) { return { kind: 'a', value: node }; } +function isB(node) { return node.type === 'b'; } +function resolveB(node) { return helper(node); } +function isC(node) { return node.type === 'c'; } +function resolveC(node) { return { kind: 'c', value: node }; } +function helper(node) { return node; } + +const HANDLERS = [ + { matches: isA, resolve: resolveA }, + { matches: isB, resolve: resolveB }, + { matches: isC, resolve: resolveC }, +]; + +function dispatch(node) { + for (const h of HANDLERS) { + if (h.matches(node)) return h.resolve(node); + } + return null; +} + +module.exports = { dispatch }; +`, + 'data.js': ` +const SOME_CONSTANT = 'hello'; +const dataConfig = { name: SOME_CONSTANT, label: 'literal', count: 42 }; +module.exports = { dataConfig }; +`, +}; + +const HANDLER_NAMES = ['isA', 'resolveA', 'isB', 'resolveB', 'isC', 'resolveC']; +const DEAD_ROLES = new Set(['dead-unresolved', 'dead-leaf', 'dead-entry', 'dead-ffi']); + +function readNodesWithRoles(dbPath: string) { + const db = new Database(dbPath, { readonly: true }); + try { + return db.prepare('SELECT name, kind, role FROM nodes ORDER BY name').all() as Array<{ + name: string; + kind: string; + role: string | null; + }>; + } finally { + db.close(); + } +} + +function countCallEdgesTo(dbPath: string, targetName: string): number { + const db = new Database(dbPath, { readonly: true }); + try { + const row = db + .prepare( + `SELECT COUNT(*) AS cnt + FROM edges e + JOIN nodes t ON e.target_id = t.id + WHERE e.kind = 'calls' AND t.name = ?`, + ) + .get(targetName) as { cnt: number }; + return row.cnt; + } finally { + db.close(); + } +} + +// `dynamic_kind` is only persisted on UNRESOLVED sink edges (confidence=0, +// the flag-only fallback for eval/computed-key/reflection/unresolved-dynamic +// call sites that never found a target) — by existing, consistent design +// across every dynamicKind category, a call site that DOES resolve (as +// value-ref calls into a real function/method target always do here) is +// persisted as a plain `dynamic=1` `calls` edge with `dynamic_kind = NULL`. +// So a resolved value-ref edge is identified by `dynamic = 1`, not by the +// `dynamic_kind` column (see resolveFallbackTargets / emitDirectCallEdgesForCall). +function readDynamicCallEdges(dbPath: string) { + const db = new Database(dbPath, { readonly: true }); + try { + return db + .prepare( + `SELECT s.name AS src, t.name AS tgt + FROM edges e + JOIN nodes s ON e.source_id = s.id + JOIN nodes t ON e.target_id = t.id + WHERE e.kind = 'calls' AND e.dynamic = 1 + ORDER BY s.name, t.name`, + ) + .all() as Array<{ src: string; tgt: string }>; + } finally { + db.close(); + } +} + +describe('dispatch-table value-ref edges (#1771) — WASM', () => { + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-1771-')); + for (const [rel, content] of Object.entries(FIXTURE)) { + fs.writeFileSync(path.join(tmpDir, rel), content); + } + await buildGraph(tmpDir, { engine: 'wasm', incremental: false, skipRegistry: true }); + }); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('emits a value-ref calls edge from the dispatch table to every handler', () => { + const dbPath = path.join(tmpDir, '.codegraph', 'graph.db'); + const edges = readDynamicCallEdges(dbPath); + for (const name of HANDLER_NAMES) { + expect( + edges.some((e) => e.src === 'HANDLERS' && e.tgt === name), + `Expected a value-ref edge HANDLERS -> ${name}; got: ${JSON.stringify(edges)}`, + ).toBe(true); + } + }); + + it('gives every handler at least one inbound calls edge (fan-in >= 1)', () => { + const dbPath = path.join(tmpDir, '.codegraph', 'graph.db'); + for (const name of HANDLER_NAMES) { + expect(countCallEdgesTo(dbPath, name), `${name} has no inbound calls edge`).toBeGreaterThan( + 0, + ); + } + }); + + it('does not classify any dispatch-table handler as dead, regardless of its own fanOut', () => { + const dbPath = path.join(tmpDir, '.codegraph', 'graph.db'); + const nodes = readNodesWithRoles(dbPath); + for (const name of HANDLER_NAMES) { + const node = nodes.find((n) => n.name === name && n.kind === 'function'); + expect(node, `${name} node not found`).toBeDefined(); + expect( + node!.role, + `${name} was classified as ${node!.role} — expected a non-dead role now that a real edge exists`, + ).not.toBe(undefined); + expect(DEAD_ROLES.has(node!.role ?? '')).toBe(false); + } + }); + + it('does not fabricate a value-ref edge to a plain data constant', () => { + const dbPath = path.join(tmpDir, '.codegraph', 'graph.db'); + const edges = readDynamicCallEdges(dbPath); + expect(edges.some((e) => e.tgt === 'SOME_CONSTANT')).toBe(false); + expect(countCallEdgesTo(dbPath, 'SOME_CONSTANT')).toBe(0); + }); +}); + +// ── Native engine parity ──────────────────────────────────────────────────── +// Skipped when the native addon is not installed. + +describe.skipIf(!isNativeAvailable())('dispatch-table value-ref edges (#1771) — native', () => { + let nativeTmpDir: string; + + beforeAll(async () => { + nativeTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-1771-native-')); + for (const [rel, content] of Object.entries(FIXTURE)) { + fs.writeFileSync(path.join(nativeTmpDir, rel), content); + } + await buildGraph(nativeTmpDir, { engine: 'native', incremental: false, skipRegistry: true }); + }, 60_000); + + afterAll(() => { + fs.rmSync(nativeTmpDir, { recursive: true, force: true }); + }); + + it('emits a value-ref calls edge from the dispatch table to every handler', () => { + const dbPath = path.join(nativeTmpDir, '.codegraph', 'graph.db'); + const edges = readDynamicCallEdges(dbPath); + for (const name of HANDLER_NAMES) { + expect( + edges.some((e) => e.src === 'HANDLERS' && e.tgt === name), + `Expected a native value-ref edge HANDLERS -> ${name}; got: ${JSON.stringify(edges)}`, + ).toBe(true); + } + }); + + it('does not classify any dispatch-table handler as dead', () => { + const dbPath = path.join(nativeTmpDir, '.codegraph', 'graph.db'); + const nodes = readNodesWithRoles(dbPath); + for (const name of HANDLER_NAMES) { + const node = nodes.find((n) => n.name === name && n.kind === 'function'); + expect(node, `${name} node not found (native)`).toBeDefined(); + expect(DEAD_ROLES.has(node!.role ?? '')).toBe(false); + } + }); + + it('does not fabricate a value-ref edge to a plain data constant', () => { + const dbPath = path.join(nativeTmpDir, '.codegraph', 'graph.db'); + const edges = readDynamicCallEdges(dbPath); + expect(edges.some((e) => e.tgt === 'SOME_CONSTANT')).toBe(false); + expect(countCallEdgesTo(dbPath, 'SOME_CONSTANT')).toBe(0); + }); +}); diff --git a/tests/parsers/javascript.test.ts b/tests/parsers/javascript.test.ts index c785eb02c..79174055f 100644 --- a/tests/parsers/javascript.test.ts +++ b/tests/parsers/javascript.test.ts @@ -1108,6 +1108,75 @@ describe('JavaScript parser', () => { }); }); + describe('object-literal value-ref extraction (#1771)', () => { + it('extracts a value-ref call for a bare-identifier property value', () => { + const symbols = parseJS(`const table = { resolve: resolveWrapperParam };`); + expect(symbols.calls).toContainEqual( + expect.objectContaining({ + name: 'resolveWrapperParam', + dynamic: true, + dynamicKind: 'value-ref', + }), + ); + }); + + it('extracts value-ref calls for every handler in a dispatch-table array', () => { + // Mirrors this repo's own PARAM_NODE_HANDLERS pattern (issue #1771): + // an array of `{ matches, resolve }` objects where `resolve` is a bare + // function identifier dispatched at runtime via `handler.resolve(...)`. + const symbols = parseJS(` + const HANDLERS = [ + { matches: isA, resolve: resolveA }, + { matches: isB, resolve: resolveB }, + { matches: isC, resolve: resolveC }, + ]; + `); + for (const name of ['isA', 'resolveA', 'isB', 'resolveB', 'isC', 'resolveC']) { + expect(symbols.calls).toContainEqual( + expect.objectContaining({ name, dynamic: true, dynamicKind: 'value-ref' }), + ); + } + }); + + it('extracts a value-ref call for a shorthand property', () => { + const symbols = parseJS(`const table = { someFunction };`); + expect(symbols.calls).toContainEqual( + expect.objectContaining({ name: 'someFunction', dynamic: true, dynamicKind: 'value-ref' }), + ); + }); + + it('does not extract a value-ref call for a call-expression value', () => { + const symbols = parseJS(`const table = { resolve: someFunction() };`); + expect(symbols.calls).not.toContainEqual( + expect.objectContaining({ name: 'someFunction', dynamicKind: 'value-ref' }), + ); + }); + + it('does not extract a value-ref call for a member-expression value', () => { + const symbols = parseJS(`const table = { resolve: obj.someFunction };`); + expect(symbols.calls).not.toContainEqual( + expect.objectContaining({ dynamicKind: 'value-ref', name: 'someFunction' }), + ); + }); + + it('does not extract a value-ref call for an inline function/arrow value', () => { + const symbols = parseJS(`const table = { resolve: () => {}, other: function () {} };`); + expect(symbols.calls.filter((c) => c.dynamicKind === 'value-ref')).toHaveLength(0); + }); + + it('does not extract a value-ref call for literal or data-shaped values', () => { + const symbols = parseJS(` + const config = { name: 'literal', count: 42, active: true, empty: null, list: [1, 2] }; + `); + expect(symbols.calls.filter((c) => c.dynamicKind === 'value-ref')).toHaveLength(0); + }); + + it('excludes builtin globals from value-ref extraction', () => { + const symbols = parseJS(`const table = { log: console, Ctor: Object };`); + expect(symbols.calls.filter((c) => c.dynamicKind === 'value-ref')).toHaveLength(0); + }); + }); + describe('Phase 8.3f: object-destructuring rest parameter binding extraction', () => { function parseJS(code) { const parser = parsers.get('javascript'); From 30c491a97ca1901b888f3674f5c2a2fcb8e79767 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Mon, 6 Jul 2026 10:19:51 -0600 Subject: [PATCH 46/67] refactor: unify call-ref and tests rendering format between audit and inspect commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracts the duplicated "Calls"/"Called by"/"Tests" section rendering from presentation/audit.ts's renderCallRefs/renderRelatedTests and presentation/queries-cli/inspect.ts's renderExplainEdges into shared helpers in the new presentation/call-ref-sections.ts (renderCallRefsSection, renderNoCallEdgesFallback, renderRelatedTestsSection), following the same pattern established by #1756's impact-levels.ts unification. inspect.ts's richer format (indent-aware, "no call edges" fallback, singular/plural "Tests" label) is adopted as canonical in both commands. BEHAVIOR CHANGE: `codegraph audit`'s plain-text output (not --json/--ndjson) now matches `codegraph audit --quick`/`codegraph explain` exactly for these sections: - Prints "(no call edges found -- may be invoked dynamically or via re-exports)" when a function has neither callers nor callees. Previously audit printed nothing in this case. - "Tests (N):" is now "Tests (N file):"/"Tests (N files):" (singular/plural), matching explain's existing label. No changes to `codegraph audit --quick`/`codegraph explain` output — inspect.ts keeps its exact current format, just via the shared helper. docs check acknowledged: this is an internal presentation-layer refactor (no new command, language, or architecture change). CLAUDE.md's presentation/*.ts catch-all row already covers the new shared helper file, matching the precedent set by #1756's impact-levels.ts, which likewise did not get its own table row. Fixes #1772 Impact: 14 functions changed, 6 affected --- src/presentation/audit.ts | 37 +++------ src/presentation/call-ref-sections.ts | 103 ++++++++++++++++++++++++ src/presentation/queries-cli/inspect.ts | 34 +++----- 3 files changed, 123 insertions(+), 51 deletions(-) create mode 100644 src/presentation/call-ref-sections.ts diff --git a/src/presentation/audit.ts b/src/presentation/audit.ts index 2670b7696..add3a7385 100644 --- a/src/presentation/audit.ts +++ b/src/presentation/audit.ts @@ -2,6 +2,11 @@ import { kindIcon } from '../domain/queries.js'; import { auditData } from '../features/audit.js'; import { outputResult } from '../infrastructure/result-formatter.js'; import type { AuditFunctionEntry, AuditResult, CodegraphConfig } from '../types.js'; +import { + renderCallRefsSection, + renderNoCallEdgesFallback, + renderRelatedTestsSection, +} from './call-ref-sections.js'; import { renderImpactLevels } from './impact-levels.js'; interface AuditOpts { @@ -17,9 +22,6 @@ interface AuditOpts { config?: CodegraphConfig; } -/** A caller/callee reference as rendered under the "Calls"/"Called by" sections. */ -type CallRef = AuditFunctionEntry['callees'][number]; - /** Render health metrics for a single audit function. */ function renderHealthMetrics(fn: AuditFunctionEntry): void { if (fn.health.cognitive == null) return; @@ -66,38 +68,21 @@ function renderThresholdBreaches(fn: AuditFunctionEntry): void { /** Render the transitive-dependent impact summary, one block per BFS level. */ function renderImpactSection(fn: AuditFunctionEntry): void { console.log(`\n Impact: ${fn.impact.totalDependents} transitive dependent(s)`); - // No "0 found" message here -- the count above already conveys it, matching this - // file's other sections (e.g. renderCallRefs), which print nothing when empty. + // No "0 found" message here -- the count above already conveys it, matching the + // shared call-ref-sections helpers below, which print nothing when empty. renderImpactLevels(fn.impact.levels, { emptyMessage: null }); } -/** Render a labeled list of call references (used for both "Calls" and "Called by"). */ -function renderCallRefs(label: string, refs: CallRef[]): void { - if (refs.length === 0) return; - console.log(`\n ${label} (${refs.length}):`); - for (const c of refs) { - console.log(` ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}`); - } -} - -/** Render the related-test-file list for an audited function. */ -function renderRelatedTests(fn: AuditFunctionEntry): void { - if (fn.relatedTests.length === 0) return; - console.log(`\n Tests (${fn.relatedTests.length}):`); - for (const t of fn.relatedTests) { - console.log(` ${t.file}`); - } -} - /** Render a single audited function with all its sections. */ function renderAuditFunction(fn: AuditFunctionEntry): void { renderFunctionHeader(fn); renderHealthMetrics(fn); renderThresholdBreaches(fn); renderImpactSection(fn); - renderCallRefs('Calls', fn.callees); - renderCallRefs('Called by', fn.callers); - renderRelatedTests(fn); + renderCallRefsSection('Calls', fn.callees); + renderCallRefsSection('Called by', fn.callers); + renderRelatedTestsSection(fn.relatedTests); + renderNoCallEdgesFallback(fn.callees.length, fn.callers.length); console.log(); } diff --git a/src/presentation/call-ref-sections.ts b/src/presentation/call-ref-sections.ts new file mode 100644 index 000000000..328df243f --- /dev/null +++ b/src/presentation/call-ref-sections.ts @@ -0,0 +1,103 @@ +/** + * Shared renderers for the "Calls" / "Called by" / "Tests" sections rendered by + * both `codegraph audit` (presentation/audit.js) and `codegraph explain`/`codegraph query` + * (presentation/queries-cli/inspect.js — `audit --quick` is an alias for `explain`). + * + * Both commands render these sections from the same underlying shape and must stay + * visually identical — this module is the single source of truth for that format. + */ + +import { kindIcon } from '../domain/queries.js'; + +/** A single call reference rendered under the "Calls"/"Called by" sections. */ +export interface CallRefLike { + name: string; + kind: string; + file: string; + line: number; +} + +/** A single related-test-file reference rendered under the "Tests" section. */ +export interface RelatedTestRefLike { + file: string; +} + +export interface RenderCallRefsSectionOpts { + /** + * Indent prefix applied to every printed line. Used by `explain`'s recursive + * dependency rendering; top-level callers (e.g. `codegraph audit`) omit this + * and get the sensible default of no indent. + * Default: ''. + */ + indent?: string; +} + +/** + * Render a single labeled call-reference list — used for both the "Calls" and + * "Called by" sections (same shape, different label): + * + * {indent} Calls (2): + * {indent} f parse src/p.ts:1 + * {indent} m Parser.run src/p.ts:20 + * + * Prints nothing when `refs` is empty (see `renderNoCallEdgesFallback` for the + * combined "no edges at all" message). + */ +export function renderCallRefsSection( + label: string, + refs: CallRefLike[], + opts: RenderCallRefsSectionOpts = {}, +): void { + if (refs.length === 0) return; + const { indent = '' } = opts; + console.log(`\n${indent} ${label} (${refs.length}):`); + for (const c of refs) { + console.log(`${indent} ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}`); + } +} + +export interface RenderNoCallEdgesFallbackOpts { + /** Indent prefix applied to the printed line. Default: ''. */ + indent?: string; +} + +/** + * Print the "no call edges" fallback line when a function has neither callers + * nor callees. Call this once, after rendering both the "Calls" and "Called by" + * sections (which print nothing individually when empty) — it no-ops unless + * both counts are zero. + */ +export function renderNoCallEdgesFallback( + calleeCount: number, + callerCount: number, + opts: RenderNoCallEdgesFallbackOpts = {}, +): void { + if (calleeCount > 0 || callerCount > 0) return; + const { indent = '' } = opts; + console.log(`${indent} (no call edges found -- may be invoked dynamically or via re-exports)`); +} + +export interface RenderRelatedTestsSectionOpts { + /** Indent prefix applied to every printed line. Default: ''. */ + indent?: string; +} + +/** + * Render the related-test-file list for an audited/explained function, with a + * singular/plural "file"/"files" label: + * + * {indent} Tests (1 file): + * {indent} tests/parse.test.ts + */ +export function renderRelatedTestsSection( + tests: RelatedTestRefLike[], + opts: RenderRelatedTestsSectionOpts = {}, +): void { + if (tests.length === 0) return; + const { indent = '' } = opts; + const label = tests.length === 1 ? 'file' : 'files'; + console.log(`\n${indent} Tests (${tests.length} ${label}):`); + for (const t of tests) { + console.log(`${indent} ${t.file}`); + } +} diff --git a/src/presentation/queries-cli/inspect.ts b/src/presentation/queries-cli/inspect.ts index e900289d2..39fe7c368 100644 --- a/src/presentation/queries-cli/inspect.ts +++ b/src/presentation/queries-cli/inspect.ts @@ -9,6 +9,11 @@ import { whereData, } from '../../domain/queries.js'; import { outputResult } from '../../infrastructure/result-formatter.js'; +import { + renderCallRefsSection, + renderNoCallEdgesFallback, + renderRelatedTestsSection, +} from '../call-ref-sections.js'; interface SymbolRef { kind: string; @@ -477,31 +482,10 @@ function renderExplainComplexity( } function renderExplainEdges(r: FunctionExplainResult, indent: string): void { - if (r.callees.length > 0) { - console.log(`\n${indent} Calls (${r.callees.length}):`); - for (const c of r.callees) { - console.log(`${indent} ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}`); - } - } - - if (r.callers.length > 0) { - console.log(`\n${indent} Called by (${r.callers.length}):`); - for (const c of r.callers) { - console.log(`${indent} ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}`); - } - } - - if (r.relatedTests.length > 0) { - const label = r.relatedTests.length === 1 ? 'file' : 'files'; - console.log(`\n${indent} Tests (${r.relatedTests.length} ${label}):`); - for (const t of r.relatedTests) { - console.log(`${indent} ${t.file}`); - } - } - - if (r.callees.length === 0 && r.callers.length === 0) { - console.log(`${indent} (no call edges found -- may be invoked dynamically or via re-exports)`); - } + renderCallRefsSection('Calls', r.callees, { indent }); + renderCallRefsSection('Called by', r.callers, { indent }); + renderRelatedTestsSection(r.relatedTests, { indent }); + renderNoCallEdgesFallback(r.callees.length, r.callers.length, { indent }); } function renderFunctionExplain(r: FunctionExplainResult, indent = ''): void { From 9db911b08d0a1e3911264787cafdcdd562eef40b Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Mon, 6 Jul 2026 10:48:33 -0600 Subject: [PATCH 47/67] fix: classify destructured bindings as constant, not function (docs check acknowledged) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Object-pattern destructuring targets (const { a, b } = x; and renamed const { a: b } = x;) were hardcoded to kind: 'function' in both the TS/WASM extractor (extractDestructuredBindings, shared by the walk and query paths) and its native Rust mirror (extract_destructured_bindings), regardless of what the destructured value actually held. Non-function bindings (e.g. const { dbPath } = workerData) had no call-graph edges pointing at them by name, so the dead-code classifier risked flagging them dead-unresolved even when read repeatedly elsewhere in the file. Both engines now emit kind: 'constant', matching the existing convention for plain `const x = ` bindings and array-pattern destructuring (which was already correct). Every call site of these functions is const-gated, so 'constant' is unconditionally correct — no let/var case exists here. Call- target resolution is kind-agnostic and constant is already included in the top-level-binding caller-attribution fallback, so destructured callback-style bindings (e.g. `const { handler } = router; handler(req)`) still resolve correctly; verified via the resolution-benchmark suite showing identical precision/recall across all 40 fixture languages before and after. Array-pattern destructuring was checked and does not have this bug (already kind: 'constant' in both engines' TS path); a separate, pre-existing native vs WASM parity gap for array patterns was found and filed as #1901. Docs check acknowledged: kind-classification bug fix only, no new languages/features/architecture changes — README/ROADMAP tables unaffected. Fixes #1773 Impact: 2 functions changed, 7 affected --- .../src/extractors/javascript.rs | 48 +++++- src/extractors/javascript.ts | 27 ++- ...sue-1773-destructured-binding-kind.test.ts | 163 ++++++++++++++++++ tests/parsers/javascript.test.ts | 32 +++- 4 files changed, 256 insertions(+), 14 deletions(-) create mode 100644 tests/integration/issue-1773-destructured-binding-kind.test.ts diff --git a/crates/codegraph-core/src/extractors/javascript.rs b/crates/codegraph-core/src/extractors/javascript.rs index 2168a30d6..2e17db27f 100644 --- a/crates/codegraph-core/src/extractors/javascript.rs +++ b/crates/codegraph-core/src/extractors/javascript.rs @@ -2502,6 +2502,19 @@ fn handle_object_literal_shorthand_value_ref(node: &Node, source: &[u8], calls: }); } +/// Extract definitions from destructured object bindings: `const { handleToken, +/// checkPermissions } = initAuth(...)` creates definitions for `handleToken` +/// and `checkPermissions`, kind `constant` — matching the convention for plain +/// `const x = ` bindings and array-pattern destructuring. +/// +/// Every call site of this function is already gated to `const` declarations +/// (never `let`/`var`), so `constant` is unconditionally correct here. Prior to +/// #1773 this used `kind: "function"` on the theory that destructured names +/// are usually callbacks, but that miscategorized every non-function +/// destructured value (e.g. `const { dbPath } = workerData`). `constant`-kind +/// nodes remain fully resolvable as call targets — call-target resolution is +/// kind-agnostic — so callback-style destructured bindings still resolve. +/// Mirrors the TS extractor's `extractDestructuredBindings`. fn extract_destructured_bindings( pattern: &Node, source: &[u8], @@ -2515,7 +2528,7 @@ fn extract_destructured_bindings( "shorthand_property_identifier_pattern" | "shorthand_property_identifier" => { definitions.push(Definition { name: node_text(&child, source).to_string(), - kind: "function".to_string(), + kind: "constant".to_string(), line, end_line: Some(end_line), decorators: None, @@ -2531,7 +2544,7 @@ fn extract_destructured_bindings( { definitions.push(Definition { name: node_text(&value, source).to_string(), - kind: "function".to_string(), + kind: "constant".to_string(), line, end_line: Some(end_line), decorators: None, @@ -4527,12 +4540,33 @@ mod tests { #[test] fn extracts_destructured_const_bindings() { + // kind is "constant" (#1773), not "function" — matches the plain + // `const x = ` and array-pattern destructuring convention. + // Destructured names remain resolvable as call targets regardless of + // kind (call-target resolution is kind-agnostic), so callback-style + // destructured bindings like `handleToken` still resolve when called. let s = parse_js("const { handleToken, checkPermissions } = initAuth(config);"); let names: Vec<&str> = s.definitions.iter().map(|d| d.name.as_str()).collect(); assert!(names.contains(&"handleToken"), "should extract handleToken definition"); assert!(names.contains(&"checkPermissions"), "should extract checkPermissions definition"); let ht = s.definitions.iter().find(|d| d.name == "handleToken").unwrap(); - assert_eq!(ht.kind, "function"); + assert_eq!(ht.kind, "constant"); + } + + #[test] + fn extracts_non_renamed_destructured_bindings_with_kind_constant() { + // Regression guard for issue #1773: plain (non-renamed) destructured + // bindings from a non-call RHS (e.g. `workerData`) must not default to + // kind "function" — they hold arbitrary values, not callables. + let s = parse_js("const { dbPath, name, force } = workerData;"); + for expected in ["dbPath", "name", "force"] { + let def = s + .definitions + .iter() + .find(|d| d.name == expected) + .unwrap_or_else(|| panic!("should extract {expected} definition")); + assert_eq!(def.kind, "constant"); + } } #[test] @@ -4573,7 +4607,13 @@ mod tests { #[test] fn extracts_renamed_destructured_binding() { let s = parse_js("const { original: renamed } = initAuth();"); - assert!(s.definitions.iter().any(|d| d.name == "renamed"), "should use the local alias"); + let renamed = s + .definitions + .iter() + .find(|d| d.name == "renamed") + .expect("should use the local alias"); + // kind is "constant" (#1773) — see comment on extracts_destructured_const_bindings. + assert_eq!(renamed.kind, "constant"); assert!(!s.definitions.iter().any(|d| d.name == "original"), "should not use the original key"); } diff --git a/src/extractors/javascript.ts b/src/extractors/javascript.ts index f780290b2..bf0a0e741 100644 --- a/src/extractors/javascript.ts +++ b/src/extractors/javascript.ts @@ -1112,7 +1112,23 @@ function handleTypeAliasDecl(node: TreeSitterNode, ctx: ExtractorOutput): void { /** * Extract definitions from destructured object bindings. * `const { handleToken, checkPermissions } = initAuth(...)` creates definitions - * for handleToken and checkPermissions so they can be resolved as call targets. + * for handleToken and checkPermissions, kind `constant` — matching the + * convention for plain `const x = ` bindings (handleConstIdentifierAssignment) + * and array-pattern destructuring (the sibling branch in the callers below). + * + * Every call site of this function is already gated to `const` declarations + * (never `let`/`var`), so `constant` is unconditionally correct here — there is + * no live binding-mutability to branch on. Prior to #1773 this used `kind: + * 'function'` on the theory that destructured names are usually callbacks, but + * that miscategorized every non-function destructured value (e.g. `const { + * dbPath } = workerData`), which polluted `--kind function` queries and caused + * the dead-code classifier to misjudge them via the wrong kind's heuristics. + * `constant`-kind nodes remain fully resolvable as call targets — call-target + * resolution (`resolveByGlobal`'s exact by-name lookup) is kind-agnostic, and + * `constant` is already in the caller-attribution fallback tier + * (`TOP_LEVEL_BINDING_KINDS` in call-resolver.ts) — so callback-style + * destructured bindings (`const { handleToken } = router; handleToken(req)`) + * still resolve correctly. */ function extractDestructuredBindings( pattern: TreeSitterNode, @@ -1128,7 +1144,7 @@ function extractDestructuredBindings( child.type === 'shorthand_property_identifier' ) { // { handleToken } — shorthand binding - definitions.push({ name: child.text, kind: 'function', line, endLine }); + definitions.push({ name: child.text, kind: 'constant', line, endLine }); } else if (child.type === 'pair_pattern' || child.type === 'pair') { // { original: renamed } — renamed binding, use the local alias const value = child.childForFieldName('value'); @@ -1136,7 +1152,7 @@ function extractDestructuredBindings( value && (value.type === 'identifier' || value.type === 'shorthand_property_identifier_pattern') ) { - definitions.push({ name: value.text, kind: 'function', line, endLine }); + definitions.push({ name: value.text, kind: 'constant', line, endLine }); } } } @@ -1259,8 +1275,9 @@ function handleConstObjectPatternAssignment( ctx: ExtractorOutput, ): void { // Destructured bindings: const { handleToken, checkPermissions } = initAuth(...) - // Each destructured property becomes a function definition so it can be - // resolved when passed as a callback (e.g. router.use(handleToken)). + // Each destructured property becomes a constant definition (#1773) — still + // resolvable when passed as a callback (e.g. router.use(handleToken)), since + // call-target resolution is kind-agnostic (see extractDestructuredBindings). // Restricted to const to avoid creating spurious definitions for // transient let/var destructuring (e.g. let { userId } = parseRequest(req)). // Scope guard mirrors extractDestructuredBindingsWalk (query path) and diff --git a/tests/integration/issue-1773-destructured-binding-kind.test.ts b/tests/integration/issue-1773-destructured-binding-kind.test.ts new file mode 100644 index 000000000..a0b7e0fbf --- /dev/null +++ b/tests/integration/issue-1773-destructured-binding-kind.test.ts @@ -0,0 +1,163 @@ +/** + * Integration test for #1773: destructured object-pattern binding targets + * (renamed and non-renamed) were classified with `kind: "function"` instead + * of `kind: "constant"`, regardless of what the destructured value actually + * held. Because these bindings had no call-graph edges pointing at them by + * name, `codegraph roles --role dead` risked flagging them `dead-unresolved` + * (the "genuinely dead callable" bucket) even when read repeatedly elsewhere. + * + * Root cause: `extractDestructuredBindings` (src/extractors/javascript.ts, + * shared by both the walk and query extraction paths) and its native mirror + * `extract_destructured_bindings` (crates/codegraph-core/src/extractors/ + * javascript.rs) hardcoded `kind: 'function'` for every object-pattern + * binding target, on the theory that destructured names are usually + * callbacks. That miscategorized any destructured value that wasn't a + * function (e.g. `const { dbPath } = workerData`). + * + * Fix: both engines now emit `kind: 'constant'`, matching the existing + * convention for plain `const x = ` bindings and array-pattern + * destructuring. Constants remain fully resolvable as call targets (call- + * target resolution is kind-agnostic), so callback-style destructured + * bindings still resolve; and constants with active call-graph-connected + * siblings in the same file are classified `leaf`, not `dead-unresolved` — + * exactly like any other same-file constant. + */ + +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'; +import { isNativeAvailable } from '../../src/infrastructure/native.js'; + +// Repro 1 (renamed destructuring, issue #1773): a file with other real, +// call-graph-connected functions (giving it "active file siblings") plus a +// renamed destructured binding read repeatedly afterward — mirrors +// scripts/token-benchmark.ts's `const { values: flags } = parseArgs(...)`. +// Repro 2 (non-renamed destructuring, issue #1773): an isolated worker-style +// file with *only* a destructured binding from a non-call RHS and no other +// callables — mirrors tests/unit/snapshot-race-worker.mjs's +// `const { dbPath, name, force } = workerData`. +const FIXTURE = { + 'renamed-repro.js': ` +function parseArgs(opts) { return { values: computeDefaults(opts) }; } +function computeDefaults(opts) { return { runs: 3, model: opts.model }; } + +const { values: flags } = parseArgs({ model: 'x' }); + +function main() { + console.log(flags.runs, flags.model); +} +main(); +`, + 'worker-repro.mjs': ` +const { dbPath, name, force } = workerData; +save(name, { dbPath, force }); +`, +}; + +function readNode(dbPath: string, file: string, name: string) { + const db = new Database(dbPath, { readonly: true }); + try { + return db + .prepare('SELECT name, kind, role FROM nodes WHERE file = ? AND name = ?') + .get(file, name) as { name: string; kind: string; role: string | null } | undefined; + } finally { + db.close(); + } +} + +function expectFixedKindAndRole(dbPath: string) { + // Repro 1: renamed destructured binding, active file siblings present. + const flags = readNode(dbPath, 'renamed-repro.js', 'flags'); + expect(flags, 'flags node not found').toBeDefined(); + expect(flags!.kind, 'flags must be kind constant, not function').toBe('constant'); + expect( + flags!.role, + `flags was classified as ${flags!.role} — must not be dead-unresolved`, + ).not.toBe('dead-unresolved'); + + // Repro 2: non-renamed destructured bindings, no active file siblings. + for (const varName of ['dbPath', 'name', 'force']) { + const node = readNode(dbPath, 'worker-repro.mjs', varName); + expect(node, `${varName} node not found`).toBeDefined(); + expect(node!.kind, `${varName} must be kind constant, not function`).toBe('constant'); + // With no other call-graph-connected callable in the file, these fall + // back to 'dead-leaf' — the same honest classification any isolated, + // unreferenced-by-calls constant gets (properties/constants are leaf + // nodes by definition; call-graph reachability can't prove liveness for + // pure value bindings). The bug this test guards against is the far more + // misleading 'dead-unresolved' ("genuinely dead callable") label that a + // wrong kind: 'function' classification used to produce. + expect( + node!.role, + `${varName} was classified as ${node!.role} — must not be dead-unresolved`, + ).not.toBe('dead-unresolved'); + } +} + +describe('destructured binding kind classification (#1773) — WASM', () => { + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-1773-')); + for (const [rel, content] of Object.entries(FIXTURE)) { + fs.writeFileSync(path.join(tmpDir, rel), content); + } + await buildGraph(tmpDir, { engine: 'wasm', incremental: false, skipRegistry: true }); + }); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('classifies renamed and non-renamed destructured bindings as kind constant, not dead-unresolved', () => { + expectFixedKindAndRole(path.join(tmpDir, '.codegraph', 'graph.db')); + }); + + it('still resolves calls made through a destructured callback-style binding', () => { + // `flags.runs`/`flags.model` are property reads, not calls, but `flags` + // itself must still show up as the attributed caller of `parseArgs` — the + // fix must not break caller attribution for the top-level binding. + const dbPath = path.join(tmpDir, '.codegraph', 'graph.db'); + const db = new Database(dbPath, { readonly: true }); + try { + const row = db + .prepare( + `SELECT COUNT(*) AS cnt + FROM edges e + JOIN nodes s ON e.source_id = s.id + JOIN nodes t ON e.target_id = t.id + WHERE s.name = 'flags' AND t.name = 'parseArgs' AND e.kind = 'calls'`, + ) + .get() as { cnt: number }; + expect(row.cnt, 'expected flags -> parseArgs calls edge to survive the kind fix').toBe(1); + } finally { + db.close(); + } + }); +}); + +describe.skipIf(!isNativeAvailable())( + 'destructured binding kind classification (#1773) — native', + () => { + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-1773-native-')); + for (const [rel, content] of Object.entries(FIXTURE)) { + fs.writeFileSync(path.join(tmpDir, rel), content); + } + await buildGraph(tmpDir, { engine: 'native', incremental: false, skipRegistry: true }); + }, 60_000); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('classifies renamed and non-renamed destructured bindings as kind constant, not dead-unresolved', () => { + expectFixedKindAndRole(path.join(tmpDir, '.codegraph', 'graph.db')); + }); + }, +); diff --git a/tests/parsers/javascript.test.ts b/tests/parsers/javascript.test.ts index 79174055f..0d45c26bf 100644 --- a/tests/parsers/javascript.test.ts +++ b/tests/parsers/javascript.test.ts @@ -981,22 +981,43 @@ describe('JavaScript parser', () => { // Destructured bindings it('extracts definitions from destructured const bindings', () => { + // kind is 'constant' (#1773), not 'function' — matches the plain + // `const x = ` and array-pattern destructuring convention. + // Destructured names remain resolvable as call targets regardless of + // kind (call-target resolution is kind-agnostic), so callback-style + // destructured bindings like `handleToken` still resolve when called. const symbols = parseJS(`const { handleToken, checkPermissions } = initAuth(config);`); expect(symbols.definitions).toContainEqual( - expect.objectContaining({ name: 'handleToken', kind: 'function' }), + expect.objectContaining({ name: 'handleToken', kind: 'constant' }), ); expect(symbols.definitions).toContainEqual( - expect.objectContaining({ name: 'checkPermissions', kind: 'function' }), + expect.objectContaining({ name: 'checkPermissions', kind: 'constant' }), ); }); it('extracts definitions from exported destructured const bindings', () => { const symbols = parseJS(`export const { handleToken, checkPermissions } = initAuth(config);`); expect(symbols.definitions).toContainEqual( - expect.objectContaining({ name: 'handleToken', kind: 'function' }), + expect.objectContaining({ name: 'handleToken', kind: 'constant' }), ); expect(symbols.definitions).toContainEqual( - expect.objectContaining({ name: 'checkPermissions', kind: 'function' }), + expect.objectContaining({ name: 'checkPermissions', kind: 'constant' }), + ); + }); + + it('extracts non-renamed destructured const bindings with kind constant (#1773)', () => { + // Regression guard for issue #1773: plain (non-renamed) destructured + // bindings from a non-call RHS (e.g. `workerData`) must not default to + // kind 'function' — they hold arbitrary values, not callables. + const symbols = parseJS(`const { dbPath, name, force } = workerData;`); + expect(symbols.definitions).toContainEqual( + expect.objectContaining({ name: 'dbPath', kind: 'constant' }), + ); + expect(symbols.definitions).toContainEqual( + expect.objectContaining({ name: 'name', kind: 'constant' }), + ); + expect(symbols.definitions).toContainEqual( + expect.objectContaining({ name: 'force', kind: 'constant' }), ); }); @@ -1013,9 +1034,10 @@ describe('JavaScript parser', () => { }); it('extracts renamed destructured const binding under its local alias', () => { + // kind is 'constant' (#1773) — see comment on the non-renamed case above. const symbols = parseJS(`const { original: renamed } = initAuth();`); expect(symbols.definitions).toContainEqual( - expect.objectContaining({ name: 'renamed', kind: 'function' }), + expect.objectContaining({ name: 'renamed', kind: 'constant' }), ); expect(symbols.definitions).not.toContainEqual(expect.objectContaining({ name: 'original' })); }); From 70559fcf6d88065b40073bdfb14b6f9c7608aa5f Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Mon, 6 Jul 2026 11:06:21 -0600 Subject: [PATCH 48/67] refactor: adopt timeMedian in remaining benchmark timing loops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deduplicates the "loop N times, time each run, compute median" pattern across the 7 remaining call sites that predated scripts/lib/bench-timing.ts: - query-benchmark.ts's benchDepths()/benchDiffImpact() and incremental-benchmark.ts's parent-process nativeBatchMs/jsFallbackMs computations and benchmark.ts's benchQuery() now adopt timeMedian() directly (converted to async; propagation stops at each call site since all callers are already at ESM top-level/top-level-await scope). - incremental-benchmark.ts's and benchmark.ts's "1-file rebuild" measurements need the phases of the median-timed run, not just the numeric median, so bench-timing.ts gains timeMedianWithValue(fn, runs, beforeEach?) — returns the {ms, value} pair for the median-duration run, with an optional untimed beforeEach(i) hook for per-iteration setup (writing the probe file) that must stay outside the timed window. Verified no methodology regression: ran query-benchmark.ts and incremental-benchmark.ts before/after on this repo (3 runs and 1 run respectively) with consistent latencies, plus an isolated microbenchmark quantifying the await-on-sync-value overhead at ~1 microsecond/call — negligible against the millisecond-to-hundred-millisecond latencies these scripts measure. Fixes #1774 Impact: 4 functions changed, 4 affected --- scripts/benchmark.ts | 47 ++++++++++++++++---------------- scripts/incremental-benchmark.ts | 45 ++++++++++++------------------ scripts/lib/bench-timing.ts | 33 ++++++++++++++++++++++ scripts/query-benchmark.ts | 43 +++++++++++++---------------- 4 files changed, 93 insertions(+), 75 deletions(-) diff --git a/scripts/benchmark.ts b/scripts/benchmark.ts index 65cad01a3..39e68f337 100644 --- a/scripts/benchmark.ts +++ b/scripts/benchmark.ts @@ -17,7 +17,7 @@ import { fileURLToPath } from 'node:url'; import Database from 'better-sqlite3'; import { resolveBenchmarkExcludes, resolveBenchmarkSource, srcImport } from './lib/bench-config.js'; import { isWorker, workerEngine, workerTargets, forkEngines } from './lib/fork-engine.js'; -import { median, round1, timeMedian } from './lib/bench-timing.js'; +import { round1, timeMedian, timeMedianWithValue } from './lib/bench-timing.js'; // ── Parent process: fork one child per engine, assemble final output ───── if (!isWorker()) { @@ -134,7 +134,7 @@ const buildTimeMs = performance.now() - buildStart; // query latency with NAPI/rusqlite/OS-page-cache init costs (~65ms on // macOS) and inflated growth from test-fixture files pulled in by new // native extractors. See #1113 for the methodology rationale. -const queryTimeMs = benchQuery(fnDepsData, 'buildGraph', dbPath, { depth: 3, noTests: true }); +const queryTimeMs = await benchQuery(fnDepsData, 'buildGraph', dbPath, { depth: 3, noTests: true }); const stats = statsData(dbPath); const totalFiles = stats.files.total; @@ -168,17 +168,16 @@ try { fs.writeFileSync(PROBE_FILE, original + `\n// warmup-${i}\n`); await buildGraph(root, { engine, incremental: true, exclude: BENCH_EXCLUDE }); } - const oneFileRuns = []; - for (let i = 0; i < INCREMENTAL_RUNS; i++) { - fs.writeFileSync(PROBE_FILE, original + `\n// probe-${i}\n`); - const start = performance.now(); - const res = await buildGraph(root, { engine, incremental: true, exclude: BENCH_EXCLUDE }); - oneFileRuns.push({ ms: performance.now() - start, phases: res?.phases || null }); - } - oneFileRuns.sort((a, b) => a.ms - b.ms); - const medianRun = oneFileRuns[Math.floor(oneFileRuns.length / 2)]; + const medianRun = await timeMedianWithValue( + async () => { + const res = await buildGraph(root, { engine, incremental: true, exclude: BENCH_EXCLUDE }); + return res?.phases || null; + }, + INCREMENTAL_RUNS, + (i) => fs.writeFileSync(PROBE_FILE, original + `\n// probe-${i}\n`), + ); oneFileRebuildMs = Math.round(medianRun.ms); - oneFilePhases = medianRun.phases; + oneFilePhases = medianRun.value; } catch (err) { console.error(` [${engine}] 1-file rebuild failed: ${(err as Error).message}`); } finally { @@ -195,26 +194,26 @@ console.error(` [${engine}] Benchmarking queries...`); const targets = workerTargets() || selectTargets(); console.error(` hub=${targets.hub}, leaf=${targets.leaf}`); -function benchQuery(fn, ...args) { +async function benchQuery(fn, ...args) { // Warmup runs prime NAPI bindings, the rusqlite statement cache, and the // OS page cache so the timed loop measures steady-state query latency // rather than first-call init (~65ms on macOS). Each call site warms // independently — methodology does not rely on call ordering elsewhere. for (let i = 0; i < QUERY_WARMUP_RUNS; i++) fn(...args); - const timings = []; - for (let i = 0; i < QUERY_RUNS; i++) { - const start = performance.now(); - fn(...args); - timings.push(performance.now() - start); - } - return round1(median(timings)); + return round1(await timeMedian(() => fn(...args), QUERY_RUNS)); } const queries = { - fnDepsMs: fnDepsData ? benchQuery(fnDepsData, targets.hub, dbPath, { depth: 3, noTests: true }) : null, - fnImpactMs: fnImpactData ? benchQuery(fnImpactData, targets.hub, dbPath, { depth: 3, noTests: true }) : null, - pathMs: pathData ? benchQuery(pathData, targets.hub, targets.leaf, dbPath, { noTests: true }) : null, - rolesMs: rolesData ? benchQuery(rolesData, dbPath, { noTests: true }) : null, + fnDepsMs: fnDepsData + ? await benchQuery(fnDepsData, targets.hub, dbPath, { depth: 3, noTests: true }) + : null, + fnImpactMs: fnImpactData + ? await benchQuery(fnImpactData, targets.hub, dbPath, { depth: 3, noTests: true }) + : null, + pathMs: pathData + ? await benchQuery(pathData, targets.hub, targets.leaf, dbPath, { noTests: true }) + : null, + rolesMs: rolesData ? await benchQuery(rolesData, dbPath, { noTests: true }) : null, }; // Restore console.log for JSON output diff --git a/scripts/incremental-benchmark.ts b/scripts/incremental-benchmark.ts index b2f3b53d7..974212352 100644 --- a/scripts/incremental-benchmark.ts +++ b/scripts/incremental-benchmark.ts @@ -16,7 +16,7 @@ import { performance } from 'node:perf_hooks'; import { fileURLToPath } from 'node:url'; import { resolveBenchmarkExcludes, resolveBenchmarkSource, srcImport } from './lib/bench-config.js'; import { isWorker, workerEngine, forkEngines } from './lib/fork-engine.js'; -import { median, round1, timeMedian } from './lib/bench-timing.js'; +import { round1, timeMedian, timeMedianWithValue } from './lib/bench-timing.js'; // ── Parent process: fork one child per engine, assemble final output ───── if (!isWorker()) { @@ -87,13 +87,7 @@ if (!isWorker()) { for (let i = 0; i < WARMUP_RUNS; i++) { parentBatch(inputs, rootParent, null); } - const timings = []; - for (let i = 0; i < RUNS; i++) { - const start = performance.now(); - parentBatch(inputs, rootParent, null); - timings.push(performance.now() - start); - } - nativeBatchMs = round1(median(timings)); + nativeBatchMs = round1(await timeMedian(() => parentBatch(inputs, rootParent, null), RUNS)); perImportNativeMs = inputs.length > 0 ? round1(nativeBatchMs / inputs.length) : 0; } for (let i = 0; i < WARMUP_RUNS; i++) { @@ -101,15 +95,13 @@ if (!isWorker()) { parentJS(fromFile, importSource, rootParent, null); } } - const jsTimings = []; - for (let i = 0; i < RUNS; i++) { - const start = performance.now(); - for (const { fromFile, importSource } of inputs) { - parentJS(fromFile, importSource, rootParent, null); - } - jsTimings.push(performance.now() - start); - } - const jsFallbackMs = round1(median(jsTimings)); + const jsFallbackMs = round1( + await timeMedian(() => { + for (const { fromFile, importSource } of inputs) { + parentJS(fromFile, importSource, rootParent, null); + } + }, RUNS), + ); const perImportJsMs = inputs.length > 0 ? round1(jsFallbackMs / inputs.length) : 0; const resolve = { imports: inputs.length, nativeBatchMs, jsFallbackMs, perImportNativeMs, perImportJsMs }; @@ -217,17 +209,16 @@ try { fs.writeFileSync(PROBE_FILE, original + `\n// warmup-${i}\n`); await buildGraph(root, { ...BUILD_OPTS, incremental: true }); } - const oneFileRuns = []; - for (let i = 0; i < RUNS; i++) { - fs.writeFileSync(PROBE_FILE, original + `\n// probe-${i}\n`); - const start = performance.now(); - const res = await buildGraph(root, { ...BUILD_OPTS, incremental: true }); - oneFileRuns.push({ ms: performance.now() - start, phases: res?.phases || null }); - } - oneFileRuns.sort((a, b) => a.ms - b.ms); - const medianRun = oneFileRuns[Math.floor(oneFileRuns.length / 2)]; + const medianRun = await timeMedianWithValue( + async () => { + const res = await buildGraph(root, { ...BUILD_OPTS, incremental: true }); + return res?.phases || null; + }, + RUNS, + (i) => fs.writeFileSync(PROBE_FILE, original + `\n// probe-${i}\n`), + ); oneFileRebuildMs = Math.round(medianRun.ms); - oneFilePhases = medianRun.phases; + oneFilePhases = medianRun.value; } catch (err) { console.error(` [${engine}] 1-file rebuild failed: ${(err as Error).message}`); } finally { diff --git a/scripts/lib/bench-timing.ts b/scripts/lib/bench-timing.ts index 233fb33a7..136218cd5 100644 --- a/scripts/lib/bench-timing.ts +++ b/scripts/lib/bench-timing.ts @@ -15,6 +15,11 @@ * const fullBuildMs = Math.round( * await timeMedian(() => buildGraph(root, { engine, incremental: false }), RUNS), * ); + * + * `timeMedianWithValue` covers the same loop shape for call sites that also + * need side data from whichever run turned out to be the median-duration one + * (e.g. the build-phase breakdown of a "1-file rebuild" measurement) — data + * `timeMedian`'s bare `Promise` return can't carry. */ import { performance } from 'node:perf_hooks'; @@ -51,3 +56,31 @@ export async function timeMedian(fn: () => unknown, runs: number): Promise( + fn: (i: number) => T | Promise, + runs: number, + beforeEach?: (i: number) => void | Promise, +): Promise<{ ms: number; value: T }> { + const samples: { ms: number; value: T }[] = []; + for (let i = 0; i < runs; i++) { + if (beforeEach) await beforeEach(i); + const start = performance.now(); + const value = await fn(i); + samples.push({ ms: performance.now() - start, value }); + } + samples.sort((a, b) => a.ms - b.ms); + return samples[Math.floor(samples.length / 2)]; +} diff --git a/scripts/query-benchmark.ts b/scripts/query-benchmark.ts index d19aa8e2f..1e9d72863 100644 --- a/scripts/query-benchmark.ts +++ b/scripts/query-benchmark.ts @@ -18,7 +18,7 @@ import { fileURLToPath } from 'node:url'; import Database from 'better-sqlite3'; import { resolveBenchmarkExcludes, resolveBenchmarkSource, srcImport } from './lib/bench-config.js'; import { isWorker, workerEngine, workerTargets, forkEngines } from './lib/fork-engine.js'; -import { median, round1 } from './lib/bench-timing.js'; +import { round1, timeMedian } from './lib/bench-timing.js'; // ── Parent process: fork one child per engine, assemble final output ───── if (!isWorker()) { @@ -169,19 +169,15 @@ function selectTargets() { } } -function benchDepths(fn, name, depths) { +async function benchDepths(fn, name, depths) { const result = {}; for (const depth of depths) { for (let i = 0; i < WARMUP_RUNS; i++) { fn(name, dbPath, { depth, noTests: true }); } - const timings = []; - for (let i = 0; i < RUNS; i++) { - const start = performance.now(); - fn(name, dbPath, { depth, noTests: true }); - timings.push(performance.now() - start); - } - result[`depth${depth}Ms`] = round1(median(timings)); + result[`depth${depth}Ms`] = round1( + await timeMedian(() => fn(name, dbPath, { depth, noTests: true }), RUNS), + ); } return result; } @@ -201,7 +197,7 @@ function resolveDbFile(rootDir: string, dbFile: string): string | null { return null; } -function benchDiffImpact(hubName) { +async function benchDiffImpact(hubName) { const db = new Database(dbPath, { readonly: true }); const row = db .prepare(`SELECT file FROM nodes WHERE name = ? LIMIT 1`) @@ -224,16 +220,15 @@ function benchDiffImpact(hubName) { fs.writeFileSync(hubFile, original + '\n// benchmark-probe\n'); execFileSync('git', ['add', hubFile], { cwd: root, stdio: 'pipe' }); - const timings = []; let lastResult = null; - for (let i = 0; i < RUNS; i++) { - const start = performance.now(); - lastResult = diffImpactData(dbPath, { staged: true, depth: 3, noTests: true }); - timings.push(performance.now() - start); - } + const latencyMs = round1( + await timeMedian(() => { + lastResult = diffImpactData(dbPath, { staged: true, depth: 3, noTests: true }); + }, RUNS), + ); return { - latencyMs: round1(median(timings)), + latencyMs, affectedFunctions: lastResult?.affectedFunctions?.length || 0, affectedFiles: lastResult?.affectedFiles?.length || 0, }; @@ -253,15 +248,15 @@ console.error(`Targets: hub=${targets.hub}, mid=${targets.mid}, leaf=${targets.l const fnDeps = {}; const fnImpact = {}; -fnDeps.depth1Ms = benchDepths(fnDepsData, targets.hub, [1]).depth1Ms; -fnDeps.depth3Ms = benchDepths(fnDepsData, targets.hub, [3]).depth3Ms; -fnDeps.depth5Ms = benchDepths(fnDepsData, targets.hub, [5]).depth5Ms; +fnDeps.depth1Ms = (await benchDepths(fnDepsData, targets.hub, [1])).depth1Ms; +fnDeps.depth3Ms = (await benchDepths(fnDepsData, targets.hub, [3])).depth3Ms; +fnDeps.depth5Ms = (await benchDepths(fnDepsData, targets.hub, [5])).depth5Ms; -fnImpact.depth1Ms = benchDepths(fnImpactData, targets.hub, [1]).depth1Ms; -fnImpact.depth3Ms = benchDepths(fnImpactData, targets.hub, [3]).depth3Ms; -fnImpact.depth5Ms = benchDepths(fnImpactData, targets.hub, [5]).depth5Ms; +fnImpact.depth1Ms = (await benchDepths(fnImpactData, targets.hub, [1])).depth1Ms; +fnImpact.depth3Ms = (await benchDepths(fnImpactData, targets.hub, [3])).depth3Ms; +fnImpact.depth5Ms = (await benchDepths(fnImpactData, targets.hub, [5])).depth5Ms; -const diffImpact = benchDiffImpact(targets.hub); +const diffImpact = await benchDiffImpact(targets.hub); // Restore console.log for JSON output console.log = origLog; From 822e0ebbbd3cac8fc9274405dc01f90bfa61dc01 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Mon, 6 Jul 2026 11:20:15 -0600 Subject: [PATCH 49/67] refactor: decompose runPerfBenchmarks in token-benchmark.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit runPerfBenchmarks mixed build benchmarking, stats collection, hub-selection, and query benchmarking in one function, exceeding complexity thresholds (cognitive 15, cyclomatic 13) and the titan-gate halstead.bugs FAIL threshold (1.2559 > 1.0). Extract runBuildBenchmarks(engines, dbPath, nextjsDir, buildGraph) and runQueryBenchmarks(hubName, dbPath, fnDepsData, fnImpactData) as named async helpers; runPerfBenchmarks becomes a thin orchestrator. Pure decomposition, verified with a throwaway call-sequence harness — no control-flow or value changes. Fixes #1775 Impact: 3 functions changed, 3 affected --- scripts/token-benchmark.ts | 104 ++++++++++++++++++++++--------------- 1 file changed, 61 insertions(+), 43 deletions(-) diff --git a/scripts/token-benchmark.ts b/scripts/token-benchmark.ts index 87d4c9698..15bf5692a 100644 --- a/scripts/token-benchmark.ts +++ b/scripts/token-benchmark.ts @@ -260,6 +260,65 @@ async function runSession(mode, issue, nextjsDir) { const PERF_RUNS = 3; +/** + * Run full-build and no-op-rebuild benchmarks for each available engine. + * Returns a map of engine name -> { fullBuildMs, noopRebuildMs }. + */ +async function runBuildBenchmarks(engines, dbPath, nextjsDir, buildGraph) { + const buildResults = {}; + for (const engine of engines) { + console.error(` Full build (${engine})...`); + const fullBuildMs = Math.round( + await timeMedian(async () => { + if (fs.existsSync(dbPath)) fs.unlinkSync(dbPath); + await buildGraph(nextjsDir, { engine, incremental: false }); + }, PERF_RUNS), + ); + + // No-op rebuild + console.error(` No-op rebuild (${engine})...`); + const noopRebuildMs = Math.round( + await timeMedian(() => buildGraph(nextjsDir, { engine, incremental: true }), PERF_RUNS), + ); + + buildResults[engine] = { fullBuildMs, noopRebuildMs }; + console.error(` full=${fullBuildMs}ms noop=${noopRebuildMs}ms`); + } + return buildResults; +} + +/** + * Run fnDeps/fnImpact query benchmarks at depths 1/3/5 against `hubName`. + * Returns an empty object when there's no hub to query against. + */ +async function runQueryBenchmarks(hubName, dbPath, fnDepsData, fnImpactData) { + const queryResults = {}; + if (!hubName) return queryResults; + + console.error(` Query target (hub): ${hubName}`); + + for (const depth of [1, 3, 5]) { + // fnDeps + queryResults[`fnDeps_depth${depth}Ms`] = round1( + await timeMedian(() => fnDepsData(hubName, dbPath, { depth, noTests: true }), PERF_RUNS), + ); + + // fnImpact + queryResults[`fnImpact_depth${depth}Ms`] = round1( + await timeMedian(() => fnImpactData(hubName, dbPath, { depth, noTests: true }), PERF_RUNS), + ); + } + + console.error( + ` fnDeps: d1=${queryResults.fnDeps_depth1Ms}ms d3=${queryResults.fnDeps_depth3Ms}ms d5=${queryResults.fnDeps_depth5Ms}ms`, + ); + console.error( + ` fnImpact: d1=${queryResults.fnImpact_depth1Ms}ms d3=${queryResults.fnImpact_depth3Ms}ms d5=${queryResults.fnImpact_depth5Ms}ms`, + ); + + return queryResults; +} + /** * Run build/query/stats benchmarks against the Next.js graph. * Reuses the same codegraph APIs as the existing benchmark scripts. @@ -298,25 +357,7 @@ async function runPerfBenchmarks(nextjsDir) { if (!isNativeAvailable()) { console.error(' Native engine not available — skipping native perf benchmark'); } - const buildResults = {}; - for (const engine of engines) { - console.error(` Full build (${engine})...`); - const fullBuildMs = Math.round( - await timeMedian(async () => { - if (fs.existsSync(dbPath)) fs.unlinkSync(dbPath); - await buildGraph(nextjsDir, { engine, incremental: false }); - }, PERF_RUNS), - ); - - // No-op rebuild - console.error(` No-op rebuild (${engine})...`); - const noopRebuildMs = Math.round( - await timeMedian(() => buildGraph(nextjsDir, { engine, incremental: true }), PERF_RUNS), - ); - - buildResults[engine] = { fullBuildMs, noopRebuildMs }; - console.error(` full=${fullBuildMs}ms noop=${noopRebuildMs}ms`); - } + const buildResults = await runBuildBenchmarks(engines, dbPath, nextjsDir, buildGraph); // ── Stats ───────────────────────────────────────────────────────── // Ensure we have a graph (rebuild with first available engine if needed) @@ -352,30 +393,7 @@ async function runPerfBenchmarks(nextjsDir) { db.close(); const hubName = hubRow?.name || null; - const queryResults = {}; - - if (hubName) { - console.error(` Query target (hub): ${hubName}`); - - for (const depth of [1, 3, 5]) { - // fnDeps - queryResults[`fnDeps_depth${depth}Ms`] = round1( - await timeMedian(() => fnDepsData(hubName, dbPath, { depth, noTests: true }), PERF_RUNS), - ); - - // fnImpact - queryResults[`fnImpact_depth${depth}Ms`] = round1( - await timeMedian(() => fnImpactData(hubName, dbPath, { depth, noTests: true }), PERF_RUNS), - ); - } - - console.error( - ` fnDeps: d1=${queryResults.fnDeps_depth1Ms}ms d3=${queryResults.fnDeps_depth3Ms}ms d5=${queryResults.fnDeps_depth5Ms}ms`, - ); - console.error( - ` fnImpact: d1=${queryResults.fnImpact_depth1Ms}ms d3=${queryResults.fnImpact_depth3Ms}ms d5=${queryResults.fnImpact_depth5Ms}ms`, - ); - } + const queryResults = await runQueryBenchmarks(hubName, dbPath, fnDepsData, fnImpactData); return { repo: 'vercel/next.js', From fcdfe7d78f9490c359904b51f108fddf6e22fe47 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Mon, 6 Jul 2026 11:47:47 -0600 Subject: [PATCH 50/67] fix: attribute liveness to Lua functions assigned to global/builtin identifiers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codegraph roles --role dead flagged Lua functions monkey-patched onto a global/builtin identifier (e.g. `require = tracedRequire`) as dead-unresolved, despite being genuinely invoked at runtime through every later unqualified use of the builtin name. Root cause: neither engine's Lua extractor inspected bare (non-`local`) assignment_statement nodes at all. `require = tracedRequire` produced zero extraction output — not a call, and the assignment itself was invisible to the walker's switch, which only handled function_declaration, variable_declaration, and function_call. Fix: both engines now emit a dynamic `calls` edge (dynamic=1, dynamicKind/dynamic_kind = 'value-ref' — the same classification #1771 uses for object-literal property-value references) from the enclosing scope to the RHS function, for any plain `identifier = identifier` assignment whose LHS matches a recognized Lua builtin/stdlib-module name (LUA_BUILTIN_GLOBALS). `value-ref` resolution already restricts matches to function/method-kind targets only (build-edges.ts / incremental.ts / build_edges.rs, unchanged), so a builtin reassigned to a non-function value is silently dropped rather than fabricating a nonsensical edge. Scoped narrowly to the reported pattern per #1776's own framing: the LHS must be a recognized Lua builtin/module name specifically, because a builtin identifier is not a locally-scoped variable that could ever be tracked by conventional alias/points-to resolution — general local-to-local aliasing (`local a = someFunc; a()`) is a separate, much larger points-to problem this does not attempt to solve, and JS/TS already handles that case correctly via the existing points-to solver. Both the bare top-level form and the `local`-declared shadow form (`local require = tracedRequire`) are handled identically, since shadowing a builtin name is the same "redirect every later unqualified use" idiom, just lexically scoped. Multi-assignment (`a, b = f, g`) is paired positionally to avoid shifting pairs when a mix of identifier/non-identifier variables is present. Applied to both engines: - WASM/TS: src/extractors/lua.ts (LUA_BUILTIN_GLOBALS, handleLuaAssignmentStatement, wired into walkLuaNode's switch). - Native: crates/codegraph-core/src/extractors/lua.rs (LUA_BUILTIN_GLOBALS, handle_lua_assignment_statement, wired into match_lua_node), plus a new `mod tests` block (this extractor previously had none). No changes needed to build-edges.ts / incremental.ts / build_edges.rs — the `value-ref` DynamicKind's function/method-kind resolution filter already applies generically regardless of source language. Broadened the `value-ref` doc comments (types.ts, ADR-002) to describe it as syntax-position-agnostic (object-literal value or assignment to a global/builtin identifier) rather than JS-object-literal-specific. Verified against the exact repro (tests/benchmarks/resolution/tracer/lua-tracer.lua's traced_require): fn-impact now reports totalDependents=1 and role=core on both engines (previously dead-unresolved, totalDependents=0); `roles --role dead` for that file now returns zero symbols. Lua resolution-benchmark fixture is unaffected (100% precision / 15.4% recall, unchanged before and after — the fixture files don't contain this assignment pattern). Full suite: 3581 tests passed (0 failed) across both engines; cargo test: 475 passed (0 failed), including 9 new Lua extractor tests. Filed #1909 for an unrelated, pre-existing gap found incidentally: the WASM Lua extractor is missing eval/computed-key dynamic-call detection that the native extractor already has (from an earlier phase-6 parity PR that only updated the Rust side). Fixes #1776 docs check acknowledged — internal edge-emission/resolver bug fix to an existing language extractor; no new commands, languages, or architecture to document. ADR-002 updated in place to broaden the existing value-ref DynamicKind description. Impact: 2 functions changed, 2 affected --- crates/codegraph-core/src/extractors/lua.rs | 161 +++++++++++++ .../decisions/002-dynamic-call-resolution.md | 21 +- src/extractors/lua.ts | 120 ++++++++- src/types.ts | 2 +- ...ssue-1776-lua-builtin-reassignment.test.ts | 227 ++++++++++++++++++ tests/parsers/lua.test.ts | 92 +++++++ 6 files changed, 618 insertions(+), 5 deletions(-) create mode 100644 tests/integration/issue-1776-lua-builtin-reassignment.test.ts diff --git a/crates/codegraph-core/src/extractors/lua.rs b/crates/codegraph-core/src/extractors/lua.rs index 5629d09e4..24b5812de 100644 --- a/crates/codegraph-core/src/extractors/lua.rs +++ b/crates/codegraph-core/src/extractors/lua.rs @@ -5,6 +5,21 @@ use crate::types::*; use super::helpers::*; use super::SymbolExtractor; +/// Lua base-library global function names and standard-library module +/// tables. Mirrors `LUA_BUILTIN_GLOBALS` in `src/extractors/lua.ts` — see +/// `handle_lua_assignment_statement` (this file) and that file's +/// `handleLuaAssignmentStatement` for the full rationale (issue #1776). +const LUA_BUILTIN_GLOBALS: &[&str] = &[ + "assert", "collectgarbage", "dofile", "error", "getfenv", "getmetatable", + "ipairs", "load", "loadfile", "loadstring", "module", "next", "pairs", + "pcall", "print", "rawequal", "rawget", "rawlen", "rawset", "require", + "select", "setfenv", "setmetatable", "tonumber", "tostring", "type", + "unpack", "xpcall", + // Standard-library module tables — wholesale replacement (e.g. sandboxing) + // is the same "escapes local scope" shape as a single builtin function. + "string", "table", "math", "io", "os", "coroutine", "debug", "utf8", "bit32", +]; + pub struct LuaExtractor; impl SymbolExtractor for LuaExtractor { @@ -20,6 +35,7 @@ fn match_lua_node(node: &Node, source: &[u8], symbols: &mut FileSymbols, _depth: match node.kind() { "function_declaration" => handle_lua_function_decl(node, source, symbols), "function_call" => handle_lua_function_call(node, source, symbols), + "assignment_statement" => handle_lua_assignment_statement(node, source, symbols), _ => {} } } @@ -88,6 +104,52 @@ fn extract_lua_params(func_node: &Node, source: &[u8]) -> Vec { params } +/// Detect ` = ` assignments — a locally declared +/// function bound to a Lua global/builtin identifier (e.g. +/// `require = traced_require`), the monkey-patch pattern from issue #1776. +/// Mirrors `handleLuaAssignmentStatement` in `src/extractors/lua.ts` — see +/// that function's doc comment for the full rationale. +/// +/// Emits a dynamic `value-ref` call for the RHS identifier, restricted to +/// plain `identifier = identifier` pairs where the LHS matches +/// `LUA_BUILTIN_GLOBALS`. `value-ref` is resolved downstream +/// (build_edges.rs) against function/method-kind targets only, so a +/// builtin reassigned to a non-function value is silently dropped rather +/// than fabricating a nonsensical edge. +/// +/// Multi-assignment (`a, b = f, g`) is handled positionally: each side is +/// indexed independently by position (not pre-filtered to identifiers +/// first), so mixed variable kinds (`t.b, a = f, g`) do not shift the +/// pairing. +fn handle_lua_assignment_statement(node: &Node, source: &[u8], symbols: &mut FileSymbols) { + let Some(variable_list) = find_child(node, "variable_list") else { return }; + let Some(expression_list) = find_child(node, "expression_list") else { return }; + + let pair_count = variable_list + .named_child_count() + .min(expression_list.named_child_count()); + + for i in 0..pair_count { + let Some(lhs) = variable_list.named_child(i) else { continue }; + let Some(rhs) = expression_list.named_child(i) else { continue }; + if lhs.kind() != "identifier" || rhs.kind() != "identifier" { + continue; + } + let lhs_text = node_text(&lhs, source); + let rhs_text = node_text(&rhs, source); + if !LUA_BUILTIN_GLOBALS.contains(&lhs_text) || LUA_BUILTIN_GLOBALS.contains(&rhs_text) { + continue; + } + symbols.calls.push(Call { + name: rhs_text.to_string(), + line: start_line(&rhs), + dynamic: Some(true), + dynamic_kind: Some("value-ref".to_string()), + ..Default::default() + }); + } +} + fn handle_lua_function_call(node: &Node, source: &[u8], symbols: &mut FileSymbols) { let name_node = match node.child_by_field_name("name") { Some(n) => n, @@ -199,3 +261,102 @@ fn handle_lua_function_call(node: &Node, source: &[u8], symbols: &mut FileSymbol } } } + +#[cfg(test)] +mod tests { + use super::*; + use tree_sitter::Parser; + + fn parse_lua(code: &str) -> FileSymbols { + let mut parser = Parser::new(); + parser + .set_language(&tree_sitter_lua::LANGUAGE.into()) + .unwrap(); + let tree = parser.parse(code.as_bytes(), None).unwrap(); + LuaExtractor.extract(&tree, code.as_bytes(), "test.lua") + } + + // ── #1776: builtin/global reassignment value-ref extraction ───────────── + + #[test] + fn extracts_value_ref_call_for_function_assigned_to_builtin_global() { + let s = parse_lua( + "local function traced_require(modname)\n return modname\nend\nrequire = traced_require", + ); + let value_refs: Vec<_> = s + .calls + .iter() + .filter(|c| c.dynamic_kind.as_deref() == Some("value-ref")) + .collect(); + assert!(value_refs.iter().any(|c| c.name == "traced_require")); + assert!(value_refs.iter().all(|c| c.dynamic == Some(true))); + } + + #[test] + fn extracts_value_ref_call_for_local_shadow_of_builtin() { + let s = parse_lua( + "local function traced_require(modname)\n return modname\nend\nlocal require = traced_require", + ); + assert!(s + .calls + .iter() + .any(|c| c.name == "traced_require" && c.dynamic_kind.as_deref() == Some("value-ref"))); + } + + #[test] + fn no_value_ref_call_when_lhs_is_not_a_recognized_builtin() { + let s = parse_lua("local function helper() end\nmyCustomGlobal = helper"); + assert!(s.calls.iter().all(|c| c.dynamic_kind.as_deref() != Some("value-ref"))); + } + + #[test] + fn no_value_ref_call_when_rhs_is_itself_a_builtin() { + let s = parse_lua("print = tostring"); + assert!(s.calls.iter().all(|c| c.dynamic_kind.as_deref() != Some("value-ref"))); + } + + #[test] + fn no_value_ref_call_for_local_non_builtin_alias() { + let s = parse_lua("local function helper() end\nlocal orig_helper = helper"); + assert!(s.calls.iter().all(|c| c.dynamic_kind.as_deref() != Some("value-ref"))); + } + + #[test] + fn no_value_ref_call_when_rhs_is_a_call_expression() { + let s = parse_lua("require = wrapRequire(require)"); + assert!(s.calls.iter().all(|c| c.dynamic_kind.as_deref() != Some("value-ref"))); + } + + #[test] + fn no_value_ref_call_when_rhs_is_a_member_expression() { + let s = parse_lua("require = mymodule.customRequire"); + assert!(s.calls.iter().all(|c| c.dynamic_kind.as_deref() != Some("value-ref"))); + } + + #[test] + fn pairs_multi_assignment_positionally() { + // `t.b` occupies position 0 (a dot_index_expression, not a plain + // identifier) — pairing must not shift, or `require` (position 1) + // would incorrectly pair with `helperA` (position 0). + let s = parse_lua( + "local function helperA() end\nlocal function helperB() end\nt.b, require = helperA, helperB", + ); + let value_refs: Vec<&str> = s + .calls + .iter() + .filter(|c| c.dynamic_kind.as_deref() == Some("value-ref")) + .map(|c| c.name.as_str()) + .collect(); + assert!(value_refs.contains(&"helperB")); + assert!(!value_refs.contains(&"helperA")); + } + + #[test] + fn extracts_value_ref_call_for_stdlib_module_table_reassignment() { + let s = parse_lua("local function fakeOs() end\nos = fakeOs"); + assert!(s + .calls + .iter() + .any(|c| c.name == "fakeOs" && c.dynamic_kind.as_deref() == Some("value-ref"))); + } +} diff --git a/docs/architecture/decisions/002-dynamic-call-resolution.md b/docs/architecture/decisions/002-dynamic-call-resolution.md index 5159f7b99..c017742e5 100644 --- a/docs/architecture/decisions/002-dynamic-call-resolution.md +++ b/docs/architecture/decisions/002-dynamic-call-resolution.md @@ -77,9 +77,12 @@ export type DynamicKind = | 'reflection' // .call/.apply/.bind, getattr, Method.invoke, $obj->$m() | 'eval' // eval(), new Function() — undecidable | 'unresolved-dynamic' // detected dynamic call we cannot resolve - | 'value-ref'; // bare identifier used as an object-literal property value - // (dispatch tables, e.g. `{ resolve: someFn }`) — resolvable - // against function/method-kind targets only (#1771) + | 'value-ref'; // bare identifier used as a value reference, not a call site — + // object-literal property value (dispatch tables, e.g. + // `{ resolve: someFn }`, #1771) or assignment to a Lua + // global/builtin identifier (e.g. `require = tracedRequire`, + // #1776) — resolvable against function/method-kind targets + // only ``` `dynamic?: boolean` is kept to avoid churning every `call.dynamic ? 1 : 0` site. @@ -90,6 +93,18 @@ plain data reference like `{ name: SOME_CONSTANT }`), that's the common case, no an undecidable dynamic call site — so it's silently dropped rather than flagged, unlike `eval`/`computed-key`/`unresolved-dynamic`. +`value-ref` is deliberately syntax-position-agnostic: any bare identifier that +names a known function/method and appears somewhere other than a call site +qualifies, regardless of which language or grammar shape produced it. +Object-literal property values (#1771) and Lua assignment to a +global/builtin identifier (#1776, `require = tracedRequire` — a builtin name +isn't a locally-scoped variable that alias/points-to resolution could ever +observe, so this is the narrow, language-specific case where a plain +reference edge is the correct substitute for real alias tracking) are two +independent extraction sites feeding the same resolution/filtering logic +downstream; new languages/positions can add a third without touching +`build-edges.ts` / `incremental.ts` / `build_edges.rs` again. + ### Sink edges reuse `kind='calls'`, not a new edge kind A new `EdgeKind` would ripple through every edge-kind switch, role classifier, exporter, MCP tool, and the viewer — high blast radius. Instead: DB migration adds `dynamic_kind TEXT` column to `edges`; sink edges use `kind='calls'`, `dynamic=1`, `dynamic_kind=`, `confidence=0.0`. Confidence below `DEFAULT_MIN_CONFIDENCE=0.5` means they never pollute normal queries or exports but remain queryable when explicitly requested. diff --git a/src/extractors/lua.ts b/src/extractors/lua.ts index 85c99b827..468c3cfc9 100644 --- a/src/extractors/lua.ts +++ b/src/extractors/lua.ts @@ -5,7 +5,60 @@ import type { TreeSitterNode, TreeSitterTree, } from '../types.js'; -import { findChild, nodeEndLine } from './helpers.js'; +import { findChild, nodeEndLine, nodeStartLine } from './helpers.js'; + +/** + * Lua base-library global function names and standard-library module + * tables. A plain `identifier = identifier` assignment whose LHS is one of + * these escapes local/lexical scoping entirely: the LHS names a language + * builtin rather than a locally declared variable that scope-based tracking + * could follow, so a function assigned here (`require = tracedRequire`, the + * monkey-patch pattern from issue #1776) becomes reachable through every + * later unqualified use of the builtin name — anywhere in the codebase, + * since it's a genuine global, not just within this file's call graph. + * Mirrors `LUA_BUILTIN_GLOBALS` in `crates/codegraph-core/src/extractors/lua.rs`. + */ +const LUA_BUILTIN_GLOBALS: Set = new Set([ + 'assert', + 'collectgarbage', + 'dofile', + 'error', + 'getfenv', + 'getmetatable', + 'ipairs', + 'load', + 'loadfile', + 'loadstring', + 'module', + 'next', + 'pairs', + 'pcall', + 'print', + 'rawequal', + 'rawget', + 'rawlen', + 'rawset', + 'require', + 'select', + 'setfenv', + 'setmetatable', + 'tonumber', + 'tostring', + 'type', + 'unpack', + 'xpcall', + // Standard-library module tables — wholesale replacement (e.g. sandboxing) + // is the same "escapes local scope" shape as a single builtin function. + 'string', + 'table', + 'math', + 'io', + 'os', + 'coroutine', + 'debug', + 'utf8', + 'bit32', +]); /** * Extract symbols from Lua files. @@ -35,6 +88,9 @@ function walkLuaNode(node: TreeSitterNode, ctx: ExtractorOutput): void { case 'function_call': handleLuaFunctionCall(node, ctx); break; + case 'assignment_statement': + handleLuaAssignmentStatement(node, ctx); + break; } for (let i = 0; i < node.childCount; i++) { @@ -128,6 +184,68 @@ function checkForRequire(node: TreeSitterNode, ctx: ExtractorOutput): void { } } +/** + * Detect ` = ` assignments — a locally declared + * function bound to a Lua global/builtin identifier (e.g. + * `require = tracedRequire`), the monkey-patch pattern from issue #1776. + * Every later unqualified call to the builtin name (`require(...)`) + * anywhere in the codebase actually invokes the RHS function, but that call + * site names the builtin, never the RHS function directly — so without + * this, the RHS function has no inbound edge at all and is misclassified + * dead-unresolved. + * + * Emits a dynamic `value-ref` call for the RHS identifier — the same + * classification #1771 uses for bare identifiers referenced as + * object-literal property values: a bare identifier used in a value + * position, not a call site. Resolution downstream (build-edges.ts / + * incremental.ts / build_edges.rs) already restricts `value-ref` calls to + * function/method-kind targets only, so a builtin reassigned to a + * non-function value (`unpack = someTable`) is silently dropped rather than + * fabricating a nonsensical edge — no further changes needed there. + * + * Scoped narrowly to plain `identifier = identifier` pairs where the LHS + * matches a known Lua builtin/stdlib-module name (`LUA_BUILTIN_GLOBALS`). + * General local-to-local variable aliasing (`local a = someFunc; a()`) is a + * much larger points-to/alias-tracking problem this fix does not attempt to + * solve — see #1776 for the scoping rationale. + * + * Handles both the bare top-level form (`require = tracedRequire`, a + * standalone `assignment_statement`) and the `local`-declared form + * (`local require = tracedRequire`, an `assignment_statement` nested inside + * `variable_declaration`) identically: shadowing a builtin name with + * `local` is the same "redirect every later unqualified use" idiom, just + * lexically scoped rather than truly global. + * + * Multi-assignment (`a, b = f, g`) is handled positionally, matching Lua's + * own assignment semantics — mixed variable kinds (`t.b, a = f, g`) do not + * shift the pairing, since each side is indexed independently by position + * rather than pre-filtered to identifiers first. + */ +function handleLuaAssignmentStatement(node: TreeSitterNode, ctx: ExtractorOutput): void { + const variableList = findChild(node, 'variable_list'); + const expressionList = findChild(node, 'expression_list'); + if (!variableList || !expressionList) return; + + const variables = variableList.namedChildren; + const expressions = expressionList.namedChildren; + const pairCount = Math.min(variables.length, expressions.length); + + for (let i = 0; i < pairCount; i++) { + const lhs = variables[i]; + const rhs = expressions[i]; + if (!lhs || !rhs) continue; + if (lhs.type !== 'identifier' || rhs.type !== 'identifier') continue; + if (!LUA_BUILTIN_GLOBALS.has(lhs.text) || LUA_BUILTIN_GLOBALS.has(rhs.text)) continue; + + ctx.calls.push({ + name: rhs.text, + line: nodeStartLine(rhs), + dynamic: true, + dynamicKind: 'value-ref', + }); + } +} + function handleLuaFunctionCall(node: TreeSitterNode, ctx: ExtractorOutput): void { const nameNode = node.childForFieldName('name'); if (!nameNode) return; diff --git a/src/types.ts b/src/types.ts index c352bb0b5..7b90058ee 100644 --- a/src/types.ts +++ b/src/types.ts @@ -488,7 +488,7 @@ export type DynamicKind = | 'reflection' // .call/.apply/.bind / Reflect.* / callable-ref — resolved when target is in codebase; sink edge emitted if unresolved | 'eval' // eval() / new Function() — undecidable; always flagged | 'unresolved-dynamic' // any other detected dynamic pattern; flagged - | 'value-ref'; // bare identifier used as an object-literal property value (dispatch-table pattern, e.g. `{ resolve: someFn }`) — resolved only against function/method-kind targets; unresolved (e.g. plain data references) are dropped silently, NOT flagged + | 'value-ref'; // bare identifier used as a value reference rather than a call site — object-literal property value (dispatch-table pattern, e.g. `{ resolve: someFn }`, #1771) or assignment to a Lua global/builtin identifier (e.g. `require = tracedRequire`, #1776) — resolved only against function/method-kind targets; unresolved (e.g. plain data references) are dropped silently, NOT flagged /** A function/method call detected by an extractor. */ export interface Call { diff --git a/tests/integration/issue-1776-lua-builtin-reassignment.test.ts b/tests/integration/issue-1776-lua-builtin-reassignment.test.ts new file mode 100644 index 000000000..a9d14fd2d --- /dev/null +++ b/tests/integration/issue-1776-lua-builtin-reassignment.test.ts @@ -0,0 +1,227 @@ +/** + * Integration test for #1776: a Lua function assigned to a global/builtin + * identifier (the `require = tracedRequire` monkey-patch pattern) was + * misclassified `dead-unresolved` despite being genuinely invoked at + * runtime through every later unqualified use of the builtin name. + * + * Root cause: the function is never called by its own name — it's assigned + * to the `require` builtin, then invoked only via subsequent `require(...)` + * call sites. Codegraph's static resolver had no extraction logic at all + * for bare (non-`local`) `assignment_statement` nodes, so `require = + * tracedRequire` produced zero edges: not a call, and the assignment itself + * was never even inspected. + * + * Fix: both engines now emit a dynamic `calls` edge (dynamic=1, + * dynamicKind/dynamic_kind = 'value-ref' — the same classification #1771 + * uses for object-literal property-value references) from the enclosing + * scope to the RHS function, for any plain `identifier = identifier` + * assignment whose LHS matches a recognized Lua builtin/stdlib-module name. + * Deliberately scoped to that pattern only — reassigning a locally-declared + * (non-builtin) global, or aliasing via a `local` variable that isn't + * itself a builtin name, is a general points-to/alias-tracking problem this + * fix does not attempt to solve, and remains dead-unresolved as before. + */ + +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'; +import { isNativeAvailable } from '../../src/infrastructure/native.js'; + +// Mirrors the real-world fixture from issue #1776 +// (tests/benchmarks/resolution/tracer/lua-tracer.lua): `traced_require` is +// declared as a local function, then assigned to the global `require` +// builtin so every later `require(...)` call site actually invokes it. +// +// `aliasedToCustomGlobal` and `trulyDeadFn` are negative controls: assigning +// a function to a global that is NOT a recognized Lua builtin/stdlib-module +// name must NOT rescue it (scope-discipline check — this fix targets the +// reported builtin-reassignment pattern specifically, not general aliasing). +const FIXTURE = { + 'main.lua': ` +local orig_require = require +local function traced_require(modname) + local mod = orig_require(modname) + return mod +end +require = traced_require + +local function aliasedToCustomGlobal() + return 42 +end +myCustomGlobal = aliasedToCustomGlobal + +local function trulyDeadFn() + return 99 +end + +local function entryPoint() + local m = require("some.module") + return m +end +`, +}; + +const DEAD_ROLES = new Set(['dead-unresolved', 'dead-leaf', 'dead-entry', 'dead-ffi']); + +function readNodesWithRoles(dbPath: string) { + const db = new Database(dbPath, { readonly: true }); + try { + return db.prepare('SELECT name, kind, role FROM nodes ORDER BY name').all() as Array<{ + name: string; + kind: string; + role: string | null; + }>; + } finally { + db.close(); + } +} + +function countCallEdgesTo(dbPath: string, targetName: string): number { + const db = new Database(dbPath, { readonly: true }); + try { + const row = db + .prepare( + `SELECT COUNT(*) AS cnt + FROM edges e + JOIN nodes t ON e.target_id = t.id + WHERE e.kind = 'calls' AND t.name = ?`, + ) + .get(targetName) as { cnt: number }; + return row.cnt; + } finally { + db.close(); + } +} + +// Mirrors the #1771 integration test's rationale: a call site that DOES +// resolve (as value-ref calls into a real function target always do here) +// is persisted as a plain `dynamic=1` `calls` edge — `dynamic_kind` is only +// persisted on unresolved sink edges. So a resolved value-ref edge is +// identified by `dynamic = 1`, not by the `dynamic_kind` column. +function readDynamicCallEdges(dbPath: string) { + const db = new Database(dbPath, { readonly: true }); + try { + return db + .prepare( + `SELECT s.name AS src, t.name AS tgt + FROM edges e + JOIN nodes s ON e.source_id = s.id + JOIN nodes t ON e.target_id = t.id + WHERE e.kind = 'calls' AND e.dynamic = 1 + ORDER BY s.name, t.name`, + ) + .all() as Array<{ src: string; tgt: string }>; + } finally { + db.close(); + } +} + +describe('Lua builtin-reassignment value-ref edges (#1776) — WASM', () => { + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-1776-')); + for (const [rel, content] of Object.entries(FIXTURE)) { + fs.writeFileSync(path.join(tmpDir, rel), content); + } + await buildGraph(tmpDir, { engine: 'wasm', incremental: false, skipRegistry: true }); + }); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('emits a value-ref calls edge from the enclosing scope to the reassigned function', () => { + const dbPath = path.join(tmpDir, '.codegraph', 'graph.db'); + const edges = readDynamicCallEdges(dbPath); + expect( + edges.some((e) => e.tgt === 'traced_require'), + `Expected a value-ref edge into traced_require; got: ${JSON.stringify(edges)}`, + ).toBe(true); + }); + + it('gives the reassigned function at least one inbound calls edge (fan-in >= 1)', () => { + const dbPath = path.join(tmpDir, '.codegraph', 'graph.db'); + expect(countCallEdgesTo(dbPath, 'traced_require')).toBeGreaterThan(0); + }); + + it('does not classify the reassigned function as dead', () => { + const dbPath = path.join(tmpDir, '.codegraph', 'graph.db'); + const nodes = readNodesWithRoles(dbPath); + const node = nodes.find((n) => n.name === 'traced_require' && n.kind === 'function'); + expect(node, 'traced_require node not found').toBeDefined(); + expect( + node!.role, + `traced_require was classified as ${node!.role} — expected a non-dead role now that a real edge exists`, + ).not.toBe(undefined); + expect(DEAD_ROLES.has(node!.role ?? '')).toBe(false); + }); + + it('does not rescue a function aliased to a non-builtin global (scope-discipline control)', () => { + const dbPath = path.join(tmpDir, '.codegraph', 'graph.db'); + const edges = readDynamicCallEdges(dbPath); + expect(edges.some((e) => e.tgt === 'aliasedToCustomGlobal')).toBe(false); + const nodes = readNodesWithRoles(dbPath); + const node = nodes.find((n) => n.name === 'aliasedToCustomGlobal' && n.kind === 'function'); + expect(node, 'aliasedToCustomGlobal node not found').toBeDefined(); + expect(DEAD_ROLES.has(node!.role ?? '')).toBe(true); + }); + + it('leaves an unrelated, genuinely unreferenced function classified dead (baseline control)', () => { + const dbPath = path.join(tmpDir, '.codegraph', 'graph.db'); + const nodes = readNodesWithRoles(dbPath); + const node = nodes.find((n) => n.name === 'trulyDeadFn' && n.kind === 'function'); + expect(node, 'trulyDeadFn node not found').toBeDefined(); + expect(DEAD_ROLES.has(node!.role ?? '')).toBe(true); + }); +}); + +// ── Native engine parity ──────────────────────────────────────────────────── +// Skipped when the native addon is not installed. + +describe.skipIf(!isNativeAvailable())( + 'Lua builtin-reassignment value-ref edges (#1776) — native', + () => { + let nativeTmpDir: string; + + beforeAll(async () => { + nativeTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-1776-native-')); + for (const [rel, content] of Object.entries(FIXTURE)) { + fs.writeFileSync(path.join(nativeTmpDir, rel), content); + } + await buildGraph(nativeTmpDir, { engine: 'native', incremental: false, skipRegistry: true }); + }, 60_000); + + afterAll(() => { + fs.rmSync(nativeTmpDir, { recursive: true, force: true }); + }); + + it('emits a value-ref calls edge from the enclosing scope to the reassigned function', () => { + const dbPath = path.join(nativeTmpDir, '.codegraph', 'graph.db'); + const edges = readDynamicCallEdges(dbPath); + expect( + edges.some((e) => e.tgt === 'traced_require'), + `Expected a native value-ref edge into traced_require; got: ${JSON.stringify(edges)}`, + ).toBe(true); + }); + + it('does not classify the reassigned function as dead', () => { + const dbPath = path.join(nativeTmpDir, '.codegraph', 'graph.db'); + const nodes = readNodesWithRoles(dbPath); + const node = nodes.find((n) => n.name === 'traced_require' && n.kind === 'function'); + expect(node, 'traced_require node not found (native)').toBeDefined(); + expect(DEAD_ROLES.has(node!.role ?? '')).toBe(false); + }); + + it('does not rescue a function aliased to a non-builtin global (scope-discipline control)', () => { + const dbPath = path.join(nativeTmpDir, '.codegraph', 'graph.db'); + const nodes = readNodesWithRoles(dbPath); + const node = nodes.find((n) => n.name === 'aliasedToCustomGlobal' && n.kind === 'function'); + expect(node, 'aliasedToCustomGlobal node not found (native)').toBeDefined(); + expect(DEAD_ROLES.has(node!.role ?? '')).toBe(true); + }); + }, +); diff --git a/tests/parsers/lua.test.ts b/tests/parsers/lua.test.ts index 7872c9ff5..1020a19ae 100644 --- a/tests/parsers/lua.test.ts +++ b/tests/parsers/lua.test.ts @@ -52,4 +52,96 @@ end`); string.format("%s", name)`); expect(symbols.calls).toContainEqual(expect.objectContaining({ name: 'print' })); }); + + describe('builtin/global reassignment value-ref extraction (#1776)', () => { + it('extracts a value-ref call for a function assigned to a builtin global', () => { + const symbols = parseLua(` + local function traced_require(modname) + return modname + end + require = traced_require + `); + expect(symbols.calls).toContainEqual( + expect.objectContaining({ + name: 'traced_require', + dynamic: true, + dynamicKind: 'value-ref', + }), + ); + }); + + it('extracts a value-ref call for the local-shadow form of the same pattern', () => { + const symbols = parseLua(` + local function traced_require(modname) + return modname + end + local require = traced_require + `); + expect(symbols.calls).toContainEqual( + expect.objectContaining({ + name: 'traced_require', + dynamic: true, + dynamicKind: 'value-ref', + }), + ); + }); + + it('does not extract a value-ref call when the LHS is not a recognized builtin', () => { + const symbols = parseLua(` + local function helper() end + myCustomGlobal = helper + `); + expect(symbols.calls.filter((c) => c.dynamicKind === 'value-ref')).toHaveLength(0); + }); + + it('does not extract a value-ref call when the RHS is itself a builtin', () => { + const symbols = parseLua(`print = tostring`); + expect(symbols.calls.filter((c) => c.dynamicKind === 'value-ref')).toHaveLength(0); + }); + + it('does not extract a value-ref call for a local (non-builtin) alias', () => { + const symbols = parseLua(` + local function helper() end + local orig_helper = helper + `); + expect(symbols.calls.filter((c) => c.dynamicKind === 'value-ref')).toHaveLength(0); + }); + + it('does not extract a value-ref call when the RHS is a call expression', () => { + const symbols = parseLua(`require = wrapRequire(require)`); + expect(symbols.calls.filter((c) => c.dynamicKind === 'value-ref')).toHaveLength(0); + }); + + it('does not extract a value-ref call when the RHS is a member expression', () => { + const symbols = parseLua(`require = mymodule.customRequire`); + expect(symbols.calls.filter((c) => c.dynamicKind === 'value-ref')).toHaveLength(0); + }); + + it('pairs multi-assignment positionally, matching Lua assignment semantics', () => { + // `t.b` (a dot_index_expression, not a plain identifier) occupies + // position 0 — pairing must not shift, or `require` (position 1) + // would incorrectly pair with `helperA` (position 0) instead of + // `helperB` (position 1). + const symbols = parseLua(` + local function helperA() end + local function helperB() end + t.b, require = helperA, helperB + `); + const valueRefs = symbols.calls.filter((c) => c.dynamicKind === 'value-ref'); + expect(valueRefs).toContainEqual( + expect.objectContaining({ name: 'helperB', dynamicKind: 'value-ref' }), + ); + expect(valueRefs.some((c) => c.name === 'helperA')).toBe(false); + }); + + it('extracts a value-ref call for a standard-library module table reassignment', () => { + const symbols = parseLua(` + local function fakeOs() end + os = fakeOs + `); + expect(symbols.calls).toContainEqual( + expect.objectContaining({ name: 'fakeOs', dynamic: true, dynamicKind: 'value-ref' }), + ); + }); + }); }); From ee6362c1b0608371d005bdc3614d573332dfcfdc Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Mon, 6 Jul 2026 12:04:14 -0600 Subject: [PATCH 51/67] refactor: extract shared sedi() helper for tracer scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The portable "sed -i (GNU vs BSD)" helper was duplicated: native-tracer.sh and jvm-tracer.sh each defined a byte-identical sedi() function, and go-tracer.sh inlined the same GNU-detection branch twice instead of extracting a helper at all. jvm-tracer.sh additionally left two more GNU-detection branches inlined in its java case (method-body injection, main-dump injection) rather than routing them through the sedi() it had already defined for its kotlin/scala/groovy cases. Extracts sedi() into tests/benchmarks/resolution/tracer/tracer-common.sh, sourced by all three scripts via a path relative to the script's own location (robust regardless of invoking cwd — run-tracer.mjs spawns these with cwd set to the fixture dir, not the tracer dir). Removes every inline copy, including both in go-tracer.sh and the two leftover ones in jvm-tracer.sh's java case. Pure structural dedup: every replaced sed invocation keeps its exact original script content. Verified byte-identical stdout/stderr/exit code before and after for every case reachable in this environment: native-tracer.sh's rust and swift (full end-to-end success) and c/cpp (pre-existing unrelated compile failures, filed as #1914); jvm-tracer.sh's java (pre-existing unrelated BSD-sed syntax failure, filed as #1913); go-tracer.sh (go toolchain unavailable, graceful skip before any sed call is reached). Also verified via run-tracer.mjs directly and from an unrelated cwd to confirm the source path resolves correctly. tracer-validation.test.ts (42/42) and the full suite (3581 passed, 30 skipped, 2 todo) pass; lint is unaffected (Biome doesn't process .sh files). Fixes #1777 Impact: 1 functions changed, 9 affected --- .../benchmarks/resolution/tracer/go-tracer.sh | 27 ++++--------- .../resolution/tracer/jvm-tracer.sh | 40 +++++-------------- .../resolution/tracer/native-tracer.sh | 9 +---- .../resolution/tracer/tracer-common.sh | 18 +++++++++ 4 files changed, 36 insertions(+), 58 deletions(-) create mode 100644 tests/benchmarks/resolution/tracer/tracer-common.sh diff --git a/tests/benchmarks/resolution/tracer/go-tracer.sh b/tests/benchmarks/resolution/tracer/go-tracer.sh index a662fb325..e9bd44ac1 100644 --- a/tests/benchmarks/resolution/tracer/go-tracer.sh +++ b/tests/benchmarks/resolution/tracer/go-tracer.sh @@ -8,6 +8,8 @@ set -euo pipefail +source "$(dirname "${BASH_SOURCE[0]}")/tracer-common.sh" + FIXTURE_DIR="${1:-}" if [[ -z "$FIXTURE_DIR" ]]; then echo "Usage: go-tracer.sh " >&2 @@ -135,28 +137,15 @@ for gofile in "$TMP_DIR"/*.go; do # Use sed to inject traceCall() after function opening braces # Match: func ... { at end of line -> add traceCall() on next line - # Use portable sed -i: GNU sed uses -i alone, BSD sed (macOS) requires -i '' - if sed --version 2>/dev/null | grep -q GNU; then - sed -i -E 's/^(func [^{]*\{)\s*$/\1\n\ttraceCall()/' "$gofile" - else - sed -i '' -E 's/^(func [^{]*\{)\s*$/\1\n\ttraceCall()/' "$gofile" - fi + sedi -E 's/^(func [^{]*\{)\s*$/\1\n\ttraceCall()/' "$gofile" done # Inject defer dumpTrace() at start of main() -if sed --version 2>/dev/null | grep -q GNU; then - sed -i -E '/^func main\(\)\s*\{/{ - a\ defer dumpTrace() - }' "$TMP_DIR/main.go" - # Suppress any os.Exit calls that would skip deferred dumpTrace - sed -i 's/os\.Exit(/\/\/ os.Exit(/g' "$TMP_DIR/main.go" -else - sed -i '' -E '/^func main\(\)\s*\{/{ - a\ defer dumpTrace() - }' "$TMP_DIR/main.go" - # Suppress any os.Exit calls that would skip deferred dumpTrace - sed -i '' 's/os\.Exit(/\/\/ os.Exit(/g' "$TMP_DIR/main.go" -fi +sedi -E '/^func main\(\)\s*\{/{ + a\ defer dumpTrace() +}' "$TMP_DIR/main.go" +# Suppress any os.Exit calls that would skip deferred dumpTrace +sedi 's/os\.Exit(/\/\/ os.Exit(/g' "$TMP_DIR/main.go" # Redirect fmt.Print* to stderr so only JSON goes to stdout cat >> "$TMP_DIR/trace_support.go" <<'REDIRECT' diff --git a/tests/benchmarks/resolution/tracer/jvm-tracer.sh b/tests/benchmarks/resolution/tracer/jvm-tracer.sh index eed1c7adb..563a5e75e 100644 --- a/tests/benchmarks/resolution/tracer/jvm-tracer.sh +++ b/tests/benchmarks/resolution/tracer/jvm-tracer.sh @@ -49,14 +49,7 @@ esac TMP_DIR="$(mktemp -d)" trap 'rm -rf "$TMP_DIR"' EXIT -# Portable sed -i (GNU vs BSD) -sedi() { - if sed --version 2>/dev/null | grep -q GNU; then - sed -i "$@" - else - sed -i '' "$@" - fi -} +source "$(dirname "${BASH_SOURCE[0]}")/tracer-common.sh" # Copy fixture files case "$LANG" in @@ -147,34 +140,19 @@ case "$LANG" in [[ "$base" == "CallTracer.java" ]] && continue # Add CallTracer.traceCall() after method opening braces # Match lines like: public void method(...) { - # Use portable sed -i: GNU sed uses -i alone, BSD sed (macOS) requires -i '' # The first sed pass matches all method/constructor opening braces, # so a second pass is unnecessary (it would double-inject traceCall). - if sed --version 2>/dev/null | grep -q GNU; then - sed -i -E '/\)\s*\{$/{ - /class |interface /!{ - a\ CallTracer.traceCall(); - } - }' "$javafile" - else - sed -i '' -E '/\)\s*\{$/{ - /class |interface /!{ - a\ CallTracer.traceCall(); - } - }' "$javafile" - fi + sedi -E '/\)\s*\{$/{ + /class |interface /!{ + a\ CallTracer.traceCall(); + } + }' "$javafile" done # Add dump call at end of main - if sed --version 2>/dev/null | grep -q GNU; then - sed -i '/public static void main/,/\}/ { - /^\s*\}/ i\ CallTracer.dump(); - }' "$TMP_DIR/Main.java" 2>/dev/null || true - else - sed -i '' '/public static void main/,/\}/ { - /^\s*\}/ i\ CallTracer.dump(); - }' "$TMP_DIR/Main.java" 2>/dev/null || true - fi + sedi '/public static void main/,/\}/ { + /^\s*\}/ i\ CallTracer.dump(); + }' "$TMP_DIR/Main.java" 2>/dev/null || true # Compile and run cd "$TMP_DIR" diff --git a/tests/benchmarks/resolution/tracer/native-tracer.sh b/tests/benchmarks/resolution/tracer/native-tracer.sh index e83d3276d..1546d31d9 100644 --- a/tests/benchmarks/resolution/tracer/native-tracer.sh +++ b/tests/benchmarks/resolution/tracer/native-tracer.sh @@ -29,14 +29,7 @@ FIXTURE_DIR="$(cd "$FIXTURE_DIR" && pwd)" TMP_DIR="$(mktemp -d)" trap 'rm -rf "$TMP_DIR"' EXIT -# Portable sed -i (GNU vs BSD) -sedi() { - if sed --version 2>/dev/null | grep -q GNU; then - sed -i "$@" - else - sed -i '' "$@" - fi -} +source "$(dirname "${BASH_SOURCE[0]}")/tracer-common.sh" empty_result() { local reason="${1:-toolchain not available}" diff --git a/tests/benchmarks/resolution/tracer/tracer-common.sh b/tests/benchmarks/resolution/tracer/tracer-common.sh new file mode 100644 index 000000000..e2a4001de --- /dev/null +++ b/tests/benchmarks/resolution/tracer/tracer-common.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Shared helpers for the dynamic call tracer scripts in this directory. +# Each tracer (native-tracer.sh, jvm-tracer.sh, go-tracer.sh, ...) is spawned +# as its own subprocess by run-tracer.mjs, so sharing logic between them means +# sourcing this file rather than importing a function — source it with a path +# relative to the caller's own location so it resolves regardless of the +# invoking process's working directory: +# +# source "$(dirname "${BASH_SOURCE[0]}")/tracer-common.sh" + +# Portable sed -i (GNU vs BSD) +sedi() { + if sed --version 2>/dev/null | grep -q GNU; then + sed -i "$@" + else + sed -i '' "$@" + fi +} From d4e6212d4fcb290602c3c11b9b9c0f095abdaad5 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Mon, 6 Jul 2026 12:40:12 -0600 Subject: [PATCH 52/67] fix: restore reflection dynamicKind for .call/.apply/.bind (docs check acknowledged) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #1778: /parity found WASM emits dyn=0 (plain static call) while native emits dyn=1/dynamicKind='reflection' for identifier.call(...)/.apply(...)/ .bind(...) call sites, even though both fully resolve the target (confidence 1). Root cause: PR #1693 (closing #1687) made the WASM extractor unconditionally drop dynamic/dynamicKind for these calls on identifier receivers, to fix a narrow dedup-collision bug in build-edges.ts's dynZeroEdgeRows upgrade path. That fix overcorrected — it silenced the `reflection` DynamicKind for every identifier-based .call/.apply/.bind, not just the collision case, diverging from native (which never changed). Chose Option A (revert + fix narrowly), not Option B (mirror WASM's drop in Rust): ADR-002 defines `reflection` as informational metadata independent of resolution/confidence, queryable via `codegraph roles --dynamic` — dropping it for .call/.apply/.bind would erase real information about a genuinely reflective invocation mechanism, and native's classification was never wrong. Changes: - src/extractors/javascript.ts: extractMemberExprCallInfo once again tags .call/.apply/.bind on identifier receivers as dynamic:true, dynamicKind:'reflection', matching the member-expression branch and native. - src/domain/graph/builder/stages/build-edges.ts: emitDirectCallEdgesForCall's dyn=0 -> dyn=1 upgrade (dynZeroEdgeRows) now compares source lines instead of firing on any dynamicKind-tagged collision. It only upgrades when the incoming call's line is EARLIER than the recorded dyn=0 call's line — which only happens when the query path's two-phase collection (query matches, then a supplementary walk pass for bare decorators, #1683) reorders a genuinely-earlier call to arrive later. A later .call/.apply/.bind to an already-called target is collected in the same phase as the direct call (true source order already holds), so it must not flip an established dyn=0 edge — matching native's plain first-recorded-wins dedup. - crates/codegraph-core/src/extractors/javascript.rs: no behavior change (native was already correct); added 4 unit tests pinning the identifier/member-expression reflection tagging so this doesn't regress. - tests/parsers/javascript.test.ts, tests/engines/dynamic-call-ffi.test.ts: updated to assert the restored dynamic/reflection tagging at the parser level. - tests/integration/issue-1778-reflection-dynamic-kind-parity.test.ts: new engine-parity tests (wasm + native) covering the #1778 minimal repro (no prior direct call -> dyn=1), the original #1687 dedup scenario (direct call then .call() -> single dyn=0 edge), the reverse-order case, and a #1683 regression guard (bare decorator before call-expression decorator still upgrades to dyn=1). Verified: `node scripts/parity-compare.mjs` shows zero edge diffs across all 42 fixtures (previously 6/8/12 diffs on dynamic-javascript/javascript/ jelly-micro). Resolution-benchmark precision/recall unchanged for every language. Full npm test suite green (3589 passed). cargo test green (479 passed, +4 new). Fixes #1778 Impact: 6 functions changed, 13 affected --- .../src/extractors/javascript.rs | 44 ++++ .../graph/builder/stages/build-edges.ts | 74 +++++-- src/extractors/javascript.ts | 28 +-- tests/engines/dynamic-call-ffi.test.ts | 23 ++- ...778-reflection-dynamic-kind-parity.test.ts | 190 ++++++++++++++++++ tests/parsers/javascript.test.ts | 37 ++-- 6 files changed, 343 insertions(+), 53 deletions(-) create mode 100644 tests/integration/issue-1778-reflection-dynamic-kind-parity.test.ts diff --git a/crates/codegraph-core/src/extractors/javascript.rs b/crates/codegraph-core/src/extractors/javascript.rs index 2e17db27f..1bbd78782 100644 --- a/crates/codegraph-core/src/extractors/javascript.rs +++ b/crates/codegraph-core/src/extractors/javascript.rs @@ -4246,6 +4246,50 @@ mod tests { assert!(dynamic_calls.is_empty()); } + // ── #1778: .call/.apply/.bind reflection tagging (parity pin) ─────────── + // + // Pins the native extractor's classification of `.call/.apply/.bind` on both + // identifier and member-expression receivers as dynamic/reflection. This is + // the Option-A semantic from #1778: the WASM extractor previously stripped + // this tag for identifier receivers only, diverging from native. These tests + // guard against either engine's classification drifting again — the + // dedup-collision case that originally motivated the WASM regression (#1687) + // is a downstream build-edges.ts concern, not an extraction concern, so it is + // deliberately NOT re-tested here (see the JS-side pins in + // tests/integration for that). + + #[test] + fn call_on_identifier_receiver_tags_reflection() { + let s = parse_js("function test(ctx) { greet.call(ctx, 'world'); }"); + let c = s.calls.iter().find(|c| c.name == "greet").unwrap(); + assert_eq!(c.dynamic, Some(true)); + assert_eq!(c.dynamic_kind.as_deref(), Some("reflection")); + } + + #[test] + fn apply_on_identifier_receiver_tags_reflection() { + let s = parse_js("function test(ctx) { greet.apply(ctx, ['world']); }"); + let c = s.calls.iter().find(|c| c.name == "greet").unwrap(); + assert_eq!(c.dynamic, Some(true)); + assert_eq!(c.dynamic_kind.as_deref(), Some("reflection")); + } + + #[test] + fn bind_on_identifier_receiver_tags_reflection() { + let s = parse_js("var bound = greet.bind(ctx);"); + let c = s.calls.iter().find(|c| c.name == "greet").unwrap(); + assert_eq!(c.dynamic, Some(true)); + assert_eq!(c.dynamic_kind.as_deref(), Some("reflection")); + } + + #[test] + fn call_on_member_expression_receiver_tags_reflection() { + let s = parse_js("obj.method.call({});"); + let c = s.calls.iter().find(|c| c.name == "method").unwrap(); + assert_eq!(c.dynamic, Some(true)); + assert_eq!(c.dynamic_kind.as_deref(), Some("reflection")); + } + // ── #1771: object-literal value-ref extraction ────────────────────────── #[test] diff --git a/src/domain/graph/builder/stages/build-edges.ts b/src/domain/graph/builder/stages/build-edges.ts index 03ab97c63..9144f0611 100644 --- a/src/domain/graph/builder/stages/build-edges.ts +++ b/src/domain/graph/builder/stages/build-edges.ts @@ -66,6 +66,18 @@ import { getResolved, isBarrelFile, resolveBarrelExportCached } from './resolve- type EdgeRowTuple = [number, number, string, number, number, string | null, string | null]; // src tgt kind conf dyn technique dynamic_kind +/** + * Tracks a dyn=0 direct-call edge row so a later dynamicKind-tagged call to the + * same (caller, target) pair can decide whether to upgrade it in-place — see + * {@link emitDirectCallEdgesForCall}. `line` is the source line of the call that + * produced the row, used to detect out-of-source-order collection artifacts + * (bare decorators processed after call-expression matches in the query path). + */ +interface DynZeroEdgeEntry { + idx: number; + line: number; +} + interface NodeIdStmt { get(name: string, kind: string, file: string, line: number): { id: number } | undefined; } @@ -1395,8 +1407,12 @@ function resolveFallbackTargets( * - If a pts edge already exists for this pair, upgrades it in-place to * direct-call confidence and promotes to seenCallEdges. * - If a dyn=0 edge already exists and the incoming call has an explicit - * dynamicKind (e.g. 'reflection' for bare decorators), upgrades the - * existing row to dyn=1 in-place so the semantic classification wins. + * dynamicKind AND textually precedes the recorded dyn=0 call (e.g. a bare + * decorator `@Log` reordered after `@Log()` by the query path's + * query-then-walk collection — see buildFileCallEdges), upgrades the + * existing row to dyn=1 in-place so the earlier-in-source classification + * wins, matching what native's single-pass source-order walk produces + * natively. * - Otherwise records a new `calls` edge with `ts-native` technique. */ function emitDirectCallEdgesForCall( @@ -1405,11 +1421,12 @@ function emitDirectCallEdgesForCall( importedFrom: string | null | undefined, isDynamic: number, hasDynamicKind: boolean, + callLine: number, relPath: string, seenCallEdges: Set, ptsEdgeRows: Map, allEdgeRows: EdgeRowTuple[], - dynZeroEdgeRows?: Map, + dynZeroEdgeRows?: Map, ): void { // Sort targets by confidence descending before emitting edges. // For multi-target calls with duplicate (source_id, target_id) pairs the @@ -1432,14 +1449,30 @@ function emitDirectCallEdgesForCall( if (seenCallEdges.has(edgeKey)) { // Edge already emitted. If the incoming call carries an explicit semantic // dynamic classification (dynamicKind set — e.g. 'reflection' for bare - // decorators) and the existing edge was recorded with dyn=0, upgrade it - // in-place so the more specific classification wins. - // Generic dynamic=true without dynamicKind (alias/callback calls) does - // NOT override dyn=0 to avoid false positives on f.call/f.bind patterns. + // decorators or .call/.apply/.bind) and the existing edge was recorded + // with dyn=0, only upgrade it in-place when the incoming call's source + // line is EARLIER than the recorded dyn=0 call's line. + // + // Why line order, not just "hasDynamicKind": the query path collects + // calls in two phases — tree-sitter query matches (callfn_node/callmem_node, + // true source order) first, then a supplementary walk pass for constructs + // the query grammar can't capture (bare decorators, object-literal + // value-refs) appended AFTERWARD regardless of true position (#1683). + // A bare `@Log` at an earlier line can therefore reach this branch AFTER + // `@Log()` at a later line already recorded dyn=0 — upgrading is correct + // there because native's single-pass source-order walk would have seen + // `@Log` first and kept dyn=1. + // + // But `.call/.apply/.bind` calls (e.g. `f(); f.call({})`, #1687/#1778) are + // ordinary call_expressions collected in the SAME query phase as the + // direct call, so true source order is already preserved: when the + // dynamic-flavored call's line is LATER than the recorded dyn=0 call, it + // is genuinely a second, later reference to the same target — native's + // dedup (first-recorded-wins, no upgrade) drops it, so WASM must too. if (isDynamic === 1 && hasDynamicKind && dynZeroEdgeRows) { - const dynZeroIdx = dynZeroEdgeRows.get(edgeKey); - if (dynZeroIdx !== undefined) { - const row = allEdgeRows[dynZeroIdx]; + const dynZeroEntry = dynZeroEdgeRows.get(edgeKey); + if (dynZeroEntry !== undefined && callLine < dynZeroEntry.line) { + const row = allEdgeRows[dynZeroEntry.idx]; if (row) row[4] = 1; dynZeroEdgeRows.delete(edgeKey); } @@ -1463,10 +1496,12 @@ function emitDirectCallEdgesForCall( seenCallEdges.add(edgeKey); const newIdx = allEdgeRows.length; allEdgeRows.push([caller.id, t.id, 'calls', confidence, isDynamic, 'ts-native', null]); - // Track dyn=0 edges so a later dyn=1+dynamicKind call for the same pair - // can upgrade them (e.g. bare decorator after call-expression decorator). + // Track dyn=0 edges (with their source line) so a later dyn=1+dynamicKind + // call for the same pair can decide whether to upgrade them — see the + // line-order comparison above (e.g. bare decorator reordered ahead of a + // call-expression decorator by the query path, #1683). if (isDynamic === 0 && dynZeroEdgeRows) { - dynZeroEdgeRows.set(edgeKey, newIdx); + dynZeroEdgeRows.set(edgeKey, { idx: newIdx, line: callLine }); } } } @@ -1736,11 +1771,13 @@ function buildFileCallEdges( // no longer tracked here. const ptsEdgeRows = new Map(); - // Tracks direct-call edges emitted with dyn=0 (edgeKey → allEdgeRows index). - // When a later call to the same target has dyn=1 (e.g. a bare decorator `@Log` - // processed after the call-expression `@Log()` in the query path), the existing - // dyn=0 row is upgraded in-place so the more specific dynamic classification wins. - const dynZeroEdgeRows = new Map(); + // Tracks direct-call edges emitted with dyn=0 (edgeKey → { row index, source line }). + // When a later call to the same target has dyn=1 and textually precedes the recorded + // call (e.g. a bare decorator `@Log` reordered after the call-expression `@Log()` by + // the query path, #1683), the existing dyn=0 row is upgraded in-place. See the line-order + // comparison in emitDirectCallEdgesForCall for why line order (not mere dynamicKind + // presence) gates the upgrade — this is also what keeps #1687/#1778 from regressing. + const dynZeroEdgeRows = new Map(); // Pre-compute the set of names that appear as lhs in fnRefBindings so that // case (c) of the pts gate below only fires for names that are genuine @@ -1773,6 +1810,7 @@ function buildFileCallEdges( importedFrom, isDynamic, !!call.dynamicKind, + call.line, relPath, seenCallEdges, ptsEdgeRows, diff --git a/src/extractors/javascript.ts b/src/extractors/javascript.ts index bf0a0e741..511226d79 100644 --- a/src/extractors/javascript.ts +++ b/src/extractors/javascript.ts @@ -353,9 +353,11 @@ function dispatchQueryMatch( if (callfnInfo) calls.push(callfnInfo); calls.push(...extractCallbackReferenceCalls(c.callfn_node)); } else if (c.callmem_node) { - // extractCallInfo → extractMemberExprCallInfo applies the plain-identifier guard for - // .call/.apply/.bind: when the object is a bare identifier (e.g. `fn.call(ctx)`), - // the call is emitted as static (no dynamic flag), matching the walk path and native engine. + // extractCallInfo → extractMemberExprCallInfo tags .call/.apply/.bind (e.g. `fn.call(ctx)`) + // as dynamic/reflection regardless of receiver shape, matching the walk path and native + // engine (#1778). The #1687 dedup-collision case — the same target already reached by a + // direct call from the same caller in the same scope — is resolved downstream in + // build-edges.ts's emitDirectCallEdgesForCall, not here. const callInfo = extractCallInfo(c.callmem_fn!, c.callmem_node); if (callInfo) calls.push(callInfo); const cbDef = extractCallbackDefinition(c.callmem_node, c.callmem_fn); @@ -3357,16 +3359,18 @@ function extractMemberExprCallInfo(fn: TreeSitterNode, callNode: TreeSitterNode) }; } - // .call()/.apply()/.bind() — this-rebinding; the wrapped function is the real callee. - // When the object is a plain identifier (e.g. `f.call({})`), the target is statically - // known so we emit a static call (no dynamic flag). This keeps parity with the native - // Rust engine, which also resolves these as dyn=0, and prevents the dynZeroEdgeRows - // upgrade path in emitDirectCallEdgesForCall from wrongly converting a dyn=0 edge - // (emitted by a prior direct `f()` call) to dyn=1. - // When the object is a member_expression (e.g. `obj.method.call({})`), we still mark - // it dynamic/reflection because the inner callee requires a second resolution hop. + // .call()/.apply()/.bind() — this-rebinding; the wrapped function is the real callee, but + // invoking it through .call/.apply/.bind is a genuinely reflective mechanism (a distinct + // invocation path from a plain `f()` call), so both identifier and member-expression + // receivers are tagged dynamic/reflection — matching the native Rust engine and preserving + // the informational value of the `reflection` DynamicKind (queryable via + // `codegraph roles --dynamic`; see ADR-002). This does NOT reintroduce #1687: that bug was + // a dedup-collision in build-edges.ts (a direct `f()` edge getting wrongly flipped to dyn=1 + // by a later `f.call()` to the same target in the same scope), fixed narrowly at the + // edge-emission layer in emitDirectCallEdgesForCall rather than by suppressing the tag here. if (propText === 'call' || propText === 'apply' || propText === 'bind') { - if (obj && obj.type === 'identifier') return { name: obj.text, line: callLine }; + if (obj && obj.type === 'identifier') + return { name: obj.text, line: callLine, dynamic: true, dynamicKind: 'reflection' }; if (obj && obj.type === 'member_expression') { const innerProp = obj.childForFieldName('property'); if (innerProp) diff --git a/tests/engines/dynamic-call-ffi.test.ts b/tests/engines/dynamic-call-ffi.test.ts index 060563e82..9ea8c25eb 100644 --- a/tests/engines/dynamic-call-ffi.test.ts +++ b/tests/engines/dynamic-call-ffi.test.ts @@ -65,28 +65,31 @@ describe('dynamic call classification — dynamicKind and keyExpr fields', () => expect(c?.dynamic).toBe(true); }); - it('resolves fn.call(ctx) as a static call — no dynamic flag (#1687)', () => { - // `greet.call(ctx, 'world')` — plain-identifier receiver; target is statically known. - // We emit a static call (no dynamic, no dynamicKind) to match native Rust parity and - // prevent the dynZeroEdgeRows upgrade from promoting a prior dyn=0 edge to dyn=1. + it('tags fn.call(ctx) as reflection kind (#1778)', () => { + // `greet.call(ctx, 'world')` — plain-identifier receiver. The wrapped function + // is the real callee, but invoking it via .call is a genuinely reflective + // mechanism, so it's tagged dynamic/reflection — matching native Rust parity. + // (Option A of #1778: the dedup-collision bug this used to work around + // — #1687 — is now fixed narrowly at the build-edges.ts edge-emission layer + // instead of by suppressing this tag for every identifier receiver.) const out = parseJS(` function test(ctx) { greet.call(ctx, 'world'); } `); const c = out.calls.find((c) => c.name === 'greet'); expect(c).toBeDefined(); - expect(c?.dynamic).toBeFalsy(); - expect(c?.dynamicKind).toBeUndefined(); + expect(c?.dynamicKind).toBe('reflection'); + expect(c?.dynamic).toBe(true); }); - it('resolves fn.apply(ctx, args) as a static call — no dynamic flag (#1687)', () => { - // Same as .call(): plain-identifier receiver → static call. + it('tags fn.apply(ctx, args) as reflection kind (#1778)', () => { + // Same as .call(): plain-identifier receiver → dynamic/reflection. const out = parseJS(` function test(ctx) { greet.apply(ctx, ['world']); } `); const c = out.calls.find((c) => c.name === 'greet'); expect(c).toBeDefined(); - expect(c?.dynamic).toBeFalsy(); - expect(c?.dynamicKind).toBeUndefined(); + expect(c?.dynamicKind).toBe('reflection'); + expect(c?.dynamic).toBe(true); }); it('tags obj[a + b]() as unresolved-dynamic kind', () => { diff --git a/tests/integration/issue-1778-reflection-dynamic-kind-parity.test.ts b/tests/integration/issue-1778-reflection-dynamic-kind-parity.test.ts new file mode 100644 index 000000000..eb0fdca92 --- /dev/null +++ b/tests/integration/issue-1778-reflection-dynamic-kind-parity.test.ts @@ -0,0 +1,190 @@ +/** + * Engine-parity tests for `.call/.apply/.bind` reflection tagging and the + * narrow dedup-collision fix (#1778). + * + * BACKGROUND + * ────────── + * PR #1693 (closing #1687) made the WASM/TS extractor unconditionally drop + * `dynamic`/`dynamicKind` for `.call()/.apply()/.bind()` on identifier + * receivers (e.g. `f.call({})`), to fix a narrow dedup-collision bug: a + * direct `f()` call followed by `f.call({})` to the SAME target in the SAME + * scope was wrongly promoting the already-recorded dyn=0 edge to dyn=1 via + * the `dynZeroEdgeRows` upgrade path in `emitDirectCallEdgesForCall` + * (build-edges.ts). That fix overcorrected: it silenced the `reflection` + * DynamicKind for EVERY identifier-based `.call/.apply/.bind`, not just the + * narrow dedup-collision case — diverging from the native Rust engine, which + * never changed and still tags these calls `dynamic=true, + * dynamicKind='reflection'` unconditionally (see ADR-002). + * + * FIX (#1778, Option A) + * ────────────────────── + * 1. The WASM/TS extractor (`extractMemberExprCallInfo`) once again tags + * `.call/.apply/.bind` on identifier receivers as dynamic/reflection, + * matching native and preserving the informational value of the + * `reflection` DynamicKind (queryable via `codegraph roles --dynamic`). + * 2. The dedup-collision from #1687 is fixed narrowly, at the edge-emission + * layer: `emitDirectCallEdgesForCall`'s dyn=0 → dyn=1 upgrade now compares + * SOURCE LINES. It only upgrades when the incoming dynamicKind-tagged call + * textually PRECEDES the already-recorded dyn=0 call — which only happens + * when the query path's two-phase call collection (query matches, then a + * supplementary walk pass for constructs like bare decorators, #1683) + * reorders a genuinely-earlier call to arrive later. A `.call/.apply/.bind` + * call is an ordinary call_expression collected in the SAME phase as any + * prior direct call, so true source order is already preserved — when it + * arrives LATER than a recorded dyn=0 edge, it is a genuine second + * reference to the same target and must NOT flip the edge, matching + * native's plain first-recorded-wins dedup (no upgrade logic at all). + * + * These tests build a real graph with each engine and assert on the + * PERSISTED edge — the only thing that must match across engines. + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import Database from 'better-sqlite3'; +import { afterEach, describe, expect, it } from 'vitest'; +import { buildGraph } from '../../src/domain/graph/builder.js'; + +const ENGINES = ['wasm', 'native'] as const; + +interface CallEdgeRow { + source: string; + target: string; + confidence: number; + dynamic: number; +} + +function writeFixture(baseDir: string, files: Record): void { + for (const [rel, content] of Object.entries(files)) { + const fullPath = path.join(baseDir, rel); + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, content); + } +} + +function readCallEdgesTo(dbPath: string, targetName: string): CallEdgeRow[] { + const db = new Database(dbPath, { readonly: true }); + try { + return db + .prepare( + `SELECT n1.name AS source, n2.name AS target, e.confidence, 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' AND n2.name = ? + ORDER BY n1.name`, + ) + .all(targetName) as CallEdgeRow[]; + } finally { + db.close(); + } +} + +let tmpDirs: string[] = []; +afterEach(() => { + for (const d of tmpDirs) { + try { + fs.rmSync(d, { recursive: true, force: true }); + } catch { + /* ignore cleanup races */ + } + } + tmpDirs = []; +}); + +async function buildAndReadEdgesTo( + files: Record, + engine: 'wasm' | 'native', + targetName: string, +): Promise { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), `cg-1778-${engine}-`)); + tmpDirs.push(tmpDir); + writeFixture(tmpDir, files); + await buildGraph(tmpDir, { engine, incremental: false, skipRegistry: true }); + return readCallEdgesTo(path.join(tmpDir, '.codegraph', 'graph.db'), targetName); +} + +describe('#1778: .call/.apply/.bind reflection tagging — engine parity', () => { + it.each( + ENGINES, + )('%s: greet.call(ctx) with NO prior direct call resolves dyn=1 (minimal repro, no dedup collision)', async (engine) => { + // Exactly the issue's own minimal repro: no direct call to `greet` exists + // anywhere, so the dedup-collision path in emitDirectCallEdgesForCall never + // fires — this is the plain, uncomplicated case the #1693 fix wrongly broke. + const edges = await buildAndReadEdgesTo( + { + 'index.js': [ + 'export function greet(name) { return name; }', + "export function runCall(ctx) { return greet.call(ctx, 'world'); }", + '', + ].join('\n'), + }, + engine, + 'greet', + ); + expect(edges).toHaveLength(1); + expect(edges[0]).toMatchObject({ source: 'runCall', target: 'greet', confidence: 1 }); + expect(edges[0].dynamic).toBe(1); + }); + + it.each( + ENGINES, + )('%s: direct f() followed by f.call({}) to the same target dedups to a single dyn=0 edge (#1687)', async (engine) => { + // The original #1687 scenario: a direct call and a reflection-style call to + // the SAME target from the SAME caller/scope. Must collapse to ONE edge + // (no double-edge emission) and that edge must be dyn=0, matching native's + // plain first-recorded-wins dedup (the direct call is recorded first, in + // true source order, and the later reflection call must not flip it). + const edges = await buildAndReadEdgesTo( + { 'index.js': ['function f() {}', 'f();', 'f.call({});', ''].join('\n') }, + engine, + 'f', + ); + expect(edges).toHaveLength(1); + expect(edges[0].dynamic).toBe(0); + }); + + it.each( + ENGINES, + )('%s: f.call({}) followed by direct f() to the same target dedups to a single dyn=1 edge (reverse-order sanity)', async (engine) => { + // Mirror of the #1687 fixture with the two call sites swapped: the + // reflection call is now genuinely first in source order, so it should win + // the dedup and the later direct call must not downgrade it. + const edges = await buildAndReadEdgesTo( + { 'index.js': ['function f() {}', 'f.call({});', 'f();', ''].join('\n') }, + engine, + 'f', + ); + expect(edges).toHaveLength(1); + expect(edges[0].dynamic).toBe(1); + }); + + it.each( + ENGINES, + )('%s: bare decorator before call-expression decorator still upgrades to dyn=1 (#1683 regression guard)', async (engine) => { + // Regression guard for the ORIGINAL motivating case of the dynZeroEdgeRows + // upgrade path: the WASM query path collects `@Log()` (dyn=0) before the + // bare `@Log` (dyn=1) despite `@Log` appearing earlier in the source — the + // line-order comparison introduced by #1778's fix must still upgrade this + // to dyn=1, exactly as the pre-#1778 unconditional-upgrade logic did. + const edges = await buildAndReadEdgesTo( + { + 'index.ts': [ + 'export function Log(target: unknown): void {}', + '', + '@Log', + 'export class UserController {}', + '', + '@Log()', + 'export class OrderController {}', + '', + ].join('\n'), + }, + engine, + 'Log', + ); + expect(edges).toHaveLength(1); + expect(edges[0].dynamic).toBe(1); + }); +}); diff --git a/tests/parsers/javascript.test.ts b/tests/parsers/javascript.test.ts index 0d45c26bf..51a0b25ab 100644 --- a/tests/parsers/javascript.test.ts +++ b/tests/parsers/javascript.test.ts @@ -201,17 +201,22 @@ describe('JavaScript parser', () => { expect(symbols.imports[0].reexport).toBe(true); }); - it('resolves .call()/.apply() on plain identifiers as static calls', () => { - // `fn.call(null, arg)` — plain-identifier receiver; target is statically known, - // so we emit a static call (dyn=0). This keeps parity with the native Rust engine - // and prevents the dynZeroEdgeRows upgrade from wrongly promoting dyn=0 → dyn=1. + it('tags .call()/.apply() on plain identifiers as dynamic/reflection (#1778)', () => { + // `fn.call(null, arg)` — plain-identifier receiver; the wrapped function is the + // real callee, but invoking it via .call/.apply is a genuinely reflective + // mechanism, so it's tagged dynamic/reflection — matching the native Rust engine + // (Option A of #1778; the WASM extractor previously stripped this tag for + // identifier receivers only, to work around a dedup-collision bug now fixed + // narrowly in build-edges.ts's emitDirectCallEdgesForCall, see #1687/#1778). const symbols = parseJS(`fn.call(null, arg); obj.apply(undefined, args);`); const fnCall = symbols.calls.find((c) => c.name === 'fn'); expect(fnCall).toBeDefined(); - expect(fnCall.dynamic).toBeFalsy(); + expect(fnCall.dynamic).toBe(true); + expect(fnCall.dynamicKind).toBe('reflection'); const objCall = symbols.calls.find((c) => c.name === 'obj'); expect(objCall).toBeDefined(); - expect(objCall.dynamic).toBeFalsy(); + expect(objCall.dynamic).toBe(true); + expect(objCall.dynamicKind).toBe('reflection'); }); it('captures receiver for method calls', () => { @@ -688,15 +693,21 @@ describe('JavaScript parser', () => { expect(fnCall.receiver).toBeUndefined(); }); - it('emits static call for .call/.apply/.bind on plain identifier (#1687)', () => { - // `f.call({})` where f is a plain identifier — target is statically known; - // must emit dyn=0 (no dynamic flag) to match native Rust engine parity. + it('tags f.call({}) as dynamic/reflection even alongside a direct f() call (#1687/#1778)', () => { + // `f(); f.call({})` — at the PARSER level, each call site is classified on its + // own terms: the direct `f()` call is static, and `f.call({})` is tagged + // dynamic/reflection regardless of the sibling direct call, matching native. + // The #1687 dedup-collision (collapsing these two call sites into a single + // graph edge without letting the reflection tag wrongly flip an + // already-recorded dyn=0 edge) is a build-edges.ts concern, verified at the + // graph level in tests/integration/issue-1778-reflection-dynamic-kind-parity.test.ts + // — not here, since the parser has no visibility into sibling call sites. const symbols = parseJS(`const f = function () {}.bind({}); f(); f.call({});`); const fCallCalls = symbols.calls.filter((c) => c.name === 'f'); - expect(fCallCalls.length).toBeGreaterThanOrEqual(1); - for (const c of fCallCalls) { - expect(c.dynamic).toBeFalsy(); // all f() / f.call({}) calls must be dyn=0 - } + expect(fCallCalls.length).toBe(2); + expect(fCallCalls[0].dynamic).toBeFalsy(); // f() — direct call + expect(fCallCalls[1].dynamic).toBe(true); // f.call({}) — reflection + expect(fCallCalls[1].dynamicKind).toBe('reflection'); }); it('still emits dynamic/reflection for .call on member-expression object', () => { From aec5d2b0953b88c6703c7616ec890e06dcb8ae7b Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Mon, 6 Jul 2026 13:41:33 -0600 Subject: [PATCH 53/67] fix: restrict entry-role classification to function/method-kind symbols (docs check acknowledged) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codegraph roles --role entry classified any exported, zero-fan-in node as `entry` regardless of kind, so non-exported interfaces/constants (e.g. ParsedUserConfig, WorkspaceEntry, _configCache in config.ts) were also sweeping into the entry bucket via a separate bug: the barrel-reexport-chain "exported" inference (#837) marks every symbol in a re-exported file as exported, including symbols that were never independently exportable (class/interface methods) and symbols without the `export` keyword. Two fixes, mirrored in the TS classifier and the Rust native engine: - classifyNodeRole / classify_node: the exported+zero-fan-in branch now requires kind IN ('function', 'method') to return `entry`. Every other exported kind (interface/type/constant/class) is classified `leaf` instead — a live, intentional part of the public surface, but not something invoked from outside the codebase. This also refines two #1583 cases (exported interface with same-file-only type usage) from `entry` to the more accurate `leaf`, while keeping their "never dead" guarantee intact. - classifyNodeRolesFull / classifyNodeRolesIncremental (structure.ts) and do_classify_full / do_classify_incremental (roles.rs): exclude `method` from the barrel-reexport-chain exported-inference query. A `reexports` edge only ever concerns top-level module bindings, so a class method can never be an independently re-exportable binding — without this exclusion, abstract base-class methods (e.g. Repository.findNodeById) were promoted to `entry` merely because a sibling symbol in the same file was re-exported through a barrel. Verified real entry points are unaffected: CLI command execute/validate methods, MCP tool handlers, ESM loader hooks, and event:/route:/command: framework-prefixed handlers all still classify as `entry`. Symptom 2 from the same issue (dead-unresolved for LanguageRules interface members in visitor-utils.ts) was already fully fixed by #1723's isTypeDeclarationMember exemption — verified via fresh build, no changes needed. Fixes #1780 Impact: 3 functions changed, 4 affected --- .../src/graph/classifiers/roles.rs | 29 +++- src/features/structure.ts | 14 +- src/graph/classifiers/roles.ts | 16 +- tests/graph/classifiers/roles.test.ts | 142 +++++++++++++++++- tests/integration/roles.test.ts | 17 +++ tests/unit/roles.test.ts | 45 +++++- 6 files changed, 251 insertions(+), 12 deletions(-) diff --git a/crates/codegraph-core/src/graph/classifiers/roles.rs b/crates/codegraph-core/src/graph/classifiers/roles.rs index b4572e93d..75bd507b9 100644 --- a/crates/codegraph-core/src/graph/classifiers/roles.rs +++ b/crates/codegraph-core/src/graph/classifiers/roles.rs @@ -256,7 +256,20 @@ fn classify_node( } if fan_in == 0 && is_exported { - return "entry"; + // Exported, zero fan-in. A genuine entry point (CLI command handler, + // exported API function called from outside the codebase, ESM loader + // hook, MCP tool handler, etc.) is always a function or method. Every + // other exported kind (interface/type/constant/class) is a live, + // intentional part of the public surface — but a data shape or config + // value, not something invoked from outside the codebase — so it's + // `leaf`: never `dead-*` (#1583) and never `entry` (#1780), regardless + // of whether the file has other active siblings. Mirrors JS + // `classifyNodeRole`. + return if kind == "function" || kind == "method" { + "entry" + } else { + "leaf" + }; } // Test-only: has callers but all are in test files @@ -403,6 +416,15 @@ pub(crate) fn do_classify_full(conn: &Connection) -> rusqlite::Result rusqlite::Result { expect(classifyRoles([]).size).toBe(0); }); - it('classifies entry nodes (no fan-in, exported)', () => { - const nodes = [{ id: '1', name: 'init', fanIn: 0, fanOut: 3, isExported: true }]; + it('classifies entry nodes (no fan-in, exported, function kind)', () => { + const nodes = [ + { id: '1', name: 'init', kind: 'function', fanIn: 0, fanOut: 3, isExported: true }, + ]; const roles = classifyRoles(nodes); expect(roles.get('1')).toBe('entry'); }); @@ -628,4 +630,140 @@ describe('classifyRoles', () => { const roles = classifyRoles(nodes); expect(roles.get('2')).toBe('dead-unresolved'); }); + + // ── entry role requires function/method kind (#1780) ─────────────── + + it('classifies an exported interface with zero fan-in as leaf, not entry', () => { + // Mirrors the #1780 repro: `interface ParsedUserConfig { ... }` in + // src/infrastructure/config.ts. Even if the symbol were exported, an + // interface is a data-shape declaration, never a callable entry point. + const nodes = [ + { + id: '1', + name: 'ParsedUserConfig', + kind: 'interface', + file: 'src/infrastructure/config.ts', + fanIn: 0, + fanOut: 0, + isExported: true, + }, + ]; + const roles = classifyRoles(nodes); + expect(roles.get('1')).toBe('leaf'); + }); + + it('classifies an exported constant with zero fan-in as leaf, not entry', () => { + // Mirrors the #1780 repro: module-level `const BUILD_HASH_KEYS = [...]`. + // A constant can never be an invoked entry point regardless of export status. + const nodes = [ + { + id: '1', + name: 'BUILD_HASH_KEYS', + kind: 'constant', + file: 'src/infrastructure/config.ts', + fanIn: 0, + fanOut: 0, + isExported: true, + }, + ]; + const roles = classifyRoles(nodes); + expect(roles.get('1')).toBe('leaf'); + }); + + it('classifies an exported class with zero fan-in as leaf, not entry', () => { + // A class declaration itself is instantiated via `new`, not "invoked" the + // way a CLI command handler or API function is — not a real entry point. + const nodes = [ + { + id: '1', + name: 'Widget', + kind: 'class', + file: 'src/widget.ts', + fanIn: 0, + fanOut: 0, + isExported: true, + }, + ]; + const roles = classifyRoles(nodes); + expect(roles.get('1')).toBe('leaf'); + }); + + it('classifies an exported type alias with zero fan-in as leaf, not entry', () => { + const nodes = [ + { + id: '1', + name: 'WorkspaceEntry', + kind: 'type', + file: 'src/infrastructure/config.ts', + fanIn: 0, + fanOut: 0, + isExported: true, + }, + ]; + const roles = classifyRoles(nodes); + expect(roles.get('1')).toBe('leaf'); + }); + + it('still classifies an exported function with zero fan-in as entry (no over-correction)', () => { + // Genuine entry points (CLI handlers, MCP tool handlers, ESM loader hooks) + // are, by definition, called from outside the codebase, so zero in-repo + // fan-in is expected and correct for them — the fix must not lose this. + const nodes = [ + { + id: '1', + name: 'handler', + kind: 'function', + file: 'src/mcp/tools/audit.ts', + fanIn: 0, + fanOut: 4, + isExported: true, + }, + ]; + const roles = classifyRoles(nodes); + expect(roles.get('1')).toBe('entry'); + }); + + it('still classifies an exported method with zero fan-in as entry (no over-correction)', () => { + const nodes = [ + { + id: '1', + name: 'run', + kind: 'method', + file: 'src/cli/commands/custom.ts', + fanIn: 0, + fanOut: 2, + isExported: true, + }, + ]; + const roles = classifyRoles(nodes); + expect(roles.get('1')).toBe('entry'); + }); + + it('classifies a non-exported interface with zero fan-in the same as an exported one (leaf, active siblings)', () => { + // Whether or not the interface itself is exported, it's still not a + // callable entry point — both must land on the same non-entry path. + const nodes = [ + { + id: '1', + name: 'ConsentResolutionResult', + kind: 'interface', + file: 'src/infrastructure/config.ts', + fanIn: 0, + fanOut: 0, + isExported: false, + hasActiveFileSiblings: true, + }, + { + id: '2', + name: 'loadConfig', + kind: 'function', + file: 'src/infrastructure/config.ts', + fanIn: 5, + fanOut: 3, + isExported: true, + }, + ]; + const roles = classifyRoles(nodes); + expect(roles.get('1')).toBe('leaf'); + }); }); diff --git a/tests/integration/roles.test.ts b/tests/integration/roles.test.ts index b5f3a8a5f..5d715c5de 100644 --- a/tests/integration/roles.test.ts +++ b/tests/integration/roles.test.ts @@ -115,6 +115,9 @@ describe('barrel re-export role classification', () => { const _helperFn = insertNode(db, 'helperFn', 'function', 'src/inspect.ts', 30); const _appMain = insertNode(db, 'appMain', 'function', 'src/app.ts', 1); const testFn = insertNode(db, 'testQueryName', 'function', 'tests/inspect.test.ts', 1); + // A class-method-kind member (e.g. an abstract base-class method) in the same + // re-exported file, with no callers and no outgoing calls (#1780). + const _abstractHelper = insertNode(db, 'abstractHelper', 'method', 'src/inspect.ts', 50); // Barrel re-exports inspect.ts insertEdge(db, fBarrel, fInspect, 'reexports'); @@ -156,6 +159,20 @@ describe('barrel re-export role classification', () => { // With fanIn=0 and isExported=true → entry (exported but uncalled) expect(helperResult!.role).toBe('entry'); }); + + test('method-kind member in a re-exported file does not inherit exported status (#1780)', () => { + // A `reexports` edge only ever concerns top-level module bindings — a class + // method can never be an independently re-exportable binding on its own, so + // it must not inherit "exported" status merely because a sibling top-level + // symbol (helperFn/queryName) in the same file is re-exported through the + // barrel. Before the fix, this method landed on `entry` the same way + // `helperFn` incorrectly did; it must now fall through to normal dead-code + // classification instead (no callers, no outgoing calls → dead-unresolved). + const data = rolesData(barrelDbPath); + const methodResult = data.symbols.find((s) => s.name === 'abstractHelper'); + expect(methodResult).toBeDefined(); + expect(methodResult!.role).toBe('dead-unresolved'); + }); }); // ─── Multi-level barrel re-export chain (#837) ─────────────────────── diff --git a/tests/unit/roles.test.ts b/tests/unit/roles.test.ts index 88d5ee1e0..298958b67 100644 --- a/tests/unit/roles.test.ts +++ b/tests/unit/roles.test.ts @@ -235,7 +235,43 @@ describe('classifyNodeRoles', () => { classifyNodeRoles(db); const role = db.prepare("SELECT role FROM nodes WHERE name = 'MyOpts'").get(); - // Should be entry (exported, fan-in 0), not dead-unresolved + // Should be leaf (exported, fan-in 0), not dead-unresolved. An interface is a + // data-shape declaration, never a callable entry point, so exported+zero-fanin + // interfaces land on `leaf` rather than `entry` (#1780) — but #1583's original + // guarantee (never `dead-*`) still holds. + expect(role.role).toBe('leaf'); + }); + + it('classifies exported constant with no callers as leaf, not entry (#1780)', () => { + // A module-level constant is a data value, never a callable entry point, + // regardless of its exported=1 flag. + db.prepare('INSERT INTO nodes (name, kind, file, line, exported) VALUES (?, ?, ?, ?, ?)').run( + 'DEFAULT_OPTIONS', + 'constant', + 'src/helpers.ts', + 5, + 1, + ); + + classifyNodeRoles(db); + const role = db.prepare("SELECT role FROM nodes WHERE name = 'DEFAULT_OPTIONS'").get(); + expect(role.role).toBe('leaf'); + }); + + it('still classifies an exported function with no callers as entry (#1780 no over-correction)', () => { + // A genuinely exported, callable function with zero fan-in is exactly what + // `entry` is meant to capture — e.g. a public API function invoked only by + // external consumers of the package. The kind-gate fix must not lose this. + db.prepare('INSERT INTO nodes (name, kind, file, line, exported) VALUES (?, ?, ?, ?, ?)').run( + 'publicApiFn', + 'function', + 'src/helpers.ts', + 40, + 1, + ); + + classifyNodeRoles(db); + const role = db.prepare("SELECT role FROM nodes WHERE name = 'publicApiFn'").get(); expect(role.role).toBe('entry'); }); @@ -364,7 +400,7 @@ describe('classifyNodeRoles', () => { it('incremental path: does not classify exported interface as dead when used only as same-file type annotation (#1583)', () => { // Exercises classifyNodeRolesIncremental (triggered by passing changedFiles). - // An exported=1 interface with no cross-file edges must be promoted to entry, + // An exported=1 interface with no cross-file edges must be promoted to leaf, // not dead-unresolved, on the incremental path just as on the full path. db.prepare('INSERT INTO nodes (name, kind, file, line, exported) VALUES (?, ?, ?, ?, ?)').run( 'IncrementalOpts', @@ -377,8 +413,9 @@ describe('classifyNodeRoles', () => { // Pass the file as the changed-files list to trigger the incremental path. classifyNodeRoles(db, ['src/helpers.ts']); const role = db.prepare("SELECT role FROM nodes WHERE name = 'IncrementalOpts'").get(); - // Should be entry (exported, fan-in 0), not dead-unresolved - expect(role.role).toBe('entry'); + // Should be leaf (exported, fan-in 0), not dead-unresolved or entry — an + // interface is a data-shape declaration, never a callable entry point (#1780). + expect(role.role).toBe('leaf'); }); // ── Parameters and interface members are not dead-code targets (#1723) ── From d250f8d6fdbc0d49a75071c4b7e747ca27de5999 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Mon, 6 Jul 2026 14:21:06 -0600 Subject: [PATCH 54/67] fix: credit destructured dynamic import() bindings as exports consumers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit const { X, Y } = (await import('./mod.js')) as {...} — a dynamic import() whose awaited result is wrapped in redundant parentheses and/or a TypeScript `as` type assertion before being destructured — never resolved to a `calls` edge. extractDynamicImportNames (TS) and extract_dynamic_import_names (Rust) only skipped a single optional await_expression before requiring the immediate parent to be a variable_declarator, so the extra parenthesized_expression / as_expression layers broke the walk-up and silently returned an empty names list. Without names, importedNames never gained an entry for the destructured bindings, so calls through them never resolved, and `codegraph exports` reported the target functions as zero-consumer dead exports despite being called from production code (e.g. buildDataflowVerticesFromMap from native-orchestrator.ts). Generalize the walk-up in both engines to skip any combination or nesting of await_expression, parenthesized_expression, and as_expression wrappers before checking for the enclosing variable_declarator. Scoped to static string-literal import specifiers, the only case that is statically resolvable at all; computed/variable specifiers remain unresolvable by design. Verified on both engines against the exact repro (codegraph exports src/features/dataflow.ts -T --json now credits buildDataflowVerticesFromMap with its real consumer) and against the resolution-benchmark suite (no change: 206/206 tests, 63.8% aggregate recall before and after). No language/architecture/command surface changed (docs check acknowledged). Fixes #1781 Impact: 1 functions changed, 8 affected --- .../src/extractors/javascript.rs | 64 ++++- src/extractors/javascript.ts | 33 ++- ...ynamic-import-destructure-consumer.test.ts | 257 ++++++++++++++++++ tests/parsers/javascript.test.ts | 61 +++++ 4 files changed, 404 insertions(+), 11 deletions(-) create mode 100644 tests/integration/issue-1781-dynamic-import-destructure-consumer.test.ts diff --git a/crates/codegraph-core/src/extractors/javascript.rs b/crates/codegraph-core/src/extractors/javascript.rs index 1bbd78782..b58da1dfc 100644 --- a/crates/codegraph-core/src/extractors/javascript.rs +++ b/crates/codegraph-core/src/extractors/javascript.rs @@ -3031,14 +3031,29 @@ fn find_parent_class_no_fn_boundary(node: &Node, source: &[u8]) -> Option Vec { - // Walk up: call_expression → await_expression? → variable_declarator + // Walk up through any combination/nesting of await/parenthesized/as-cast + // wrappers to reach the variable_declarator. let mut current = call_node.parent(); - if let Some(parent) = current { - if parent.kind() == "await_expression" { + while let Some(parent) = current { + if DYNAMIC_IMPORT_WRAPPER_KINDS.contains(&parent.kind()) { current = parent.parent(); + } else { + break; } } let declarator = match current { @@ -4200,6 +4215,47 @@ mod tests { assert!(!dyn_imports[0].names.contains(&"nested".to_string())); } + // Regression tests for #1781: `codegraph exports` failed to credit consumers + // reached via `const { X } = (await import('./mod.js')) as {...}` — the + // walk-up from the import() call to its enclosing variable_declarator only + // skipped a single optional await_expression, so the extra + // parenthesized_expression / as_expression layers introduced by wrapping + // parens and a TS type-assertion caused name extraction to bail out with + // an empty list, exactly as if the destructured names couldn't be + // determined at all. + + #[test] + fn finds_dynamic_import_with_parenthesized_destructuring() { + let s = parse_ts("const { a, b } = (await import('./foo.js'));"); + let dyn_imports: Vec<_> = s.imports.iter().filter(|i| i.dynamic_import == Some(true)).collect(); + assert_eq!(dyn_imports.len(), 1); + assert!(dyn_imports[0].names.contains(&"a".to_string())); + assert!(dyn_imports[0].names.contains(&"b".to_string())); + } + + #[test] + fn finds_dynamic_import_with_as_cast_destructuring() { + let s = parse_ts("const { a, b } = await import('./foo.js') as { a: Fn; b: Fn };"); + let dyn_imports: Vec<_> = s.imports.iter().filter(|i| i.dynamic_import == Some(true)).collect(); + assert_eq!(dyn_imports.len(), 1); + assert!(dyn_imports[0].names.contains(&"a".to_string())); + assert!(dyn_imports[0].names.contains(&"b".to_string())); + } + + #[test] + fn finds_dynamic_import_with_parenthesized_as_cast_destructuring() { + // Exact repro shape from #1781 (native-orchestrator.ts): + // `const { X, Y } = (await import('../mod.js')) as { X: Fn; Y: Fn };` + let s = parse_ts( + "const { buildDataflowVerticesFromMap, buildDataflowEdges } = (await import('../../../../features/dataflow.js')) as { buildDataflowVerticesFromMap: Fn; buildDataflowEdges: Fn };", + ); + let dyn_imports: Vec<_> = s.imports.iter().filter(|i| i.dynamic_import == Some(true)).collect(); + assert_eq!(dyn_imports.len(), 1); + assert_eq!(dyn_imports[0].source, "../../../../features/dataflow.js"); + assert!(dyn_imports[0].names.contains(&"buildDataflowVerticesFromMap".to_string())); + assert!(dyn_imports[0].names.contains(&"buildDataflowEdges".to_string())); + } + #[test] fn extracts_callback_reference_in_router_use() { let s = parse_js("router.use(handleToken);"); diff --git a/src/extractors/javascript.ts b/src/extractors/javascript.ts index 511226d79..a56e0904c 100644 --- a/src/extractors/javascript.ts +++ b/src/extractors/javascript.ts @@ -4046,22 +4046,41 @@ function extractImportNames( return names; } +/** + * Wrapper node types that can sit between a dynamic `import()` call and its + * enclosing `variable_declarator` without changing which value gets bound — + * `await`, redundant parentheses, and TypeScript `as` casts. Real-world + * dynamic-import call sites often combine several of these, e.g. + * `const { X } = (await import('./mod.js')) as { X: Fn }` nests + * await_expression → parenthesized_expression → as_expression before + * reaching the declarator (#1781). + */ +const DYNAMIC_IMPORT_WRAPPER_TYPES = new Set([ + 'await_expression', + 'parenthesized_expression', + 'as_expression', +]); + /** * Extract destructured names from a dynamic import() call expression. * * Handles: - * const { a, b } = await import('./foo.js') → ['a', 'b'] - * const mod = await import('./foo.js') → ['mod'] - * import('./foo.js') → [] (no names extractable) + * const { a, b } = await import('./foo.js') → ['a', 'b'] + * const mod = await import('./foo.js') → ['mod'] + * const { a } = (await import('./foo.js')) as { a: Fn } → ['a'] + * import('./foo.js') → [] (no names extractable) * - * Walks up the AST from the call_expression to find the enclosing + * Walks up the AST from the call_expression — through any nesting of + * await/parenthesized/as-cast wrappers — to find the enclosing * variable_declarator and reads the name/object_pattern. */ function extractDynamicImportNames(callNode: TreeSitterNode): string[] { - // Walk up: call_expression → await_expression → variable_declarator + // Walk up through await_expression / parenthesized_expression / as_expression + // wrappers, in any combination or order, to reach the variable_declarator. let current = callNode.parent; - // Skip await_expression wrapper if present - if (current && current.type === 'await_expression') current = current.parent; + while (current && DYNAMIC_IMPORT_WRAPPER_TYPES.has(current.type)) { + current = current.parent; + } // We should now be at a variable_declarator (or not, if standalone import()) if (current?.type !== 'variable_declarator') return []; diff --git a/tests/integration/issue-1781-dynamic-import-destructure-consumer.test.ts b/tests/integration/issue-1781-dynamic-import-destructure-consumer.test.ts new file mode 100644 index 000000000..8f0264788 --- /dev/null +++ b/tests/integration/issue-1781-dynamic-import-destructure-consumer.test.ts @@ -0,0 +1,257 @@ +/** + * Integration test for #1781: `codegraph exports` did not credit consumers + * reached via a dynamic `import()` expression whose destructured result is + * wrapped in redundant parentheses and/or a TypeScript `as {...}` type + * assertion — the exact shape used throughout + * `src/domain/graph/builder/stages/native-orchestrator.ts`: + * + * const { buildDataflowVerticesFromMap, ... } = + * (await import('../../../../features/dataflow.js')) as {...}; + * + * Root cause: `extractDynamicImportNames` (TS) / `extract_dynamic_import_names` + * (Rust) walked up from the `import()` call through at most one optional + * `await_expression` before requiring the immediate parent to be a + * `variable_declarator`. The extra `parenthesized_expression` and/or + * `as_expression` wrapper layers introduced by parens and a type assertion + * broke that walk-up, so no destructured names were ever extracted — the + * import was recorded with `names: []`, `importedNames` never gained an + * entry for the destructured bindings, and the later call through the + * destructured local name never resolved to a `calls` edge. Both `codegraph + * exports` (consumer count) and `codegraph roles --role dead` therefore + * treated a genuinely-consumed export as unreferenced. + * + * Fix: the walk-up now skips any nesting/combination of `await_expression`, + * `parenthesized_expression`, and `as_expression` wrappers before checking + * for the enclosing `variable_declarator`, in both engines. + * + * Two consumer shapes are exercised against the same target module: + * - `usesPlain`: bare `const { X } = await import(...)` (no cast) — the + * pre-existing baseline that must keep working. + * - `usesCast`: `const { Y } = (await import(...)) as {...}` — the exact + * regression shape from native-orchestrator.ts. + * + * The target module and its consumer are deliberately placed in *different* + * directories (`features/` vs `domain/stages/`, mirroring the real repo + * layout) rather than side by side. A same-directory fixture does not + * discriminate this bug at all: the resolver's directory-scoped fallback + * tier (a same-directory, name-only match — one of several confidence tiers + * below the import-aware match) coincidentally "rescues" the call even when + * `extractDynamicImportNames` returns no names, producing a `calls` edge + * regardless of whether the fix is present. Cross-directory placement is + * required so the only way to resolve `usesCast`'s call is through the + * import-aware path this bug actually breaks. + */ + +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'; +import { exportsData } from '../../src/domain/queries.js'; +import { isNativeAvailable } from '../../src/infrastructure/native.js'; + +const DEAD_ROLES = new Set(['dead-unresolved', 'dead-leaf', 'dead-entry', 'dead-ffi']); + +const TARGET_FILE = 'features/dataflow.ts'; + +const FIXTURE = { + [TARGET_FILE]: ` +export function buildDataflowVerticesFromMap(): number { + return 42; +} + +export function buildDataflowP4ForNative(): number { + return 7; +} +`, + 'domain/stages/consumer.ts': ` +export async function usesPlain() { + const { buildDataflowVerticesFromMap } = await import('../../features/dataflow.js'); + return buildDataflowVerticesFromMap(); +} + +export async function usesCast() { + const { buildDataflowP4ForNative } = (await import('../../features/dataflow.js')) as { + buildDataflowP4ForNative: () => number; + }; + return buildDataflowP4ForNative(); +} +`, +}; + +function readNodesWithRoles(dbPath: string) { + const db = new Database(dbPath, { readonly: true }); + try { + return db.prepare('SELECT name, kind, role FROM nodes ORDER BY name').all() as Array<{ + name: string; + kind: string; + role: string | null; + }>; + } finally { + db.close(); + } +} + +function getCallEdges(dbPath: string) { + const db = new Database(dbPath, { readonly: true }); + try { + return db + .prepare( + `SELECT n1.name AS src, n2.name AS tgt, n2.file AS tgt_file + 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; tgt_file: string }>; + } finally { + db.close(); + } +} + +function writeFixture(rootDir: string) { + for (const [rel, content] of Object.entries(FIXTURE)) { + const abs = path.join(rootDir, rel); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, content); + } +} + +describe('dynamic import() + destructure consumer crediting (#1781) — WASM', () => { + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-1781-wasm-')); + writeFixture(tmpDir); + await buildGraph(tmpDir, { engine: 'wasm', incremental: false, skipRegistry: true }); + }); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('creates a calls edge for the bare (uncast) destructured binding', () => { + const dbPath = path.join(tmpDir, '.codegraph', 'graph.db'); + const edges = getCallEdges(dbPath); + const edge = edges.find( + (e) => e.src === 'usesPlain' && e.tgt === 'buildDataflowVerticesFromMap', + ); + expect( + edge, + `Expected usesPlain -> buildDataflowVerticesFromMap; got: ${JSON.stringify(edges)}`, + ).toBeDefined(); + expect(edge?.tgt_file).toBe(TARGET_FILE); + }); + + it('creates a calls edge for the parenthesized + `as`-cast destructured binding', () => { + const dbPath = path.join(tmpDir, '.codegraph', 'graph.db'); + const edges = getCallEdges(dbPath); + const edge = edges.find((e) => e.src === 'usesCast' && e.tgt === 'buildDataflowP4ForNative'); + expect( + edge, + `Expected usesCast -> buildDataflowP4ForNative; got: ${JSON.stringify(edges)}`, + ).toBeDefined(); + expect(edge?.tgt_file).toBe(TARGET_FILE); + }); + + it('codegraph exports credits both dynamically-imported functions with real consumers', () => { + const dbPath = path.join(tmpDir, '.codegraph', 'graph.db'); + const data = exportsData(TARGET_FILE, dbPath); + + const vertices = data.results.find( + (r: { name: string }) => r.name === 'buildDataflowVerticesFromMap', + ); + expect(vertices).toBeDefined(); + expect(vertices.consumerCount).toBeGreaterThanOrEqual(1); + expect(vertices.consumers.map((c: { name: string }) => c.name)).toContain('usesPlain'); + + const p4 = data.results.find((r: { name: string }) => r.name === 'buildDataflowP4ForNative'); + expect(p4).toBeDefined(); + expect(p4.consumerCount).toBeGreaterThanOrEqual(1); + expect(p4.consumers.map((c: { name: string }) => c.name)).toContain('usesCast'); + }); + + it('does not classify either dynamically-imported export as dead', () => { + const dbPath = path.join(tmpDir, '.codegraph', 'graph.db'); + const nodes = readNodesWithRoles(dbPath); + for (const name of ['buildDataflowVerticesFromMap', 'buildDataflowP4ForNative']) { + const node = nodes.find((n) => n.name === name && n.kind === 'function'); + expect(node, `${name} node not found`).toBeDefined(); + expect( + DEAD_ROLES.has(node!.role ?? ''), + `${name} was classified as ${node!.role} — expected a non-dead role now that a real edge exists`, + ).toBe(false); + } + }); +}); + +// ── Native engine parity ──────────────────────────────────────────────────── +// Skipped when the native addon is not installed. + +describe.skipIf(!isNativeAvailable())( + 'dynamic import() + destructure consumer crediting (#1781) — native', + () => { + let nativeTmpDir: string; + + beforeAll(async () => { + nativeTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-1781-native-')); + writeFixture(nativeTmpDir); + await buildGraph(nativeTmpDir, { engine: 'native', incremental: false, skipRegistry: true }); + }, 60_000); + + afterAll(() => { + fs.rmSync(nativeTmpDir, { recursive: true, force: true }); + }); + + it('creates a calls edge for the bare (uncast) destructured binding', () => { + const dbPath = path.join(nativeTmpDir, '.codegraph', 'graph.db'); + const edges = getCallEdges(dbPath); + const edge = edges.find( + (e) => e.src === 'usesPlain' && e.tgt === 'buildDataflowVerticesFromMap', + ); + expect( + edge, + `Expected native usesPlain -> buildDataflowVerticesFromMap; got: ${JSON.stringify(edges)}`, + ).toBeDefined(); + expect(edge?.tgt_file).toBe(TARGET_FILE); + }); + + it('creates a calls edge for the parenthesized + `as`-cast destructured binding', () => { + const dbPath = path.join(nativeTmpDir, '.codegraph', 'graph.db'); + const edges = getCallEdges(dbPath); + const edge = edges.find((e) => e.src === 'usesCast' && e.tgt === 'buildDataflowP4ForNative'); + expect( + edge, + `Expected native usesCast -> buildDataflowP4ForNative; got: ${JSON.stringify(edges)}`, + ).toBeDefined(); + expect(edge?.tgt_file).toBe(TARGET_FILE); + }); + + it('codegraph exports credits both dynamically-imported functions with real consumers', () => { + const dbPath = path.join(nativeTmpDir, '.codegraph', 'graph.db'); + const data = exportsData(TARGET_FILE, dbPath); + + const vertices = data.results.find( + (r: { name: string }) => r.name === 'buildDataflowVerticesFromMap', + ); + expect(vertices).toBeDefined(); + expect(vertices.consumerCount).toBeGreaterThanOrEqual(1); + + const p4 = data.results.find((r: { name: string }) => r.name === 'buildDataflowP4ForNative'); + expect(p4).toBeDefined(); + expect(p4.consumerCount).toBeGreaterThanOrEqual(1); + }); + + it('does not classify either dynamically-imported export as dead', () => { + const dbPath = path.join(nativeTmpDir, '.codegraph', 'graph.db'); + const nodes = readNodesWithRoles(dbPath); + for (const name of ['buildDataflowVerticesFromMap', 'buildDataflowP4ForNative']) { + const node = nodes.find((n) => n.name === name && n.kind === 'function'); + expect(node, `${name} node not found (native)`).toBeDefined(); + expect(DEAD_ROLES.has(node!.role ?? '')).toBe(false); + } + }); + }, +); diff --git a/tests/parsers/javascript.test.ts b/tests/parsers/javascript.test.ts index 51a0b25ab..127c860d0 100644 --- a/tests/parsers/javascript.test.ts +++ b/tests/parsers/javascript.test.ts @@ -176,6 +176,67 @@ describe('JavaScript parser', () => { }); }); + describe('dynamic import() destructuring through parens/as-cast wrappers (#1781)', () => { + function parseTS(code) { + const parser = parsers.get('typescript'); + const tree = parser.parse(code); + return extractSymbols(tree, 'test.ts'); + } + + // Before the fix, extractDynamicImportNames walked up from the import() + // call through at most one optional await_expression before requiring the + // immediate parent to be a variable_declarator. Wrapping the awaited call + // in redundant parens and/or a TS `as {...}` cast — exactly the pattern + // used throughout native-orchestrator.ts — inserted extra + // parenthesized_expression/as_expression layers that broke the walk-up, + // so `names` came back empty and the destructured bindings never got + // credited as real consumers of the target module's exports (#1781). + + it('extracts destructured names from a bare dynamic import (no wrapper)', () => { + const symbols = parseJS(`const { a, b } = await import('./foo.js');`); + expect(symbols.imports).toHaveLength(1); + expect(symbols.imports[0].names).toEqual(['a', 'b']); + expect(symbols.imports[0].dynamicImport).toBe(true); + }); + + it('extracts destructured names when the awaited import is wrapped in redundant parens', () => { + const symbols = parseTS(`const { a, b } = (await import('./foo.js'));`); + expect(symbols.imports).toHaveLength(1); + expect(symbols.imports[0].names).toEqual(['a', 'b']); + }); + + it('extracts destructured names through a TypeScript `as {...}` type assertion (no parens)', () => { + const symbols = parseTS(`const { a, b } = await import('./foo.js') as { a: Fn; b: Fn };`); + expect(symbols.imports).toHaveLength(1); + expect(symbols.imports[0].names).toEqual(['a', 'b']); + }); + + it('extracts destructured names through parens + `as`-cast combined (exact repro shape)', () => { + // Matches native-orchestrator.ts's actual production pattern: + // const { X, Y } = (await import('./mod.js')) as { X: Fn; Y: Fn }; + const symbols = parseTS(` + const { buildDataflowVerticesFromMap, buildDataflowEdges } = + (await import('../../../../features/dataflow.js')) as { + buildDataflowVerticesFromMap: (db: unknown) => number; + buildDataflowEdges: (db: unknown) => Promise; + }; + `); + expect(symbols.imports).toHaveLength(1); + expect(symbols.imports[0].source).toBe('../../../../features/dataflow.js'); + expect(symbols.imports[0].names).toEqual([ + 'buildDataflowVerticesFromMap', + 'buildDataflowEdges', + ]); + expect(symbols.imports[0].dynamicImport).toBe(true); + }); + + it('still extracts a single namespace-style binding through parens + as-cast', () => { + const symbols = parseTS(`const mod = (await import('./foo.js')) as { a: number };`); + expect(symbols.imports).toHaveLength(1); + expect(symbols.imports[0].names).toEqual(['mod']); + }); + }); + it('extracts call expressions', () => { const symbols = parseJS(`import { foo } from './bar'; foo(); baz();`); expect(symbols.calls).toContainEqual(expect.objectContaining({ name: 'foo' })); From 75978e23cf85bdda5144d836e81fbc5b1c8a8c58 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Mon, 6 Jul 2026 15:00:26 -0600 Subject: [PATCH 55/67] fix: recognize Lua function nodes in complexity metrics computation (docs check acknowledged) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codegraph complexity --file --health -T --json returned functions:[] for every Lua file even though symbol extraction (codegraph where) already worked correctly. Root cause: Lua had zero entries in COMPLEXITY_RULES / HALSTEAD_RULES (src/ast-analysis/rules/index.ts) and the native mirror (lang_rules / halstead_rules in crates/codegraph-core/src/ast_analysis/ complexity.rs) — not a wrong node-type mapping, but a completely missing per-language configuration on both engines, so the complexity visitor never ran for Lua at all. Adds complexityLua/halsteadLua (src/ast-analysis/rules/b3.ts, wired into COMPLEXITY_RULES/HALSTEAD_RULES in rules/index.ts) and the matching LUA_RULES/LUA_HALSTEAD (crates/codegraph-core/src/ast_analysis/ complexity.rs, wired into lang_rules/halstead_rules), derived from the real tree-sitter-lua grammar shape (verified by parsing probe snippets with both the WASM grammar and the native tree-sitter-lua crate): elseif_statement/ else_statement are flat siblings of if_statement via repeated `alternative:` fields (same shape as Python's elif_clause/else_clause), and Lua's single generic binary_expression node covers arithmetic/comparison/logical operators alike, filtered by operator token. function_declaration matches the same node type the existing extractor and dataflowLua rules already use, so complexity results attach to the same definitions `where` reports. Also adds a Lua entry to the LOC comment-prefix maps (`--`) on both engines, which were defaulting to C-style prefixes and undercounting comment lines. Verified against all 5 Lua files in the repo (tracer + 4 fixtures) via both engines. Filed #1922 (WASM-only parity gap for files where every function has a dot-qualified name, e.g. Lua's `M.foo` module pattern) and #1923 (the same missing-complexity-config gap affects 24 other languages) as separate follow-ups rather than expanding this fix. Fixes #1782 --- .../src/ast_analysis/complexity.rs | 153 ++++++++++++++++++ src/ast-analysis/metrics.ts | 1 + src/ast-analysis/rules/b3.ts | 114 ++++++++++++- src/ast-analysis/rules/index.ts | 2 + tests/unit/complexity.test.ts | 95 ++++++++++- 5 files changed, 360 insertions(+), 5 deletions(-) diff --git a/crates/codegraph-core/src/ast_analysis/complexity.rs b/crates/codegraph-core/src/ast_analysis/complexity.rs index 9827b091f..e5c8e2d49 100644 --- a/crates/codegraph-core/src/ast_analysis/complexity.rs +++ b/crates/codegraph-core/src/ast_analysis/complexity.rs @@ -427,6 +427,44 @@ pub static BASH_RULES: LangRules = LangRules { switch_like_nodes: &["case_statement"], }; +// Lua's `if_statement` is flat, not nested: `elseif`/`else` are separate node +// kinds (`elseif_statement`, `else_statement`) attached to the *same* +// `if_statement` via repeated `alternative:` fields — confirmed by parsing +// `if a then .. elseif b then .. else .. end` with tree-sitter-lua and +// inspecting the S-expression. Structurally identical to Python's +// elif_clause/else_clause (Pattern B), not JS's nested else_clause>if_statement +// (Pattern A) or Go's alternative-holds-nested-if (Pattern C) — so +// else_via_alternative: false, and neither elseif_statement nor else_statement +// is in nesting_nodes (they're siblings of the primary if, not separately +// nested branches). Mirrors `complexityLua` in `src/ast-analysis/rules/b3.ts`. +// +// binary_expression is Lua's single generic binary-op node (arithmetic, +// comparison, concat, AND logical `and`/`or`) — same shared-type pattern as +// Ruby's `binary` node; handle_logical_op only fires when `node.child(1)` (the +// operator token) is in logical_operators, so comparisons/arithmetic sharing +// the same node kind are correctly ignored. +pub static LUA_RULES: LangRules = LangRules { + branch_nodes: &[ + "if_statement", + "elseif_statement", + "else_statement", + "for_statement", + "while_statement", + "repeat_statement", + ], + case_nodes: &[], + logical_operators: &["and", "or"], + logical_node_types: &["binary_expression"], + optional_chain_type: None, + nesting_nodes: &["if_statement", "for_statement", "while_statement", "repeat_statement"], + function_nodes: &["function_declaration"], + if_node_type: Some("if_statement"), + else_node_type: Some("else_statement"), + elif_node_type: Some("elseif_statement"), + else_via_alternative: false, + switch_like_nodes: &[], +}; + /// Look up complexity rules by language ID (matches `COMPLEXITY_RULES` keys in JS). pub fn lang_rules(lang_id: &str) -> Option<&'static LangRules> { match lang_id { @@ -444,6 +482,7 @@ pub fn lang_rules(lang_id: &str) -> Option<&'static LangRules> { "swift" => Some(&SWIFT_RULES), "scala" => Some(&SCALA_RULES), "bash" => Some(&BASH_RULES), + "lua" => Some(&LUA_RULES), _ => None, } } @@ -1022,6 +1061,34 @@ pub static BASH_HALSTEAD: HalsteadRules = HalsteadRules { skip_types: &[], }; +// Member/method access (`dot_index_expression` `.`, `method_index_expression` +// `:`) and invocation (`function_call`) are wrapper nodes without a dedicated +// "call happened" token, so they're counted as compound operators — mirrors +// Python's ["call", "subscript", "attribute"]. The '.'/':' separator tokens +// are ALSO in operator_leaf_types (matching Python counting both "attribute" +// and "."), so member access contributes two distinct operator kinds, +// consistent with the other languages above. Mirrors `halsteadLua` in +// `src/ast-analysis/rules/b3.ts`. +pub static LUA_HALSTEAD: HalsteadRules = HalsteadRules { + operator_leaf_types: &[ + "+", "-", "*", "/", "//", "%", "^", "#", "..", + "==", "~=", "<=", ">=", "<", ">", "=", + "and", "or", "not", + "&", "|", "~", "<<", ">>", + ".", ",", ":", "::", ";", + "if", "then", "else", "elseif", "end", + "for", "while", "do", "repeat", "until", + "function", "local", "return", "break", "goto", "in", + ], + operand_leaf_types: &[ + "identifier", "number", "string_content", "true", "false", "nil", "...", + ], + compound_operators: &[ + "function_call", "bracket_index_expression", "dot_index_expression", "method_index_expression", + ], + skip_types: &[], +}; + /// Look up Halstead rules by language ID. pub fn halstead_rules(lang_id: &str) -> Option<&'static HalsteadRules> { match lang_id { @@ -1039,6 +1106,7 @@ pub fn halstead_rules(lang_id: &str) -> Option<&'static HalsteadRules> { "swift" => Some(&SWIFT_HALSTEAD), "scala" => Some(&SCALA_HALSTEAD), "bash" => Some(&BASH_HALSTEAD), + "lua" => Some(&LUA_HALSTEAD), _ => None, } } @@ -1056,6 +1124,7 @@ pub fn comment_prefixes(lang_id: &str) -> &'static [&'static str] { "swift" => &["//", "/*"], "scala" => &["//", "/*"], "bash" => &["#"], + "lua" => &["--"], _ => &["//", "/*", "*", "*/"], } } @@ -1604,4 +1673,88 @@ mod tests { assert_eq!(m.cognitive, 1); assert_eq!(m.cyclomatic, 2); } + + // ─── Lua tests (issue #1782) ──────────────────────────────────────────── + + fn compute_lua(code: &str) -> ComplexityMetrics { + let mut parser = Parser::new(); + parser + .set_language(&tree_sitter_lua::LANGUAGE.into()) + .unwrap(); + let tree = parser.parse(code.as_bytes(), None).unwrap(); + let root = tree.root_node(); + let func = find_first_function(&root, &LUA_RULES).expect("no function found"); + compute_function_complexity(&func, &LUA_RULES) + } + + #[test] + fn lua_empty_function() { + let m = compute_lua("local function f() end"); + assert_eq!(m.cognitive, 0); + assert_eq!(m.cyclomatic, 1); + assert_eq!(m.max_nesting, 0); + } + + #[test] + fn lua_single_if() { + let m = compute_lua("local function f(x)\n if x > 0 then\n return 1\n end\nend"); + assert_eq!(m.cognitive, 1); + assert_eq!(m.cyclomatic, 2); + assert_eq!(m.max_nesting, 1); + } + + #[test] + fn lua_if_elseif_else() { + // elseif_statement/else_statement are siblings of if_statement via + // repeated `alternative:` fields (Pattern B, like Python's elif/else). + let m = compute_lua( + "local function f(x)\n if x > 0 then\n return 1\n elseif x < 0 then\n return -1\n else\n return 0\n end\nend", + ); + // if: +1 cog, +1 cyc; elseif: +1 cog, +1 cyc; else: +1 cog + assert_eq!(m.cognitive, 3); + assert_eq!(m.cyclomatic, 3); + assert_eq!(m.max_nesting, 1); + } + + #[test] + fn lua_numeric_for_loop() { + let m = compute_lua("local function f()\n for i = 1, 10 do\n print(i)\n end\nend"); + assert_eq!(m.cognitive, 1); + assert_eq!(m.cyclomatic, 2); + assert_eq!(m.max_nesting, 1); + } + + #[test] + fn lua_while_loop() { + let m = compute_lua("local function f(n)\n while n > 0 do\n n = n - 1\n end\nend"); + assert_eq!(m.cognitive, 1); + assert_eq!(m.cyclomatic, 2); + assert_eq!(m.max_nesting, 1); + } + + #[test] + fn lua_repeat_until_loop() { + let m = compute_lua("local function f(n)\n repeat\n n = n - 1\n until n <= 0\nend"); + assert_eq!(m.cognitive, 1); + assert_eq!(m.cyclomatic, 2); + assert_eq!(m.max_nesting, 1); + } + + #[test] + fn lua_logical_operators() { + let m = compute_lua("local function f(a, b)\n if a and b then\n return 1\n end\nend"); + // if: +1 cog, +1 cyc; and: +1 cog, +1 cyc + assert_eq!(m.cognitive, 2); + assert_eq!(m.cyclomatic, 3); + } + + #[test] + fn lua_nested_if() { + let m = compute_lua( + "local function f(x, y)\n if x > 0 then\n if y > 0 then\n return 1\n end\n end\nend", + ); + assert_eq!(m.cognitive, 3); + assert_eq!(m.cyclomatic, 3); + assert_eq!(m.max_nesting, 2); + } } diff --git a/src/ast-analysis/metrics.ts b/src/ast-analysis/metrics.ts index 4f94bb444..e4def246c 100644 --- a/src/ast-analysis/metrics.ts +++ b/src/ast-analysis/metrics.ts @@ -71,6 +71,7 @@ const COMMENT_PREFIXES = new Map([ ['python', ['#']], ['ruby', ['#']], ['php', ['//', '#', '/*', '*', '*/']], + ['lua', ['--']], ]); /** diff --git a/src/ast-analysis/rules/b3.ts b/src/ast-analysis/rules/b3.ts index 165f3d617..edc0c5b9b 100644 --- a/src/ast-analysis/rules/b3.ts +++ b/src/ast-analysis/rules/b3.ts @@ -1,4 +1,4 @@ -import type { DataflowRulesConfig } from '../../types.js'; +import type { ComplexityRules, DataflowRulesConfig, HalsteadRules } from '../../types.js'; import { makeDataflowRules } from '../shared.js'; // ─── Lua ────────────────────────────────────────────────────────────────────── @@ -30,6 +30,118 @@ export const dataflowLua: DataflowRulesConfig = makeDataflowRules({ memberPropertyField: 'field', }); +// Lua's `if_statement` is flat, not nested: `elseif`/`else` are separate node +// types (`elseif_statement`, `else_statement`) attached to the *same* +// `if_statement` via repeated `alternative:` fields — confirmed by parsing +// `if a then .. elseif b then .. else .. end` and inspecting the S-expression: +// `(if_statement condition: (...) consequence: (...) alternative: (elseif_statement ...) alternative: (else_statement ...))`. +// This is structurally identical to Python's elif_clause/else_clause pattern +// (Pattern B), not JS's nested else_clause>if_statement (Pattern A) or Go's +// alternative-field-holds-nested-if (Pattern C) — so elseViaAlternative: false +// and neither elseif_statement nor else_statement is in nestingNodes (matching +// how Python's elif_clause/else_clause are siblings of the primary if, not +// separately-nested branches). +// +// binary_expression is Lua's single generic binary-op node (arithmetic, +// comparison, concat, AND logical `and`/`or`) — same shared-type pattern as +// Ruby's `binary` node. classifyLogicalOp/handleLogicalOperator only acts when +// `node.child(1)` (the operator token) is in logicalOperators, so comparisons +// and arithmetic on the same node type are correctly ignored. +export const complexityLua: ComplexityRules = { + branchNodes: new Set([ + 'if_statement', + 'elseif_statement', + 'else_statement', + 'for_statement', + 'while_statement', + 'repeat_statement', + ]), + caseNodes: new Set([]), + logicalOperators: new Set(['and', 'or']), + logicalNodeType: 'binary_expression', + optionalChainType: null, + nestingNodes: new Set(['if_statement', 'for_statement', 'while_statement', 'repeat_statement']), + functionNodes: new Set(['function_declaration']), + ifNodeType: 'if_statement', + elseNodeType: 'else_statement', + elifNodeType: 'elseif_statement', + elseViaAlternative: false, + switchLikeNodes: new Set([]), +}; + +// Member/method access (`dot_index_expression` `.`, `method_index_expression` +// `:`) and invocation (`function_call`) are wrapper nodes without a dedicated +// "call happened" token, so they're counted as compound operators — mirrors +// Python's ['call', 'subscript', 'attribute']. The '.'/':' separator tokens are +// ALSO in operatorLeafTypes (matching Python counting both 'attribute' and +// '.'), so member access contributes two distinct operator kinds, consistent +// with the existing per-language precedent. +export const halsteadLua: HalsteadRules = { + operatorLeafTypes: new Set([ + '+', + '-', + '*', + '/', + '//', + '%', + '^', + '#', + '..', + '==', + '~=', + '<=', + '>=', + '<', + '>', + '=', + 'and', + 'or', + 'not', + '&', + '|', + '~', + '<<', + '>>', + '.', + ',', + ':', + '::', + ';', + 'if', + 'then', + 'else', + 'elseif', + 'end', + 'for', + 'while', + 'do', + 'repeat', + 'until', + 'function', + 'local', + 'return', + 'break', + 'goto', + 'in', + ]), + operandLeafTypes: new Set([ + 'identifier', + 'number', + 'string_content', + 'true', + 'false', + 'nil', + '...', + ]), + compoundOperators: new Set([ + 'function_call', + 'bracket_index_expression', + 'dot_index_expression', + 'method_index_expression', + ]), + skipTypes: new Set([]), +}; + // ─── R ──────────────────────────────────────────────────────────────────────── // // R functions are defined as: `name <- function_definition` (binary_operator with `<-`). diff --git a/src/ast-analysis/rules/index.ts b/src/ast-analysis/rules/index.ts index 298ce6fb0..392967427 100644 --- a/src/ast-analysis/rules/index.ts +++ b/src/ast-analysis/rules/index.ts @@ -31,6 +31,7 @@ export const COMPLEXITY_RULES: Map = new Map([ ['csharp', csharp.complexity], ['ruby', ruby.complexity], ['php', php.complexity], + ['lua', b3.complexityLua], ]); // ─── Halstead Rules ─────────────────────────────────────────────────────── @@ -46,6 +47,7 @@ export const HALSTEAD_RULES: Map = new Map([ ['csharp', csharp.halstead], ['ruby', ruby.halstead], ['php', php.halstead], + ['lua', b3.halsteadLua], ]); // ─── CFG Rules ──────────────────────────────────────────────────────────── diff --git a/tests/unit/complexity.test.ts b/tests/unit/complexity.test.ts index 1d671ddc3..52a41211f 100644 --- a/tests/unit/complexity.test.ts +++ b/tests/unit/complexity.test.ts @@ -255,8 +255,8 @@ describe('COMPLEXITY_RULES', () => { expect(COMPLEXITY_RULES.has('tsx')).toBe(true); }); - it('supports all 10 languages, not hcl', () => { - for (const lang of ['python', 'go', 'rust', 'java', 'csharp', 'ruby', 'php']) { + it('supports all 11 languages, not hcl', () => { + for (const lang of ['python', 'go', 'rust', 'java', 'csharp', 'ruby', 'php', 'lua']) { expect(COMPLEXITY_RULES.has(lang)).toBe(true); } expect(COMPLEXITY_RULES.has('hcl')).toBe(false); @@ -347,8 +347,8 @@ describe('HALSTEAD_RULES', () => { expect(HALSTEAD_RULES.has('tsx')).toBe(true); }); - it('supports all 10 languages, not hcl', () => { - for (const lang of ['python', 'go', 'rust', 'java', 'csharp', 'ruby', 'php']) { + it('supports all 11 languages, not hcl', () => { + for (const lang of ['python', 'go', 'rust', 'java', 'csharp', 'ruby', 'php', 'lua']) { expect(HALSTEAD_RULES.has(lang)).toBe(true); } expect(HALSTEAD_RULES.has('hcl')).toBe(false); @@ -949,6 +949,93 @@ describe('PHP complexity', () => { }); }); +// ─── Lua (#1782) ───────────────────────────────────────────────────────── + +describe('Lua complexity', () => { + const { analyze, halstead, loc } = makeHelpers('lua', sharedParsers()); + + it('simple function', () => { + const r = analyze('local function add(a, b)\n return a + b\nend\n'); + expect(r).toEqual({ cognitive: 0, cyclomatic: 1, maxNesting: 0 }); + }); + + it('single if', () => { + const r = analyze( + 'local function check(x)\n if x > 0 then\n return true\n end\n return false\nend\n', + ); + expect(r).toEqual({ cognitive: 1, cyclomatic: 2, maxNesting: 1 }); + }); + + it('if/elseif/else chain', () => { + // elseif_statement/else_statement are flat siblings of if_statement + // (repeated `alternative:` fields), not nested — same shape as Python's + // elif_clause/else_clause. + const r = analyze( + 'local function classify(x)\n if x > 0 then\n return "pos"\n elseif x < 0 then\n return "neg"\n else\n return "zero"\n end\nend\n', + ); + expect(r).toEqual({ cognitive: 3, cyclomatic: 3, maxNesting: 1 }); + }); + + it('nested if', () => { + const r = analyze( + 'local function nested(x, y)\n if x > 0 then\n if y > 0 then\n return true\n end\n end\n return false\nend\n', + ); + expect(r).toEqual({ cognitive: 3, cyclomatic: 3, maxNesting: 2 }); + }); + + it('numeric for loop with condition', () => { + const r = analyze( + 'local function search(arr, t)\n for i = 1, #arr do\n if arr[i] == t then\n return true\n end\n end\n return false\nend\n', + ); + expect(r).toEqual({ cognitive: 3, cyclomatic: 3, maxNesting: 2 }); + }); + + it('generic for-in loop', () => { + const r = analyze( + 'local function search(t, target)\n for k, v in pairs(t) do\n if v == target then\n return k\n end\n end\nend\n', + ); + expect(r).toEqual({ cognitive: 3, cyclomatic: 3, maxNesting: 2 }); + }); + + it('while loop', () => { + const r = analyze('local function countdown(n)\n while n > 0 do\n n = n - 1\n end\nend\n'); + expect(r).toEqual({ cognitive: 1, cyclomatic: 2, maxNesting: 1 }); + }); + + it('repeat/until loop', () => { + const r = analyze( + 'local function countdown(n)\n repeat\n n = n - 1\n until n <= 0\nend\n', + ); + expect(r).toEqual({ cognitive: 1, cyclomatic: 2, maxNesting: 1 }); + }); + + it('logical operators', () => { + const r = analyze( + 'local function check(a, b)\n if a and b then\n return true\n end\nend\n', + ); + expect(r.cognitive).toBe(2); + expect(r.cyclomatic).toBe(3); + }); + + it('method declaration (colon syntax) is recognized as a function', () => { + const r = analyze( + 'local Obj = {}\nfunction Obj:method(x)\n if x > 0 then\n return x\n end\nend\n', + ); + expect(r).toEqual({ cognitive: 1, cyclomatic: 2, maxNesting: 1 }); + }); + + it('halstead: positive volume', () => { + const h = halstead('local function add(a, b)\n return a + b\nend\n'); + expect(h).not.toBeNull(); + expect(h.volume).toBeGreaterThan(0); + }); + + it('LOC: -- comments detected', () => { + const l = loc('local function f()\n -- comment\n return 1\nend\n'); + expect(l.commentLines).toBeGreaterThanOrEqual(1); + }); +}); + // ─── Parity: standalone DFS vs visitor-based computeAllMetrics ────────── describe('DFS vs visitor parity', () => { From af17b6d42fbb722585653b1828bd529684745a32 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Mon, 6 Jul 2026 15:35:06 -0600 Subject: [PATCH 56/67] fix: scope global call-resolution fallback to same-language candidates (docs check acknowledged) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The global-by-name call-resolution fallback (resolveByGlobal/resolveByReceiver in strategy.ts, resolveReceiverEdge in call-resolver.ts, and their Rust mirrors) filtered candidates purely by proximity-based confidence (computeConfidence), with no language-consistency check at all. A bare-name call with no import/receiver match could therefore resolve against a same-named symbol in a completely unrelated language, as long as the two files were proximate enough (e.g. same directory). Concretely: ruby-tracer.rb's builtin `Kernel#load` call was falsely attributed as a consumer of loader-hooks.mjs's unrelated `load` export, because both files live in the same directory and computeConfidence scores same-directory pairs at 0.7 — well above the resolver's 0.5 threshold — with no check that a Ruby caller and a JS target aren't even in the same language. Adds isSameLanguageFamily()/is_same_language_family() to resolve.ts/resolve.rs, derived from LANGUAGE_REGISTRY (TS) / LanguageKind::from_extension (Rust), with JavaScript/TypeScript/TSX collapsed into one family since those routinely call into each other within the same project. computeConfidence now rejects cross-family candidates before scoring proximity, which transitively hardens every fallback tier gated on it (resolveByGlobal's exact-name lookup, resolveByReceiver's typed/prototype/direct-qualified/ composite-pts lookups, the same-class-sibling and accessor-this-dispatch fallbacks, and the CHA/property-propagation post-passes). resolveReceiverEdge's global fallback had no confidence gate at all, so it gets an explicit isSameLanguageFamily filter instead. Verified via the resolution-benchmark suite: precision/recall are unchanged across all 40 single-language fixtures (expected, since none mix languages within one directory); the fix is validated by the exact repro plus new targeted unit tests covering both cross-language rejection and same-language regression safety in both engines. Fixes #1783 Impact: 4 functions changed, 30 affected --- .../graph/builder/stages/build_edges.rs | 65 ++++++++- .../src/domain/graph/resolve.rs | 123 ++++++++++++++++++ src/domain/graph/builder/call-resolver.ts | 10 +- src/domain/graph/resolve.ts | 67 ++++++++++ tests/unit/call-resolver.test.ts | 82 ++++++++++++ tests/unit/resolve.test.ts | 87 +++++++++++++ 6 files changed, 431 insertions(+), 3 deletions(-) diff --git a/crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs b/crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs index 01a4fbaed..a74e71ec9 100644 --- a/crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs +++ b/crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs @@ -1342,9 +1342,13 @@ fn emit_receiver_edge( samefile_candidates } else { // Fall back to any cross-file class/struct/interface candidate. + // Cross-language candidates are never legitimate receiver targets + // (#1783) — a `new Foo()` in one language can't statically resolve to + // an unrelated same-named class in another. Mirrors JS resolveReceiverEdge. ctx.nodes_by_name.get(effective_receiver).cloned().unwrap_or_default() .into_iter() - .filter(|n| ctx.receiver_kinds.contains(n.kind.as_str())) + .filter(|n| ctx.receiver_kinds.contains(n.kind.as_str()) + && resolve::is_same_language_family(rel_path, &n.file)) .collect() }; @@ -2319,6 +2323,65 @@ mod call_edge_tests { ); } + /// Issue #1783: the global (cross-file) receiver fallback had no + /// language-consistency check at all, so `Widget.render()` in a Python + /// caller with no same-file `Widget` definition could resolve to an + /// unrelated same-named class declared in a JS file purely by name. + #[test] + fn receiver_edge_rejects_cross_language_match() { + let all_nodes = vec![ + node(1, "main", "function", "widget.py", 3), + node(2, "Widget", "class", "widget.js", 1), + ]; + + let files = vec![make_file( + "widget.py", + 10, + vec![def("main", "function", 3, 8)], + vec![call("render", 7, Some("Widget"))], + vec![], + vec![], + )]; + + let edges = build_call_edges(files, all_nodes, vec![], MAX_SOLVER_ITERATIONS); + + let receiver_edge = edges.iter().find(|e| e.kind == "receiver"); + assert!( + receiver_edge.is_none(), + "a Python caller must not resolve a receiver edge to an unrelated same-named JS class; got: {:?}", + edges.iter().map(|e| (&e.kind, e.source_id, e.target_id)).collect::>() + ); + } + + /// Same-language global receiver fallback must still work after the + /// #1783 language-scoping fix — only cross-language candidates are rejected. + #[test] + fn receiver_edge_still_resolves_same_language_cross_file_match() { + let all_nodes = vec![ + node(1, "main", "function", "widget.py", 3), + node(2, "Widget", "class", "widget_impl.py", 1), + ]; + + let files = vec![make_file( + "widget.py", + 10, + vec![def("main", "function", 3, 8)], + vec![call("render", 7, Some("Widget"))], + vec![], + vec![], + )]; + + let edges = build_call_edges(files, all_nodes, vec![], MAX_SOLVER_ITERATIONS); + + let receiver_edge = edges.iter().find(|e| e.kind == "receiver"); + assert!( + receiver_edge.is_some(), + "same-language cross-file receiver fallback must still resolve; got: {:?}", + edges.iter().map(|e| (&e.kind, e.source_id, e.target_id)).collect::>() + ); + assert_eq!(receiver_edge.unwrap().target_id, 2); + } + /// Issue #1453: `this.logger.error()` inside `UserService.create` where the /// constructor seeded the class-scoped key `UserService.logger → Logger`. /// The resolver must fall back to the `ClassName.prop` typeMap key (#1323). diff --git a/crates/codegraph-core/src/domain/graph/resolve.rs b/crates/codegraph-core/src/domain/graph/resolve.rs index 36d3ec8ec..0ff060f6c 100644 --- a/crates/codegraph-core/src/domain/graph/resolve.rs +++ b/crates/codegraph-core/src/domain/graph/resolve.rs @@ -3,6 +3,7 @@ use std::path::{Path, PathBuf}; use rayon::prelude::*; +use crate::domain::parser::LanguageKind; use crate::types::{ImportResolutionInput, PathAliases, ResolvedImport}; /// Check file existence using known_files set when available, falling back to FS. @@ -281,6 +282,40 @@ fn directory_distance(a: &str, b: &str) -> usize { usize::MAX } +/// Coarse "language family" for a file, derived from its extension via +/// `LanguageKind::from_extension`. Collapses TypeScript/Tsx into the same +/// family as JavaScript: despite being distinct `LanguageKind` variants (one +/// per tree-sitter grammar), `.ts`/`.tsx` files routinely import from and +/// call into `.js` files and vice versa within the same project (this +/// codebase's own `src/` tree does this throughout) — treating them as +/// separate families would reject huge amounts of legitimate same-project +/// resolution. Every other `LanguageKind` variant keeps its own family, +/// preserving `from_extension`'s existing per-language extension groupings +/// (e.g. C's `.c`+`.h`, C++'s `.cpp`/`.cc`/`.cxx`/`.hpp`). +fn language_family(file: &str) -> Option { + match LanguageKind::from_extension(file) { + Some(LanguageKind::TypeScript) | Some(LanguageKind::Tsx) => Some(LanguageKind::JavaScript), + other => other, + } +} + +/// True when `file_a` and `file_b` belong to the same language family, or +/// when either extension is unrecognised (ambiguous cases are not rejected — +/// they fall through to normal scoring). False only when both extensions are +/// recognised AND resolve to different families. +/// +/// Guards the global-by-name call-resolution fallback against matching a +/// same-named symbol across unrelated languages — e.g. a Ruby file's bare +/// `load` call has no static relationship to a same-named `load` export in a +/// JS file, even when both happen to live in the same directory (issue +/// #1783). Mirrors `isSameLanguageFamily()` in resolve.ts. +pub fn is_same_language_family(file_a: &str, file_b: &str) -> bool { + match (language_family(file_a), language_family(file_b)) { + (Some(a), Some(b)) => a == b, + _ => true, + } +} + /// Compute proximity-based confidence for call resolution. /// Mirrors `computeConfidence()` in resolve.ts. pub fn compute_confidence( @@ -299,6 +334,12 @@ pub fn compute_confidence( return 1.0; } } + // Cross-language candidates are never legitimate call targets (#1783) — + // reject before scoring proximity so a same-directory, same-named symbol + // in an unrelated language can never pass the resolver's 0.5 threshold. + if !is_same_language_family(caller_file, target_file) { + return 0.0; + } let caller_dir = Path::new(caller_file) .parent() @@ -532,4 +573,86 @@ mod tests { // not a direct sibling pair despite sharing the `src` ancestor. assert_eq!(directory_distance("src/graph/algorithms", "src/features"), 3); } + + // Regression tests for #1783: the global-by-name call-resolution fallback + // had no language-consistency check at all, so a bare-name call with no + // import/receiver match could resolve against a same-named symbol in a + // completely unrelated language — e.g. a Ruby file's builtin `Kernel#load` + // call matched a JS ESM loader hook's unrelated `load` export purely + // because both files sat in the same directory (confidence 0.7 from + // proximity alone, well above the resolver's 0.5 threshold). + + #[test] + fn is_same_language_family_rejects_ruby_and_js() { + assert!(!is_same_language_family("tracer/ruby-tracer.rb", "tracer/loader-hooks.mjs")); + } + + #[test] + fn is_same_language_family_rejects_python_and_go() { + assert!(!is_same_language_family("src/main.py", "src/main.go")); + } + + #[test] + fn is_same_language_family_accepts_same_extension() { + assert!(is_same_language_family("src/a.rb", "lib/b.rb")); + } + + #[test] + fn is_same_language_family_merges_javascript_and_typescript() { + assert!(is_same_language_family("src/a.ts", "src/b.js")); + assert!(is_same_language_family("src/a.tsx", "src/b.mjs")); + assert!(is_same_language_family("src/a.cjs", "src/b.jsx")); + } + + #[test] + fn is_same_language_family_merges_c_source_and_header() { + assert!(is_same_language_family("src/a.c", "src/a.h")); + } + + #[test] + fn is_same_language_family_merges_cpp_source_and_header_variants() { + assert!(is_same_language_family("src/a.cpp", "src/a.hpp")); + assert!(is_same_language_family("src/a.cc", "src/a.cxx")); + } + + #[test] + fn is_same_language_family_does_not_merge_c_and_cpp() { + assert!(!is_same_language_family("src/a.c", "src/a.cpp")); + } + + #[test] + fn is_same_language_family_does_not_reject_unrecognised_extensions() { + // Ambiguous (unrecognised) extensions fall through rather than being rejected. + assert!(is_same_language_family("README", "src/b.rb")); + assert!(is_same_language_family("src/a.rb", "Makefile")); + } + + #[test] + fn compute_confidence_rejects_cross_language_same_directory_match() { + // The exact #1783 repro shape: same directory, different languages. + let conf = compute_confidence( + "tests/benchmarks/resolution/tracer/ruby-tracer.rb", + "tests/benchmarks/resolution/tracer/loader-hooks.mjs", + None, + ); + assert_eq!(conf, 0.0); + } + + #[test] + fn compute_confidence_still_scores_same_language_same_directory_pair() { + let conf = compute_confidence( + "tests/benchmarks/resolution/tracer/ruby-tracer.rb", + "tests/benchmarks/resolution/tracer/other-tracer.rb", + None, + ); + assert_eq!(conf, 0.7); + } + + #[test] + fn compute_confidence_does_not_regress_same_project_js_ts_resolution() { + // A .ts caller resolving a same-directory .js target must be unaffected — + // TS/JS are one family despite being different LanguageKind variants. + let conf = compute_confidence("src/graph/a.ts", "src/graph/b.js", None); + assert_eq!(conf, 0.7); + } } diff --git a/src/domain/graph/builder/call-resolver.ts b/src/domain/graph/builder/call-resolver.ts index dde9fb234..fb97377fd 100644 --- a/src/domain/graph/builder/call-resolver.ts +++ b/src/domain/graph/builder/call-resolver.ts @@ -9,7 +9,7 @@ * `resolveByMethodOrGlobal` delegates its two branches to strategy helpers * in `../resolver/strategy.ts` to keep per-strategy complexity manageable. */ -import { computeConfidence } from '../resolve.js'; +import { computeConfidence, isSameLanguageFamily } from '../resolve.js'; import { isModuleScopedLanguage, resolveByGlobal, @@ -347,9 +347,15 @@ export function resolveReceiverEdge( const sameFileAll = lookup.byNameAndFile(effectiveReceiver, relPath); const isLocalDefinition = sameFileAll.length > 0 && !importedNames?.has(effectiveReceiver); const sameFileCandidates = sameFileAll.filter((n) => RECEIVER_KINDS.has(n.kind ?? '')); + // Cross-language candidates are never legitimate receiver targets (#1783) — + // a `new Foo()` in one language can't statically resolve to an unrelated + // same-named class in another. Only the global (cross-file) branch needs + // the check: sameFileCandidates are already scoped to relPath itself. const candidates = isLocalDefinition ? sameFileCandidates - : lookup.byName(effectiveReceiver).filter((n) => RECEIVER_KINDS.has(n.kind ?? '')); + : lookup + .byName(effectiveReceiver) + .filter((n) => RECEIVER_KINDS.has(n.kind ?? '') && isSameLanguageFamily(relPath, n.file)); if (candidates.length === 0) return null; const recvTarget = candidates[0]!; const recvKey = `recv|${caller.id}|${recvTarget.id}`; diff --git a/src/domain/graph/resolve.ts b/src/domain/graph/resolve.ts index 3dd05fe36..0c262664f 100644 --- a/src/domain/graph/resolve.ts +++ b/src/domain/graph/resolve.ts @@ -5,6 +5,7 @@ import { loadNative } from '../../infrastructure/native.js'; import { normalizePath } from '../../shared/constants.js'; import { toErrorMessage } from '../../shared/errors.js'; import type { BareSpecifier, BatchResolvedMap, ImportBatchItem, PathAliases } from '../../types.js'; +import { LANGUAGE_REGISTRY } from '../parser.js'; // ── package.json exports resolution ───────────────────────────────── @@ -496,6 +497,68 @@ function directoryDistance(a: string, b: string): number { return Infinity; } +// ── Language-family scoping for global-by-name fallback resolution ───────── + +/** + * LANGUAGE_REGISTRY ids that must be treated as one family for cross-file + * call resolution. TypeScript/TSX compile to and interoperate directly with + * JavaScript — a `.ts` file routinely imports from and calls into a `.js` + * file and vice versa (this codebase's own `src/` tree does this + * throughout) — despite being three separate grammar entries in + * LANGUAGE_REGISTRY. Every other registry id keeps its own family, which + * preserves LANGUAGE_REGISTRY's existing per-language extension groupings + * (e.g. C's `.c`+`.h`, C++'s `.cpp`/`.cc`/`.cxx`/`.hpp`). + */ +const JS_FAMILY_REGISTRY_IDS = new Set(['javascript', 'typescript', 'tsx']); + +/** + * extension → language-family lookup, derived from LANGUAGE_REGISTRY (the + * single source of truth for language definitions) so newly-added languages + * are automatically covered without a second hand-maintained extension list. + */ +const _extToLanguageFamily: Map = new Map(); +for (const entry of LANGUAGE_REGISTRY) { + const family = JS_FAMILY_REGISTRY_IDS.has(entry.id) ? 'javascript' : entry.id; + for (const ext of entry.extensions) { + _extToLanguageFamily.set(ext, family); + } +} + +/** + * Resolve a file's coarse language family from its extension. Returns null + * for extensionless or unrecognised files so ambiguous cases fall through to + * ordinary distance-based scoring rather than being rejected outright. + */ +function languageFamily(file: string): string | null { + const dot = file.lastIndexOf('.'); + if (dot === -1) return null; + return _extToLanguageFamily.get(file.slice(dot).toLowerCase()) ?? null; +} + +/** + * True when `fileA` and `fileB` belong to the same language family, or when + * either extension is unrecognised (ambiguous cases are not rejected — they + * fall through to normal scoring). False only when both extensions are + * recognised AND resolve to different families. + * + * Guards the global-by-name call-resolution fallback against matching a + * same-named symbol across unrelated languages — e.g. a Ruby file's bare + * `load` call has no static relationship to a same-named `load` export in a + * JS file, even when both happen to live in the same directory (issue + * #1783). This codebase has no cross-language static-call mechanism its + * resolvers legitimately model (the `dead-ffi` role classifier only + * suppresses false dead-code flags for compiled-language files consumed via + * FFI — it never creates call edges), so rejecting cross-family candidates + * is a strict precision improvement with no legitimate resolution to + * regress. + */ +export function isSameLanguageFamily(fileA: string, fileB: string): boolean { + const famA = languageFamily(fileA); + const famB = languageFamily(fileB); + if (!famA || !famB) return true; + return famA === famB; +} + function computeConfidenceJS( callerFile: string, targetFile: string, @@ -506,6 +569,10 @@ function computeConfidenceJS( if (importedFrom === targetFile) return 1.0; // Workspace-resolved imports get high confidence even across package boundaries if (importedFrom && _workspaceResolvedPaths.has(importedFrom)) return 0.95; + // Cross-language candidates are never legitimate call targets (#1783) — reject + // before scoring proximity so a same-directory, same-named symbol in an + // unrelated language can never pass the resolver's 0.5 confidence threshold. + if (!isSameLanguageFamily(callerFile, targetFile)) return 0; const dist = directoryDistance(path.dirname(callerFile), path.dirname(targetFile)); if (dist === 0) return 0.7; // same directory if (dist === 1) return 0.6; // direct parent/child directory diff --git a/tests/unit/call-resolver.test.ts b/tests/unit/call-resolver.test.ts index a50a1045c..0ee65d17e 100644 --- a/tests/unit/call-resolver.test.ts +++ b/tests/unit/call-resolver.test.ts @@ -213,6 +213,50 @@ describe('resolveByMethodOrGlobal — bare-call JS/TS module-scope guard (#1407) }); }); +describe('resolveByMethodOrGlobal — cross-language global fallback rejection (#1783)', () => { + // Mirrors the #1783 repro: ruby-tracer.rb's bare `Kernel#load` call has no + // static relationship to loader-hooks.mjs's unrelated `load` export, even + // though both files live in the same directory (which would otherwise + // score confidence 0.7 — well above the resolver's 0.5 threshold). + it('does not resolve a bare call to a same-directory, same-named symbol in a different language', () => { + const jsExport = { id: 1, file: 'tracer/loader-hooks.mjs', kind: 'function' }; + const lookup = makeLookup({ load: [jsExport] }); + const result = resolveByMethodOrGlobal( + lookup, + { name: 'load', receiver: null }, + 'tracer/ruby-tracer.rb', + new Map(), + ); + expect(result).toEqual([]); + }); + + it('still resolves a bare call to a same-directory, same-named symbol in the SAME language', () => { + const rbTarget = { id: 2, file: 'tracer/other-tracer.rb', kind: 'function' }; + const lookup = makeLookup({ load: [rbTarget] }); + const result = resolveByMethodOrGlobal( + lookup, + { name: 'load', receiver: null }, + 'tracer/ruby-tracer.rb', + new Map(), + ); + expect(result).toEqual([rbTarget]); + }); + + it('does not resolve a typed-method lookup to a same-named type in a different language', () => { + // receiver 'w' typed to 'Widget' via typeMap; a same-directory JS 'Widget.render' + // method must not satisfy a Python caller's 'w.render()' call. + const jsMethod = { id: 3, file: 'lib/widget.js', kind: 'method' }; + const lookup = makeLookup({ 'Widget.render': [jsMethod] }); + const result = resolveByMethodOrGlobal( + lookup, + { name: 'render', receiver: 'w' }, + 'lib/widget.py', + new Map([['w', 'Widget']]), + ); + expect(result).toEqual([]); + }); +}); + // ── resolveReceiverEdge ────────────────────────────────────────────────────── /** @@ -290,6 +334,44 @@ describe('resolveReceiverEdge — local function constructor blocks global class }); }); +describe('resolveReceiverEdge — cross-language global fallback rejection (#1783)', () => { + // The global (cross-file) receiver-resolution branch used no confidence or + // language check at all, so `new Widget()` in one language could resolve + // to an unrelated same-named class declared in a completely different + // language. Only the global branch needs the check — sameFileCandidates + // are already scoped to the caller's own file (trivially same-language). + it('does not resolve a receiver to a same-named class in a different language', () => { + const jsClass = { id: 1, file: 'lib/Widget.js', kind: 'class' }; + const lookup = makeReceiverLookup({}, { Widget: [jsClass] }); + const result = resolveReceiverEdge( + lookup, + { name: 'render', receiver: 'Widget' }, + { id: 99 }, + 'lib/widget.py', + new Map(), + new Set(), + new Map(), + ); + expect(result).toBeNull(); + }); + + it('still resolves a receiver to a same-named class in the SAME language', () => { + const pyClass = { id: 2, file: 'lib/widget_impl.py', kind: 'class' }; + const lookup = makeReceiverLookup({}, { Widget: [pyClass] }); + const result = resolveReceiverEdge( + lookup, + { name: 'render', receiver: 'Widget' }, + { id: 99 }, + 'lib/widget.py', + new Map(), + new Set(), + new Map(), + ); + expect(result).not.toBeNull(); + expect(result?.receiverId).toBe(2); + }); +}); + // ── resolveDefinePropertyAccessorTarget ─────────────────────────────────── /** diff --git a/tests/unit/resolve.test.ts b/tests/unit/resolve.test.ts index 7ad38e4d4..ba63efbd6 100644 --- a/tests/unit/resolve.test.ts +++ b/tests/unit/resolve.test.ts @@ -15,6 +15,7 @@ import { computeConfidence, computeConfidenceJS, convertAliasesForNative, + isSameLanguageFamily, isWorkspaceResolved, parseBareSpecifier, resolveImportPathJS, @@ -728,3 +729,89 @@ describe('computeConfidenceJS workspace confidence', () => { expect(conf).toBeLessThan(0.95); }); }); + +// ─── Cross-language fallback rejection (#1783) ─────────────────────── + +// Regression tests for #1783: the global-by-name call-resolution fallback +// had no language-consistency check at all, so a bare-name call with no +// import/receiver match could resolve against a same-named symbol in a +// completely unrelated language — e.g. a Ruby file's builtin `Kernel#load` +// call matched a JS ESM loader hook's unrelated `load` export purely because +// both files sat in the same directory (confidence 0.7 from proximity alone). +describe('isSameLanguageFamily (#1783)', () => { + it('returns false for a Ruby file and a JS file', () => { + expect(isSameLanguageFamily('tracer/ruby-tracer.rb', 'tracer/loader-hooks.mjs')).toBe(false); + }); + + it('returns false for a Python file and a Go file', () => { + expect(isSameLanguageFamily('src/main.py', 'src/main.go')).toBe(false); + }); + + it('returns true for two files with the same extension', () => { + expect(isSameLanguageFamily('src/a.rb', 'lib/b.rb')).toBe(true); + }); + + it('treats JavaScript and TypeScript as the same family', () => { + expect(isSameLanguageFamily('src/a.ts', 'src/b.js')).toBe(true); + expect(isSameLanguageFamily('src/a.tsx', 'src/b.mjs')).toBe(true); + expect(isSameLanguageFamily('src/a.cjs', 'src/b.jsx')).toBe(true); + }); + + it('treats C and its own header extension as the same family', () => { + expect(isSameLanguageFamily('src/a.c', 'src/a.h')).toBe(true); + }); + + it('treats C++ source and header extensions as the same family', () => { + expect(isSameLanguageFamily('src/a.cpp', 'src/a.hpp')).toBe(true); + expect(isSameLanguageFamily('src/a.cc', 'src/a.cxx')).toBe(true); + }); + + it('does not treat C and C++ as the same family', () => { + expect(isSameLanguageFamily('src/a.c', 'src/a.cpp')).toBe(false); + }); + + it('returns true (does not reject) when either extension is unrecognised', () => { + expect(isSameLanguageFamily('README', 'src/b.rb')).toBe(true); + expect(isSameLanguageFamily('src/a.rb', 'Makefile')).toBe(true); + }); +}); + +describe('computeConfidenceJS — cross-language rejection (#1783)', () => { + it('returns 0 for same-directory files in different languages (the #1783 repro shape)', () => { + // ruby-tracer.rb's bare `load` call must never match loader-hooks.mjs's + // `load` export just because both files live in the same directory. + const conf = computeConfidenceJS( + 'tests/benchmarks/resolution/tracer/ruby-tracer.rb', + 'tests/benchmarks/resolution/tracer/loader-hooks.mjs', + undefined, + ); + expect(conf).toBe(0); + }); + + it('still returns same-directory confidence for a same-language pair', () => { + const conf = computeConfidenceJS( + 'tests/benchmarks/resolution/tracer/ruby-tracer.rb', + 'tests/benchmarks/resolution/tracer/other-tracer.rb', + undefined, + ); + expect(conf).toBe(0.7); + }); + + it('does not regress same-project JS/TS cross-file resolution', () => { + // A .ts caller resolving a same-directory .js target must be unaffected — + // TS/JS are one family despite being different LANGUAGE_REGISTRY entries. + const conf = computeConfidenceJS('src/graph/a.ts', 'src/graph/b.js', undefined); + expect(conf).toBe(0.7); + }); + + it('rejects a cross-language match even when same-file/importedFrom shortcuts do not apply', () => { + const conf = computeConfidenceJS('src/main.py', 'src/main.go', undefined); + expect(conf).toBeLessThan(0.5); + expect(conf).toBe(0); + }); + + it('does not reject when the target extension is unrecognised (falls through to distance scoring)', () => { + const conf = computeConfidenceJS('src/a.rb', 'src/README', undefined); + expect(conf).toBeGreaterThan(0); + }); +}); From dd447bce56c601ce430179e46bcbee61a99f54c7 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Mon, 6 Jul 2026 16:17:26 -0600 Subject: [PATCH 57/67] fix: credit instanceof ClassName checks as exports consumers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codegraph exports did not credit `instanceof ClassName` checks (or other bare-reference, no-call-site usages) as consumers. CodegraphError showed consumerCount:0 via `codegraph exports src/shared/errors.ts --json` despite two real production usages (src/cli.ts, src/mcp/server.ts) that only ever reference it via `err instanceof CodegraphError` — never `new CodegraphError(...)`. Subclasses consumed via constructor calls were correctly credited (ConfigError, consumerCount:23); only bare-reference usages like instanceof were invisible to the consumer counter, so any base/parent class whose primary cross-file use is instanceof narrowing falsely presented as dead/unused. Root cause: no edge at all was created for the right-hand operand of an `instanceof` binary expression — the same "no edge for a bare-identifier value reference" gap as #1771 (object-literal property values) and #1776 (Lua builtin reassignment), just at a different syntactic position. Fix: both engines now extract a dynamic `calls` edge (dynamicKind/ dynamic_kind = 'value-ref') when instanceof's right operand is a bare identifier, reusing the #1771/#1776 taxonomy entry rather than introducing a new DynamicKind — ADR-002 explicitly anticipated this as "syntax-position- agnostic" and invited a third extraction site to reuse it. Unlike the function/method-only #1771/#1776 sites, the resolver-side kind filter for value-ref now also accepts class-kind targets, since instanceof's operand is always a class/constructor (never a plain data reference) — it still accepts function-kind too, correctly covering the pre-ES6 "constructor function" instanceof idiom (`x instanceof SomeFunction`). Applied to both engines: - WASM/TS: src/extractors/javascript.ts (collectInstanceofValueRefCall, wired into the shared runCollectorWalk's binary_expression case), src/domain/graph/builder/stages/build-edges.ts (resolveFallbackTargets kind filter extended to accept 'class'), src/domain/graph/builder/ incremental.ts (mirrored filter for the incremental/watch-mode path). - Native: crates/codegraph-core/src/extractors/javascript.rs (handle_instanceof_value_ref, wired into match_js_node's binary_expression arm — dispatches for JavaScript/TypeScript/Tsx alike), crates/ codegraph-core/src/domain/graph/builder/stages/build_edges.rs (matching kind filter in process_file). - src/types.ts / docs/architecture/decisions/002-dynamic-call-resolution.md: documented instanceof as the third value-ref extraction site and the class-kind target-set extension. Verified against the exact repro on both engines: CodegraphError now shows consumerCount:2 (src/cli.ts, createCallToolHandler in src/mcp/server.ts), identical between native and WASM. codegraph roles --role dead does not (and did not) flag the CodegraphError class itself — exported classes are never classified dead by role, independent of this fix; the fix's measurable effect is the consumer-count/fan-in correction. Total dead-symbol count across the repo's own graph dropped from 816 to 810 as a side effect of crediting other instanceof-only-referenced symbols codebase-wide. Resolution-benchmark: no fixture exercises instanceof, so precision/recall is byte-identical before and after (javascript 100/100, typescript 95.7/93.6, aggregate 63.8% recall) — confirmed empirically, not assumed. Fixes #1784 docs check acknowledged — internal edge-emission/resolver bug fix, no new commands, languages, or architecture to document. Impact: 5 functions changed, 17 affected --- .../graph/builder/stages/build_edges.rs | 19 +- .../src/extractors/javascript.rs | 106 +++++++ .../decisions/002-dynamic-call-resolution.md | 38 ++- src/domain/graph/builder/incremental.ts | 11 +- .../graph/builder/stages/build-edges.ts | 19 +- src/extractors/javascript.ts | 45 ++- src/types.ts | 2 +- ...ue-1784-instanceof-consumer-credit.test.ts | 278 ++++++++++++++++++ tests/parsers/javascript.test.ts | 55 ++++ 9 files changed, 535 insertions(+), 38 deletions(-) create mode 100644 tests/integration/issue-1784-instanceof-consumer-credit.test.ts diff --git a/crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs b/crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs index a74e71ec9..4cb2c05bb 100644 --- a/crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs +++ b/crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs @@ -785,16 +785,21 @@ fn process_file<'a>( let mut targets = resolve_call_targets( ctx, call, fc.rel_path, imported_from, &fc.type_map, caller_name, imported_original_name, ); - // #1771: object-literal property-value references resolve against - // function/method-kind targets only — a bare identifier there is as - // likely to be a plain data reference (`{ name: SOME_CONSTANT }`) as - // a function reference, so drop any non-callable match rather than - // fabricating a "calls" edge to a constant/class/etc. Applied once - // here (after all resolve_call_targets tiers), mirroring the + // #1771/#1784: value-ref references (object-literal property values, + // Lua builtin reassignment, `instanceof ClassName`) resolve against + // function/method/class-kind targets only. A bare identifier in one + // of these positions is as likely to be a plain data reference + // (`{ name: SOME_CONSTANT }`) as a real function/class, so drop any + // other-kind match rather than fabricating a "calls" edge to a + // constant. `class` is included alongside function/method because + // `instanceof`'s right operand is always a class/constructor + // (#1784) — unlike the original #1771 object-literal case, which is + // function/method only. Applied once here (after all + // resolve_call_targets tiers), mirroring the // `dynamicKind === 'value-ref'` filter in resolveFallbackTargets // (stages/build-edges.ts). if call.dynamic_kind.as_deref() == Some("value-ref") { - targets.retain(|t| t.kind == "function" || t.kind == "method"); + targets.retain(|t| t.kind == "function" || t.kind == "method" || t.kind == "class"); } sort_targets_by_confidence(&mut targets, fc.rel_path, imported_from); emit_call_edges(&targets, caller_id, is_dynamic, fc.rel_path, imported_from, &mut seen_edges, &mut pts_edge_map, edges); diff --git a/crates/codegraph-core/src/extractors/javascript.rs b/crates/codegraph-core/src/extractors/javascript.rs index b58da1dfc..27de77d9b 100644 --- a/crates/codegraph-core/src/extractors/javascript.rs +++ b/crates/codegraph-core/src/extractors/javascript.rs @@ -1081,6 +1081,8 @@ fn match_js_node(node: &Node, source: &[u8], symbols: &mut FileSymbols, _depth: "shorthand_property_identifier" => { handle_object_literal_shorthand_value_ref(node, source, &mut symbols.calls) } + // #1784: `instanceof ClassName` checks, e.g. `err instanceof CodegraphError`. + "binary_expression" => handle_instanceof_value_ref(node, source, &mut symbols.calls), _ => {} } } @@ -2502,6 +2504,47 @@ fn handle_object_literal_shorthand_value_ref(node: &Node, source: &[u8], calls: }); } +/// Collect a dynamic value-ref `Call` for the right-hand operand of an +/// `instanceof` binary expression when it's a bare identifier — e.g. +/// `err instanceof CodegraphError` (issue #1784). `instanceof` reads its +/// right operand as a value (a prototype-chain check), never calls it, so +/// this is the same "referenced as a value, not a call site" shape as the +/// object-literal (#1771) and Lua builtin-reassignment (#1776) sites — +/// reused rather than given its own `dynamic_kind` (see ADR-002). +/// +/// Restricted to plain `identifier` right operands: `a instanceof B.C` +/// (`member_expression`) and `a instanceof (foo())` (parenthesized/call +/// expressions) are left unresolved rather than guessing — same +/// "restrict to the simplest syntactic shape" precedent as #1771. +/// +/// Unlike the function/method-only value-ref sites, `instanceof`'s operand +/// is always a class/constructor — the resolver-side kind filter in +/// `build_edges.rs` accepts `class`-kind targets in addition to +/// function/method for this reason. +/// +/// Mirrors `collectInstanceofValueRefCall` in `src/extractors/javascript.ts`. +fn handle_instanceof_value_ref(node: &Node, source: &[u8], calls: &mut Vec) { + let Some(operator_n) = node.child_by_field_name("operator") else { return }; + if node_text(&operator_n, source) != "instanceof" { + return; + } + let Some(right_n) = node.child_by_field_name("right") else { return }; + if right_n.kind() != "identifier" { + return; + } + let text = node_text(&right_n, source); + if JS_BUILTIN_GLOBALS.contains(&text) { + return; + } + calls.push(Call { + name: text.to_string(), + line: start_line(&right_n), + dynamic: Some(true), + dynamic_kind: Some("value-ref".to_string()), + ..Default::default() + }); +} + /// Extract definitions from destructured object bindings: `const { handleToken, /// checkPermissions } = initAuth(...)` creates definitions for `handleToken` /// and `checkPermissions`, kind `constant` — matching the convention for plain @@ -4423,6 +4466,69 @@ mod tests { assert!(s.calls.iter().all(|c| c.dynamic_kind.as_deref() != Some("value-ref"))); } + // ── #1784: instanceof value-ref extraction ────────────────────────────── + + #[test] + fn extracts_value_ref_call_for_instanceof_class_name() { + let s = parse_js( + "function handle(err) { if (err instanceof CodegraphError) { report(err); } }", + ); + let value_refs: Vec<_> = s + .calls + .iter() + .filter(|c| c.dynamic_kind.as_deref() == Some("value-ref")) + .collect(); + assert!(value_refs.iter().any(|c| c.name == "CodegraphError")); + assert!(value_refs.iter().all(|c| c.dynamic == Some(true))); + } + + #[test] + fn extracts_value_ref_call_for_instanceof_as_expression_value() { + let s = parse_js("const isConfig = (err) => err instanceof ConfigError;"); + let value_refs: Vec<_> = s + .calls + .iter() + .filter(|c| c.dynamic_kind.as_deref() == Some("value-ref")) + .collect(); + assert!(value_refs.iter().any(|c| c.name == "ConfigError")); + } + + #[test] + fn no_value_ref_call_for_instanceof_member_expression_operand() { + let s = parse_js("const check = (a) => a instanceof ns.SomeClass;"); + assert!( + s.calls + .iter() + .all(|c| !(c.dynamic_kind.as_deref() == Some("value-ref") && c.name == "SomeClass")) + ); + } + + #[test] + fn no_value_ref_call_for_instanceof_call_expression_operand() { + let s = parse_js("const check = (a) => a instanceof getClass();"); + assert!(s.calls.iter().all(|c| c.dynamic_kind.as_deref() != Some("value-ref"))); + } + + #[test] + fn no_value_ref_call_for_instanceof_builtin_globals() { + let s = parse_js( + "function isBuiltin(x) { return x instanceof Error || x instanceof Array || x instanceof Map; }", + ); + assert!(s.calls.iter().all(|c| c.dynamic_kind.as_deref() != Some("value-ref"))); + } + + #[test] + fn no_value_ref_call_for_in_operator() { + let s = parse_js("const has = (obj) => 'key' in obj;"); + assert!(s.calls.iter().all(|c| c.dynamic_kind.as_deref() != Some("value-ref"))); + } + + #[test] + fn no_value_ref_call_for_other_binary_operators() { + let s = parse_js("const sum = (a, b) => a + b === Total;"); + assert!(s.calls.iter().all(|c| c.dynamic_kind.as_deref() != Some("value-ref"))); + } + #[test] fn no_duplicate_call_for_call_expression_arg() { let s = parse_js("router.use(checkPermissions(['admin']));"); diff --git a/docs/architecture/decisions/002-dynamic-call-resolution.md b/docs/architecture/decisions/002-dynamic-call-resolution.md index c017742e5..b2ed23ccb 100644 --- a/docs/architecture/decisions/002-dynamic-call-resolution.md +++ b/docs/architecture/decisions/002-dynamic-call-resolution.md @@ -79,31 +79,41 @@ export type DynamicKind = | 'unresolved-dynamic' // detected dynamic call we cannot resolve | 'value-ref'; // bare identifier used as a value reference, not a call site — // object-literal property value (dispatch tables, e.g. - // `{ resolve: someFn }`, #1771) or assignment to a Lua + // `{ resolve: someFn }`, #1771), assignment to a Lua // global/builtin identifier (e.g. `require = tracedRequire`, - // #1776) — resolvable against function/method-kind targets - // only + // #1776), or the right operand of an `instanceof` check + // (e.g. `err instanceof CodegraphError`, #1784) — + // resolvable against function/method-kind targets, plus + // class-kind for the instanceof site ``` `dynamic?: boolean` is kept to avoid churning every `call.dynamic ? 1 : 0` site. `value-ref` is Track A (resolvable) but deliberately **not** added to the flag-only -sink-edge set: when the identifier doesn't resolve to a function/method (e.g. a +sink-edge set: when the identifier doesn't resolve to a function/method/class (e.g. a plain data reference like `{ name: SOME_CONSTANT }`), that's the common case, not an undecidable dynamic call site — so it's silently dropped rather than flagged, unlike `eval`/`computed-key`/`unresolved-dynamic`. `value-ref` is deliberately syntax-position-agnostic: any bare identifier that -names a known function/method and appears somewhere other than a call site -qualifies, regardless of which language or grammar shape produced it. -Object-literal property values (#1771) and Lua assignment to a -global/builtin identifier (#1776, `require = tracedRequire` — a builtin name -isn't a locally-scoped variable that alias/points-to resolution could ever -observe, so this is the narrow, language-specific case where a plain -reference edge is the correct substitute for real alias tracking) are two -independent extraction sites feeding the same resolution/filtering logic -downstream; new languages/positions can add a third without touching -`build-edges.ts` / `incremental.ts` / `build_edges.rs` again. +names a known function/method/class and appears somewhere other than a call +site qualifies, regardless of which language or grammar shape produced it. +Object-literal property values (#1771), Lua assignment to a global/builtin +identifier (#1776, `require = tracedRequire` — a builtin name isn't a +locally-scoped variable that alias/points-to resolution could ever observe, +so this is the narrow, language-specific case where a plain reference edge +is the correct substitute for real alias tracking), and the right operand of +an `instanceof` check (#1784, `err instanceof CodegraphError` — `instanceof` +evaluates its right operand as a value, never calls it) are three independent +extraction sites feeding the same resolution/filtering logic downstream. The +`instanceof` site is the first to resolve against `class`-kind targets (the +other two are function/method only, since `instanceof`'s operand is always a +class/constructor) — the resolver-side filter is keyed on `dynamicKind`, not +on which site produced the call, so this is a per-kind allowed-target-kind +set rather than a per-site one. New languages/positions can add a fourth +without touching `build-edges.ts` / `incremental.ts` / `build_edges.rs` +again, beyond widening the allowed-target-kind set if the new site's operand +isn't a function/method/class. ### Sink edges reuse `kind='calls'`, not a new edge kind diff --git a/src/domain/graph/builder/incremental.ts b/src/domain/graph/builder/incremental.ts index 6c5b1e7cc..416414663 100644 --- a/src/domain/graph/builder/incremental.ts +++ b/src/domain/graph/builder/incremental.ts @@ -768,11 +768,14 @@ function buildCallEdges( initialTargets, ); - // #1771: object-literal property-value references resolve against - // function/method-kind targets only — mirrors the same filter in - // resolveFallbackTargets (stages/build-edges.ts, full-build path). + // #1771/#1784: value-ref references resolve against function/method/ + // class-kind targets only (class included for `instanceof ClassName`, + // #1784) — mirrors the same filter in resolveFallbackTargets + // (stages/build-edges.ts, full-build path). if (call.dynamicKind === 'value-ref') { - targets = targets.filter((t) => t.kind === 'function' || t.kind === 'method'); + targets = targets.filter( + (t) => t.kind === 'function' || t.kind === 'method' || t.kind === 'class', + ); } edgesAdded += emitIncrementalCallEdges( diff --git a/src/domain/graph/builder/stages/build-edges.ts b/src/domain/graph/builder/stages/build-edges.ts index 9144f0611..7e81e3c03 100644 --- a/src/domain/graph/builder/stages/build-edges.ts +++ b/src/domain/graph/builder/stages/build-edges.ts @@ -1380,19 +1380,24 @@ function resolveFallbackTargets( if (qualified.length > 0) targets = qualified; } - // #1771: object-literal property-value references (`{ resolve: someFn }`) - // resolve against function/method-kind targets only — a bare identifier - // there is as likely to be a plain data reference (`{ name: SOME_CONSTANT }`) - // as a function, so drop any non-callable match rather than fabricating a - // "calls" edge to a constant/class/etc. Applied once here, after every - // fallback tier above, so it covers whichever tier produced the match. + // #1771/#1784: value-ref references (object-literal property values, + // Lua builtin reassignment, `instanceof ClassName`) resolve against + // function/method/class-kind targets only. A bare identifier in one of + // these positions is as likely to be a plain data reference + // (`{ name: SOME_CONSTANT }`) as a real function/class, so drop any + // other-kind match rather than fabricating a "calls" edge to a constant. + // `class` is included alongside function/method because `instanceof`'s + // right operand is always a class/constructor (#1784) — unlike the + // original #1771 object-literal case, which is function/method only. + // Applied once here, after every fallback tier above, so it covers + // whichever tier produced the match. if (call.dynamicKind === 'value-ref') { // `targets` is typed without `kind` when it flows straight through from // resolveCallTargets (call-resolver.ts's declared return type omits it), // but every underlying CallNodeLookup method actually populates it — the // same gap the preQualifiedTargets cast above already works around. targets = (targets as ReadonlyArray<{ id: number; file: string; kind?: string }>).filter( - (t) => t.kind === 'function' || t.kind === 'method', + (t) => t.kind === 'function' || t.kind === 'method' || t.kind === 'class', ); } diff --git a/src/extractors/javascript.ts b/src/extractors/javascript.ts index a56e0904c..cc23d2872 100644 --- a/src/extractors/javascript.ts +++ b/src/extractors/javascript.ts @@ -3185,6 +3185,37 @@ function collectObjectLiteralValueRefCall(pairNode: TreeSitterNode, calls: Call[ }); } +/** + * Collect a dynamic value-ref `Call` for the right-hand operand of an + * `instanceof` binary expression when it's a bare identifier — e.g. + * `err instanceof CodegraphError` (issue #1784). `instanceof` reads its + * right operand as a value (a prototype-chain check), never calls it, so + * this is the same "referenced as a value, not a call site" shape as the + * object-literal (#1771) and Lua builtin-reassignment (#1776) sites — reused + * rather than given its own DynamicKind (see ADR-002). + * + * Restricted to plain `identifier` right operands: `a instanceof B.C` + * (`member_expression`) and `a instanceof (foo())` (parenthesized/call + * expressions) are left unresolved rather than guessing — same + * "restrict to the simplest syntactic shape" precedent as #1771. + * + * Unlike the function/method-only value-ref sites, `instanceof`'s operand is + * always a class/constructor — the resolver-side kind filter + * (`resolveFallbackTargets` / `build_edges.rs`) accepts `class`-kind targets + * in addition to function/method for this reason. + */ +function collectInstanceofValueRefCall(binaryNode: TreeSitterNode, calls: Call[]): void { + if (binaryNode.childForFieldName('operator')?.text !== 'instanceof') return; + const rightNode = binaryNode.childForFieldName('right'); + if (rightNode?.type !== 'identifier' || BUILTIN_GLOBALS.has(rightNode.text)) return; + calls.push({ + name: rightNode.text, + line: nodeStartLine(rightNode), + dynamic: true, + dynamicKind: 'value-ref', + }); +} + function extractReceiverName(objNode: TreeSitterNode | null): string | undefined { if (!objNode) return undefined; const t = objNode.type; @@ -3740,11 +3771,11 @@ function collectThisCallAndBindings( * `valueRefCalls` is REQUIRED (unlike `calls`) — both paths route * object-literal value-ref extraction through this single field, since * neither `walkJavaScriptNode` (walk path) nor the compiled query patterns - * (query path) visit `pair`/`shorthand_property_identifier` nodes on their - * own (#1771). Both callers pass their own `calls` array here; it's a - * separate field from the optional `calls` above purely so this collector - * isn't accidentally gated off by the walk path's "don't double-collect - * call_expression" omission. + * (query path) visit `pair`/`shorthand_property_identifier`/`binary_expression` + * nodes on their own (#1771, #1784). Both callers pass their own `calls` + * array here; it's a separate field from the optional `calls` above purely + * so this collector isn't accidentally gated off by the walk path's "don't + * double-collect call_expression" omission. */ interface CollectorWalkTargets { definitions: Definition[]; @@ -3849,6 +3880,10 @@ function runCollectorWalk(rootNode: TreeSitterNode, targets: CollectorWalkTarget }); } break; + case 'binary_expression': + // #1784: `instanceof ClassName` checks, e.g. `err instanceof CodegraphError`. + collectInstanceofValueRefCall(node, targets.valueRefCalls); + break; } for (let i = 0; i < node.childCount; i++) { walk(node.child(i)!, depth + 1, childInDynamicImport); diff --git a/src/types.ts b/src/types.ts index 7b90058ee..19ff09970 100644 --- a/src/types.ts +++ b/src/types.ts @@ -488,7 +488,7 @@ export type DynamicKind = | 'reflection' // .call/.apply/.bind / Reflect.* / callable-ref — resolved when target is in codebase; sink edge emitted if unresolved | 'eval' // eval() / new Function() — undecidable; always flagged | 'unresolved-dynamic' // any other detected dynamic pattern; flagged - | 'value-ref'; // bare identifier used as a value reference rather than a call site — object-literal property value (dispatch-table pattern, e.g. `{ resolve: someFn }`, #1771) or assignment to a Lua global/builtin identifier (e.g. `require = tracedRequire`, #1776) — resolved only against function/method-kind targets; unresolved (e.g. plain data references) are dropped silently, NOT flagged + | 'value-ref'; // bare identifier used as a value reference rather than a call site — object-literal property value (dispatch-table pattern, e.g. `{ resolve: someFn }`, #1771), assignment to a Lua global/builtin identifier (e.g. `require = tracedRequire`, #1776), or the right operand of an `instanceof` check (e.g. `err instanceof CodegraphError`, #1784) — resolved only against function/method/class-kind targets (class only relevant for the instanceof site); unresolved (e.g. plain data references) are dropped silently, NOT flagged /** A function/method call detected by an extractor. */ export interface Call { diff --git a/tests/integration/issue-1784-instanceof-consumer-credit.test.ts b/tests/integration/issue-1784-instanceof-consumer-credit.test.ts new file mode 100644 index 000000000..63148b1b5 --- /dev/null +++ b/tests/integration/issue-1784-instanceof-consumer-credit.test.ts @@ -0,0 +1,278 @@ +/** + * Integration test for #1784: `codegraph exports` did not credit `instanceof + * ClassName` checks (or other bare-reference, no-call-site usages) as + * consumers — a base/parent class whose primary cross-file use is + * `instanceof` narrowing falsely presented as dead/unused. + * + * Repro (this repo's own source): `src/shared/errors.ts`'s `CodegraphError` + * showed `consumerCount: 0` via `codegraph exports src/shared/errors.ts + * --json` despite two real production usages (`src/cli.ts`, + * `src/mcp/server.ts`) that only ever reference it via + * `err instanceof CodegraphError` — never `new CodegraphError(...)`. + * + * Root cause: no edge at all was created for the right-hand operand of an + * `instanceof` binary expression — the same "no edge for a bare-identifier + * value reference" gap as #1771 (object-literal property values) and #1776 + * (Lua builtin reassignment), just at a different syntactic position. + * + * Fix: both engines now emit a dynamic `calls` edge (dynamicKind/dynamic_kind + * = 'value-ref', reusing the #1771/#1776 taxonomy entry per ADR-002) from the + * enclosing scope to the referenced symbol when `instanceof`'s right operand + * is a bare identifier. Unlike the function/method-only #1771/#1776 sites, + * the resolver-side kind filter for `instanceof` additionally accepts + * `class`-kind targets (and still accepts `function`-kind, covering the + * pre-ES6 "constructor function" `instanceof` idiom) since `instanceof`'s + * operand is never a plain data reference in valid JS. + */ + +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'; +import { exportsData } from '../../src/domain/queries.js'; +import { isNativeAvailable } from '../../src/infrastructure/native.js'; + +const DEAD_ROLES = new Set(['dead-unresolved', 'dead-leaf', 'dead-entry', 'dead-ffi']); + +const TARGET_FILE = 'shared/errors.ts'; + +// `BaseError` mirrors this repo's own `CodegraphError`: a class consumed +// ONLY via `instanceof`, never `new`'d, in another file. `LegacyCtor` +// exercises the pre-ES6 "constructor function" `instanceof` idiom +// (`x instanceof SomeFunction` is valid JS, checking the prototype chain) — +// the resolver-side kind filter must keep accepting function-kind targets, +// not just add class. `ERROR_LABEL` is a plain data constant used as +// `instanceof`'s right operand (invalid at runtime, but syntactically legal) +// to prove the resolver does not fabricate an edge to a non-callable target, +// mirroring #1771's "does not fabricate a value-ref edge to a plain data +// constant" guard. +const FIXTURE = { + [TARGET_FILE]: ` +export class BaseError extends Error { + code: string; + constructor(message: string, code: string) { + super(message); + this.code = code; + } +} + +export function LegacyCtor(this: { legacy: boolean }) { + this.legacy = true; +} + +export const ERROR_LABEL = 'base-error'; +`, + 'cli/handler.ts': ` +import { BaseError, ERROR_LABEL, LegacyCtor } from '../shared/errors.js'; + +export function handle(err: unknown): string { + if (err instanceof BaseError) { + return err.code; + } + return String(err); +} + +export function isLegacy(x: unknown): boolean { + return x instanceof LegacyCtor; +} + +export function describe(err: unknown): string { + return err instanceof ERROR_LABEL ? 'label' : 'other'; +} +`, +}; + +function readNodesWithRoles(dbPath: string) { + const db = new Database(dbPath, { readonly: true }); + try { + return db.prepare('SELECT name, kind, role FROM nodes ORDER BY name').all() as Array<{ + name: string; + kind: string; + role: string | null; + }>; + } finally { + db.close(); + } +} + +function getCallEdges(dbPath: string) { + const db = new Database(dbPath, { readonly: true }); + try { + return db + .prepare( + `SELECT n1.name AS src, n2.name AS tgt, n2.kind AS tgt_kind + 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; tgt_kind: string }>; + } finally { + db.close(); + } +} + +function countCallEdgesTo(dbPath: string, targetName: string): number { + const db = new Database(dbPath, { readonly: true }); + try { + const row = db + .prepare( + `SELECT COUNT(*) AS cnt + FROM edges e + JOIN nodes t ON e.target_id = t.id + WHERE e.kind = 'calls' AND t.name = ?`, + ) + .get(targetName) as { cnt: number }; + return row.cnt; + } finally { + db.close(); + } +} + +function writeFixture(rootDir: string) { + for (const [rel, content] of Object.entries(FIXTURE)) { + const abs = path.join(rootDir, rel); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, content); + } +} + +describe('instanceof ClassName consumer crediting (#1784) — WASM', () => { + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-1784-wasm-')); + writeFixture(tmpDir); + await buildGraph(tmpDir, { engine: 'wasm', incremental: false, skipRegistry: true }); + }); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('creates a calls edge from the instanceof check to the class', () => { + const dbPath = path.join(tmpDir, '.codegraph', 'graph.db'); + const edges = getCallEdges(dbPath); + expect( + edges.some((e) => e.src === 'handle' && e.tgt === 'BaseError' && e.tgt_kind === 'class'), + `Expected handle -> BaseError (class); got: ${JSON.stringify(edges)}`, + ).toBe(true); + }); + + it('creates a calls edge from instanceof against a plain constructor function', () => { + const dbPath = path.join(tmpDir, '.codegraph', 'graph.db'); + const edges = getCallEdges(dbPath); + expect( + edges.some( + (e) => e.src === 'isLegacy' && e.tgt === 'LegacyCtor' && e.tgt_kind === 'function', + ), + `Expected isLegacy -> LegacyCtor (function); got: ${JSON.stringify(edges)}`, + ).toBe(true); + }); + + it('does not fabricate an edge from instanceof against a plain data constant', () => { + const dbPath = path.join(tmpDir, '.codegraph', 'graph.db'); + const edges = getCallEdges(dbPath); + expect(edges.some((e) => e.tgt === 'ERROR_LABEL')).toBe(false); + expect(countCallEdgesTo(dbPath, 'ERROR_LABEL')).toBe(0); + }); + + it('codegraph exports credits BaseError with a consumer from handle', () => { + const dbPath = path.join(tmpDir, '.codegraph', 'graph.db'); + const data = exportsData(TARGET_FILE, dbPath); + + const baseError = data.results.find((r: { name: string }) => r.name === 'BaseError'); + expect(baseError).toBeDefined(); + expect(baseError.consumerCount).toBeGreaterThanOrEqual(1); + expect(baseError.consumers.map((c: { name: string }) => c.name)).toContain('handle'); + }); + + it('does not classify BaseError or LegacyCtor as dead', () => { + const dbPath = path.join(tmpDir, '.codegraph', 'graph.db'); + const nodes = readNodesWithRoles(dbPath); + for (const [name, kind] of [ + ['BaseError', 'class'], + ['LegacyCtor', 'function'], + ] as const) { + const node = nodes.find((n) => n.name === name && n.kind === kind); + expect(node, `${name} node not found`).toBeDefined(); + expect( + DEAD_ROLES.has(node!.role ?? ''), + `${name} was classified as ${node!.role} — expected a non-dead role now that a real edge exists`, + ).toBe(false); + } + }); +}); + +// ── Native engine parity ──────────────────────────────────────────────────── +// Skipped when the native addon is not installed. + +describe.skipIf(!isNativeAvailable())( + 'instanceof ClassName consumer crediting (#1784) — native', + () => { + let nativeTmpDir: string; + + beforeAll(async () => { + nativeTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-1784-native-')); + writeFixture(nativeTmpDir); + await buildGraph(nativeTmpDir, { engine: 'native', incremental: false, skipRegistry: true }); + }, 60_000); + + afterAll(() => { + fs.rmSync(nativeTmpDir, { recursive: true, force: true }); + }); + + it('creates a calls edge from the instanceof check to the class', () => { + const dbPath = path.join(nativeTmpDir, '.codegraph', 'graph.db'); + const edges = getCallEdges(dbPath); + expect( + edges.some((e) => e.src === 'handle' && e.tgt === 'BaseError' && e.tgt_kind === 'class'), + `Expected native handle -> BaseError (class); got: ${JSON.stringify(edges)}`, + ).toBe(true); + }); + + it('creates a calls edge from instanceof against a plain constructor function', () => { + const dbPath = path.join(nativeTmpDir, '.codegraph', 'graph.db'); + const edges = getCallEdges(dbPath); + expect( + edges.some( + (e) => e.src === 'isLegacy' && e.tgt === 'LegacyCtor' && e.tgt_kind === 'function', + ), + `Expected native isLegacy -> LegacyCtor (function); got: ${JSON.stringify(edges)}`, + ).toBe(true); + }); + + it('does not fabricate an edge from instanceof against a plain data constant', () => { + const dbPath = path.join(nativeTmpDir, '.codegraph', 'graph.db'); + const edges = getCallEdges(dbPath); + expect(edges.some((e) => e.tgt === 'ERROR_LABEL')).toBe(false); + expect(countCallEdgesTo(dbPath, 'ERROR_LABEL')).toBe(0); + }); + + it('codegraph exports credits BaseError with a consumer from handle', () => { + const dbPath = path.join(nativeTmpDir, '.codegraph', 'graph.db'); + const data = exportsData(TARGET_FILE, dbPath); + + const baseError = data.results.find((r: { name: string }) => r.name === 'BaseError'); + expect(baseError).toBeDefined(); + expect(baseError.consumerCount).toBeGreaterThanOrEqual(1); + expect(baseError.consumers.map((c: { name: string }) => c.name)).toContain('handle'); + }); + + it('does not classify BaseError or LegacyCtor as dead', () => { + const dbPath = path.join(nativeTmpDir, '.codegraph', 'graph.db'); + const nodes = readNodesWithRoles(dbPath); + for (const [name, kind] of [ + ['BaseError', 'class'], + ['LegacyCtor', 'function'], + ] as const) { + const node = nodes.find((n) => n.name === name && n.kind === kind); + expect(node, `${name} node not found (native)`).toBeDefined(); + expect(DEAD_ROLES.has(node!.role ?? '')).toBe(false); + } + }); + }, +); diff --git a/tests/parsers/javascript.test.ts b/tests/parsers/javascript.test.ts index 127c860d0..b41ad9d79 100644 --- a/tests/parsers/javascript.test.ts +++ b/tests/parsers/javascript.test.ts @@ -1271,6 +1271,61 @@ describe('JavaScript parser', () => { }); }); + describe('instanceof value-ref extraction (#1784)', () => { + it('extracts a value-ref call for `instanceof ClassName`', () => { + const symbols = parseJS(` + function handle(err) { + if (err instanceof CodegraphError) { report(err); } + } + `); + expect(symbols.calls).toContainEqual( + expect.objectContaining({ + name: 'CodegraphError', + dynamic: true, + dynamicKind: 'value-ref', + }), + ); + }); + + it('extracts a value-ref call for `instanceof` used as an expression value', () => { + const symbols = parseJS(`const isConfig = (err) => err instanceof ConfigError;`); + expect(symbols.calls).toContainEqual( + expect.objectContaining({ name: 'ConfigError', dynamic: true, dynamicKind: 'value-ref' }), + ); + }); + + it('does not extract a value-ref call for a member-expression right operand', () => { + const symbols = parseJS(`const check = (a) => a instanceof ns.SomeClass;`); + expect(symbols.calls).not.toContainEqual( + expect.objectContaining({ dynamicKind: 'value-ref', name: 'SomeClass' }), + ); + }); + + it('does not extract a value-ref call for a call-expression right operand', () => { + const symbols = parseJS(`const check = (a) => a instanceof getClass();`); + expect(symbols.calls.filter((c) => c.dynamicKind === 'value-ref')).toHaveLength(0); + }); + + it('excludes builtin globals from instanceof value-ref extraction', () => { + const symbols = parseJS(` + function isBuiltin(x) { + return x instanceof Error || x instanceof Array || x instanceof Map; + } + `); + expect(symbols.calls.filter((c) => c.dynamicKind === 'value-ref')).toHaveLength(0); + }); + + it('does not extract a value-ref call for the unrelated `in` operator', () => { + const symbols = parseJS(`const has = (obj) => 'key' in obj;`); + expect(symbols.calls.filter((c) => c.dynamicKind === 'value-ref')).toHaveLength(0); + }); + + it('does not extract a value-ref call for other binary operators', () => { + const symbols = parseJS(`const sum = (a, b) => a + b === Total;`); + expect(symbols.calls.filter((c) => c.dynamicKind === 'value-ref')).toHaveLength(0); + }); + }); + describe('Phase 8.3f: object-destructuring rest parameter binding extraction', () => { function parseJS(code) { const parser = parsers.get('javascript'); From a79a8a1f3ef90340e339ba3970625648e6e07859 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Mon, 6 Jul 2026 16:33:56 -0600 Subject: [PATCH 58/67] fix: update automated-review workflow to use current claude-code-action inputs anthropics/claude-code-action v1 (pinned at 558b1d6cab4085c7753fe402c10bef0fbb92ac7a) no longer accepts `model` or `direct_prompt` as inputs, so both jobs' overrides were being silently ignored on every run (warning annotations only, no CI failure). - `direct_prompt` -> `prompt` (documented drop-in rename) - `model` -> `claude_args: --model ` (model selection moved to the Claude CLI arguments; there is no dedicated top-level input anymore) Fixes #1802 --- .github/workflows/claude.yml | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 12c59d4c4..25a3c6355 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -52,8 +52,11 @@ jobs: uses: anthropics/claude-code-action@558b1d6cab4085c7753fe402c10bef0fbb92ac7a with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - model: claude-sonnet-4-6 - direct_prompt: | + # claude-code-action v1 dropped the `model` input; model selection now goes + # through `claude_args` (see anthropics/claude-code-action migration guide). + claude_args: | + --model claude-sonnet-4-6 + prompt: | ## Review this pull request You are reviewing a PR for **codegraph** — a local code dependency graph CLI that parses @@ -211,6 +214,9 @@ jobs: uses: anthropics/claude-code-action@558b1d6cab4085c7753fe402c10bef0fbb92ac7a with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - model: claude-sonnet-4-6 + # claude-code-action v1 dropped the `model` input; model selection now goes + # through `claude_args` (see anthropics/claude-code-action migration guide). + claude_args: | + --model claude-sonnet-4-6 additional_permissions: | actions: read From d84f6b5aaf82508e09a8c99ab1f51558585ff9fc Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Mon, 6 Jul 2026 16:51:50 -0600 Subject: [PATCH 59/67] refactor: widen outputResult signature, remove redundant casts at call sites outputResult took data: Record, whose any-typed index signature already made TypeScript skip the "index signature is missing" check for plain interfaces -- so callers never actually needed the defensive `as unknown as Record` cast; they just carried it forward as noise. Widen the parameter to `object` (outputResult only serializes/ formats data and never returns or re-exposes its shape, so a generic isn't needed) and move the one necessary cast to Record inside outputResult itself, where it's actually justified by printNdjson/printCsv/ printAutoTable needing key access for NDJSON field plucking and CSV/table flattening. Removes the cast from all 22 current call sites across audit.ts, triage.ts, owners.ts, and queries-cli/{impact,path,exports, inspect,overview}.ts. docs check acknowledged: internal type-signature cleanup only, no new features/languages/architecture/CLI surface -- README.md, CLAUDE.md, and ROADMAP.md do not need updates. Fixes #1803 Impact: 23 functions changed, 79 affected --- src/presentation/audit.ts | 2 +- src/presentation/owners.ts | 2 +- src/presentation/queries-cli/exports.ts | 2 +- src/presentation/queries-cli/impact.ts | 10 +++++----- src/presentation/queries-cli/inspect.ts | 14 +++++++------- src/presentation/queries-cli/overview.ts | 12 ++++-------- src/presentation/queries-cli/path.ts | 4 ++-- src/presentation/result-formatter.ts | 17 +++++++++-------- src/presentation/triage.ts | 2 +- 9 files changed, 31 insertions(+), 34 deletions(-) diff --git a/src/presentation/audit.ts b/src/presentation/audit.ts index add3a7385..423be87a8 100644 --- a/src/presentation/audit.ts +++ b/src/presentation/audit.ts @@ -94,7 +94,7 @@ export function audit( ): void { const data: AuditResult = auditData(target, customDbPath, opts); - if (outputResult(data as unknown as Record, null, opts)) return; + if (outputResult(data, null, opts)) return; if (data.functions.length === 0) { console.log(`No ${data.kind === 'file' ? 'file' : 'function/symbol'} matching "${target}"`); diff --git a/src/presentation/owners.ts b/src/presentation/owners.ts index 04a8c1506..4ea0d09bc 100644 --- a/src/presentation/owners.ts +++ b/src/presentation/owners.ts @@ -43,7 +43,7 @@ interface OwnersResult { export function owners(customDbPath: string | undefined, opts: OwnersOpts = {}): void { const data = ownersData(customDbPath, opts as any) as OwnersResult; - if (outputResult(data as unknown as Record, null, opts)) return; + if (outputResult(data, null, opts)) return; if (!data.codeownersFile) { console.log('No CODEOWNERS file found.'); diff --git a/src/presentation/queries-cli/exports.ts b/src/presentation/queries-cli/exports.ts index a30f51031..44c6b7831 100644 --- a/src/presentation/queries-cli/exports.ts +++ b/src/presentation/queries-cli/exports.ts @@ -123,7 +123,7 @@ function printReexportedSection(data: ExportsDataResult, opts: ExportsOpts): voi export function fileExports(file: string, customDbPath: string, opts: ExportsOpts = {}): void { const data = exportsData(file, customDbPath, opts) as ExportsDataResult; - if (outputResult(data as unknown as Record, 'results', opts)) return; + if (outputResult(data, 'results', opts)) return; const hasReexported = data.reexportedSymbols && data.reexportedSymbols.length > 0; diff --git a/src/presentation/queries-cli/impact.ts b/src/presentation/queries-cli/impact.ts index 16cc0fe7a..b664cc1af 100644 --- a/src/presentation/queries-cli/impact.ts +++ b/src/presentation/queries-cli/impact.ts @@ -126,7 +126,7 @@ interface OutputOpts { export function fileDeps(file: string, customDbPath: string, opts: OutputOpts = {}): void { const data = fileDepsData(file, customDbPath, opts) as unknown as FileDepsData; - if (outputResult(data as unknown as Record, 'results', opts)) return; + if (outputResult(data, 'results', opts)) return; if (data.results.length === 0) { console.log(`No file matching "${file}" in graph`); @@ -181,7 +181,7 @@ function printFnDepsTransitive(transitiveCallers: Record): export function fnDeps(name: string, customDbPath: string, opts: OutputOpts = {}): void { const data = fnDepsData(name, customDbPath, opts) as unknown as FnDepsData; - if (outputResult(data as unknown as Record, 'results', opts)) return; + if (outputResult(data, 'results', opts)) return; if (data.results.length === 0) { console.log(`No function/method/class matching "${name}"`); @@ -202,7 +202,7 @@ export function fnDeps(name: string, customDbPath: string, opts: OutputOpts = {} export function impactAnalysis(file: string, customDbPath: string, opts: OutputOpts = {}): void { const data = impactAnalysisData(file, customDbPath, opts) as unknown as ImpactData; - if (outputResult(data as unknown as Record, 'sources', opts)) return; + if (outputResult(data, 'sources', opts)) return; if (data.sources.length === 0) { console.log(`No file matching "${file}" in graph`); @@ -231,7 +231,7 @@ export function impactAnalysis(file: string, customDbPath: string, opts: OutputO export function fnImpact(name: string, customDbPath: string, opts: OutputOpts = {}): void { const data = fnImpactData(name, customDbPath, opts) as unknown as FnImpactData; - if (outputResult(data as unknown as Record, 'results', opts)) return; + if (outputResult(data, 'results', opts)) return; if (data.results.length === 0) { console.log(`No function/method/class matching "${name}"`); @@ -302,7 +302,7 @@ export function diffImpact(customDbPath: string, opts: OutputOpts = {}): void { } const data = diffImpactData(customDbPath, opts) as unknown as DiffImpactData; if (opts.format === 'json') opts = { ...opts, json: true }; - if (outputResult(data as unknown as Record, 'affectedFunctions', opts)) return; + if (outputResult(data, 'affectedFunctions', opts)) return; if (data.error) { console.log(data.error); diff --git a/src/presentation/queries-cli/inspect.ts b/src/presentation/queries-cli/inspect.ts index 39fe7c368..22b52fba5 100644 --- a/src/presentation/queries-cli/inspect.ts +++ b/src/presentation/queries-cli/inspect.ts @@ -222,7 +222,7 @@ function renderWhereFileResults(results: WhereFileResult[]): void { export function where(target: string, customDbPath: string, opts: OutputOpts = {}): void { const data = whereData(target, customDbPath, opts as Record) as WhereData; - if (outputResult(data as unknown as Record, 'results', opts)) return; + if (outputResult(data, 'results', opts)) return; if (data.results.length === 0) { console.log( @@ -247,7 +247,7 @@ export function queryName(name: string, customDbPath: string, opts: OutputOpts = limit: opts.limit, offset: opts.offset, }) as QueryNameData; - if (outputResult(data as unknown as Record, 'results', opts)) return; + if (outputResult(data, 'results', opts)) return; if (data.results.length === 0) { console.log(`No results for "${name}"`); @@ -275,7 +275,7 @@ export function queryName(name: string, customDbPath: string, opts: OutputOpts = export function context(name: string, customDbPath: string, opts: OutputOpts = {}): void { const data = contextData(name, customDbPath, opts as Record) as ContextData; - if (outputResult(data as unknown as Record, 'results', opts)) return; + if (outputResult(data, 'results', opts)) return; if (data.results.length === 0) { console.log(`No function/method/class matching "${name}"`); @@ -289,7 +289,7 @@ export function context(name: string, customDbPath: string, opts: OutputOpts = { export function children(name: string, customDbPath: string, opts: OutputOpts = {}): void { const data = childrenData(name, customDbPath, opts as Record) as ChildrenData; - if (outputResult(data as unknown as Record, 'results', opts)) return; + if (outputResult(data, 'results', opts)) return; if (data.results.length === 0) { console.log(`No symbol matching "${name}"`); @@ -505,7 +505,7 @@ function renderFunctionExplain(r: FunctionExplainResult, indent = ''): void { export function explain(target: string, customDbPath: string, opts: OutputOpts = {}): void { const data = explainData(target, customDbPath, opts as Record) as ExplainData; - if (outputResult(data as unknown as Record, 'results', opts)) return; + if (outputResult(data, 'results', opts)) return; if (data.results.length === 0) { console.log(`No ${data.kind === 'file' ? 'file' : 'function/symbol'} matching "${target}"`); @@ -529,7 +529,7 @@ export function implementations(name: string, customDbPath: string, opts: Output customDbPath, opts as Record, ) as ImplementationsData; - if (outputResult(data as unknown as Record, 'results', opts)) return; + if (outputResult(data, 'results', opts)) return; if (data.results.length === 0) { console.log(`No symbol matching "${name}"`); @@ -556,7 +556,7 @@ export function interfaces(name: string, customDbPath: string, opts: OutputOpts customDbPath, opts as Record, ) as InterfacesData; - if (outputResult(data as unknown as Record, 'results', opts)) return; + if (outputResult(data, 'results', opts)) return; if (data.results.length === 0) { console.log(`No symbol matching "${name}"`); diff --git a/src/presentation/queries-cli/overview.ts b/src/presentation/queries-cli/overview.ts index 2b9889e8f..69a7287d9 100644 --- a/src/presentation/queries-cli/overview.ts +++ b/src/presentation/queries-cli/overview.ts @@ -262,7 +262,7 @@ export async function stats(customDbPath: string, opts: OutputOpts = {}): Promis debug(`stats: community detection failed (optional): ${toErrorMessage(e)}`); } - if (outputResult(data as unknown as Record, null, opts)) return; + if (outputResult(data, null, opts)) return; console.log('\n# Codegraph Stats\n'); printNodes(data); @@ -280,7 +280,7 @@ export async function stats(customDbPath: string, opts: OutputOpts = {}): Promis export function moduleMap(customDbPath: string, limit = 20, opts: OutputOpts = {}): void { const data = moduleMapData(customDbPath, limit, { noTests: opts.noTests }) as ModuleMapData; - if (outputResult(data as unknown as Record, 'topNodes', opts)) return; + if (outputResult(data, 'topNodes', opts)) return; console.log(`\nModule map (top ${limit} most-connected nodes):\n`); const dirs = new Map(); @@ -326,11 +326,7 @@ function printRoleGroup(role: string, symbols: RoleSymbol[]): void { export function dynamicCalls(customDbPath: string, opts: OutputOpts = {}): void { const rows = dynamicCallsData(customDbPath); if (opts.json || opts.ndjson) { - outputResult( - { dynamic_calls: rows } as unknown as Record, - 'dynamic_calls', - opts, - ); + outputResult({ dynamic_calls: rows }, 'dynamic_calls', opts); return; } if (rows.length === 0) { @@ -353,7 +349,7 @@ export function dynamicCalls(customDbPath: string, opts: OutputOpts = {}): void export function roles(customDbPath: string, opts: OutputOpts = {}): void { const data = rolesData(customDbPath, opts) as RolesData; - if (outputResult(data as unknown as Record, 'symbols', opts)) return; + if (outputResult(data, 'symbols', opts)) return; if (data.count === 0) { console.log('No classified symbols found. Run "codegraph build" first.'); diff --git a/src/presentation/queries-cli/path.ts b/src/presentation/queries-cli/path.ts index 307a9a788..78a56a41e 100644 --- a/src/presentation/queries-cli/path.ts +++ b/src/presentation/queries-cli/path.ts @@ -86,7 +86,7 @@ export function symbolPath( } const data = pathData(from, to, customDbPath, opts) as PathDataResult; - if (outputResult(data as unknown as Record, null, opts)) return; + if (outputResult(data, null, opts)) return; if (data.error) { console.log(data.error); @@ -160,7 +160,7 @@ function printFilePathSteps(data: FilePathDataResult): void { function filePath(from: string, to: string, customDbPath: string, opts: PathOpts = {}): void { const data = filePathData(from, to, customDbPath, opts) as FilePathDataResult; - if (outputResult(data as unknown as Record, null, opts)) return; + if (outputResult(data, null, opts)) return; if (data.error) { console.log(data.error); diff --git a/src/presentation/result-formatter.ts b/src/presentation/result-formatter.ts index 72a06380c..cadf11da4 100644 --- a/src/presentation/result-formatter.ts +++ b/src/presentation/result-formatter.ts @@ -132,17 +132,18 @@ export interface OutputOpts { display?: DisplayOpts; } -export function outputResult( - data: Record, - field: string | null, - opts: OutputOpts, -): boolean { +export function outputResult(data: object, field: string | null, opts: OutputOpts): boolean { + // The formatting helpers below need key/index access (NDJSON field pluck, CSV/table + // flattening). Callers only ever hand off a concrete result shape for serialization, + // so the cast lives here once instead of as a defensive `as unknown as Record<...>` + // at every call site. + const record = data as Record; if (opts.ndjson) { if (field === null) { // No field key — emit the whole object as a single NDJSON line console.log(JSON.stringify(data)); } else { - printNdjson(data, field); + printNdjson(record, field); } return true; } @@ -151,11 +152,11 @@ export function outputResult( return true; } if (opts.csv) { - return printCsv(data, field) !== false; + return printCsv(record, field) !== false; } if (opts.table) { const displayOpts = opts.display ?? (loadConfig() as { display: DisplayOpts }).display; - return printAutoTable(data, field, displayOpts) !== false; + return printAutoTable(record, field, displayOpts) !== false; } return false; } diff --git a/src/presentation/triage.ts b/src/presentation/triage.ts index 11353c09d..2b8b5e472 100644 --- a/src/presentation/triage.ts +++ b/src/presentation/triage.ts @@ -42,7 +42,7 @@ export function triage(customDbPath: string | undefined, opts: TriageOpts = {}): summary: TriageSummary; }; - if (outputResult(data as unknown as Record, 'items', opts)) return; + if (outputResult(data, 'items', opts)) return; if (data.items.length === 0) { if (data.summary.total === 0) { From e7a2255dca8d54c74439b094f9cbcc0ddfc22070 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Mon, 6 Jul 2026 18:30:35 -0600 Subject: [PATCH 60/67] fix: port Leiden algorithm to native Rust for cross-engine community-detection parity `codegraph communities`/`--drift` ran genuinely different algorithms depending on which engine was active: the native path ran classic Louvain (crates/codegraph-core/src/graph/algorithms/louvain.rs, undirected modularity optimization), while the JS/WASM fallback ran Leiden (src/graph/algorithms/leiden/*, hardened by #1734/#1755/#1770 earlier in this batch). Louvain and Leiden are related but distinct algorithms with different guarantees (Leiden avoids Louvain's disconnected-community defect), so results diverged purely based on whether the native addon loaded, independent of any change to the analyzed codebase. Ported Leiden to Rust (crates/codegraph-core/src/graph/algorithms/leiden.rs, ~950 lines) as a faithful line-by-line translation of the TS reference: graph adapter with undirected symmetrization (averaging reciprocal edge weights, not summing them -- a second, independent divergence the old Louvain implementation had), partition with incremental aggregate deltas, modularity quality/diff functions, greedy local-move phase, true Leiden refinement (singleton start, singleton guard, Boltzmann probabilistic selection), post-refinement disconnected-community splitting, and multi-level coarsening. Also ported a mulberry32 PRNG matching the TS reference's exact bit-pattern arithmetic (u32 wrapping ops), since the refinement phase's random draws must stay in lockstep with the JS engine bit-for-bit. Scope: covers exactly the option surface `louvainCommunities`/ `LouvainOptions` (src/graph/algorithms/louvain.ts) ever exercises -- undirected, modularity-only, "neighbors" candidate strategy, refine always on, uniform edge weight/node size. The TS reference's directed mode, CPM quality, alternate candidate strategies, allowNewCommunity, fixedNodes, and preserveLabels are not reachable from this binding and are not ported; see leiden.rs's module doc and follow-up issue #1936. Determinism (per #1734's exact failure mode): every place the TS reference relies on Map/Array *insertion order* for tie-breaking, this port uses an insertion-order-preserving Vec + HashMap-as-lookup-only pattern rather than a BTreeMap -- a BTreeMap would be deterministic but *sorted*, which does not match the JS engine's insertion order and would silently reintroduce cross-engine divergence. Retired the old classic-Louvain louvain.rs entirely (confirmed via repo-wide grep that its only consumer was the mod.rs re-export feeding this one napi binding) and renamed the native binding louvainCommunities -> leidenCommunities (types.ts's NativeAddon.leidenCommunities is marked optional so older published native packages that predate this rename correctly fall back to the JS engine instead of silently running the old, now-removed algorithm). Updated the misleading "native: Louvain, JS: Leiden" doc comments in louvain.ts, config.ts, and the communities CLI/MCP descriptions that documented the mismatch as intentional. Verification: - Before fix: this repo's own dependency graph via native vs JS differed in both community count and modularity at file level (322 vs 335 communities, Q=0.566 vs 0.536) and function level (5540 vs 6072 communities, Q=0.850 vs 0.655), confirming the bug was real. - First native Leiden port attempt still diverged from TS beyond level 0 of the multi-level pipeline; root-caused to the coarse-graph builder reusing raw first-seen-edge order instead of replicating the TS reference's two-stage process (building a synthetic ascending-id CodeGraph, then re-reading it through the undirected dedup generator, which reorders each community's adjacency list). Fixed by making build_coarse_graph replicate both stages exactly. - After fix: native and JS produce byte-identical assignments and modularity (including exact community-id labels, not just equivalent groupings) across 40 seed/resolution/knob combinations on this repo's own file- and function-level graphs, 72 combinations across 8 other fixture projects (multiple languages, this repo's own Rust crate at 6072/13062 nodes/edges), and a suite of hand-built/synthetic graphs specifically covering reciprocal edges, self-loops, and forced multi-level coarsening. - Determinism re-verified for the new implementation: 12 separate process invocations plus 2 full clean rebuilds (3 independently compiled binaries total) all produce byte-identical (SHA-256-matching) output on this repo's own graph. - npm test: 223 files, 3665 passed, 0 failed, 30 skipped, 2 todo. - npm run lint: clean. - cargo test: 513 passed, 0 failed. cargo clippy: leiden.rs clean. - Resolution-benchmark suite: 206 passed (community detection isn't part of it, but confirms nothing else regressed). Fixes #1804 Impact: 2 functions changed, 12 affected --- CLAUDE.md | 4 +- .../src/graph/algorithms/leiden.rs | 1410 +++++++++++++++++ .../src/graph/algorithms/louvain.rs | 476 ------ .../src/graph/algorithms/mod.rs | 4 +- crates/codegraph-core/src/shared/constants.rs | 35 +- src/cli/commands/communities.ts | 4 +- src/graph/algorithms/louvain.ts | 40 +- src/infrastructure/config.ts | 12 +- src/mcp/tool-registry.ts | 4 +- src/types.ts | 16 +- .../algorithms/leiden-native-parity.test.ts | 235 +++ tests/graph/algorithms/louvain.test.ts | 83 +- 12 files changed, 1749 insertions(+), 574 deletions(-) create mode 100644 crates/codegraph-core/src/graph/algorithms/leiden.rs delete mode 100644 crates/codegraph-core/src/graph/algorithms/louvain.rs create mode 100644 tests/graph/algorithms/leiden-native-parity.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index f309cace4..32ab58f67 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -128,7 +128,7 @@ Source is TypeScript in `src/`, compiled via `tsup`. The Rust native engine live | `features/boundaries.ts` | Architecture boundary rules with onion architecture preset | | `features/cfg.ts` | Control-flow graph generation | | `features/check.ts` | CI validation predicates (cycles, complexity, blast radius, boundaries) | -| `features/communities.ts` | Louvain community detection, drift analysis (delegates to `graph/` subsystem) | +| `features/communities.ts` | Leiden community detection, drift analysis (delegates to `graph/` subsystem) | | `features/complexity.ts` | Cognitive, cyclomatic, Halstead, MI computation from AST | | `features/dataflow.ts` | Dataflow analysis | | `features/export.ts` | Graph export orchestration: loads data from DB, delegates to `presentation/` serializers | @@ -148,7 +148,7 @@ Source is TypeScript in `src/`, compiled via `tsup`. The Rust native engine live | `presentation/sequence-renderer.ts` | Mermaid sequence diagram rendering | | `presentation/table.ts`, `result-formatter.ts`, `colors.ts` | CLI table formatting, JSON/NDJSON output, color constants | | **`graph/`** | **Unified graph model** | -| `graph/` | `CodeGraph` class (`model.ts`), algorithms (Tarjan SCC, Louvain, BFS, shortest path, centrality), classifiers (role, risk), builders (dependency, structure, temporal) | +| `graph/` | `CodeGraph` class (`model.ts`), algorithms (Tarjan SCC, Leiden, BFS, shortest path, centrality), classifiers (role, risk), builders (dependency, structure, temporal) | | **`mcp/`** | **MCP server** | | `mcp/` | MCP server exposing graph queries to AI agents; single-repo by default, `--multi-repo` to enable cross-repo access | | `ast-analysis/` | Unified AST analysis framework: shared DFS walker (`visitor.ts`), engine orchestrator (`engine.ts`), extracted metrics (`metrics.ts`), and pluggable visitors for complexity, dataflow, and AST-store | diff --git a/crates/codegraph-core/src/graph/algorithms/leiden.rs b/crates/codegraph-core/src/graph/algorithms/leiden.rs new file mode 100644 index 000000000..823db557a --- /dev/null +++ b/crates/codegraph-core/src/graph/algorithms/leiden.rs @@ -0,0 +1,1410 @@ +//! Leiden community detection (undirected modularity), ported from the +//! TypeScript reference implementation in `src/graph/algorithms/leiden/*` +//! (vendored from ngraph.leiden, MIT — see that directory's LICENSE) to fix +//! issue #1804: the native (this file, formerly classic Louvain) and JS +//! fallback (`detectClusters` in the TS `leiden/` directory) engines used to +//! run two genuinely different community-detection algorithms, so +//! `codegraph communities`/`--drift` reported different partitions purely +//! based on whether the native addon loaded. Both engines must now run +//! Leiden. +//! +//! ## Scope +//! +//! This port covers exactly the option surface reachable through +//! `louvainCommunities`/`LouvainOptions` +//! (`src/graph/algorithms/louvain.ts`): undirected graphs, modularity +//! quality (not CPM), the default "neighbors" candidate strategy, +//! `refine: true` (always), uniform node size (1.0) and edge weight (1.0 +//! per edge — `GraphEdge` carries no weight field), no +//! `maxCommunitySize`/`fixedNodes`/`preserveLabels` overrides. These are the +//! *only* knobs `louvainCommunities` ever threads through to either engine +//! (see `LouvainOptions` and `louvainJS()`'s call into `detectClusters`). +//! +//! The TS `leiden/` directory's directed-graph mode, CPM quality function, +//! alternate candidate strategies (all/random/random-neighbor), +//! `allowNewCommunity`, `fixedNodes`, and `preserveLabels` knobs are **not** +//! ported — they are unreachable from this binding today. Issue #1936 +//! tracks porting that remaining surface if a caller ever needs to drive +//! native Leiden with it. +//! +//! ## Determinism +//! +//! Every place the TS reference relies on `Map`/`Array` *insertion order* +//! (not sorted order) to break ties deterministically, this port uses an +//! explicit insertion-order-preserving structure (a `Vec` of records plus a +//! `HashMap` used purely for O(1) index lookup, never iterated) rather than +//! a `BTreeMap`. A `BTreeMap` would still be deterministic across runs, but +//! it iterates in *sorted* order, which does not match the TS reference's +//! *insertion* order — silently reordering a node's adjacency list relative +//! to the JS engine and changing which candidate community wins a tie in +//! the local-move/refinement phases. That would reintroduce a cross-engine +//! divergence of exactly the kind this file exists to fix, so `HashMap` is +//! used strictly as a lookup index here, never as an iterated collection. +//! +//! Separately (and orthogonally), every `HashMap` used as a plain lookup +//! (e.g. `id_to_idx`) is safe from issue #1734's failure mode (Rust's +//! per-process-randomized hasher reordering iteration) because it is never +//! iterated — only `.get()`/`.insert()` are used. + +use std::collections::HashMap; + +use napi_derive::napi; + +use crate::shared::constants::{ + DEFAULT_RANDOM_SEED, LEIDEN_DEFAULT_CAPACITY_GROWTH_FACTOR, LEIDEN_DEFAULT_MAX_LEVELS, + LEIDEN_DEFAULT_MAX_LOCAL_PASSES, LEIDEN_DEFAULT_REFINEMENT_THETA, LEIDEN_DEFAULT_RESOLUTION, + LEIDEN_GAIN_EPSILON, +}; +use crate::types::GraphEdge; + +// ════════════════════════════════════════════════════════════════════════ +// napi-facing types + entry point +// ════════════════════════════════════════════════════════════════════════ + +#[napi(object)] +#[derive(Debug, Clone)] +pub struct LeidenCommunityAssignment { + pub node: String, + pub community: i32, +} + +#[napi(object)] +#[derive(Debug, Clone)] +pub struct LeidenCommunitiesResult { + pub assignments: Vec, + pub modularity: f64, +} + +/// Leiden community detection (undirected modularity optimization). +/// +/// Mirrors `detectClusters(graph, { resolution, randomSeed, directed: false, +/// maxLevels, maxLocalPasses, refinementTheta, capacityGrowthFactor })` in +/// `src/graph/algorithms/leiden/index.ts` exactly — see the module doc for +/// the precise (and deliberately narrower) option surface covered. +#[napi] +#[allow(clippy::too_many_arguments)] +pub fn leiden_communities( + edges: Vec, + node_ids: Vec, + resolution: Option, + random_seed: Option, + max_levels: Option, + max_local_passes: Option, + refinement_theta: Option, + capacity_growth_factor: Option, +) -> LeidenCommunitiesResult { + if edges.is_empty() || node_ids.is_empty() { + return LeidenCommunitiesResult { + assignments: vec![], + modularity: 0.0, + }; + } + + let cfg = LeidenConfig { + resolution: resolution.unwrap_or(LEIDEN_DEFAULT_RESOLUTION), + max_levels: (max_levels.unwrap_or(LEIDEN_DEFAULT_MAX_LEVELS as u32) as usize).max(1), + max_local_passes: (max_local_passes.unwrap_or(LEIDEN_DEFAULT_MAX_LOCAL_PASSES as u32) + as usize) + .max(1), + // TS throws a RangeError for theta <= 0 (optimiser.ts). This binding + // is never reached with a caller-controlled theta outside + // `.codegraphrc.json`'s `community.refinementTheta` (config default + // 1.0), so rather than panic across the FFI boundary for a + // misconfiguration, fall back to the default -- strictly safer than + // the old native path, which silently ignored this option entirely. + refinement_theta: refinement_theta + .filter(|&t| t > 0.0) + .unwrap_or(LEIDEN_DEFAULT_REFINEMENT_THETA), + capacity_growth_factor: capacity_growth_factor + .unwrap_or(LEIDEN_DEFAULT_CAPACITY_GROWTH_FACTOR), + }; + let seed = random_seed.unwrap_or(DEFAULT_RANDOM_SEED); + + let n = node_ids.len(); + let mut id_to_idx: HashMap<&str, usize> = HashMap::with_capacity(n); + for (i, id) in node_ids.iter().enumerate() { + id_to_idx.insert(id.as_str(), i); + } + // Edge weight is always 1.0: `GraphEdge` carries no weight field, and + // `graph.toEdgeArray()` (the sole caller, in louvain.ts) never attaches + // one either -- matching the TS reference's default `linkWeight` + // fallback, which is the only value ever exercised by this binding. + let raw_edges: Vec<(usize, usize, f64)> = edges + .iter() + .filter_map(|e| { + let a = *id_to_idx.get(e.source.as_str())?; + let b = *id_to_idx.get(e.target.as_str())?; + Some((a, b, 1.0)) + }) + .collect(); + + let base_graph = build_adapter(n, &raw_edges, &vec![1.0; n]); + + if base_graph.total_weight == 0.0 { + return LeidenCommunitiesResult { + assignments: node_ids + .iter() + .enumerate() + .map(|(i, id)| LeidenCommunityAssignment { + node: id.clone(), + community: i as i32, + }) + .collect(), + modularity: 0.0, + }; + } + + let original_to_current = run_leiden(&base_graph, &cfg, seed); + let modularity = compute_final_modularity(&base_graph, &original_to_current); + + let assignments = node_ids + .iter() + .enumerate() + .map(|(i, id)| LeidenCommunityAssignment { + node: id.clone(), + community: original_to_current[i] as i32, + }) + .collect(); + + LeidenCommunitiesResult { + assignments, + modularity, + } +} + +// ════════════════════════════════════════════════════════════════════════ +// Configuration +// ════════════════════════════════════════════════════════════════════════ + +#[derive(Clone, Copy)] +struct LeidenConfig { + resolution: f64, + max_levels: usize, + max_local_passes: usize, + refinement_theta: f64, + capacity_growth_factor: f64, +} + +// ════════════════════════════════════════════════════════════════════════ +// RNG — mulberry32, bit-for-bit port of src/graph/algorithms/leiden/rng.ts +// ════════════════════════════════════════════════════════════════════════ + +/// Mulberry32 PRNG. All arithmetic is done on `u32` with wrapping ops, which +/// is bit-for-bit equivalent to the TS reference's `|0`/`>>>`/`Math.imul` +/// int32 bit-pattern arithmetic: JS's 32-bit bitwise/imul operations only +/// ever depend on the operands' 32-bit bit patterns, not their signed vs. +/// unsigned interpretation, so representing the state as `u32` throughout +/// (rather than mirroring JS's "signed int32" framing) preserves the exact +/// same bit patterns at every step. +struct Mulberry32 { + state: u32, +} + +impl Mulberry32 { + fn new(seed: u32) -> Self { + Self { state: seed } + } + + fn next_f64(&mut self) -> f64 { + self.state = self.state.wrapping_add(0x6d2b79f5); + let s = self.state; + let t0 = (s ^ (s >> 15)).wrapping_mul(1 | s); + let t = t0.wrapping_add((t0 ^ (t0 >> 7)).wrapping_mul(61 | t0)) ^ t0; + ((t ^ (t >> 14)) as f64) / 4294967296.0 + } +} + +/// Fisher-Yates shuffle, in the exact same iteration/draw order as +/// `shuffleArrayInPlace` in optimiser.ts. +fn shuffle_in_place(arr: &mut [usize], rng: &mut Mulberry32) { + for i in (1..arr.len()).rev() { + let j = (rng.next_f64() * (i + 1) as f64).floor() as usize; + arr.swap(i, j); + } +} + +// ════════════════════════════════════════════════════════════════════════ +// Graph adapter — undirected-only port of leiden/adapter.ts +// ════════════════════════════════════════════════════════════════════════ + +struct GraphAdapter { + n: usize, + /// Self-loop weight per node (single-w convention, matching adapter.ts). + self_loop: Vec, + /// Node strength (== degree for unweighted graphs). TS keeps separate + /// `strengthOut`/`strengthIn` arrays even in undirected mode (both + /// populated identically by symmetrization), but only `strengthOut` is + /// ever read by the undirected-only code paths this file ports, so a + /// single array replaces both here. + strength: Vec, + /// Node size (always 1.0 for every node reachable through this binding + /// at level 0; propagated from the previous level's community sizes at + /// coarser levels — see `build_coarse_graph`). + size: Vec, + /// Adjacency list. Undirected edges are symmetrized: an edge between i + /// and j appears once in `out_edges[i]` and once in `out_edges[j]`, in + /// first-seen order (see `build_adapter`). TS also keeps `inEdges`, but + /// it is never read by the undirected-only code paths ported here. + out_edges: Vec>, + total_weight: f64, +} + +/// One aggregated undirected-pair record while building a `GraphAdapter`: +/// accumulates edge weight for an unordered node pair while tracking which +/// direction(s) contributed a raw edge — mirrors adapter.ts's +/// `aggregateUndirectedPairs`/`recordUndirectedPairWeight`. `sum` is +/// averaged by the number of directions seen when emitted, exactly +/// replicating how the TS reference symmetrizes a graph that may store +/// independent per-direction weights (e.g. two files that import each other +/// both contribute to the same undirected community-detection edge). +struct PairAgg { + sum: f64, + seen_ab: bool, + seen_ba: bool, +} + +/// Build a `GraphAdapter` from a flat, possibly-directed/possibly-duplicate +/// edge list, mirroring `makeGraphAdapter(graph, { directed: false })` in +/// adapter.ts. Used uniformly for the level-0 graph (raw input) and every +/// coarsened level's graph (from `build_coarse_graph`), exactly like the TS +/// reference calls `makeGraphAdapter` uniformly at every level. +fn build_adapter(n: usize, raw_edges: &[(usize, usize, f64)], sizes: &[f64]) -> GraphAdapter { + let mut self_loop = vec![0.0_f64; n]; + + // First-seen insertion order is load-bearing here (see module doc): a + // `HashMap<(usize, usize), usize>` is used purely to look up the slot + // index for an already-seen pair in O(1); `pair_order`/`pair_recs` (plain + // `Vec`s, iterated below in push order) are what actually determine + // adjacency-list order. + let mut pair_index: HashMap<(usize, usize), usize> = HashMap::new(); + let mut pair_order: Vec<(usize, usize)> = Vec::new(); + let mut pair_recs: Vec = Vec::new(); + + for &(a, b, w) in raw_edges { + if a == b { + self_loop[a] += w; + continue; + } + let (i, j) = if a < b { (a, b) } else { (b, a) }; + let key = (i, j); + let idx = match pair_index.get(&key) { + Some(&existing) => existing, + None => { + let new_idx = pair_recs.len(); + pair_index.insert(key, new_idx); + pair_order.push(key); + pair_recs.push(PairAgg { + sum: 0.0, + seen_ab: false, + seen_ba: false, + }); + new_idx + } + }; + let rec = &mut pair_recs[idx]; + rec.sum += w; + if a == i { + rec.seen_ab = true; + } else { + rec.seen_ba = true; + } + } + + let mut out_edges: Vec> = vec![Vec::new(); n]; + let mut strength = vec![0.0_f64; n]; + + for (k, &(i, j)) in pair_order.iter().enumerate() { + let rec = &pair_recs[k]; + let dir_count = rec.seen_ab as u8 + rec.seen_ba as u8; + if dir_count == 0 { + continue; + } + let w = rec.sum / dir_count as f64; + if w == 0.0 { + continue; + } + out_edges[i].push((j, w)); + out_edges[j].push((i, w)); + strength[i] += w; + strength[j] += w; + } + + for v in 0..n { + let w = self_loop[v]; + if w != 0.0 { + out_edges[v].push((v, w)); + strength[v] += w; + } + } + + // Sequential left fold in index order, matching + // `strengthOut.reduce((a, b) => a + b, 0)` exactly (Rust's `Sum for f64` + // is also a sequential left fold from 0.0). + let total_weight: f64 = strength.iter().sum(); + + GraphAdapter { + n, + self_loop, + strength, + size: sizes.to_vec(), + out_edges, + total_weight, + } +} + +// ════════════════════════════════════════════════════════════════════════ +// Partition — undirected-only port of leiden/partition.ts + +// leiden/aggregate-helpers.ts +// ════════════════════════════════════════════════════════════════════════ + +struct Partition { + n: usize, + node_community: Vec, + community_count: usize, + community_total_size: Vec, + community_node_count: Vec, + community_internal_edge_weight: Vec, + community_total_strength: Vec, + /* scratch arrays for neighbor accumulation — fixed at size `n` for the + lifetime of a Partition; see module doc / `move_node_to_community` doc + for why these never need to grow (unlike the four aggregate arrays + above, which can grow via `resize_communities` after + `split_disconnected_communities` mints ids beyond the initial `n`). */ + candidate_communities: Vec, + candidate_count: usize, + neighbor_edge_weight_to_community: Vec, + is_candidate_community: Vec, + capacity_growth_factor: f64, +} + +impl Partition { + fn new(n: usize, capacity_growth_factor: f64) -> Self { + Partition { + n, + node_community: (0..n).collect(), + community_count: n, + community_total_size: vec![0.0; n], + community_node_count: vec![0; n], + community_internal_edge_weight: vec![0.0; n], + community_total_strength: vec![0.0; n], + candidate_communities: vec![0; n], + candidate_count: 0, + neighbor_edge_weight_to_community: vec![0.0; n], + is_candidate_community: vec![false; n], + capacity_growth_factor, + } + } + + /// Full aggregate recompute from `node_community`. Mirrors + /// `initializeAggregates`/`accumulateNodeAggregates`/ + /// `accumulateInternalEdgeWeights` (undirected branches only). + fn initialize_aggregates(&mut self, g: &GraphAdapter) { + for v in self.community_total_size.iter_mut() { + *v = 0.0; + } + for v in self.community_node_count.iter_mut() { + *v = 0; + } + for v in self.community_internal_edge_weight.iter_mut() { + *v = 0.0; + } + for v in self.community_total_strength.iter_mut() { + *v = 0.0; + } + + for i in 0..self.n { + let c = self.node_community[i]; + self.community_total_size[c] += g.size[i]; + self.community_node_count[c] += 1; + self.community_total_strength[c] += g.strength[i]; + if g.self_loop[i] != 0.0 { + self.community_internal_edge_weight[c] += g.self_loop[i]; + } + } + // Intra-community non-self-loop edges, each counted once (j > i) -- + // matches accumulateInternalEdgeWeights's undirected branch. + for i in 0..self.n { + let ci = self.node_community[i]; + for &(j, w) in &g.out_edges[i] { + if j <= i { + continue; + } + if ci == self.node_community[j] { + self.community_internal_edge_weight[ci] += w; + } + } + } + } + + fn reset_scratch(&mut self) { + for i in 0..self.candidate_count { + let c = self.candidate_communities[i]; + self.is_candidate_community[c] = false; + self.neighbor_edge_weight_to_community[c] = 0.0; + } + self.candidate_count = 0; + } + + fn touch_candidate(&mut self, c: usize) { + if self.is_candidate_community[c] { + return; + } + self.is_candidate_community[c] = true; + self.candidate_communities[self.candidate_count] = c; + self.candidate_count += 1; + } + + /// Mirrors `accumulateNeighborCommunityEdgeWeights`'s undirected branch: + /// always touches the node's own community first, then its neighbors' + /// communities in `out_edges[v]` order (which includes a self-loop entry + /// if present, at the end — see `build_adapter`). Returns the number of + /// distinct candidate communities touched. + fn accumulate_neighbor_community_edge_weights(&mut self, g: &GraphAdapter, v: usize) -> usize { + self.reset_scratch(); + let ci = self.node_community[v]; + self.touch_candidate(ci); + for &(j, w) in &g.out_edges[v] { + let cj = self.node_community[j]; + self.touch_candidate(cj); + self.neighbor_edge_weight_to_community[cj] += w; + } + self.candidate_count + } + + fn get_candidate_community_at(&self, i: usize) -> usize { + self.candidate_communities[i] + } + + fn get_neighbor_edge_weight_to_community(&self, c: usize) -> f64 { + if c < self.neighbor_edge_weight_to_community.len() { + self.neighbor_edge_weight_to_community[c] + } else { + 0.0 + } + } + + fn get_community_total_strength(&self, c: usize) -> f64 { + if c < self.community_total_strength.len() { + self.community_total_strength[c] + } else { + 0.0 + } + } + + fn get_community_node_count(&self, c: usize) -> usize { + if c < self.community_node_count.len() { + self.community_node_count[c] + } else { + 0 + } + } + + fn get_community_members(&self) -> Vec> { + let mut comms: Vec> = vec![Vec::new(); self.community_count]; + for i in 0..self.n { + comms[self.node_community[i]].push(i); + } + comms + } + + /// Mirrors `moveNodeToCommunity`'s undirected branch. `new_c` must + /// already be `< community_count` — unlike TS, this never grows on a + /// brand-new id, because `allowNewCommunity` (the only way TS reaches + /// that branch) is never enabled by this binding's option surface (see + /// module doc). + fn move_node_to_community(&mut self, g: &GraphAdapter, v: usize, new_c: usize) -> bool { + let old_c = self.node_community[v]; + if old_c == new_c { + return false; + } + let strength_v = g.strength[v]; + let self_loop_w = g.self_loop[v]; + let node_sz = g.size[v]; + + self.community_node_count[old_c] -= 1; + self.community_node_count[new_c] += 1; + self.community_total_size[old_c] -= node_sz; + self.community_total_size[new_c] += node_sz; + + self.community_total_strength[old_c] -= strength_v; + self.community_total_strength[new_c] += strength_v; + + // outToOld/outToNew already include the self-loop weight (self-loops + // live in out_edges), doubled here to match the "2*weight" internal + // edge convention, with the self-loop weight added back once more -- + // see applyMoveInternalEdgeWeightDeltaUndirected in partition.ts. + let weight_to_old = self.get_neighbor_edge_weight_to_community(old_c); + let weight_to_new = self.get_neighbor_edge_weight_to_community(new_c); + self.community_internal_edge_weight[old_c] -= 2.0 * weight_to_old + self_loop_w; + self.community_internal_edge_weight[new_c] += 2.0 * weight_to_new + self_loop_w; + + self.node_community[v] = new_c; + true + } + + /// Grow the four aggregate arrays (not the fixed-size scratch arrays -- + /// see their field doc) to fit at least `new_count` communities. + /// Reachable only via `split_disconnected_communities`, which can mint + /// ids beyond the initial `n` allocation when a post-refinement + /// community turns out to be disconnected. + fn ensure_capacity(&mut self, new_count: usize) { + if new_count <= self.community_total_size.len() { + return; + } + let grow_to = new_count.max( + ((self.community_total_size.len() as f64) * self.capacity_growth_factor).ceil() + as usize, + ); + self.community_total_size.resize(grow_to, 0.0); + self.community_node_count.resize(grow_to, 0); + self.community_internal_edge_weight.resize(grow_to, 0.0); + self.community_total_strength.resize(grow_to, 0.0); + } + + fn resize_communities(&mut self, new_count: usize) { + self.ensure_capacity(new_count); + self.community_count = new_count; + } + + /// Renumber communities: default (only reachable) compaction mode -- + /// descending total size, tie-broken by descending node count then + /// ascending original id. Mirrors `compactCommunityIds()` with no + /// options (`preserveLabels`/`keepOldOrder` are unreachable here). + fn compact_community_ids(&mut self, g: &GraphAdapter) { + let ids = sorted_nonempty_community_ids( + self.community_count, + &self.community_node_count, + &self.community_total_size, + ); + + let mut new_id = vec![0usize; self.community_count]; + for (i, &c) in ids.iter().enumerate() { + new_id[c] = i; + } + for slot in self.node_community.iter_mut() { + *slot = new_id[*slot]; + } + + self.community_count = ids.len(); + self.community_total_size = vec![0.0; self.community_count]; + self.community_node_count = vec![0; self.community_count]; + self.community_internal_edge_weight = vec![0.0; self.community_count]; + self.community_total_strength = vec![0.0; self.community_count]; + self.initialize_aggregates(g); + } +} + +/// Non-empty community ids in ascending original order, then sorted +/// descending by size / node count / ascending id. Extracted as a free +/// function (taking plain slices) to keep the sort's borrows trivially +/// disjoint from the `&mut self` mutations that follow it in +/// `compact_community_ids`. +fn sorted_nonempty_community_ids( + community_count: usize, + node_count: &[usize], + total_size: &[f64], +) -> Vec { + let mut ids: Vec = (0..community_count) + .filter(|&c| node_count[c] > 0) + .collect(); + ids.sort_by(|&a, &b| { + total_size[b] + .partial_cmp(&total_size[a]) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| node_count[b].cmp(&node_count[a])) + .then_with(|| a.cmp(&b)) + }); + ids +} + +// ════════════════════════════════════════════════════════════════════════ +// Modularity — undirected-only port of leiden/modularity.ts +// ════════════════════════════════════════════════════════════════════════ + +/// Mirrors `diffModularity`'s undirected branch exactly. +fn diff_modularity(partition: &Partition, g: &GraphAdapter, v: usize, c: usize, gamma: f64) -> f64 { + let old_c = partition.node_community[v]; + if c == old_c { + return 0.0; + } + let k_v = g.strength[v]; + let m2 = g.total_weight; + let k_v_in_new = partition.get_neighbor_edge_weight_to_community(c); + let k_v_in_old = partition.get_neighbor_edge_weight_to_community(old_c); + let w_tot_new = partition.get_community_total_strength(c); + let w_tot_old = partition.community_total_strength[old_c]; + let gain_remove = -(k_v_in_old / m2 - (gamma * (k_v * w_tot_old)) / (m2 * m2)); + let gain_add = k_v_in_new / m2 - (gamma * (k_v * w_tot_new)) / (m2 * m2); + gain_remove + gain_add +} + +/// Standard Newman-Girvan modularity, mirrors `qualityModularity`'s +/// undirected branch. Used only for the final quality() computation on the +/// original (level-0) graph, always at gamma=1.0 regardless of the +/// optimization resolution (see `compute_final_modularity`). +fn quality_modularity( + community_count: usize, + community_internal_edge_weight: &[f64], + community_total_strength: &[f64], + m2: f64, + gamma: f64, +) -> f64 { + let mut sum = 0.0_f64; + for c in 0..community_count { + let lc = community_internal_edge_weight[c]; + let dc = community_total_strength[c]; + sum += (2.0 * lc) / m2 - (gamma * (dc * dc)) / (m2 * m2); + } + sum +} + +// ════════════════════════════════════════════════════════════════════════ +// Optimiser — undirected-only, "neighbors"-strategy-only, refine-always-on +// port of leiden/optimiser.ts +// ════════════════════════════════════════════════════════════════════════ + +/// Evaluate every touched candidate community for `node_index` and return +/// the best (community, gain) pair — mirrors `findBestCommunityMove`'s +/// "neighbors" branch (the only candidate strategy this binding reaches; +/// `allowNewCommunity`'s new-community probe is also unreachable here). +fn find_best_community_move( + partition: &Partition, + g: &GraphAdapter, + node_index: usize, + candidate_count: usize, + resolution: f64, +) -> (usize, f64) { + let own = partition.node_community[node_index]; + let mut best_c = own; + let mut best_gain = 0.0_f64; + for t in 0..candidate_count { + let c = partition.get_candidate_community_at(t); + if c == own { + continue; + } + let gain = diff_modularity(partition, g, node_index, c, resolution); + if gain > best_gain { + best_gain = gain; + best_c = c; + } + } + (best_c, best_gain) +} + +/// Greedy local-move phase: iterate nodes in a shuffled order, moving each +/// to the best candidate community, until no improvement or +/// `max_local_passes` is reached. Mirrors `runLocalMovePhase` exactly. +fn run_local_move_phase( + g: &GraphAdapter, + partition: &mut Partition, + cfg: &LeidenConfig, + rng: &mut Mulberry32, +) { + let mut order: Vec = (0..g.n).collect(); + let mut improved = true; + let mut local_passes = 0usize; + while improved { + improved = false; + local_passes += 1; + shuffle_in_place(&mut order, rng); + for &node_index in &order { + let candidate_count = + partition.accumulate_neighbor_community_edge_weights(g, node_index); + let (best_c, best_gain) = + find_best_community_move(partition, g, node_index, candidate_count, cfg.resolution); + if best_c != partition.node_community[node_index] && best_gain > LEIDEN_GAIN_EPSILON { + partition.move_node_to_community(g, node_index, best_c); + improved = true; + } + } + if local_passes >= cfg.max_local_passes { + break; + } + } +} + +/// Boltzmann probabilistic candidate selection (Algorithm 3, Traag et al. +/// 2019). Returns `None` when the node should stay a singleton (TS's `-1` +/// sentinel), or `Some(community)` otherwise. Mirrors +/// `boltzmannSelectCandidate` exactly. +fn boltzmann_select_candidate( + cand_len: usize, + theta: f64, + rng: &mut Mulberry32, + cand_c: &[usize], + cand_gain: &[f64], + cand_weight: &mut [f64], +) -> Option { + let mut max_gain = 0.0_f64; + for &gain in cand_gain.iter().take(cand_len) { + if gain > max_gain { + max_gain = gain; + } + } + let stay_weight = ((0.0 - max_gain) / theta).exp(); + let mut total_weight = stay_weight; + for i in 0..cand_len { + cand_weight[i] = ((cand_gain[i] - max_gain) / theta).exp(); + total_weight += cand_weight[i]; + } + + let r = rng.next_f64() * total_weight; + if r < stay_weight { + return None; + } + let mut cumulative = stay_weight; + for i in 0..cand_len { + cumulative += cand_weight[i]; + if r < cumulative { + return Some(cand_c[i]); + } + } + Some(cand_c[cand_len - 1]) +} + +/// True Leiden refinement phase: singleton start, singleton guard (only +/// still-alone nodes may merge), single randomized pass, Boltzmann +/// probabilistic selection scoped to candidates sharing the same +/// macro-community. Mirrors `refineWithinCoarseCommunities` exactly. +fn refine_within_coarse_communities( + g: &GraphAdapter, + base_part: &Partition, + cfg: &LeidenConfig, + rng: &mut Mulberry32, +) -> Partition { + let mut p = Partition::new(g.n, cfg.capacity_growth_factor); + p.initialize_aggregates(g); + + // Macro-community membership per node, from the already-compacted + // local-move partition. TS's `commMacro` is built by copying + // `basePart.nodeCommunity` element-for-element into an array sized + // `p.communityCount` (== g.n for a fresh singleton partition), i.e. it + // is just a clone of `macro` at this point — reproduced directly here. + let comm_macro: Vec = base_part.node_community.clone(); + + let theta = cfg.refinement_theta; + + let mut order: Vec = (0..g.n).collect(); + shuffle_in_place(&mut order, rng); + + let mut cand_c = vec![0usize; g.n]; + let mut cand_gain = vec![0.0_f64; g.n]; + let mut cand_weight = vec![0.0_f64; g.n]; + + for &v in &order { + // Singleton guard: only nodes still alone in their community may merge. + let cur_c = p.node_community[v]; + if p.get_community_node_count(cur_c) > 1 { + continue; + } + + let macro_v = comm_macro[v]; + let touched = p.accumulate_neighbor_community_edge_weights(g, v); + + let mut cand_len = 0usize; + for t in 0..touched { + let c = p.get_candidate_community_at(t); + if c == p.node_community[v] { + continue; + } + if comm_macro[c] != macro_v { + continue; + } + let gain = diff_modularity(&p, g, v, c, cfg.resolution); + if gain > LEIDEN_GAIN_EPSILON { + cand_c[cand_len] = c; + cand_gain[cand_len] = gain; + cand_len += 1; + } + } + if cand_len == 0 { + continue; + } + + let chosen = + boltzmann_select_candidate(cand_len, theta, rng, &cand_c, &cand_gain, &mut cand_weight); + if let Some(c) = chosen { + p.move_node_to_community(g, v, c); + } + } + + p +} + +/// BFS over the subgraph induced by `in_community`, starting from `start`. +/// Mirrors `bfsComponent` (the undirected-only branch — `out_edges` alone +/// carries the full symmetrized adjacency). +fn bfs_component( + g: &GraphAdapter, + start: usize, + in_community: &[bool], + visited: &mut [bool], +) -> Vec { + let mut queue = vec![start]; + visited[start] = true; + let mut head = 0usize; + while head < queue.len() { + let v = queue[head]; + head += 1; + for &(to, _w) in &g.out_edges[v] { + if in_community[to] && !visited[to] { + visited[to] = true; + queue.push(to); + } + } + } + queue +} + +/// Post-refinement connectivity check: split any community with more than +/// one connected component into its components, reassigning secondary +/// components to fresh community ids. Mirrors `splitDisconnectedCommunities` +/// exactly (O(V+E), since communities partition V). +fn split_disconnected_communities(g: &GraphAdapter, partition: &mut Partition) { + let n = g.n; + let members = partition.get_community_members(); + let mut next_c = partition.community_count; + let mut did_split = false; + + let mut visited = vec![false; n]; + let mut in_community = vec![false; n]; + + for nodes in &members { + if nodes.len() <= 1 { + continue; + } + for &nd in nodes { + in_community[nd] = true; + } + + let mut component_count = 0usize; + for &start in nodes { + if visited[start] { + continue; + } + component_count += 1; + let component = bfs_component(g, start, &in_community, &mut visited); + if component_count > 1 { + let new_c = next_c; + next_c += 1; + for &q in &component { + partition.node_community[q] = new_c; + } + did_split = true; + } + } + + for &nd in nodes { + in_community[nd] = false; + visited[nd] = false; + } + } + + if did_split { + partition.resize_communities(next_c); + partition.initialize_aggregates(g); + } +} + +/// One level's outcome: the effective (post-refinement) partition's +/// node→community assignment and per-community sizes (needed by +/// `build_coarse_graph`), plus whether this level made no progress at all +/// (both the macro local-move phase and the refined/split partition ended +/// up fully singleton) — mirrors `runLevel`'s `{ effectivePartition, +/// terminate }`. +struct LevelOutcome { + node_community: Vec, + community_total_size: Vec, + community_count: usize, + terminate: bool, +} + +fn run_level(g: &GraphAdapter, cfg: &LeidenConfig, rng: &mut Mulberry32) -> LevelOutcome { + let mut partition = Partition::new(g.n, cfg.capacity_growth_factor); + partition.initialize_aggregates(g); + + run_local_move_phase(g, &mut partition, cfg, rng); + partition.compact_community_ids(g); + let macro_community_count = partition.community_count; + + // Leiden refinement always runs here: `louvainCommunities`'s option + // surface never threads a `refine` flag through to `detectClusters`, so + // the TS reference always takes `options.refine !== false` => true. This + // is the step that makes the algorithm Leiden rather than plain Louvain. + let mut refined = refine_within_coarse_communities(g, &partition, cfg, rng); + split_disconnected_communities(g, &mut refined); + refined.compact_community_ids(g); + + let effective_community_count = refined.community_count; + let terminate = macro_community_count == g.n && effective_community_count == g.n; + + LevelOutcome { + node_community: refined.node_community, + community_total_size: refined.community_total_size, + community_count: refined.community_count, + terminate, + } +} + +/// Build the next level's coarse graph: each community becomes a single +/// node, sized by its aggregate size from the previous level. Mirrors +/// `buildCoarseGraph` *plus* the next level's `makeGraphAdapter(coarse, { +/// directed: false })` re-read of that coarse `CodeGraph` — both stages +/// matter for byte-identical output, not just the edge-weight arithmetic: +/// +/// Stage 1 (mirrors `buildCoarseGraph`'s `acc: Map`): +/// accumulate a first-seen-order, *directional*-key (`cu:cv` and `cv:cu` as +/// distinct entries) sum over `g.out_edges`. +/// +/// Stage 2 (mirrors constructing the coarse `CodeGraph` via +/// `coarse.addNode(String(c), ...)` for `c` in ascending `0..communityCount` +/// *before* any edges are added, then `coarse.addEdge(cu, cv, ...)` per +/// stage-1 entry): because `addNode` pre-populates `_successors` with keys +/// "0","1",...,"commCount-1" in that ascending order, and `CodeGraph`'s +/// `Map`-backed adjacency never reorders an *existing* key on a later write, +/// each community's neighbor list is ordered by when a partner was *first* +/// touched (from either direction) — not by stage-1's raw discovery order. +/// Getting this wrong (e.g. reusing stage-1's order directly) is exactly +/// the bug this fix addresses: it does not change *which* nodes end up +/// grouped together for graphs with an unambiguous optimum (small hand-built +/// fixtures all still passed), but it does change candidate/tie-breaking +/// order from the second coarsening level onward, which the resolution +/// benchmark and this repo's own ~700-8800 node dependency graphs surfaced +/// as a real, reproducible native/JS divergence. +/// +/// Stage 3 (mirrors `_undirectedEdges()`'s dedup traversal of the coarse +/// `CodeGraph`, which is what the *next* level's `makeGraphAdapter` actually +/// iterates): walk communities in ascending order, each one's neighbor list +/// in its stage-2 discovery order, yielding each canonical pair once. The +/// result is fed through the same `build_adapter` used for level 0 — each +/// pair now appears exactly once (dirCount will always be 1), which is +/// mathematically the same value stage-1's directional sums would average +/// to for this binding's integer-only edge weights (see the reciprocal-edge +/// test), but replicating the real order removes any doubt. +fn build_coarse_graph( + g: &GraphAdapter, + node_community: &[usize], + community_total_size: &[f64], + community_count: usize, +) -> GraphAdapter { + let sizes: Vec = community_total_size[0..community_count].to_vec(); + + // Stage 1: `acc`-equivalent — directional-key, first-seen-order sum. + let mut acc_index: HashMap<(usize, usize), usize> = HashMap::new(); + let mut acc_order: Vec<(usize, usize)> = Vec::new(); + let mut acc_values: Vec = Vec::new(); + + for i in 0..g.n { + let cu = node_community[i]; + for &(j, w) in &g.out_edges[i] { + let cv = node_community[j]; + // Undirected: each non-self edge (i,j) appears in both + // out_edges[i] and out_edges[j]. For intra-community edges + // (cu==cv), skip the reverse occurrence to avoid inflating the + // coarse self-loop weight by 2x (matches the `j < i` guard in + // optimiser.ts's buildCoarseGraph). + if cu == cv && j < i { + continue; + } + let key = (cu, cv); + match acc_index.get(&key) { + Some(&idx) => acc_values[idx] += w, + None => { + let idx = acc_values.len(); + acc_index.insert(key, idx); + acc_order.push(key); + acc_values.push(w); + } + } + } + } + + // Stage 2: per-community neighbor discovery order (mirrors the coarse + // CodeGraph's per-node adjacency Map insertion order) + last-write-wins + // weight per canonical (undirected) pair (mirrors addEdge overwriting an + // existing Map entry's value without moving its position). + let mut neighbor_order: Vec> = vec![Vec::new(); community_count]; + let mut neighbor_seen: Vec> = + vec![std::collections::HashSet::new(); community_count]; + let mut final_weight: HashMap<(usize, usize), f64> = HashMap::new(); + + for (idx, &(cu, cv)) in acc_order.iter().enumerate() { + let w = acc_values[idx]; + if cu == cv { + if neighbor_seen[cu].insert(cu) { + neighbor_order[cu].push(cu); + } + final_weight.insert((cu, cu), w); + continue; + } + let (lo, hi) = if cu < cv { (cu, cv) } else { (cv, cu) }; + final_weight.insert((lo, hi), w); + if neighbor_seen[cu].insert(cv) { + neighbor_order[cu].push(cv); + } + if neighbor_seen[cv].insert(cu) { + neighbor_order[cv].push(cu); + } + } + + // Stage 3: ascending community order, each community's neighbors in + // discovery order, canonical-pair dedup — mirrors `_undirectedEdges()`. + let mut raw_edges: Vec<(usize, usize, f64)> = Vec::new(); + let mut yielded: std::collections::HashSet<(usize, usize)> = std::collections::HashSet::new(); + for (cu, neighbors) in neighbor_order.iter().enumerate() { + for &cv in neighbors { + let (lo, hi) = if cu < cv { (cu, cv) } else { (cv, cu) }; + if !yielded.insert((lo, hi)) { + continue; + } + let w = *final_weight + .get(&(lo, hi)) + .expect("weight recorded in stage 2"); + raw_edges.push((cu, cv, w)); + } + } + + build_adapter(community_count, &raw_edges, &sizes) +} + +/// Run the full multi-level Leiden pipeline and return the final +/// original-node → final-community mapping. Mirrors +/// `runLouvainUndirectedModularity`'s level loop exactly. +fn run_leiden(base_graph: &GraphAdapter, cfg: &LeidenConfig, seed: u32) -> Vec { + let mut rng = Mulberry32::new(seed); + let orig_n = base_graph.n; + let mut original_to_current: Vec = (0..orig_n).collect(); + + let mut coarse_graph: Option = None; + + for _level in 0..cfg.max_levels { + let graph_ref: &GraphAdapter = coarse_graph.as_ref().unwrap_or(base_graph); + + let outcome = run_level(graph_ref, cfg, &mut rng); + + for slot in original_to_current.iter_mut() { + *slot = outcome.node_community[*slot]; + } + + if outcome.terminate { + break; + } + + let next_coarse = build_coarse_graph( + graph_ref, + &outcome.node_community, + &outcome.community_total_size, + outcome.community_count, + ); + coarse_graph = Some(next_coarse); + } + + original_to_current +} + +/// Final quality(): recompute aggregates on the ORIGINAL (level-0) graph +/// using the final fine→coarse mapping, then evaluate standard modularity +/// at gamma=1.0 regardless of the optimization resolution — mirrors +/// `detectClusters().quality()`'s modularity branch (`buildOriginalPartition` +/// combined with `qualityModularity(part, baseGraph, 1.0)`) exactly. +/// Computing on the original graph (rather than the last coarse level) +/// matters, since the modularity null model depends on the degree +/// distribution, which changes after coarsening. +fn compute_final_modularity(base: &GraphAdapter, original_to_current: &[usize]) -> f64 { + let community_count = original_to_current + .iter() + .copied() + .max() + .map(|m| m + 1) + .unwrap_or(0); + if community_count == 0 || base.total_weight == 0.0 { + return 0.0; + } + + let mut total_strength = vec![0.0_f64; community_count]; + let mut internal_edge_weight = vec![0.0_f64; community_count]; + + for (i, &c) in original_to_current.iter().enumerate().take(base.n) { + total_strength[c] += base.strength[i]; + if base.self_loop[i] != 0.0 { + internal_edge_weight[c] += base.self_loop[i]; + } + } + for i in 0..base.n { + let ci = original_to_current[i]; + for &(j, w) in &base.out_edges[i] { + if j <= i { + continue; + } + if ci == original_to_current[j] { + internal_edge_weight[ci] += w; + } + } + } + + quality_modularity( + community_count, + &internal_edge_weight, + &total_strength, + base.total_weight, + 1.0, + ) +} + +// ════════════════════════════════════════════════════════════════════════ +// Tests +// ════════════════════════════════════════════════════════════════════════ + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap as StdHashMap; + + fn edge(src: &str, tgt: &str) -> GraphEdge { + GraphEdge { + source: src.to_string(), + target: tgt.to_string(), + } + } + + fn assignments_map(result: &LeidenCommunitiesResult) -> StdHashMap { + result + .assignments + .iter() + .map(|a| (a.node.clone(), a.community)) + .collect() + } + + #[test] + fn test_leiden_empty() { + let result = leiden_communities(vec![], vec![], None, None, None, None, None, None); + assert!(result.assignments.is_empty()); + assert_eq!(result.modularity, 0.0); + } + + #[test] + fn test_leiden_two_cliques() { + let edges = vec![ + edge("a", "b"), + edge("b", "c"), + edge("c", "a"), + edge("d", "e"), + edge("e", "f"), + edge("f", "d"), + ]; + let nodes: Vec = vec!["a", "b", "c", "d", "e", "f"] + .into_iter() + .map(String::from) + .collect(); + let result = leiden_communities(edges, nodes, None, None, None, None, None, None); + + let map = assignments_map(&result); + assert_eq!(map["a"], map["b"]); + assert_eq!(map["b"], map["c"]); + assert_eq!(map["d"], map["e"]); + assert_eq!(map["e"], map["f"]); + assert_ne!(map["a"], map["d"]); + assert!(result.modularity > 0.0); + } + + #[test] + fn test_leiden_single_component() { + let edges = vec![edge("a", "b"), edge("a", "c"), edge("b", "c")]; + let nodes: Vec = vec!["a", "b", "c"].into_iter().map(String::from).collect(); + let result = leiden_communities(edges, nodes, None, None, None, None, None, None); + let map = assignments_map(&result); + assert_eq!(map["a"], map["b"]); + assert_eq!(map["b"], map["c"]); + } + + /// Regression test mirroring #1734 (originally filed against the classic + /// Louvain implementation this file replaces): repeated calls with a + /// fixed seed on a graph engineered to force a genuine modularity-gain + /// tie must produce byte-identical assignments and modularity every + /// time. This graph is symmetric by construction — three disjoint + /// triangles plus a bridge node connected with equal weight to one + /// member of each triangle — so moving the bridge node into any of the + /// three triangles yields the exact same modularity gain. + #[test] + fn test_leiden_deterministic_across_repeated_calls_with_tie() { + let edges = vec![ + edge("a1", "a2"), + edge("a2", "a3"), + edge("a3", "a1"), + edge("b1", "b2"), + edge("b2", "b3"), + edge("b3", "b1"), + edge("c1", "c2"), + edge("c2", "c3"), + edge("c3", "c1"), + edge("x", "a1"), + edge("x", "b1"), + edge("x", "c1"), + ]; + let nodes: Vec = vec!["a1", "a2", "a3", "b1", "b2", "b3", "c1", "c2", "c3", "x"] + .into_iter() + .map(String::from) + .collect(); + + let mut snapshots: Vec<(String, f64)> = Vec::new(); + for _ in 0..30 { + let result = leiden_communities( + edges.clone(), + nodes.clone(), + Some(1.0), + Some(42), + None, + None, + None, + None, + ); + let mut pairs: Vec = result + .assignments + .iter() + .map(|a| format!("{}:{}", a.node, a.community)) + .collect(); + pairs.sort(); + snapshots.push((pairs.join(","), result.modularity)); + } + + let first = &snapshots[0]; + for (i, snapshot) in snapshots.iter().enumerate().skip(1) { + assert_eq!( + snapshot.0, first.0, + "run {i} produced a different assignment than run 0 — tie-breaking is not deterministic" + ); + assert_eq!( + snapshot.1, first.1, + "run {i} produced a different modularity than run 0" + ); + } + } + + /// A "mutual import" style graph: both (a,b) and (b,a) are present as + /// independent directed edges (as `graph.toEdgeArray()` would emit for a + /// directed CodeGraph with edges in both directions between two nodes). + /// These must be treated as ONE undirected edge of weight 1 (averaged), + /// not weight 2 (summed) — the classic Louvain implementation this file + /// replaces summed reciprocal edges instead of averaging them, an + /// additional (independent) source of native/JS divergence beyond the + /// algorithm mismatch that issue #1804 is about. Verified by comparing + /// against a graph with the exact same structure but only ONE direction + /// per edge: both must produce identical modularity, since averaging + /// duplicate reciprocal edges must be equivalent to de-duplicating them. + #[test] + fn test_leiden_reciprocal_edges_are_averaged_not_summed() { + let nodes: Vec = vec!["a", "b", "c", "d"] + .into_iter() + .map(String::from) + .collect(); + + let reciprocal_edges = vec![ + edge("a", "b"), + edge("b", "a"), + edge("b", "c"), + edge("c", "b"), + edge("c", "d"), + edge("d", "c"), + ]; + let single_direction_edges = vec![edge("a", "b"), edge("b", "c"), edge("c", "d")]; + + let reciprocal_result = leiden_communities( + reciprocal_edges, + nodes.clone(), + Some(1.0), + Some(42), + None, + None, + None, + None, + ); + let single_result = leiden_communities( + single_direction_edges, + nodes, + Some(1.0), + Some(42), + None, + None, + None, + None, + ); + + assert_eq!(reciprocal_result.modularity, single_result.modularity); + assert_eq!( + assignments_map(&reciprocal_result), + assignments_map(&single_result) + ); + } + + /// A node with a self-loop must have that weight counted toward its own + /// degree/strength (and hence total_weight / modularity), matching + /// adapter.ts's single-w self-loop convention — not silently dropped + /// (the classic Louvain implementation this file replaces dropped + /// self-loops entirely). + #[test] + fn test_leiden_self_loop_contributes_to_modularity() { + let edges = vec![ + edge("a", "b"), + edge("b", "c"), + edge("c", "a"), + edge("a", "a"), + ]; + let nodes: Vec = vec!["a", "b", "c"].into_iter().map(String::from).collect(); + let with_self_loop = leiden_communities( + edges, + nodes.clone(), + Some(1.0), + Some(42), + None, + None, + None, + None, + ); + + let edges_no_loop = vec![edge("a", "b"), edge("b", "c"), edge("c", "a")]; + let without_self_loop = leiden_communities( + edges_no_loop, + nodes, + Some(1.0), + Some(42), + None, + None, + None, + None, + ); + + assert_ne!(with_self_loop.modularity, without_self_loop.modularity); + } + + /// `refinementTheta`/`maxLevels`/`maxLocalPasses`/`capacityGrowthFactor` + /// must actually be threaded through (the classic Louvain implementation + /// this file replaces silently ignored all four). + #[test] + fn test_leiden_accepts_all_leiden_options() { + let edges = vec![ + edge("a", "b"), + edge("b", "c"), + edge("c", "a"), + edge("x", "y"), + edge("y", "z"), + edge("z", "x"), + edge("c", "x"), + ]; + let nodes: Vec = vec!["a", "b", "c", "x", "y", "z"] + .into_iter() + .map(String::from) + .collect(); + + let result = leiden_communities( + edges, + nodes, + Some(1.0), + Some(42), + Some(50), + Some(20), + Some(1.0), + Some(1.5), + ); + assert_eq!(result.assignments.len(), 6); + } +} diff --git a/crates/codegraph-core/src/graph/algorithms/louvain.rs b/crates/codegraph-core/src/graph/algorithms/louvain.rs deleted file mode 100644 index 98826a926..000000000 --- a/crates/codegraph-core/src/graph/algorithms/louvain.rs +++ /dev/null @@ -1,476 +0,0 @@ -use std::collections::{BTreeMap, HashMap}; - -use crate::shared::constants::{ - DEFAULT_RANDOM_SEED, LOUVAIN_MAX_LEVELS, LOUVAIN_MAX_PASSES, LOUVAIN_MIN_GAIN, -}; -use crate::types::GraphEdge; -use napi_derive::napi; - -#[napi(object)] -#[derive(Debug, Clone)] -pub struct CommunityAssignment { - pub node: String, - pub community: i32, -} - -#[napi(object)] -#[derive(Debug, Clone)] -pub struct LouvainResult { - pub assignments: Vec, - pub modularity: f64, -} - -/// Classic Louvain algorithm for undirected community detection. -/// -/// Takes an edge list and treats it as undirected. Optimizes modularity -/// via the standard two-phase Louvain approach: -/// 1. Local phase: greedily move nodes to maximize modularity gain -/// 2. Aggregation phase: collapse communities into super-nodes and repeat -#[napi] -pub fn louvain_communities( - edges: Vec, - node_ids: Vec, - resolution: Option, - random_seed: Option, -) -> LouvainResult { - if edges.is_empty() || node_ids.is_empty() { - return LouvainResult { - assignments: vec![], - modularity: 0.0, - }; - } - louvain_impl( - &edges, - &node_ids, - resolution.unwrap_or(1.0), - random_seed.unwrap_or(DEFAULT_RANDOM_SEED), - ) -} - -/// Internal state for the Louvain multi-level loop. -/// -/// `cur_edges` uses `BTreeMap` (not `HashMap`) so that iteration order is -/// deterministic across process runs. Rust's default `HashMap` hasher is -/// randomly seeded per-process (DoS resistance), so iterating a `HashMap` -/// here would silently reorder the adjacency list built in -/// `local_move_phase` on every run, changing which local optimum the greedy -/// local-move phase converges to even with a fixed `rng_state` seed (#1734). -struct LouvainState { - cur_n: usize, - cur_edges: BTreeMap<(usize, usize), f64>, - cur_degree: Vec, - original_community: Vec, - rng_state: u32, -} - -/// Build the initial index-based edge map and degree vector from raw edges. -fn louvain_init( - edges: &[GraphEdge], - node_ids: &[String], - seed: u32, -) -> (BTreeMap<(usize, usize), f64>, f64, LouvainState) { - let n = node_ids.len(); - let mut id_to_idx: HashMap<&str, usize> = HashMap::with_capacity(n); - for (i, id) in node_ids.iter().enumerate() { - id_to_idx.insert(id.as_str(), i); - } - - // Build undirected weighted edge list (deduplicate, merge parallel edges). - // BTreeMap keeps this deterministically ordered by (src, tgt) — see the - // `LouvainState.cur_edges` doc comment above for why this matters. - let mut edge_map: BTreeMap<(usize, usize), f64> = BTreeMap::new(); - for edge in edges { - if let (Some(&src), Some(&tgt)) = ( - id_to_idx.get(edge.source.as_str()), - id_to_idx.get(edge.target.as_str()), - ) { - if src == tgt { - continue; - } - let key = if src < tgt { (src, tgt) } else { (tgt, src) }; - *edge_map.entry(key).or_insert(0.0) += 1.0; - } - } - - let total_weight: f64 = edge_map.values().sum(); - - let mut cur_degree: Vec = vec![0.0; n]; - for (&(src, tgt), &w) in &edge_map { - cur_degree[src] += w; - cur_degree[tgt] += w; - } - - let rng_state = if seed == 0 { 1 } else { seed }; - - let state = LouvainState { - cur_n: n, - cur_edges: edge_map.clone(), - cur_degree, - original_community: (0..n).collect(), - rng_state, - }; - - (edge_map, total_weight, state) -} - -/// Xorshift32 PRNG step. -fn xorshift32(state: &mut u32) -> u32 { - *state ^= *state << 13; - *state ^= *state >> 17; - *state ^= *state << 5; - *state -} - -/// Local move phase: greedily reassign nodes to communities to maximize modularity. -/// Returns true if any node moved. -fn local_move_phase( - state: &mut LouvainState, - resolution: f64, - total_m2: f64, -) -> (Vec, bool) { - let cur_n = state.cur_n; - - // Build adjacency list - let mut adj: Vec> = vec![vec![]; cur_n]; - for (&(src, tgt), &w) in &state.cur_edges { - adj[src].push((tgt, w)); - adj[tgt].push((src, w)); - } - - let mut level_comm: Vec = (0..cur_n).collect(); - let mut comm_total: Vec = state.cur_degree.clone(); - - // Shuffle visit order with seeded RNG - let mut order: Vec = (0..cur_n).collect(); - for i in (1..order.len()).rev() { - let j = xorshift32(&mut state.rng_state) as usize % (i + 1); - order.swap(i, j); - } - - let mut any_moved = false; - for _pass in 0..LOUVAIN_MAX_PASSES { - let mut pass_moved = false; - for &node in &order { - let node_comm = level_comm[node]; - let node_deg = state.cur_degree[node]; - - // BTreeMap (not HashMap) so the best-move scan below visits - // candidate communities in a fixed, deterministic order — - // otherwise a genuine tie in `gain` would be broken by Rust's - // per-process-randomized HashMap iteration order instead of a - // reproducible rule (#1734). - let mut comm_w: BTreeMap = BTreeMap::new(); - for &(neighbor, w) in &adj[node] { - *comm_w.entry(level_comm[neighbor]).or_insert(0.0) += w; - } - - let w_own = *comm_w.get(&node_comm).unwrap_or(&0.0); - let remove_cost = - w_own - resolution * node_deg * (comm_total[node_comm] - node_deg) / total_m2; - - let mut best_comm = node_comm; - let mut best_gain: f64 = 0.0; - - for (&target_comm, &w_target) in &comm_w { - if target_comm == node_comm { - continue; - } - let gain = w_target - - resolution * node_deg * comm_total[target_comm] / total_m2 - - remove_cost; - if gain > best_gain { - best_gain = gain; - best_comm = target_comm; - } - } - - if best_comm != node_comm && best_gain > LOUVAIN_MIN_GAIN { - comm_total[node_comm] -= node_deg; - comm_total[best_comm] += node_deg; - level_comm[node] = best_comm; - pass_moved = true; - any_moved = true; - } - } - if !pass_moved { - break; - } - } - - (level_comm, any_moved) -} - -/// Aggregation phase: renumber communities, compose original mapping, build coarse graph. -/// Returns false if no further coarsening is possible (convergence). -fn aggregation_phase( - state: &mut LouvainState, - level_comm: &mut Vec, -) -> bool { - // Renumber communities contiguously - let mut comm_remap: HashMap = HashMap::new(); - let mut next_id: usize = 0; - for &c in level_comm.iter() { - if !comm_remap.contains_key(&c) { - comm_remap.insert(c, next_id); - next_id += 1; - } - } - for c in level_comm.iter_mut() { - *c = comm_remap[c]; - } - let coarse_n = next_id; - - if coarse_n == state.cur_n { - return false; - } - - // Compose: update original_community through this level's assignments - for oc in state.original_community.iter_mut() { - *oc = level_comm[*oc]; - } - - // Build coarse graph for next level - let mut coarse_edge_map: BTreeMap<(usize, usize), f64> = BTreeMap::new(); - for (&(src, tgt), &w) in &state.cur_edges { - let cu = level_comm[src]; - let cv = level_comm[tgt]; - if cu == cv { - continue; - } - let key = if cu < cv { (cu, cv) } else { (cv, cu) }; - *coarse_edge_map.entry(key).or_insert(0.0) += w; - } - - let mut coarse_degree: Vec = vec![0.0; coarse_n]; - for (i, °) in state.cur_degree.iter().enumerate() { - coarse_degree[level_comm[i]] += deg; - } - - state.cur_n = coarse_n; - state.cur_edges = coarse_edge_map; - state.cur_degree = coarse_degree; - - true -} - -/// Compute final modularity score: Q = sum_c [ L_c / m - gamma * (k_c / 2m)^2 ] -fn compute_modularity( - edge_map: &BTreeMap<(usize, usize), f64>, - original_community: &[usize], - total_weight: f64, - resolution: f64, - n: usize, -) -> f64 { - let m = total_weight; - let m2 = 2.0 * m; - - let mut orig_degree: Vec = vec![0.0; n]; - for (&(src, tgt), &w) in edge_map { - orig_degree[src] += w; - orig_degree[tgt] += w; - } - - let max_comm = original_community.iter().copied().max().unwrap_or(0) + 1; - let mut kc: Vec = vec![0.0; max_comm]; - let mut lc: Vec = vec![0.0; max_comm]; - - for (i, °) in orig_degree.iter().enumerate() { - kc[original_community[i]] += deg; - } - for (&(src, tgt), &w) in edge_map { - if original_community[src] == original_community[tgt] { - lc[original_community[src]] += w; - } - } - - let mut modularity: f64 = 0.0; - for c in 0..max_comm { - if kc[c] > 0.0 { - modularity += lc[c] / m - resolution * (kc[c] / m2).powi(2); - } - } - modularity -} - -fn louvain_impl( - edges: &[GraphEdge], - node_ids: &[String], - resolution: f64, - seed: u32, -) -> LouvainResult { - let n = node_ids.len(); - let (edge_map, total_weight, mut state) = louvain_init(edges, node_ids, seed); - - if total_weight == 0.0 { - return LouvainResult { - assignments: node_ids - .iter() - .enumerate() - .map(|(i, id)| CommunityAssignment { - node: id.clone(), - community: i as i32, - }) - .collect(), - modularity: 0.0, - }; - } - - // m2 = 2 x total edge weight of the ORIGINAL graph -- a constant across all levels. - // Recalculating from cur_edges would undercount because coarsening strips intra-community - // edges, inflating the penalty term and causing under-merging at coarser levels. - let total_m2: f64 = 2.0 * total_weight; - - for _level in 0..LOUVAIN_MAX_LEVELS { - if state.cur_edges.is_empty() { - break; - } - - let (mut level_comm, any_moved) = local_move_phase(&mut state, resolution, total_m2); - if !any_moved { - break; - } - - if !aggregation_phase(&mut state, &mut level_comm) { - break; - } - } - - let modularity = compute_modularity(&edge_map, &state.original_community, total_weight, resolution, n); - - let assignments = node_ids - .iter() - .enumerate() - .map(|(i, id)| CommunityAssignment { - node: id.clone(), - community: state.original_community[i] as i32, - }) - .collect(); - - LouvainResult { - assignments, - modularity, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn edge(src: &str, tgt: &str) -> GraphEdge { - GraphEdge { - source: src.to_string(), - target: tgt.to_string(), - } - } - - #[test] - fn test_louvain_empty() { - let result = louvain_communities(vec![], vec![], None, None); - assert!(result.assignments.is_empty()); - assert_eq!(result.modularity, 0.0); - } - - #[test] - fn test_louvain_two_cliques() { - let edges = vec![ - edge("a", "b"), - edge("b", "c"), - edge("c", "a"), - edge("d", "e"), - edge("e", "f"), - edge("f", "d"), - ]; - let nodes: Vec = vec!["a", "b", "c", "d", "e", "f"] - .into_iter() - .map(String::from) - .collect(); - let result = louvain_communities(edges, nodes, None, None); - - let map: HashMap = result - .assignments - .into_iter() - .map(|a| (a.node, a.community)) - .collect(); - assert_eq!(map["a"], map["b"]); - assert_eq!(map["b"], map["c"]); - assert_eq!(map["d"], map["e"]); - assert_eq!(map["e"], map["f"]); - assert_ne!(map["a"], map["d"]); - assert!(result.modularity > 0.0); - } - - #[test] - fn test_louvain_single_component() { - let edges = vec![edge("a", "b"), edge("a", "c"), edge("b", "c")]; - let nodes: Vec = vec!["a", "b", "c"].into_iter().map(String::from).collect(); - let result = louvain_communities(edges, nodes, None, None); - let map: HashMap = result - .assignments - .into_iter() - .map(|a| (a.node, a.community)) - .collect(); - assert_eq!(map["a"], map["b"]); - assert_eq!(map["b"], map["c"]); - } - - /// Regression test for #1734: `codegraph communities --drift` produced - /// different modularity/community assignments across separate full - /// rebuilds of byte-identical source. Root cause: `local_move_phase` - /// accumulated per-candidate-community weights in a `HashMap`, whose - /// iteration order is randomized per-process — so a genuine tie in - /// modularity gain between candidate communities was broken by hashmap - /// bucket order instead of a reproducible rule, even with a fixed - /// `random_seed`. Fixed by switching `cur_edges`/`comm_w` to `BTreeMap`. - /// - /// This graph is symmetric by construction — three disjoint triangles - /// plus a bridge node connected with equal weight to one member of each - /// triangle — so moving the bridge node into any of the three triangles - /// yields the exact same modularity gain, forcing a genuine tie on every - /// run of the local-move phase. - #[test] - fn test_louvain_deterministic_across_repeated_calls_with_tie() { - let edges = vec![ - edge("a1", "a2"), - edge("a2", "a3"), - edge("a3", "a1"), - edge("b1", "b2"), - edge("b2", "b3"), - edge("b3", "b1"), - edge("c1", "c2"), - edge("c2", "c3"), - edge("c3", "c1"), - edge("x", "a1"), - edge("x", "b1"), - edge("x", "c1"), - ]; - let nodes: Vec = vec!["a1", "a2", "a3", "b1", "b2", "b3", "c1", "c2", "c3", "x"] - .into_iter() - .map(String::from) - .collect(); - - let mut snapshots: Vec<(String, f64)> = Vec::new(); - for _ in 0..30 { - let result = louvain_communities(edges.clone(), nodes.clone(), Some(1.0), Some(42)); - let mut pairs: Vec = result - .assignments - .iter() - .map(|a| format!("{}:{}", a.node, a.community)) - .collect(); - pairs.sort(); - snapshots.push((pairs.join(","), result.modularity)); - } - - let first = &snapshots[0]; - for (i, snapshot) in snapshots.iter().enumerate().skip(1) { - assert_eq!( - snapshot.0, first.0, - "run {i} produced a different assignment than run 0 — \ - tie-breaking is not deterministic" - ); - assert_eq!( - snapshot.1, first.1, - "run {i} produced a different modularity than run 0" - ); - } - } -} diff --git a/crates/codegraph-core/src/graph/algorithms/mod.rs b/crates/codegraph-core/src/graph/algorithms/mod.rs index e2b87b481..9ceed17bc 100644 --- a/crates/codegraph-core/src/graph/algorithms/mod.rs +++ b/crates/codegraph-core/src/graph/algorithms/mod.rs @@ -2,13 +2,13 @@ pub mod bfs; pub mod centrality; -pub mod louvain; +pub mod leiden; pub mod shortest_path; pub mod tarjan; pub use bfs::{bfs_traversal, BfsEntry}; pub use centrality::{fan_in_out, FanInOutEntry}; -pub use louvain::{louvain_communities, CommunityAssignment, LouvainResult}; +pub use leiden::{leiden_communities, LeidenCommunitiesResult, LeidenCommunityAssignment}; pub use shortest_path::shortest_path; pub use tarjan::detect_cycles; diff --git a/crates/codegraph-core/src/shared/constants.rs b/crates/codegraph-core/src/shared/constants.rs index 9aa28a128..4a111cbb9 100644 --- a/crates/codegraph-core/src/shared/constants.rs +++ b/crates/codegraph-core/src/shared/constants.rs @@ -2,16 +2,37 @@ /// on deeply nested trees. Used by extractors, complexity, CFG, and dataflow. pub const MAX_WALK_DEPTH: usize = 200; -// ─── Louvain community detection ──────────────────────────────────── - -/// Maximum number of coarsening levels in the Louvain algorithm. -pub const LOUVAIN_MAX_LEVELS: usize = 50; +// ─── Leiden community detection ───────────────────────────────────── +// +// Mirrors the TS reference implementation's defaults exactly (both must +// produce identical output — see leiden.rs's module doc for why): +// - DEFAULT_MAX_LEVELS/DEFAULT_MAX_LOCAL_PASSES/GAIN_EPSILON: +// src/graph/algorithms/leiden/optimiser.ts +// - DEFAULT_REFINEMENT_THETA/DEFAULT_RESOLUTION: +// optimiser.ts's normalizeOptions() and src/infrastructure/config.ts's +// DEFAULTS.community +// - DEFAULT_CAPACITY_GROWTH_FACTOR: src/graph/algorithms/leiden/partition.ts + +/// Maximum number of coarsening levels. +pub const LEIDEN_DEFAULT_MAX_LEVELS: usize = 50; /// Maximum number of local-move passes per level before stopping. -pub const LOUVAIN_MAX_PASSES: usize = 20; +pub const LEIDEN_DEFAULT_MAX_LOCAL_PASSES: usize = 20; + +/// Minimum quality gain to accept a node move (avoids floating-point noise). +pub const LEIDEN_GAIN_EPSILON: f64 = 1e-12; + +/// Default Boltzmann temperature for the refinement phase's probabilistic +/// candidate selection (Algorithm 3, Traag et al. 2019). +pub const LEIDEN_DEFAULT_REFINEMENT_THETA: f64 = 1.0; + +/// Default resolution (gamma) parameter for modularity optimization. +pub const LEIDEN_DEFAULT_RESOLUTION: f64 = 1.0; -/// Minimum modularity gain to accept a node move (avoids floating-point noise). -pub const LOUVAIN_MIN_GAIN: f64 = 1e-12; +/// Growth multiplier applied when a partition's per-community arrays need to +/// grow beyond their initial capacity (post-refinement disconnected-community +/// splitting can mint more community ids than the initial allocation). +pub const LEIDEN_DEFAULT_CAPACITY_GROWTH_FACTOR: f64 = 1.5; /// Default random seed for deterministic community detection. pub const DEFAULT_RANDOM_SEED: u32 = 42; diff --git a/src/cli/commands/communities.ts b/src/cli/commands/communities.ts index e2fbda1bb..0e4d11bea 100644 --- a/src/cli/commands/communities.ts +++ b/src/cli/commands/communities.ts @@ -2,11 +2,11 @@ import type { CommandDefinition } from '../types.js'; export const command: CommandDefinition = { name: 'communities', - description: 'Detect natural module boundaries using Louvain community detection', + description: 'Detect natural module boundaries using Leiden community detection', queryOpts: true, options: [ ['--functions', 'Function-level instead of file-level'], - ['--resolution ', 'Louvain resolution parameter (default 1.0)', '1.0'], + ['--resolution ', 'Leiden resolution parameter (default 1.0)', '1.0'], ['--drift', 'Show only drift analysis'], ], async execute(_args, opts, ctx) { diff --git a/src/graph/algorithms/louvain.ts b/src/graph/algorithms/louvain.ts index 60ef439ff..b4b1cf16b 100644 --- a/src/graph/algorithms/louvain.ts +++ b/src/graph/algorithms/louvain.ts @@ -1,12 +1,21 @@ /** - * Community detection via native Rust Louvain or vendored Leiden algorithm. + * Community detection via native Rust Leiden or vendored TS Leiden. * Maintains backward-compatible API: { assignments: Map, modularity: number } * - * Native path: classic Louvain (Rust, undirected modularity optimization). - * JS fallback: Leiden algorithm via `detectClusters` (always undirected, `directed: false`). + * Both the native path and the JS fallback run the same algorithm — Leiden + * (always undirected, `directed: false`, modularity quality). Before issue + * #1804, the native path ran classic Louvain while the JS fallback ran + * Leiden: two genuinely different algorithms with different guarantees + * (Leiden avoids Louvain's disconnected-community defect), so `codegraph + * communities`/`--drift` reported different partitions purely based on + * whether the native addon loaded. `crates/codegraph-core/src/graph/algorithms/leiden.rs` + * is a faithful Rust port of `./leiden/*` covering exactly the option + * surface `LouvainOptions` exposes below (undirected, modularity-only, + * "neighbors" candidate strategy, refine always on) — see that file's module + * doc for the precise (deliberately narrower) subset ported and the + * follow-up issue tracking the rest. */ -import { debug } from '../../infrastructure/logger.js'; import { loadNative } from '../../infrastructure/native.js'; import type { CodeGraph } from '../model.js'; import type { DetectClustersResult } from './leiden/index.js'; @@ -36,20 +45,19 @@ export function louvainCommunities(graph: CodeGraph, opts: LouvainOptions = {}): const resolution: number = opts.resolution ?? 1.0; const native = loadNative(); - if (native?.louvainCommunities) { - if ( - opts.maxLevels != null || - opts.maxLocalPasses != null || - opts.refinementTheta != null || - opts.capacityGrowthFactor != null - ) { - debug( - 'louvainCommunities: maxLevels/maxLocalPasses/refinementTheta/capacityGrowthFactor are ignored by the native Rust path', - ); - } + if (native?.leidenCommunities) { const edges = graph.toEdgeArray(); const nodeIds = graph.nodeIds(); - const result = native.louvainCommunities(edges, nodeIds, resolution, DEFAULT_RANDOM_SEED); + const result = native.leidenCommunities( + edges, + nodeIds, + resolution, + DEFAULT_RANDOM_SEED, + opts.maxLevels, + opts.maxLocalPasses, + opts.refinementTheta, + opts.capacityGrowthFactor, + ); const assignments = new Map(); for (const entry of result.assignments) { assignments.set(entry.node, entry.community); diff --git a/src/infrastructure/config.ts b/src/infrastructure/config.ts index 46e0dd791..2ca7734dc 100644 --- a/src/infrastructure/config.ts +++ b/src/infrastructure/config.ts @@ -181,11 +181,13 @@ export const DEFAULTS = deepFreeze({ refinementTheta: 1.0, /** * Growth multiplier applied when a Leiden partition's per-community - * typed arrays need to be resized to fit a larger community count. - * Threaded through `communitiesData()` -> `louvainCommunities()` -> - * `detectClusters()` -> `makePartition()` to `ensureCommCapacity()` in - * `src/graph/algorithms/leiden/partition.ts`. Ignored by the native Rust - * Louvain path (classic Louvain doesn't use this Leiden-specific resize). + * arrays need to be resized to fit a larger community count. Threaded + * through `communitiesData()` -> `louvainCommunities()` to both engines: + * the JS path (`detectClusters()` -> `makePartition()` -> + * `ensureCommCapacity()` in `src/graph/algorithms/leiden/partition.ts`) + * and the native path (`leiden_communities()` -> + * `Partition::ensure_capacity()` in + * `crates/codegraph-core/src/graph/algorithms/leiden.rs`). */ capacityGrowthFactor: 1.5, }, diff --git a/src/mcp/tool-registry.ts b/src/mcp/tool-registry.ts index 84d6aaff4..081396310 100644 --- a/src/mcp/tool-registry.ts +++ b/src/mcp/tool-registry.ts @@ -538,7 +538,7 @@ const BASE_TOOLS: ToolSchema[] = [ { name: 'communities', description: - 'Detect natural module boundaries using Louvain community detection. Compares discovered communities against directory structure and surfaces architectural drift.', + 'Detect natural module boundaries using Leiden community detection. Compares discovered communities against directory structure and surfaces architectural drift.', inputSchema: { type: 'object', properties: { @@ -549,7 +549,7 @@ const BASE_TOOLS: ToolSchema[] = [ }, resolution: { type: 'number', - description: 'Louvain resolution parameter (higher = more communities)', + description: 'Leiden resolution parameter (higher = more communities)', default: 1.0, }, drift: { diff --git a/src/types.ts b/src/types.ts index 19ff09970..3ce8f716f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2271,11 +2271,25 @@ export interface NativeAddon { fanInOut( edges: Array<{ source: string; target: string }>, ): Array<{ node: string; fanIn: number; fanOut: number }>; - louvainCommunities( + /** + * Leiden community detection (undirected modularity optimization) — see + * `crates/codegraph-core/src/graph/algorithms/leiden.rs` for the full + * option-surface caveat (this binding only covers what + * `louvainCommunities`/`LouvainOptions` in `src/graph/algorithms/louvain.ts` + * ever passes through). Optional: older published addons predate this + * export (it replaced the classic-Louvain `louvainCommunities` binding — + * issue #1804), so callers must feature-detect and fall back to + * `louvainJS()`/`detectClusters()`. + */ + leidenCommunities?( edges: Array<{ source: string; target: string }>, nodeIds: string[], resolution?: number | null, randomSeed?: number | null, + maxLevels?: number | null, + maxLocalPasses?: number | null, + refinementTheta?: number | null, + capacityGrowthFactor?: number | null, ): { assignments: Array<{ node: string; community: number }>; modularity: number; diff --git a/tests/graph/algorithms/leiden-native-parity.test.ts b/tests/graph/algorithms/leiden-native-parity.test.ts new file mode 100644 index 000000000..402a1bcba --- /dev/null +++ b/tests/graph/algorithms/leiden-native-parity.test.ts @@ -0,0 +1,235 @@ +/** + * Native/JS Leiden parity tests (issue #1804). + * + * Before this fix, the native path ran classic Louvain while the JS + * fallback ran Leiden — two genuinely different algorithms, so + * `codegraph communities`/`--drift` reported different partitions purely + * based on whether the native addon loaded. Both engines now run Leiden + * (`crates/codegraph-core/src/graph/algorithms/leiden.rs` is a Rust port of + * `../leiden/*`), so this suite asserts they produce byte-identical + * community assignments and modularity scores — not just "similar" ones — + * across a variety of graph shapes, seeds, and resolutions. + * + * Skipped entirely when the native addon isn't available in this + * environment (nothing to compare against). + */ + +import { describe, expect, it } from 'vitest'; +import { detectClusters } from '../../../src/graph/algorithms/leiden/index.js'; +import { louvainCommunities } from '../../../src/graph/algorithms/louvain.js'; +import { CodeGraph } from '../../../src/graph/model.js'; +import { getNative, isNativeAvailable } from '../../../src/infrastructure/native.js'; + +const hasNative = isNativeAvailable(); + +/** Sorted "node:community" pairs — stable snapshot for deep-equal comparison. */ +function snapshot(assignments: Map): string[] { + return [...assignments.entries()].map(([node, community]) => `${node}:${community}`).sort(); +} + +/** Run the JS Leiden path directly (bypassing louvainCommunities' native preference). */ +function runJS( + graph: CodeGraph, + opts: { resolution?: number; randomSeed?: number } = {}, +): { assignments: Map; modularity: number } { + const result = detectClusters(graph, { + resolution: opts.resolution ?? 1.0, + randomSeed: opts.randomSeed ?? 42, + directed: false, + }); + const assignments = new Map(); + for (const [id] of graph.nodes()) { + const cls = result.getClass(id); + if (cls != null) assignments.set(id, cls); + } + return { assignments, modularity: result.quality() }; +} + +/** Run the native Leiden binding directly, bypassing louvainCommunities' hardcoded seed. */ +function runNativeDirect( + graph: CodeGraph, + opts: { resolution?: number; randomSeed?: number } = {}, +): { assignments: Map; modularity: number } { + const native = getNative(); + const edges = graph.toEdgeArray(); + const nodeIds = graph.nodeIds(); + const result = native.leidenCommunities!( + edges, + nodeIds, + opts.resolution ?? 1.0, + opts.randomSeed ?? 42, + ); + const assignments = new Map(); + for (const entry of result.assignments) assignments.set(entry.node, entry.community); + return { assignments, modularity: result.modularity }; +} + +/** + * Assert parity through the *public* `louvainCommunities` API (which prefers + * native and hardcodes randomSeed=42 for both engines — matching real + * `codegraph communities` usage). Only `resolution` is a real caller-facing + * knob here. + */ +function assertParity(graph: CodeGraph, opts: { resolution?: number } = {}) { + const native = louvainCommunities(graph, { resolution: opts.resolution }); + const js = runJS(graph, { resolution: opts.resolution }); + expect(snapshot(native.assignments)).toEqual(snapshot(js.assignments)); + expect(native.modularity).toBe(js.modularity); +} + +/** + * Assert parity by calling the native binding and the JS algorithm directly + * with an explicit seed, bypassing `louvainCommunities`' hardcoded seed — + * used to verify the underlying algorithms agree across seeds, independent + * of what the current public wrapper happens to expose. + */ +function assertParityDirect(graph: CodeGraph, opts: { resolution?: number; randomSeed?: number }) { + const native = runNativeDirect(graph, opts); + const js = runJS(graph, opts); + expect(snapshot(native.assignments)).toEqual(snapshot(js.assignments)); + expect(native.modularity).toBe(js.modularity); +} + +function buildTwoClusterGraph(): CodeGraph { + const g = new CodeGraph(); + g.addEdge('a', 'b'); + g.addEdge('b', 'c'); + g.addEdge('c', 'a'); + g.addEdge('x', 'y'); + g.addEdge('y', 'z'); + g.addEdge('z', 'x'); + g.addEdge('c', 'x'); + return g; +} + +/** Deterministic pseudo-random clustered graph: numClusters loosely-connected + * cliques plus sparse cross-links, large enough to force multi-level + * coarsening (the code path that originally diverged — see leiden.rs's + * `build_coarse_graph` doc). */ +function buildClusteredGraph( + numClusters: number, + clusterSize: number, + crossLinkProb: number, + seedStr: string, +): CodeGraph { + const g = new CodeGraph(); + let s = 0; + for (let i = 0; i < seedStr.length; i++) s = (s * 31 + seedStr.charCodeAt(i)) & 0x7fffffff; + const rnd = (): number => { + s = (s * 1103515245 + 12345) & 0x7fffffff; + return s / 0x7fffffff; + }; + + const clusters: string[][] = []; + for (let c = 0; c < numClusters; c++) { + const nodes: string[] = []; + for (let i = 0; i < clusterSize; i++) { + const id = `c${c}n${i}`; + nodes.push(id); + g.addNode(id); + } + for (let i = 0; i < nodes.length; i++) { + for (let j = i + 1; j < nodes.length; j++) { + if (rnd() < 0.5) g.addEdge(nodes[i]!, nodes[j]!); + } + } + clusters.push(nodes); + } + for (let c1 = 0; c1 < numClusters; c1++) { + for (let c2 = c1 + 1; c2 < numClusters; c2++) { + for (const a of clusters[c1]!) { + for (const b of clusters[c2]!) { + if (rnd() < crossLinkProb) g.addEdge(a, b); + } + } + } + } + return g; +} + +describe.skipIf(!hasNative)('native/JS Leiden parity (issue #1804)', () => { + it('matches on a small two-cluster graph', () => { + assertParity(buildTwoClusterGraph()); + }); + + it('matches on a graph with reciprocal (mutual-import-style) edges', () => { + // Both directions present between several node pairs -- exercises the + // undirected symmetrization/averaging path (adapter.ts's + // aggregateUndirectedPairs), a second, independent source of + // native/JS divergence pre-#1804 (classic Louvain summed reciprocal + // edges instead of averaging them). + const g = new CodeGraph(); + g.addEdge('a', 'b'); + g.addEdge('b', 'a'); + g.addEdge('b', 'c'); + g.addEdge('c', 'b'); + g.addEdge('c', 'd'); + g.addEdge('d', 'c'); + assertParity(g); + }); + + it('matches on a graph with self-loops', () => { + // Unweighted self-loop only: custom edge weights are out of scope for + // the native binding (GraphEdge carries no weight field, and none of + // louvainCommunities' real call sites ever set one — see leiden.rs's + // module doc). This still guards the actual regression: the classic + // Louvain implementation this file replaces dropped self-loops + // entirely instead of counting them once toward degree/modularity. + const g = new CodeGraph(); + g.addEdge('a', 'b'); + g.addEdge('b', 'c'); + g.addEdge('c', 'a'); + g.addEdge('a', 'a'); + assertParity(g); + }); + + it('matches on a graph forcing multi-level coarsening', () => { + const g = buildClusteredGraph(15, 4, 0.02, 'parity-seed-1'); + assertParity(g); + }); + + it('matches across multiple resolutions via the public API on a multi-level graph', () => { + const g = buildClusteredGraph(10, 6, 0.03, 'parity-seed-2'); + for (const resolution of [0.5, 0.8, 1.0, 1.5, 2.0]) { + assertParity(g, { resolution }); + } + }); + + it('matches across multiple seeds (direct engine calls) on a multi-level graph', () => { + // louvainCommunities hardcodes randomSeed=42 for both engines (it is not + // a caller-facing knob today), so this drives the native binding and + // detectClusters directly to confirm the underlying algorithms agree + // for any seed, not just the one value the current wrapper ever passes. + const g = buildClusteredGraph(10, 6, 0.03, 'parity-seed-2'); + for (const randomSeed of [1, 7, 42, 999, 2026]) { + assertParityDirect(g, { randomSeed }); + } + }); + + it('matches with maxLevels/maxLocalPasses/refinementTheta/capacityGrowthFactor overrides', () => { + const g = buildClusteredGraph(8, 5, 0.03, 'parity-seed-3'); + const native = louvainCommunities(g, { + resolution: 1.0, + maxLevels: 3, + maxLocalPasses: 5, + refinementTheta: 0.5, + capacityGrowthFactor: 1.2, + }); + const jsResult = detectClusters(g, { + resolution: 1.0, + randomSeed: 42, + directed: false, + maxLevels: 3, + maxLocalPasses: 5, + refinementTheta: 0.5, + capacityGrowthFactor: 1.2, + }); + const jsAssignments = new Map(); + for (const [id] of g.nodes()) { + const cls = jsResult.getClass(id); + if (cls != null) jsAssignments.set(id, cls); + } + expect(snapshot(native.assignments)).toEqual(snapshot(jsAssignments)); + expect(native.modularity).toBe(jsResult.quality()); + }); +}); diff --git a/tests/graph/algorithms/louvain.test.ts b/tests/graph/algorithms/louvain.test.ts index 0002873e0..6496de8dc 100644 --- a/tests/graph/algorithms/louvain.test.ts +++ b/tests/graph/algorithms/louvain.test.ts @@ -1,7 +1,6 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { louvainCommunities } from '../../../src/graph/algorithms/louvain.js'; import { CodeGraph } from '../../../src/graph/model.js'; -import { setVerbose } from '../../../src/infrastructure/logger.js'; describe('louvainCommunities', () => { it('returns empty for empty graph', () => { @@ -138,67 +137,29 @@ describe('louvainCommunities', () => { }); }); - describe('Leiden-knob parity logging', () => { - let stderrSpy: ReturnType; - - beforeEach(() => { - stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); - }); - - afterEach(() => { - stderrSpy.mockRestore(); - setVerbose(false); - }); - - function buildTwoClusterGraph(): CodeGraph { - const g = new CodeGraph(); - g.addEdge('a', 'b'); - g.addEdge('b', 'c'); - g.addEdge('c', 'a'); - g.addEdge('x', 'y'); - g.addEdge('y', 'z'); - g.addEdge('z', 'x'); - g.addEdge('c', 'x'); - return g; - } - - // Regression guard: DEFAULTS.community always populates maxLevels/maxLocalPasses/ - // refinementTheta, so forwarding config values used to emit `[codegraph WARN]` on - // every communities computation. Keep the parity note at debug level — never warn. - it('never emits the parity message at WARN level when config defaults are forwarded', () => { - const g = buildTwoClusterGraph(); - louvainCommunities(g, { - maxLevels: 50, - maxLocalPasses: 20, - refinementTheta: 1.0, - }); + // Regression guard for issue #1804: before that fix, the native Rust path + // ran classic Louvain and silently ignored maxLevels/maxLocalPasses/ + // refinementTheta/capacityGrowthFactor entirely (native Leiden now honors + // all four, same as the JS fallback) — this just pins that passing them + // doesn't throw or change the shape of the result, on both engines. + it('accepts maxLevels/maxLocalPasses/refinementTheta/capacityGrowthFactor without error', () => { + const g = new CodeGraph(); + g.addEdge('a', 'b'); + g.addEdge('b', 'c'); + g.addEdge('c', 'a'); + g.addEdge('x', 'y'); + g.addEdge('y', 'z'); + g.addEdge('z', 'x'); + g.addEdge('c', 'x'); - const warnWrites = stderrSpy.mock.calls - .map(([chunk]) => (typeof chunk === 'string' ? chunk : (chunk?.toString?.() ?? ''))) - .filter((line) => line.includes('[codegraph WARN]')); - expect(warnWrites).toEqual([]); + const { assignments, modularity } = louvainCommunities(g, { + maxLevels: 50, + maxLocalPasses: 20, + refinementTheta: 1.0, + capacityGrowthFactor: 1.5, }); - it('emits the parity message at DEBUG level when verbose is enabled and Leiden knobs are set', () => { - setVerbose(true); - const g = buildTwoClusterGraph(); - louvainCommunities(g, { - maxLevels: 50, - maxLocalPasses: 20, - refinementTheta: 1.0, - }); - - const writes = stderrSpy.mock.calls - .map(([chunk]) => (typeof chunk === 'string' ? chunk : (chunk?.toString?.() ?? ''))) - .join(''); - // Message is only emitted on the native path. When native is unavailable we at - // least assert no WARN was emitted (covered by the previous test); when it is - // available, it must go through the DEBUG channel and never WARN. - expect(writes).not.toContain('[codegraph WARN]'); - // Allow either outcome for DEBUG depending on engine availability. - if (writes.includes('maxLevels/maxLocalPasses/refinementTheta')) { - expect(writes).toContain('[codegraph DEBUG]'); - } - }); + expect(assignments.size).toBe(6); + expect(typeof modularity).toBe('number'); }); }); From b54972ae40a6a35ca7edd04cc20cb36c0d09b9fd Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Mon, 6 Jul 2026 18:53:07 -0600 Subject: [PATCH 61/67] feat: detect exported-symbol loss from full file deletion in checkNoSignatureChanges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit checkNoSignatureChanges could never catch a file deleted in its entirety: parseDiffOutput discarded a deleted file's `+++ /dev/null` hunk entirely (no changedRanges entry to compare against), and the graph builder purges the file's nodes rows on the very next rebuild, so the DB-driven lookup always returned zero rows for it. Deleting a file whose exports still have external callers elsewhere in the codebase silently passed the signature-change gate. parseDiffOutput now tracks fully deleted files (via the `--- a/` / `+++ /dev/null` header pair) in a new `deletedFiles` set. A new sibling predicate, checkNoDeletedExportsInUse, queries the current DB for each deleted file's exported function/method/class declarations and flags any that still have a real external (cross-file) consumer — callers that are themselves among the files this same diff deletes don't count, mirroring checkNoSignatureChanges's own exported-only rationale. Unlike checkNoSignatureChanges (which flags any touched exported declaration regardless of caller count), this predicate only flags when an actual external consumer exists, so deleting genuinely dead files is unaffected. Both predicates report under the same 'signatures' name/flag, so existing consumers (the pre-commit hook, `codegraph check --json`, titan-gate) pick up the new violations with no wiring changes. checkData's early-return guard now also accounts for delete-only diffs, which never populate changedRanges. Scope: this closes the gap for the common case where `codegraph check` runs before any rebuild has purged the deleted file's rows (e.g. this repo's own pre-commit hook, which checks staged changes without rebuilding first). Once some other `codegraph build` invocation purges the rows, this predicate has nothing left to find — same as before this predicate existed, not a regression. Follow-up #1938 tracks a durable, purge-order-independent fix. Fixes #1806 docs check acknowledged: no new CLI flag/command was added (the `--signatures` flag and 'signatures' predicate already existed and are not individually documented in README/CLAUDE.md today), no new language support, no architecture-table-level change. Impact: 19 functions changed, 7 affected --- src/features/check.ts | 170 ++++++++++++++++++++- src/presentation/check.ts | 16 +- tests/integration/check.test.ts | 257 +++++++++++++++++++++++++++++++- 3 files changed, 431 insertions(+), 12 deletions(-) diff --git a/src/features/check.ts b/src/features/check.ts index a7f638f17..5f55186ee 100644 --- a/src/features/check.ts +++ b/src/features/check.ts @@ -27,6 +27,14 @@ interface ParsedDiff { * `checkMaxBlastRadius`'s call-graph-shape exemption — see issue #1740. */ changedEdits: Map; + /** + * Files removed in their entirety (a `--- a/` header followed by a + * `+++ /dev/null` target, git's marker for a full-file deletion) — distinct + * from `changedRanges`/`oldRanges`, which never gain an entry for these + * files since there is no new-side content to track. Powers + * `checkNoDeletedExportsInUse` — see issue #1806. + */ + deletedFiles: Set; } /** An added-line run paired with whatever it replaced, for shape comparison. */ @@ -37,6 +45,7 @@ interface DiffTextEdit extends DiffRange { const HUNK_RE = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/; const NEW_FILE_RE = /^\+\+\+ b\/(.+)/; +const OLD_FILE_RE = /^--- a\/(.+)/; /** Returns true if the diff line marks the old file as /dev/null (new-file creation). */ function isDevNullSourceLine(line: string): boolean { @@ -54,6 +63,12 @@ function extractNewFileName(line: string): string | null { return m ? m[1]! : null; } +/** Extracts the old filename from a `--- a/` diff header, or null. */ +function extractOldFileName(line: string): string | null { + const m = line.match(OLD_FILE_RE); + return m ? m[1]! : null; +} + /** Returns true if the diff line marks the new file as /dev/null (file deletion). */ function isDevNullTargetLine(line: string): boolean { return line.startsWith('+++ /dev/null'); @@ -216,8 +231,14 @@ export function parseDiffOutput(diffOutput: string): ParsedDiff { const oldRanges = new Map(); const changedEdits = new Map(); const newFiles = new Set(); + const deletedFiles = new Set(); let currentFile: string | null = null; let prevIsDevNull = false; + // Old-side filename staged by a `--- a/` header, in case the very + // next header turns out to be `+++ /dev/null` (this file was deleted in + // its entirety). Cleared whenever the following header resolves to + // anything else, so it never leaks across an unrelated file's headers. + let pendingOldFile: string | null = null; const tracker = new DiffLineTracker(); for (const line of diffOutput.split('\n')) { @@ -231,10 +252,12 @@ export function parseDiffOutput(diffOutput: string): ParsedDiff { if (!tracker.insideHunk()) { if (isDevNullSourceLine(line)) { prevIsDevNull = true; + pendingOldFile = null; continue; } if (isSourceFileHeaderLine(line)) { prevIsDevNull = false; + pendingOldFile = extractOldFileName(line); continue; } const newFile = extractNewFileName(line); @@ -246,6 +269,7 @@ export function parseDiffOutput(diffOutput: string): ParsedDiff { if (!changedEdits.has(currentFile)) changedEdits.set(currentFile, []); if (prevIsDevNull) newFiles.add(currentFile); prevIsDevNull = false; + pendingOldFile = null; continue; } if (isDevNullTargetLine(line)) { @@ -253,12 +277,16 @@ export function parseDiffOutput(diffOutput: string): ParsedDiff { // extractNewFileName returned null above and this line would otherwise // fall through to tracker.consume and be misread as an added source // line under whichever file preceded this one in the diff. Flush and - // clear the file context instead — the deleted file's hunk body that - // follows has no corresponding DB entry to check against anyway (its - // nodes are purged from the graph), so there is nothing to track here. + // clear the file context instead — a deleted file never accumulates + // changedRanges/oldRanges entries (there is no new-side content, and + // the old-side body is about to disappear along with the file), but + // its pre-purge DB rows are still worth checking for lingering + // external consumers — see `checkNoDeletedExportsInUse` (#1806). if (currentFile) tracker.flush(currentFile, oldRanges, changedRanges, changedEdits); + if (pendingOldFile) deletedFiles.add(pendingOldFile); currentFile = null; prevIsDevNull = false; + pendingOldFile = null; continue; } } @@ -282,7 +310,7 @@ export function parseDiffOutput(diffOutput: string): ParsedDiff { } if (currentFile) tracker.flush(currentFile, oldRanges, changedRanges, changedEdits); - return { changedRanges, oldRanges, newFiles, changedEdits }; + return { changedRanges, oldRanges, newFiles, changedEdits, deletedFiles }; } // ─── Predicates ─────────────────────────────────────────────────────── @@ -474,11 +502,21 @@ export function checkMaxBlastRadius( return result; } +interface ConsumerRef { + name: string; + file: string; + line: number; +} + interface SignatureViolation { name: string; kind: string; file: string; line: number; + /** Present only for violations from `checkNoDeletedExportsInUse` — see #1806. */ + reason?: 'file-deleted'; + /** External (cross-file) consumers found for a deleted export — only set when `reason === 'file-deleted'`. */ + consumers?: ConsumerRef[]; } interface SignatureResult { @@ -536,6 +574,105 @@ export function checkNoSignatureChanges( return { passed: violations.length === 0, violations }; } +type DeletedDefRow = { + id: number; + name: string; + kind: string; + file: string; + line: number; +}; + +/** + * Detects exported functions/methods/classes lost when a file is deleted in + * its entirety, for deletions whose exports still have consumers elsewhere + * in the codebase. + * + * `checkNoSignatureChanges` can never see this case: a fully deleted file + * never gets a `changedRanges` entry (there is no new-side content to track + * — see `parseDiffOutput`'s `+++ /dev/null` handling), and its `nodes` rows + * are purged by the very next `codegraph build`/incremental rebuild + * (`DELETE FROM nodes WHERE file = ?`, run unconditionally for any file + * `detectChanges` no longer finds on disk — see + * `domain/graph/builder/stages/detect-changes.ts`). `checkData` itself never + * triggers a rebuild — it only opens the DB read-only — so whether this + * predicate can see a deleted file's exports depends entirely on whether + * some *other*, separate `codegraph build` invocation has already purged it + * by the time `check` runs. + * + * This closes that gap for the common case: `db` still reflects pre-purge + * state whenever `codegraph check --staged` runs before any rebuild has + * observed the deletion (e.g. this repo's own pre-commit hook, which checks + * staged changes without rebuilding first). Once some rebuild purges the + * deleted file's rows, this predicate has nothing left to find for it — the + * violation silently goes undetected, same as before this predicate + * existed. See issue #1806 for the follow-up tracking a durable, + * purge-order-independent fix (e.g. capturing this at purge time inside the + * build pipeline itself, so it survives regardless of invocation order). + * + * Unlike `checkNoSignatureChanges` (which flags any touched exported + * declaration regardless of caller count, since editing an exported line is + * inherently risky), this predicate only flags a deleted export when it has + * a real consumer OUTSIDE the deleted file — reusing the same + * cross-file-consumer shape as `domain/analysis/exports.ts` / + * `features/structure.ts` (`calls`/`imports-type` edges whose source file + * differs from the target's file). Deleting a file whose exports are never + * imported elsewhere is a legitimate, safe cleanup and must not be flagged. + * + * Known limitation: a same-commit rename (delete `old.js` + add `new.js` + * with equivalent exports, with callers updated to import from `new.js` in + * the same diff) can false-positive here, because `db` only reflects + * pre-change edges — it has no visibility into the staged content of the + * files that will import from the new location. This mirrors an existing, + * accepted trade-off in this predicate family (e.g. `checkMaxBlastRadius`'s + * paren-less-call blind spot) rather than a new class of problem. + */ +export function checkNoDeletedExportsInUse( + db: BetterSqlite3Database, + deletedFiles: Set, + noTests: boolean, +): SignatureResult { + const violations: SignatureViolation[] = []; + if (deletedFiles.size === 0) return { passed: true, violations }; + + const defsStmt = db.prepare( + `SELECT id, name, kind, file, line FROM nodes WHERE file = ? AND kind IN ('function', 'method', 'class') AND exported = 1 ORDER BY line`, + ); + const consumersStmt = db.prepare( + `SELECT DISTINCT caller.name, caller.file, caller.line + FROM edges e + JOIN nodes caller ON e.source_id = caller.id + WHERE e.target_id = ? AND e.kind IN ('calls', 'imports-type') AND caller.file != ?`, + ); + + for (const file of deletedFiles) { + if (noTests && isTestFile(file)) continue; + + const defs = defsStmt.all(file) as DeletedDefRow[]; + for (const def of defs) { + let consumers = consumersStmt.all(def.id, def.file) as ConsumerRef[]; + // A caller that is itself among the files this same diff deletes isn't + // an external caller left dangling by the diff — it's being removed + // too. Mirrors checkNoSignatureChanges's exported-only filter: only a + // caller reachable from outside the set of files this diff removes can + // be a caller the diff doesn't already account for. + consumers = consumers.filter((c) => !deletedFiles.has(c.file)); + if (noTests) consumers = consumers.filter((c) => !isTestFile(c.file)); + if (consumers.length === 0) continue; + + violations.push({ + name: def.name, + kind: def.kind, + file: def.file, + line: def.line, + reason: 'file-deleted', + consumers, + }); + } + } + + return { passed: violations.length === 0, violations }; +} + interface BoundaryViolation { from: string; to: string; @@ -604,6 +741,7 @@ interface CheckSummary { failed: number; changedFiles: number; newFiles: number; + deletedFiles: number; } interface CheckResult { @@ -694,9 +832,21 @@ function runPredicates( }); } if (flags.enableSignatures) { + // Both predicates report under the single 'signatures' name: they are + // two detection strategies for the same class of risk (an exported + // declaration this diff makes unreachable to its existing callers) — + // checkNoSignatureChanges for declarations edited in place, + // checkNoDeletedExportsInUse for declarations lost via full-file + // deletion (#1806). Merging keeps both gated by the same --signatures + // flag/config and lets existing consumers of the 'signatures' predicate + // (the pre-commit hook, `codegraph check --json`) pick up the new + // violations with no wiring changes. + const editedResult = checkNoSignatureChanges(db, diff.changedRanges, noTests); + const deletedResult = checkNoDeletedExportsInUse(db, diff.deletedFiles, noTests); predicates.push({ name: 'signatures', - ...checkNoSignatureChanges(db, diff.changedRanges, noTests), + passed: editedResult.passed && deletedResult.passed, + violations: [...editedResult.violations, ...deletedResult.violations], }); } if (flags.enableBoundaries) { @@ -712,7 +862,7 @@ function runPredicates( function makeEmptyCheck(): CheckResult { return { predicates: [], - summary: { total: 0, passed: 0, failed: 0, changedFiles: 0, newFiles: 0 }, + summary: { total: 0, passed: 0, failed: 0, changedFiles: 0, newFiles: 0, deletedFiles: 0 }, passed: true, }; } @@ -752,7 +902,12 @@ export function checkData(customDbPath: string | undefined, opts: CheckOpts = {} if (!diffOutput.trim()) return makeEmptyCheck(); const diff = parseDiffOutput(diffOutput); - if (diff.changedRanges.size === 0) return makeEmptyCheck(); + // A delete-only diff (e.g. `git rm` with no other staged changes) never + // populates changedRanges — see parseDiffOutput's `+++ /dev/null` + // handling — but must still run the predicates below (specifically + // checkNoDeletedExportsInUse) rather than short-circuiting to "no + // changes" (#1806). + if (diff.changedRanges.size === 0 && diff.deletedFiles.size === 0) return makeEmptyCheck(); const predicates = runPredicates(db, diff, flags, repoRoot, noTests, maxDepth); @@ -767,6 +922,7 @@ export function checkData(customDbPath: string | undefined, opts: CheckOpts = {} failed: failedCount, changedFiles: diff.changedRanges.size, newFiles: diff.newFiles.size, + deletedFiles: diff.deletedFiles.size, }, passed: failedCount === 0, }; diff --git a/src/presentation/check.ts b/src/presentation/check.ts index a9137a433..7bc800883 100644 --- a/src/presentation/check.ts +++ b/src/presentation/check.ts @@ -28,6 +28,9 @@ interface CheckViolation { from?: string; to?: string; edgeKind?: string; + /** Set when this violation comes from `checkNoDeletedExportsInUse` (#1806). */ + reason?: string; + consumers?: Array<{ name: string; file: string; line: number }>; } interface CheckPredicate { @@ -46,6 +49,7 @@ interface CheckDataResult { summary: { changedFiles: number; newFiles: number; + deletedFiles: number; total: number; passed: number; failed: number; @@ -74,6 +78,14 @@ function formatPredicateViolations(pred: CheckPredicate): void { if (pred.name === 'boundaries') { return `${v.from} -> ${v.to} (${v.edgeKind})`; } + if (v.reason === 'file-deleted' && v.consumers) { + const sample = v.consumers + .slice(0, 3) + .map((c) => `${c.file}:${c.line}`) + .join(', '); + const more = v.consumers.length > 3 ? `, ... and ${v.consumers.length - 3} more` : ''; + return `${v.name} (${v.kind}) — file ${v.file} deleted but still used by ${v.consumers.length} external consumer(s): ${sample}${more}`; + } return `${v.name} (${v.kind}) at ${v.file}:${v.line}`; }; @@ -113,8 +125,10 @@ export function check(customDbPath: string | undefined, opts: CheckCliOpts = {}) return; } + const deletedSuffix = + data.summary.deletedFiles > 0 ? ` Deleted files: ${data.summary.deletedFiles}` : ''; console.log( - ` Changed files: ${data.summary.changedFiles} New files: ${data.summary.newFiles}\n`, + ` Changed files: ${data.summary.changedFiles} New files: ${data.summary.newFiles}${deletedSuffix}\n`, ); for (const pred of data.predicates) { diff --git a/tests/integration/check.test.ts b/tests/integration/check.test.ts index 5b9bd9061..b33780bf5 100644 --- a/tests/integration/check.test.ts +++ b/tests/integration/check.test.ts @@ -15,6 +15,7 @@ import { checkData, checkMaxBlastRadius, checkNoBoundaryViolations, + checkNoDeletedExportsInUse, checkNoNewCycles, checkNoSignatureChanges, parseDiffOutput, @@ -198,16 +199,74 @@ describe('parseDiffOutput', () => { '-deleted line 2', ].join('\n'); - const { changedRanges, oldRanges } = parseDiffOutput(diff); + const { changedRanges, oldRanges, deletedFiles } = parseDiffOutput(diff); // kept.js's ranges must reflect only its own hunk, not the deleted // file's `+++ /dev/null` header or removed body lines. expect(changedRanges.get('src/kept.js')).toEqual([{ start: 1, end: 1 }]); expect(oldRanges.get('src/kept.js')).toEqual([{ start: 1, end: 1 }]); - // The deleted file itself is not tracked — its nodes are purged from the - // DB during the rebuild, so there is nothing for the check predicates to - // match against. + // The deleted file never gets a changedRanges/oldRanges entry — there is + // no new-side content to compare post-purge line numbers against — but + // it IS recorded in `deletedFiles` so checkNoDeletedExportsInUse can + // still check its pre-purge exports for lingering external callers + // (#1806). expect(changedRanges.has('src/removed.js')).toBe(false); expect(oldRanges.has('src/removed.js')).toBe(false); + expect(deletedFiles.has('src/removed.js')).toBe(true); + }); + + // ─── deletedFiles (issue #1806) ───────────────────────────────────── + + test('detects a fully deleted file via the /dev/null target marker', () => { + const diff = [ + '--- a/src/old-file.js', + '+++ /dev/null', + '@@ -1,2 +0,0 @@', + '-export function foo() {}', + '-export function bar() {}', + ].join('\n'); + + const { deletedFiles } = parseDiffOutput(diff); + expect(deletedFiles.has('src/old-file.js')).toBe(true); + expect(deletedFiles.size).toBe(1); + }); + + test('does not mark a modified file as deleted', () => { + const diff = ['--- a/src/kept.js', '+++ b/src/kept.js', '@@ -1,1 +1,1 @@', '-old', '+new'].join( + '\n', + ); + + const { deletedFiles } = parseDiffOutput(diff); + expect(deletedFiles.size).toBe(0); + }); + + test('a new file is not also recorded as deleted (/dev/null on the source side)', () => { + // `--- /dev/null` (new-file creation) must not be confused with + // `+++ /dev/null` (deletion) — isDevNullSourceLine clears pendingOldFile + // so a subsequent unrelated `+++ /dev/null` elsewhere can't attribute a + // deletion to this file. + const diff = ['--- /dev/null', '+++ b/src/new-file.js', '@@ -0,0 +1,1 @@', '+line1'].join('\n'); + + const { deletedFiles, newFiles } = parseDiffOutput(diff); + expect(newFiles.has('src/new-file.js')).toBe(true); + expect(deletedFiles.size).toBe(0); + }); + + test('tracks multiple deleted files in the same diff', () => { + const diff = [ + '--- a/src/first.js', + '+++ /dev/null', + '@@ -1,1 +0,0 @@', + '-export function first() {}', + '--- a/src/second.js', + '+++ /dev/null', + '@@ -1,1 +0,0 @@', + '-export function second() {}', + ].join('\n'); + + const { deletedFiles } = parseDiffOutput(diff); + expect(deletedFiles.has('src/first.js')).toBe(true); + expect(deletedFiles.has('src/second.js')).toBe(true); + expect(deletedFiles.size).toBe(2); }); test('handles multiple files', () => { @@ -667,6 +726,64 @@ describe('checkNoSignatureChanges', () => { }); }); +// ─── checkNoDeletedExportsInUse (issue #1806) ────────────────────────── + +describe('checkNoDeletedExportsInUse', () => { + test('flags an exported symbol whose file is deleted when it still has external callers', () => { + // Fixture: add (exported, src/math.js) is called by handleRequest + // (src/handler.js) and formatResult (src/utils.js) — both external. + const result = checkNoDeletedExportsInUse(db, new Set(['src/math.js']), false); + expect(result.passed).toBe(false); + const violation = result.violations.find((v) => v.name === 'add'); + expect(violation).toBeDefined(); + expect(violation.reason).toBe('file-deleted'); + expect(violation.consumers.map((c) => c.file).sort()).toEqual([ + 'src/handler.js', + 'src/utils.js', + ]); + }); + + test('does not flag an exported symbol whose only caller lives in the same deleted file', () => { + // multiply (exported, src/math.js) is only called by `add`, which lives + // in the same file being deleted — not an external consumer left + // dangling by this diff. + const result = checkNoDeletedExportsInUse(db, new Set(['src/math.js']), false); + expect(result.violations.map((v) => v.name)).not.toContain('multiply'); + }); + + test('passes for a deleted file with no exported symbols', () => { + const result = checkNoDeletedExportsInUse(db, new Set(['src/processor.js']), false); + expect(result.passed).toBe(true); + expect(result.violations).toEqual([]); + }); + + test('passes when deletedFiles is empty', () => { + const result = checkNoDeletedExportsInUse(db, new Set(), false); + expect(result.passed).toBe(true); + expect(result.violations).toEqual([]); + }); + + test('excludes a caller that is itself among the files this diff also deletes', () => { + // handler.js (home of handleRequest, one of add's two external callers) + // is being deleted in the same diff — its call to `add` must not count + // as a dangling external consumer. formatResult (utils.js) is untouched + // and must still be reported. + const result = checkNoDeletedExportsInUse( + db, + new Set(['src/math.js', 'src/handler.js']), + false, + ); + const violation = result.violations.find((v) => v.name === 'add'); + expect(violation).toBeDefined(); + expect(violation.consumers.map((c) => c.file)).toEqual(['src/utils.js']); + }); + + test('skips a deleted file when noTests is true and it is a test file', () => { + const result = checkNoDeletedExportsInUse(db, new Set(['tests/math.test.js']), true); + expect(result.passed).toBe(true); + }); +}); + // ─── checkNoBoundaryViolations ──────────────────────────────────────── describe('checkNoBoundaryViolations', () => { @@ -895,3 +1012,135 @@ describe('checkData', () => { } }); }); + +// ─── checkData: full file deletion (issue #1806) ─────────────────────── +// +// End-to-end repro of the scenario the issue describes: file A exports a +// symbol used by file B; staging A's deletion (with B untouched) must be +// flagged, while deleting a file with no external callers must not be. +// checkData is exercised directly (not via a rebuild) so `db` reflects the +// pre-purge state checkNoDeletedExportsInUse depends on — see that +// function's docstring for why purge ordering matters here. + +describe('checkData: full file deletion (issue #1806)', () => { + test('flags an exported function whose entire file is deleted while a real external caller remains', () => { + const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-check-delfile-')); + fs.mkdirSync(path.join(projectDir, '.codegraph')); + fs.mkdirSync(path.join(projectDir, 'src'), { recursive: true }); + const projectDbPath = path.join(projectDir, '.codegraph', 'graph.db'); + + const projectDb = new Database(projectDbPath); + projectDb.pragma('journal_mode = WAL'); + initSchema(projectDb); + // src/shared.js exports sharedHelper, called by src/consumer.js. + const sharedHelperId = insertNode( + projectDb, + 'sharedHelper', + 'function', + 'src/shared.js', + 1, + 3, + 1, + ); + const callerId = insertNode(projectDb, 'useShared', 'function', 'src/consumer.js', 1, 3, 0); + insertEdge(projectDb, callerId, sharedHelperId, 'calls'); + projectDb.close(); + + const { execFileSync } = require('node:child_process'); + try { + execFileSync('git', ['init'], { cwd: projectDir, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.email', 'test@test.com'], { + cwd: projectDir, + stdio: 'pipe', + }); + execFileSync('git', ['config', 'user.name', 'Test'], { cwd: projectDir, stdio: 'pipe' }); + + fs.writeFileSync( + path.join(projectDir, 'src', 'shared.js'), + 'export function sharedHelper() {\n return 1;\n}\n', + ); + fs.writeFileSync( + path.join(projectDir, 'src', 'consumer.js'), + "import { sharedHelper } from './shared.js';\nfunction useShared() {\n return sharedHelper();\n}\n", + ); + execFileSync('git', ['add', '.'], { cwd: projectDir, stdio: 'pipe' }); + execFileSync('git', ['commit', '-m', 'init'], { cwd: projectDir, stdio: 'pipe' }); + + // Stage ONLY the deletion of shared.js — consumer.js is left + // untouched, still importing/calling it. A real diff-parsing bug + // would let this slip through silently. + execFileSync('git', ['rm', 'src/shared.js'], { cwd: projectDir, stdio: 'pipe' }); + + const data = checkData(projectDbPath, { + staged: true, + signatures: true, + cycles: false, + boundaries: false, + }); + + expect(data.error).toBeUndefined(); + expect(data.summary.deletedFiles).toBe(1); + expect(data.passed).toBe(false); + + const sigPred = data.predicates.find((p) => p.name === 'signatures'); + expect(sigPred).toBeDefined(); + expect(sigPred.passed).toBe(false); + const violation = sigPred.violations.find((v) => v.name === 'sharedHelper'); + expect(violation).toBeDefined(); + expect(violation.reason).toBe('file-deleted'); + expect(violation.consumers.map((c) => c.file)).toContain('src/consumer.js'); + } finally { + fs.rmSync(projectDir, { recursive: true, force: true }); + } + }); + + test('does not flag deleting a file whose exports have no external callers', () => { + const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-check-delfile-safe-')); + fs.mkdirSync(path.join(projectDir, '.codegraph')); + fs.mkdirSync(path.join(projectDir, 'src'), { recursive: true }); + const projectDbPath = path.join(projectDir, '.codegraph', 'graph.db'); + + const projectDb = new Database(projectDbPath); + projectDb.pragma('journal_mode = WAL'); + initSchema(projectDb); + // src/orphan.js exports unusedHelper — zero callers anywhere. + insertNode(projectDb, 'unusedHelper', 'function', 'src/orphan.js', 1, 3, 1); + projectDb.close(); + + const { execFileSync } = require('node:child_process'); + try { + execFileSync('git', ['init'], { cwd: projectDir, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.email', 'test@test.com'], { + cwd: projectDir, + stdio: 'pipe', + }); + execFileSync('git', ['config', 'user.name', 'Test'], { cwd: projectDir, stdio: 'pipe' }); + + fs.writeFileSync( + path.join(projectDir, 'src', 'orphan.js'), + 'export function unusedHelper() {\n return 1;\n}\n', + ); + execFileSync('git', ['add', '.'], { cwd: projectDir, stdio: 'pipe' }); + execFileSync('git', ['commit', '-m', 'init'], { cwd: projectDir, stdio: 'pipe' }); + + execFileSync('git', ['rm', 'src/orphan.js'], { cwd: projectDir, stdio: 'pipe' }); + + const data = checkData(projectDbPath, { + staged: true, + signatures: true, + cycles: false, + boundaries: false, + }); + + expect(data.error).toBeUndefined(); + expect(data.summary.deletedFiles).toBe(1); + expect(data.passed).toBe(true); + const sigPred = data.predicates.find((p) => p.name === 'signatures'); + expect(sigPred).toBeDefined(); + expect(sigPred.passed).toBe(true); + expect(sigPred.violations).toEqual([]); + } finally { + fs.rmSync(projectDir, { recursive: true, force: true }); + } + }); +}); From 1821a8b9ca9fbfb273accc38c5917a56cbf3d0e4 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Tue, 7 Jul 2026 02:36:45 -0600 Subject: [PATCH 62/67] fix(complexity): scope summary stats to file/target/kind filters computeComplexitySummary previously queried all function_complexity rows filtered only by noTests, ignoring the file/target/kind WHERE clause used to scope the `functions` list. This made `codegraph complexity --file X` report whole-repo summary stats alongside a correctly-filtered functions table. Thread the same where/params built by buildComplexityWhere into the summary query so it reflects the same scope as `functions`. The above-threshold HAVING clause is intentionally excluded from the summary query so stats like aboveWarn stay meaningful against the full in-scope population rather than collapsing to a tautology. Impact: 3 functions changed, 5 affected --- src/features/complexity-query.ts | 22 ++++++----- tests/integration/complexity.test.ts | 57 ++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 9 deletions(-) diff --git a/src/features/complexity-query.ts b/src/features/complexity-query.ts index 135721b11..4fd935216 100644 --- a/src/features/complexity-query.ts +++ b/src/features/complexity-query.ts @@ -174,19 +174,19 @@ function exceedsAnyThreshold(r: ThresholdMetrics, thresholds: any): boolean { return getExceededMetrics(r, thresholds).length > 0; } -/** Fetch the bare metric columns (all rows) used to compute summary statistics. */ +/** Fetch the bare metric columns for rows matching `where`/`params`, used to compute summary statistics. */ function fetchAllComplexityMetrics( db: ReturnType, - noTests: boolean, + where: string, + params: unknown[], ): ThresholdMetrics[] { return db .prepare( `SELECT fc.cognitive, fc.cyclomatic, fc.max_nesting, fc.maintainability_index FROM function_complexity fc JOIN nodes n ON fc.node_id = n.id - WHERE n.kind IN ('function','method') - ${noTests ? `AND n.file NOT LIKE '%.test.%' AND n.file NOT LIKE '%.spec.%' AND n.file NOT LIKE '%__test__%' AND n.file NOT LIKE '%__tests__%' AND n.file NOT LIKE '%.stories.%'` : ''}`, + ${where}`, ) - .all(); + .all(...params); } /** Arithmetic mean, rounded to 1 decimal (matches the summary's existing precision). */ @@ -214,14 +214,15 @@ function summarizeComplexityMetrics( }; } -/** Compute summary statistics across all complexity rows. */ +/** Compute summary statistics across the complexity rows matching `where`/`params`. */ function computeComplexitySummary( db: ReturnType, - noTests: boolean, + where: string, + params: unknown[], thresholds: any, ): Record | null { try { - const allRows = fetchAllComplexityMetrics(db, noTests); + const allRows = fetchAllComplexityMetrics(db, where, params); if (allRows.length === 0) return null; return summarizeComplexityMetrics(allRows, thresholds); } catch (e: unknown) { @@ -312,7 +313,10 @@ function buildComplexityResult( const filtered = noTests ? rows.filter((r) => !isTestFile(r.file)) : rows; const functions = filtered.map((r) => mapComplexityRow(r, thresholds)); - const summary = computeComplexitySummary(db, noTests, thresholds); + // Summary is scoped by the same file/target/kind/noTests `where` as `functions`, + // but deliberately excludes the above-threshold `having` clause so stats like + // `aboveWarn` remain meaningful against the full in-scope population. + const summary = computeComplexitySummary(db, sql.where, sql.params, thresholds); const hasGraph = summary === null ? checkHasGraph(db) : false; return { functions, summary, thresholds, hasGraph }; diff --git a/tests/integration/complexity.test.ts b/tests/integration/complexity.test.ts index 62f4a9b8c..441a296e9 100644 --- a/tests/integration/complexity.test.ts +++ b/tests/integration/complexity.test.ts @@ -198,6 +198,63 @@ describe('complexityData', () => { expect(data.functions[0].kind).toBe('method'); }); + // ─── Summary scoping (issue #1807) ────────────────────────────────── + + test('summary reflects the --file scope, not the whole repo', () => { + const unfiltered = complexityData(dbPath); + const filtered = complexityData(dbPath, { file: 'handler' }); + + expect(filtered.functions.length).toBe(1); + expect(filtered.summary.analyzed).toBe(1); + expect(filtered.summary.avgCognitive).toBe(25); + expect(filtered.summary.maxCognitive).toBe(25); + expect(filtered.summary.avgMI).toBe(15); + + // Whole-repo summary must differ from the file-scoped one. + expect(unfiltered.summary.analyzed).not.toBe(filtered.summary.analyzed); + expect(unfiltered.summary.avgCognitive).not.toBe(filtered.summary.avgCognitive); + }); + + test('summary reflects the --target scope, not the whole repo', () => { + const unfiltered = complexityData(dbPath); + const filtered = complexityData(dbPath, { target: 'validate' }); + + expect(filtered.functions.length).toBe(1); + expect(filtered.summary.analyzed).toBe(1); + expect(filtered.summary.avgCognitive).toBe(12); + expect(filtered.summary.maxCognitive).toBe(12); + + expect(unfiltered.summary.analyzed).not.toBe(filtered.summary.analyzed); + }); + + test('summary reflects the --kind scope, not the whole repo', () => { + const unfiltered = complexityData(dbPath); + const filtered = complexityData(dbPath, { kind: 'method' }); + + expect(filtered.functions.length).toBe(1); + expect(filtered.summary.analyzed).toBe(1); + expect(filtered.summary.avgCognitive).toBe(25); + + expect(unfiltered.summary.analyzed).not.toBe(filtered.summary.analyzed); + }); + + test('summary respects noTests scope alongside functions list', () => { + const withTests = complexityData(dbPath); + const withoutTests = complexityData(dbPath, { noTests: true }); + + expect(withoutTests.summary.analyzed).toBeLessThan(withTests.summary.analyzed); + }); + + test('summary is not further restricted by aboveThreshold (reflects file/target/kind scope only)', () => { + // aboveThreshold narrows `functions` to violators, but `summary.analyzed` + // should still reflect the full in-scope population so `aboveWarn` stays meaningful. + const scoped = complexityData(dbPath); // whole repo, no threshold filter + const aboveThreshold = complexityData(dbPath, { aboveThreshold: true }); + + expect(aboveThreshold.functions.length).toBeLessThan(scoped.summary.analyzed); + expect(aboveThreshold.summary.analyzed).toBe(scoped.summary.analyzed); + }); + test('sort by cyclomatic', () => { const data = complexityData(dbPath, { sort: 'cyclomatic' }); expect(data.functions[0].cyclomatic).toBeGreaterThanOrEqual(data.functions[1].cyclomatic); From d45aba76791e91d662c7b700ba2f5e7e20d2484e Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Tue, 7 Jul 2026 03:11:47 -0600 Subject: [PATCH 63/67] fix(extractors): label property_signature interface/type members as kind 'property' extractInterfaceMethods (JS/TS, shared by both the walk and query extraction paths) and its Rust mirror extract_interface_methods unconditionally emitted kind: 'method' for every interface/type-literal member, even plain data properties with no parameters or body. Branch on the tree-sitter node type instead: method_signature -> 'method', property_signature -> 'property'. Fixing the mislabeling exposed a latent, mirrored bug in both engines' role classifiers: classifyNodeRolesFull/Incremental (TS) and do_classify_full/ do_classify_incremental (Rust) fast-path every kind = 'property' node straight to 'dead-leaf', bypassing the isTypeDeclarationMember/is_type_declaration_member check that correctly recognizes interface/type members as never-dead 'leaf' nodes (#1723). That fast path was only ever safe because property-signature interface members never actually had kind 'property' in practice. Both engines now partition property rows into genuine dead-leaf fields vs interface/type members before classifying, so property-kind interface members land on 'leaf' just like method-kind ones always have. Impact: 7 functions changed, 16 affected --- .../src/extractors/javascript.rs | 7 +- .../src/graph/classifiers/roles.rs | 94 +++++++++---- src/extractors/javascript.ts | 2 +- src/features/structure.ts | 123 +++++++++++++++--- src/graph/classifiers/roles.ts | 21 ++- tests/parsers/javascript.test.ts | 49 +++++++ tests/unit/roles.test.ts | 42 +++++- 7 files changed, 288 insertions(+), 50 deletions(-) diff --git a/crates/codegraph-core/src/extractors/javascript.rs b/crates/codegraph-core/src/extractors/javascript.rs index 27de77d9b..dd39aada9 100644 --- a/crates/codegraph-core/src/extractors/javascript.rs +++ b/crates/codegraph-core/src/extractors/javascript.rs @@ -2210,9 +2210,14 @@ fn extract_interface_methods( if let Some(child) = body.child(i) { if child.kind() == "method_signature" || child.kind() == "property_signature" { if let Some(name_node) = child.child_by_field_name("name") { + let kind = if child.kind() == "method_signature" { + "method" + } else { + "property" + }; definitions.push(Definition { name: format!("{}.{}", iface_name, node_text(&name_node, source)), - kind: "method".to_string(), + kind: kind.to_string(), line: start_line(&child), end_line: Some(end_line(&child)), decorators: None, diff --git a/crates/codegraph-core/src/graph/classifiers/roles.rs b/crates/codegraph-core/src/graph/classifiers/roles.rs index 75bd507b9..0aab88c1a 100644 --- a/crates/codegraph-core/src/graph/classifiers/roles.rs +++ b/crates/codegraph-core/src/graph/classifiers/roles.rs @@ -155,6 +155,26 @@ fn is_type_declaration_member( .is_some_and(|names| names.contains(owner_name)) } +/// Split raw `kind = 'property'` rows into genuine dead-leaf class/struct/object +/// fields and interface/type property-signature members (#1809). Property rows +/// never reach `classify_rows` (they're excluded from the `rows` query), so +/// `is_type_declaration_member` must be applied to them here explicitly — +/// otherwise every property-kind interface member would be misclassified +/// `dead-leaf` instead of `leaf`, the same bug #1723 fixed for `method`-kind +/// members. Returns `(dead_leaf_ids, type_member_leaf_ids)`. +fn partition_property_rows( + leaf_rows: Vec<(i64, String, String)>, + type_def_names_by_file: &HashMap>, +) -> (Vec, Vec) { + let (type_member_rows, dead_rows): (Vec<_>, Vec<_>) = leaf_rows.into_iter().partition( + |(_, name, file)| is_type_declaration_member(name, "property", file, type_def_names_by_file), + ); + ( + dead_rows.into_iter().map(|(id, _, _)| id).collect(), + type_member_rows.into_iter().map(|(id, _, _)| id).collect(), + ) +} + /// Dead sub-role classification matching JS `classifyDeadSubRole`. fn classify_dead_sub_role(_name: &str, kind: &str, file: &str) -> &'static str { // Leaf kinds @@ -348,15 +368,20 @@ pub(crate) fn do_classify_full(conn: &Connection) -> rusqlite::Result dead-leaf. + // 1. Property kind (class/struct/object fields) -> dead-leaf, except + // interface/type property-signature members (#1809) — partitioned out + // below via `is_type_declaration_member` once `type_def_names_by_file` is + // available and classified `leaf` instead. // `parameter` is deliberately NOT included here (#1723): a parameter's liveness // is a local dataflow question, not a call-graph reachability question, so // "no incoming call edges" carries zero dead-code signal for it. Parameters are // also excluded from the main rows query below, so they never receive a role // at all — the same treatment as `file`/`directory` nodes. - let leaf_ids: Vec = { - let mut stmt = tx.prepare("SELECT id FROM nodes WHERE kind = 'property'")?; - let rows = stmt.query_map([], |row| row.get::<_, i64>(0))?; + let leaf_rows: Vec<(i64, String, String)> = { + let mut stmt = tx.prepare("SELECT id, name, file FROM nodes WHERE kind = 'property'")?; + let rows = stmt.query_map([], |row| { + Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?)) + })?; rows.filter_map(|r| r.ok()).collect() }; @@ -389,7 +414,7 @@ pub(crate) fn do_classify_full(conn: &Connection) -> rusqlite::Result rusqlite::Result> = HashMap::new(); - if !leaf_ids.is_empty() { - summary.dead += leaf_ids.len() as u32; - summary.dead_leaf += leaf_ids.len() as u32; - ids_by_role.insert("dead-leaf", leaf_ids); + let (dead_leaf_ids, type_member_leaf_ids) = + partition_property_rows(leaf_rows, &type_def_names_by_file); + if !dead_leaf_ids.is_empty() { + summary.dead += dead_leaf_ids.len() as u32; + summary.dead_leaf += dead_leaf_ids.len() as u32; + ids_by_role.insert("dead-leaf", dead_leaf_ids); + } + if !type_member_leaf_ids.is_empty() { + summary.leaf += type_member_leaf_ids.len() as u32; + ids_by_role + .entry("leaf") + .or_default() + .extend(type_member_leaf_ids); } classify_rows( @@ -732,17 +766,22 @@ fn find_neighbour_files( Ok(result) } -/// Query leaf kind node IDs and callable node rows for a set of files. +/// Query leaf kind node rows and callable node rows for a set of files. /// `parameter` is intentionally excluded from the leaf query (#1723) — see -/// `do_classify_full`'s leaf_ids comment for the rationale. +/// `do_classify_full`'s leaf_rows comment for the rationale. Leaf rows carry +/// `name`/`file` (not just `id`) so callers can partition out interface/type +/// property-signature members (#1809) via `partition_property_rows`. fn query_nodes_for_files( tx: &rusqlite::Transaction, files: &[&str], -) -> rusqlite::Result<(Vec, Vec<(i64, String, String, String, u32, u32)>)> { +) -> rusqlite::Result<(Vec<(i64, String, String)>, Vec<(i64, String, String, String, u32, u32)>)> { let ph: String = files.iter().map(|_| "?").collect::>().join(","); - let leaf_sql = format!("SELECT id FROM nodes WHERE kind = 'property' AND file IN ({})", ph); - let leaf_ids: Vec = { + let leaf_sql = format!( + "SELECT id, name, file FROM nodes WHERE kind = 'property' AND file IN ({})", + ph + ); + let leaf_rows: Vec<(i64, String, String)> = { let mut stmt = tx.prepare(&leaf_sql)?; for (i, f) in files.iter().enumerate() { stmt.raw_bind_parameter(i + 1, *f)?; @@ -750,7 +789,7 @@ fn query_nodes_for_files( let mut rows = stmt.raw_query(); let mut result = Vec::new(); while let Some(row) = rows.next()? { - result.push(row.get::<_, i64>(0)?); + result.push((row.get::<_, i64>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?)); } result }; @@ -784,7 +823,7 @@ fn query_nodes_for_files( result }; - Ok((leaf_ids, rows)) + Ok((leaf_rows, rows)) } // ── Incremental classification ─────────────────────────────────────── @@ -810,9 +849,9 @@ pub(crate) fn do_classify_incremental( let (median_fan_in, median_fan_out) = compute_global_medians(&tx)?; - let (leaf_ids, rows) = query_nodes_for_files(&tx, &all_affected)?; + let (leaf_rows, rows) = query_nodes_for_files(&tx, &all_affected)?; - if rows.is_empty() && leaf_ids.is_empty() { + if rows.is_empty() && leaf_rows.is_empty() { tx.commit()?; return Ok(summary); } @@ -913,10 +952,21 @@ pub(crate) fn do_classify_incremental( let mut ids_by_role: HashMap<&str, Vec> = HashMap::new(); - if !leaf_ids.is_empty() { - summary.dead += leaf_ids.len() as u32; - summary.dead_leaf += leaf_ids.len() as u32; - ids_by_role.insert("dead-leaf", leaf_ids); + // Partition property rows: interface/type members (#1809) -> leaf, genuine + // class/struct/object fields -> dead-leaf. See do_classify_full/`partition_property_rows`. + let (dead_leaf_ids, type_member_leaf_ids) = + partition_property_rows(leaf_rows, &type_def_names_by_file); + if !dead_leaf_ids.is_empty() { + summary.dead += dead_leaf_ids.len() as u32; + summary.dead_leaf += dead_leaf_ids.len() as u32; + ids_by_role.insert("dead-leaf", dead_leaf_ids); + } + if !type_member_leaf_ids.is_empty() { + summary.leaf += type_member_leaf_ids.len() as u32; + ids_by_role + .entry("leaf") + .or_default() + .extend(type_member_leaf_ids); } classify_rows( diff --git a/src/extractors/javascript.ts b/src/extractors/javascript.ts index cc23d2872..935665350 100644 --- a/src/extractors/javascript.ts +++ b/src/extractors/javascript.ts @@ -1680,7 +1680,7 @@ function extractInterfaceMethods( if (nameNode) { definitions.push({ name: `${interfaceName}.${nameNode.text}`, - kind: 'method', + kind: child.type === 'method_signature' ? 'method' : 'property', line: nodeStartLine(child), endLine: nodeEndLine(child), }); diff --git a/src/features/structure.ts b/src/features/structure.ts index 6769009ea..47b0111dc 100644 --- a/src/features/structure.ts +++ b/src/features/structure.ts @@ -437,7 +437,12 @@ export function buildStructure( // Re-export from classifier for backward compatibility export { FRAMEWORK_ENTRY_PREFIXES } from '../graph/classifiers/roles.js'; -import { classifyRoles, median } from '../graph/classifiers/roles.js'; +import { + classifyRoles, + computeTypeDefNamesByFile, + isTypeDeclarationMember, + median, +} from '../graph/classifiers/roles.js'; interface RoleSummary { entry: number; @@ -491,13 +496,49 @@ export function classifyNodeRoles( // ─── Shared role-classification helpers ─────────────────────────────── +/** Raw `kind = 'property'` row shape needed to determine interface/type ownership. */ +interface PropertyRow { + id: number; + name: string; + file: string; +} + +/** + * Split raw `kind = 'property'` rows into genuine dead-leaf class/struct/object + * fields and interface/type property-signature members (#1809). Property rows + * never reach the fan-in/fan-out `rows` query below (excluded for performance — + * they can never have callers/callees by construction), so + * `isTypeDeclarationMember` must be applied to them here explicitly — + * otherwise every property-kind interface member is misclassified `dead-leaf` + * instead of `leaf`, the same bug #1723 fixed for `method`-kind members. + */ +function partitionPropertyRows( + propertyRows: PropertyRow[], + typeDefNamesByFile: Map>, +): { deadLeafRows: PropertyRow[]; typeMemberLeafRows: PropertyRow[] } { + const deadLeafRows: PropertyRow[] = []; + const typeMemberLeafRows: PropertyRow[] = []; + for (const row of propertyRows) { + // `row` carries no `kind` column (the query already filtered to + // kind = 'property') — supply it explicitly since isTypeDeclarationMember + // branches on it. + if (isTypeDeclarationMember({ ...row, kind: 'property' }, typeDefNamesByFile)) { + typeMemberLeafRows.push(row); + } else { + deadLeafRows.push(row); + } + } + return { deadLeafRows, typeMemberLeafRows }; +} + /** * Build a role summary and group node IDs by role from classifier output. * Shared between full and incremental classification paths. */ function buildRoleSummary( rows: { id: number }[], - leafRows: { id: number }[], + deadLeafRows: { id: number }[], + typeMemberLeafRows: { id: number }[], roleMap: Map, emptySummary: RoleSummary, ): { summary: RoleSummary; idsByRole: Map } { @@ -505,12 +546,21 @@ function buildRoleSummary( const idsByRole = new Map(); // Leaf kinds are always dead-leaf — skip classifier - if (leafRows.length > 0) { + if (deadLeafRows.length > 0) { const leafIds: number[] = []; - for (const row of leafRows) leafIds.push(row.id); + for (const row of deadLeafRows) leafIds.push(row.id); idsByRole.set('dead-leaf', leafIds); - summary.dead += leafRows.length; - summary['dead-leaf'] += leafRows.length; + summary.dead += deadLeafRows.length; + summary['dead-leaf'] += deadLeafRows.length; + } + + // Interface/type property members (#1809) are never dead — see partitionPropertyRows. + if (typeMemberLeafRows.length > 0) { + idsByRole.set( + 'leaf', + typeMemberLeafRows.map((r) => r.id), + ); + summary.leaf += typeMemberLeafRows.length; } for (const row of rows) { @@ -764,8 +814,11 @@ function writeMedianCache( } function classifyNodeRolesFull(db: BetterSqlite3Database, emptySummary: RoleSummary): RoleSummary { - // Property kind (class/struct fields) can never have callers/callees. - // Classify them directly as dead-leaf without the expensive fan-in/fan-out JOINs. + // Property kind (class/struct/object fields) can never have callers/callees. + // Classify them directly as dead-leaf without the expensive fan-in/fan-out + // JOINs — except interface/type property-signature members (#1809), which + // are partitioned out below via `partitionPropertyRows`/`isTypeDeclarationMember` + // once `typeDefNamesByFile` is available, and classified `leaf` instead. // // `parameter` is deliberately NOT included here (#1723): a parameter's liveness // is a local dataflow question (is it referenced within its own function body), @@ -773,13 +826,13 @@ function classifyNodeRolesFull(db: BetterSqlite3Database, emptySummary: RoleSumm // zero dead-code signal for it. Parameters are also excluded from the main // query below, so they never receive a role at all — the same treatment as // `file`/`directory` nodes. - const leafRows = db + const propertyRows = db .prepare( - `SELECT n.id + `SELECT n.id, n.name, n.file FROM nodes n WHERE n.kind = 'property'`, ) - .all() as { id: number }[]; + .all() as PropertyRow[]; // Only compute fan-in/fan-out for callable/classifiable nodes const rows = db @@ -798,7 +851,7 @@ function classifyNodeRolesFull(db: BetterSqlite3Database, emptySummary: RoleSumm ) .all() as CallableNodeRow[]; - if (rows.length === 0 && leafRows.length === 0) return emptySummary; + if (rows.length === 0 && propertyRows.length === 0) return emptySummary; const exportedIds = new Set( ( @@ -912,7 +965,23 @@ function classifyNodeRolesFull(db: BetterSqlite3Database, emptySummary: RoleSumm const inMemoryEdgeCount = rows.reduce((acc, r) => acc + r.fan_in, 0); writeMedianCache(db, globalMedians, inMemoryEdgeCount); - const { summary, idsByRole } = buildRoleSummary(rows, leafRows, roleMap, emptySummary); + // Partition property rows: interface/type property-signature members (#1809) + // are recognized via `isTypeDeclarationMember` against the same-file + // TYPE_DEF_KINDS names already present in `rows` (interfaces/types are never + // excluded from the callable-nodes query above). + const typeDefNamesByFile = computeTypeDefNamesByFile(rows); + const { deadLeafRows, typeMemberLeafRows } = partitionPropertyRows( + propertyRows, + typeDefNamesByFile, + ); + + const { summary, idsByRole } = buildRoleSummary( + rows, + deadLeafRows, + typeMemberLeafRows, + roleMap, + emptySummary, + ); batchUpdateRoles(db, idsByRole, () => { db.prepare('UPDATE nodes SET role = NULL').run(); @@ -970,16 +1039,18 @@ function classifyNodeRolesIncremental( globalMedians = computed; } - // 2a. Property kind (class/struct fields) in affected files — always dead-leaf. + // 2a. Property kind (class/struct/object fields) in affected files — dead-leaf, + // except interface/type property-signature members (#1809) — see + // classifyNodeRolesFull for the partitioning rationale. // `parameter` is intentionally excluded (#1723) — see classifyNodeRolesFull for // the rationale. Parameters stay unclassified (role = NULL) after the reset below. - const leafRows = db + const propertyRows = db .prepare( - `SELECT n.id FROM nodes n + `SELECT n.id, n.name, n.file FROM nodes n WHERE n.kind = 'property' AND n.file IN (${placeholders})`, ) - .all(...allAffectedFiles) as { id: number }[]; + .all(...allAffectedFiles) as PropertyRow[]; // 2b. Get callable nodes using indexed correlated subqueries (fast point lookups) const rows = db @@ -993,7 +1064,7 @@ function classifyNodeRolesIncremental( ) .all(...allAffectedFiles) as CallableNodeRow[]; - if (rows.length === 0 && leafRows.length === 0) return emptySummary; + if (rows.length === 0 && propertyRows.length === 0) return emptySummary; // 3. Get exported status for affected nodes only (scoped to changed files) const exportedIds = new Set( @@ -1087,8 +1158,22 @@ function classifyNodeRolesIncremental( ); const roleMap = classifyRoles(classifierInput, globalMedians); + // Partition property rows: interface/type property-signature members (#1809) + // — see classifyNodeRolesFull for the rationale. + const typeDefNamesByFile = computeTypeDefNamesByFile(rows); + const { deadLeafRows, typeMemberLeafRows } = partitionPropertyRows( + propertyRows, + typeDefNamesByFile, + ); + // 6. Build summary (only for affected nodes) and update only those nodes - const { summary, idsByRole } = buildRoleSummary(rows, leafRows, roleMap, emptySummary); + const { summary, idsByRole } = buildRoleSummary( + rows, + deadLeafRows, + typeMemberLeafRows, + roleMap, + emptySummary, + ); batchUpdateRoles(db, idsByRole, () => { // Reset roles only for affected files' nodes diff --git a/src/graph/classifiers/roles.ts b/src/graph/classifiers/roles.ts index 695986915..011bdeff6 100644 --- a/src/graph/classifiers/roles.ts +++ b/src/graph/classifiers/roles.ts @@ -73,13 +73,28 @@ export interface ClassifiableNode { file?: string; } +/** + * Minimal node shape needed to determine interface/type ownership by name — + * a structural subset of `RoleClassificationNode` so callers holding only + * `{ id, name, file }` rows (e.g. a raw `kind = 'property'` DB query, #1809) + * can reuse `computeTypeDefNamesByFile`/`isTypeDeclarationMember` without + * constructing a full classifier-input object. + */ +export interface NamedClassifiableNode { + name: string; + kind?: string; + file?: string; +} + /** * Compute, per file, the set of symbol names that are `TYPE_DEF_KINDS`-kind * declarations (interface/type/struct/enum/trait/record). Used by * `isTypeDeclarationMember` to recognize `Owner.member`-qualified nodes whose * owner is a type-level declaration rather than a class. */ -function computeTypeDefNamesByFile(nodes: RoleClassificationNode[]): Map> { +export function computeTypeDefNamesByFile( + nodes: readonly NamedClassifiableNode[], +): Map> { const byFile = new Map>(); for (const n of nodes) { if (n.file && n.kind && TYPE_DEF_KINDS.has(n.kind)) { @@ -111,8 +126,8 @@ function computeTypeDefNamesByFile(nodes: RoleClassificationNode[]): Map>, ): boolean { if (node.kind !== 'method' && node.kind !== 'property') return false; diff --git a/tests/parsers/javascript.test.ts b/tests/parsers/javascript.test.ts index b41ad9d79..2b01c1daf 100644 --- a/tests/parsers/javascript.test.ts +++ b/tests/parsers/javascript.test.ts @@ -1935,4 +1935,53 @@ describe('JavaScript parser', () => { expect(symbols.exports.some((e) => e.name === 'INTERNAL')).toBe(false); }); }); + + describe('interface member kind labeling (#1809)', () => { + function parseTS(code) { + const parser = parsers.get('typescript'); + const tree = parser.parse(code); + return extractSymbols(tree, 'test.ts'); + } + + it('labels a property_signature interface member as kind "property"', () => { + const symbols = parseTS(`interface ExtractParametersOptions { + paramTypes: readonly string[]; + nameField?: string | null; +}`); + expect(symbols.definitions).toContainEqual( + expect.objectContaining({ + name: 'ExtractParametersOptions.paramTypes', + kind: 'property', + }), + ); + expect(symbols.definitions).toContainEqual( + expect.objectContaining({ + name: 'ExtractParametersOptions.nameField', + kind: 'property', + }), + ); + }); + + it('still labels a method_signature interface member as kind "method"', () => { + const symbols = parseTS(`interface Repo { + find(id: string): Item | undefined; +}`); + expect(symbols.definitions).toContainEqual( + expect.objectContaining({ name: 'Repo.find', kind: 'method' }), + ); + }); + + it('labels mixed property and method interface members correctly', () => { + const symbols = parseTS(`interface Widget { + name: string; + render(): void; +}`); + expect(symbols.definitions).toContainEqual( + expect.objectContaining({ name: 'Widget.name', kind: 'property' }), + ); + expect(symbols.definitions).toContainEqual( + expect.objectContaining({ name: 'Widget.render', kind: 'method' }), + ); + }); + }); }); diff --git a/tests/unit/roles.test.ts b/tests/unit/roles.test.ts index 298958b67..f5b00591a 100644 --- a/tests/unit/roles.test.ts +++ b/tests/unit/roles.test.ts @@ -448,13 +448,12 @@ describe('classifyNodeRoles', () => { it('does not classify interface members as dead', () => { // TS `interface ExtractParametersOptions { typeMap?: Map<...> }` extracts - // `typeMap` as a top-level `method`-kind definition named - // `ExtractParametersOptions.typeMap` (property-signature members are - // currently mislabeled `method` by the extractor — tracked separately). + // `typeMap` as a top-level `property`-kind definition named + // `ExtractParametersOptions.typeMap` (property-signature members). // It has no callers by construction; it must not be flagged dead. insertNode('src/helpers.ts', 'file', 'src/helpers.ts', 0); insertNode('ExtractParametersOptions', 'interface', 'src/helpers.ts', 359); - insertNode('ExtractParametersOptions.typeMap', 'method', 'src/helpers.ts', 361); + insertNode('ExtractParametersOptions.typeMap', 'property', 'src/helpers.ts', 361); classifyNodeRoles(db); @@ -464,6 +463,41 @@ describe('classifyNodeRoles', () => { expect(role.role).toBe('leaf'); }); + it('still classifies a genuine (non-interface) class property as dead-leaf (#1809 regression guard)', () => { + // A plain class field with no owning interface/type declaration in the + // file must remain dead-leaf — the #1809 partition must not promote every + // property-kind node to leaf, only ones owned by a TYPE_DEF_KINDS symbol. + insertNode('src/widget.ts', 'file', 'src/widget.ts', 0); + insertNode('Widget', 'class', 'src/widget.ts', 1); + insertNode('Widget.label', 'property', 'src/widget.ts', 2); + + classifyNodeRoles(db); + + const role = db.prepare("SELECT role FROM nodes WHERE name = 'Widget.label'").get() as { + role: string | null; + }; + expect(role.role).toBe('dead-leaf'); + }); + + it('incremental path: does not classify property-kind interface members as dead (#1809)', () => { + // Exercises classifyNodeRolesIncremental (triggered by passing changedFiles). + // Property-kind interface members must land on `leaf`, not `dead-leaf`, on + // the incremental path just as on the full path — the fast-path property + // query in classifyNodeRolesIncremental must not blanket-classify every + // `kind = 'property'` row as dead-leaf without first ruling out interface/ + // type ownership. + insertNode('src/helpers.ts', 'file', 'src/helpers.ts', 0); + insertNode('Widget', 'interface', 'src/helpers.ts', 10); + insertNode('Widget.name', 'property', 'src/helpers.ts', 11); + + classifyNodeRoles(db, ['src/helpers.ts']); + + const role = db.prepare("SELECT role FROM nodes WHERE name = 'Widget.name'").get() as { + role: string | null; + }; + expect(role.role).toBe('leaf'); + }); + it('regression: used parameters, interface members, and a genuinely dead function are classified correctly together', () => { // Mirrors the exact issue #1723 repro: `findChild(node, type)` in // src/extractors/helpers.ts, alongside an interface and a truly dead function. From 69d8a6f1dd470294a3ce2b23537977d30fa9c52b Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Tue, 7 Jul 2026 03:32:41 -0600 Subject: [PATCH 64/67] fix(roles): exclude genuine class/struct properties from dead-role classification `codegraph roles --role dead` force-assigned every `kind: 'property'` node (real class/struct/object fields) to `dead-leaf` unconditionally, regardless of whether the field was read/written elsewhere in its owning class. A property never produces inbound `calls` edges by construction, so this heuristic guaranteed every field looked dead whether or not it was used -- the same category error #1723 fixed for `parameter`-kind nodes. Codegraph has no property-access/write edge tracking to answer the real question (is this field read/written anywhere in its class), so a field's liveness carries zero dead-code signal from call-graph reachability alone. Genuine (non-interface) properties now get no role at all -- the same treatment `parameter` already receives -- while interface/type property-signature members (#1809) continue to classify as `leaf`. Applied to both the TS/WASM classifier (features/structure.ts) and the native Rust classifier (graph/classifiers/roles.rs) per the dual-engine parity requirement; verified both engines produce identical role distributions end-to-end against this repo's own graph (216 properties -> null, 3765 interface members -> leaf, on both engines). Fixes #1810 Impact: 4 functions changed, 7 affected --- .../src/graph/classifiers/roles.rs | 74 +++++----- src/features/structure.ts | 132 ++++++++---------- src/graph/classifiers/roles.ts | 10 +- tests/unit/roles.test.ts | 37 ++++- 4 files changed, 139 insertions(+), 114 deletions(-) diff --git a/crates/codegraph-core/src/graph/classifiers/roles.rs b/crates/codegraph-core/src/graph/classifiers/roles.rs index 0aab88c1a..b0e81b292 100644 --- a/crates/codegraph-core/src/graph/classifiers/roles.rs +++ b/crates/codegraph-core/src/graph/classifiers/roles.rs @@ -155,24 +155,32 @@ fn is_type_declaration_member( .is_some_and(|names| names.contains(owner_name)) } -/// Split raw `kind = 'property'` rows into genuine dead-leaf class/struct/object -/// fields and interface/type property-signature members (#1809). Property rows -/// never reach `classify_rows` (they're excluded from the `rows` query), so -/// `is_type_declaration_member` must be applied to them here explicitly — -/// otherwise every property-kind interface member would be misclassified -/// `dead-leaf` instead of `leaf`, the same bug #1723 fixed for `method`-kind -/// members. Returns `(dead_leaf_ids, type_member_leaf_ids)`. -fn partition_property_rows( +/// Filter raw `kind = 'property'` rows down to interface/type +/// property-signature members (#1809) — the only property rows that receive a +/// role (`leaf`). Property rows never reach `classify_rows` (they're excluded +/// from the `rows` query), so `is_type_declaration_member` must be applied to +/// them here explicitly — otherwise every property-kind interface member +/// would be misclassified instead of `leaf`, the same bug #1723 fixed for +/// `method`-kind members. +/// +/// Genuine (non-interface) class/struct/object fields are deliberately left +/// out of the returned ids — they get no role at all (`NULL`), the same +/// treatment `parameter` receives (#1723). A field's liveness is a question +/// of whether it's read/written anywhere in its owning class, which is a +/// dataflow question this crate has no property-access/write edge tracking +/// to answer, so "zero inbound `calls` edges" (guaranteed by construction) +/// carries zero dead-code signal for it (#1810). +fn filter_type_member_property_rows( leaf_rows: Vec<(i64, String, String)>, type_def_names_by_file: &HashMap>, -) -> (Vec, Vec) { - let (type_member_rows, dead_rows): (Vec<_>, Vec<_>) = leaf_rows.into_iter().partition( - |(_, name, file)| is_type_declaration_member(name, "property", file, type_def_names_by_file), - ); - ( - dead_rows.into_iter().map(|(id, _, _)| id).collect(), - type_member_rows.into_iter().map(|(id, _, _)| id).collect(), - ) +) -> Vec { + leaf_rows + .into_iter() + .filter(|(_, name, file)| { + is_type_declaration_member(name, "property", file, type_def_names_by_file) + }) + .map(|(id, _, _)| id) + .collect() } /// Dead sub-role classification matching JS `classifyDeadSubRole`. @@ -368,10 +376,11 @@ pub(crate) fn do_classify_full(conn: &Connection) -> rusqlite::Result dead-leaf, except - // interface/type property-signature members (#1809) — partitioned out - // below via `is_type_declaration_member` once `type_def_names_by_file` is - // available and classified `leaf` instead. + // 1. Property kind (class/struct/object fields). Interface/type + // property-signature members (#1809) are filtered out below via + // `is_type_declaration_member` once `type_def_names_by_file` is available + // and classified `leaf`. Genuine (non-interface) fields get no role at + // all (#1810) — see `filter_type_member_property_rows`. // `parameter` is deliberately NOT included here (#1723): a parameter's liveness // is a local dataflow question, not a call-graph reachability question, so // "no incoming call edges" carries zero dead-code signal for it. Parameters are @@ -535,13 +544,7 @@ pub(crate) fn do_classify_full(conn: &Connection) -> rusqlite::Result> = HashMap::new(); - let (dead_leaf_ids, type_member_leaf_ids) = - partition_property_rows(leaf_rows, &type_def_names_by_file); - if !dead_leaf_ids.is_empty() { - summary.dead += dead_leaf_ids.len() as u32; - summary.dead_leaf += dead_leaf_ids.len() as u32; - ids_by_role.insert("dead-leaf", dead_leaf_ids); - } + let type_member_leaf_ids = filter_type_member_property_rows(leaf_rows, &type_def_names_by_file); if !type_member_leaf_ids.is_empty() { summary.leaf += type_member_leaf_ids.len() as u32; ids_by_role @@ -769,8 +772,8 @@ fn find_neighbour_files( /// Query leaf kind node rows and callable node rows for a set of files. /// `parameter` is intentionally excluded from the leaf query (#1723) — see /// `do_classify_full`'s leaf_rows comment for the rationale. Leaf rows carry -/// `name`/`file` (not just `id`) so callers can partition out interface/type -/// property-signature members (#1809) via `partition_property_rows`. +/// `name`/`file` (not just `id`) so callers can filter down to interface/type +/// property-signature members (#1809) via `filter_type_member_property_rows`. fn query_nodes_for_files( tx: &rusqlite::Transaction, files: &[&str], @@ -952,15 +955,10 @@ pub(crate) fn do_classify_incremental( let mut ids_by_role: HashMap<&str, Vec> = HashMap::new(); - // Partition property rows: interface/type members (#1809) -> leaf, genuine - // class/struct/object fields -> dead-leaf. See do_classify_full/`partition_property_rows`. - let (dead_leaf_ids, type_member_leaf_ids) = - partition_property_rows(leaf_rows, &type_def_names_by_file); - if !dead_leaf_ids.is_empty() { - summary.dead += dead_leaf_ids.len() as u32; - summary.dead_leaf += dead_leaf_ids.len() as u32; - ids_by_role.insert("dead-leaf", dead_leaf_ids); - } + // Filter property rows: interface/type members (#1809) -> leaf; genuine + // class/struct/object fields get no role at all (#1810). See + // do_classify_full/`filter_type_member_property_rows`. + let type_member_leaf_ids = filter_type_member_property_rows(leaf_rows, &type_def_names_by_file); if !type_member_leaf_ids.is_empty() { summary.leaf += type_member_leaf_ids.len() as u32; ids_by_role diff --git a/src/features/structure.ts b/src/features/structure.ts index 47b0111dc..40bc69c52 100644 --- a/src/features/structure.ts +++ b/src/features/structure.ts @@ -504,40 +504,47 @@ interface PropertyRow { } /** - * Split raw `kind = 'property'` rows into genuine dead-leaf class/struct/object - * fields and interface/type property-signature members (#1809). Property rows - * never reach the fan-in/fan-out `rows` query below (excluded for performance — - * they can never have callers/callees by construction), so - * `isTypeDeclarationMember` must be applied to them here explicitly — - * otherwise every property-kind interface member is misclassified `dead-leaf` - * instead of `leaf`, the same bug #1723 fixed for `method`-kind members. + * Filter raw `kind = 'property'` rows down to interface/type + * property-signature members (#1809) — the only property rows that receive a + * role (`leaf`). Property rows never reach the fan-in/fan-out `rows` query + * below (excluded for performance — they can never have callers/callees by + * construction), so `isTypeDeclarationMember` must be applied to them here + * explicitly — otherwise every property-kind interface member is + * misclassified instead of `leaf`, the same bug #1723 fixed for + * `method`-kind members. + * + * Genuine (non-interface) class/struct/object fields are deliberately left + * unclassified (role stays `NULL`) — the same treatment `parameter` received + * in #1723 (#1810). A field's liveness is a question of whether it's + * read/written anywhere in its owning class, which is a dataflow question + * codegraph has no property-access/write edge tracking to answer; "zero + * inbound `calls` edges" is guaranteed for every field by construction + * (property reads/writes never produce `calls` edges), so it carries zero + * dead-code signal. */ -function partitionPropertyRows( +function filterTypeMemberPropertyRows( propertyRows: PropertyRow[], typeDefNamesByFile: Map>, -): { deadLeafRows: PropertyRow[]; typeMemberLeafRows: PropertyRow[] } { - const deadLeafRows: PropertyRow[] = []; - const typeMemberLeafRows: PropertyRow[] = []; - for (const row of propertyRows) { - // `row` carries no `kind` column (the query already filtered to - // kind = 'property') — supply it explicitly since isTypeDeclarationMember - // branches on it. - if (isTypeDeclarationMember({ ...row, kind: 'property' }, typeDefNamesByFile)) { - typeMemberLeafRows.push(row); - } else { - deadLeafRows.push(row); - } - } - return { deadLeafRows, typeMemberLeafRows }; +): PropertyRow[] { + // `row` carries no `kind` column (the query already filtered to + // kind = 'property') — supply it explicitly since isTypeDeclarationMember + // branches on it. + return propertyRows.filter((row) => + isTypeDeclarationMember({ ...row, kind: 'property' }, typeDefNamesByFile), + ); } /** * Build a role summary and group node IDs by role from classifier output. * Shared between full and incremental classification paths. + * + * Property-kind rows that are not interface/type members are intentionally + * absent from both `rows` and `typeMemberLeafRows` (#1810) — they get no + * entry in `idsByRole` and so keep the `NULL` role set by the reset callback + * in `batchUpdateRoles`, the same treatment `parameter` receives. */ function buildRoleSummary( rows: { id: number }[], - deadLeafRows: { id: number }[], typeMemberLeafRows: { id: number }[], roleMap: Map, emptySummary: RoleSummary, @@ -545,16 +552,7 @@ function buildRoleSummary( const summary: RoleSummary = { ...emptySummary }; const idsByRole = new Map(); - // Leaf kinds are always dead-leaf — skip classifier - if (deadLeafRows.length > 0) { - const leafIds: number[] = []; - for (const row of deadLeafRows) leafIds.push(row.id); - idsByRole.set('dead-leaf', leafIds); - summary.dead += deadLeafRows.length; - summary['dead-leaf'] += deadLeafRows.length; - } - - // Interface/type property members (#1809) are never dead — see partitionPropertyRows. + // Interface/type property members (#1809) are never dead — see filterTypeMemberPropertyRows. if (typeMemberLeafRows.length > 0) { idsByRole.set( 'leaf', @@ -814,11 +812,19 @@ function writeMedianCache( } function classifyNodeRolesFull(db: BetterSqlite3Database, emptySummary: RoleSummary): RoleSummary { - // Property kind (class/struct/object fields) can never have callers/callees. - // Classify them directly as dead-leaf without the expensive fan-in/fan-out - // JOINs — except interface/type property-signature members (#1809), which - // are partitioned out below via `partitionPropertyRows`/`isTypeDeclarationMember` - // once `typeDefNamesByFile` is available, and classified `leaf` instead. + // Property kind (class/struct/object fields) can never have callers/callees, + // so they're excluded from the expensive fan-in/fan-out JOINs below — but + // unlike `parameter`, they aren't uniformly role-less: interface/type + // property-signature members (#1809) are partitioned out below via + // `filterTypeMemberPropertyRows`/`isTypeDeclarationMember` once + // `typeDefNamesByFile` is available, and classified `leaf`. + // + // Genuine (non-interface) class/struct/object fields get NO role at all + // (#1810), the same treatment `parameter` gets (#1723): a field's liveness + // is a question of whether it's read/written anywhere in its owning class — + // a dataflow question codegraph has no property-access/write edge tracking + // to answer — so "no incoming call edges" (guaranteed for every field by + // construction) carries zero dead-code signal for it. // // `parameter` is deliberately NOT included here (#1723): a parameter's liveness // is a local dataflow question (is it referenced within its own function body), @@ -965,23 +971,16 @@ function classifyNodeRolesFull(db: BetterSqlite3Database, emptySummary: RoleSumm const inMemoryEdgeCount = rows.reduce((acc, r) => acc + r.fan_in, 0); writeMedianCache(db, globalMedians, inMemoryEdgeCount); - // Partition property rows: interface/type property-signature members (#1809) - // are recognized via `isTypeDeclarationMember` against the same-file + // Filter property rows down to interface/type property-signature members + // (#1809), recognized via `isTypeDeclarationMember` against the same-file // TYPE_DEF_KINDS names already present in `rows` (interfaces/types are never - // excluded from the callable-nodes query above). + // excluded from the callable-nodes query above). Non-member property rows + // (genuine class/struct fields) get no role at all (#1810) — see + // `filterTypeMemberPropertyRows`. const typeDefNamesByFile = computeTypeDefNamesByFile(rows); - const { deadLeafRows, typeMemberLeafRows } = partitionPropertyRows( - propertyRows, - typeDefNamesByFile, - ); + const typeMemberLeafRows = filterTypeMemberPropertyRows(propertyRows, typeDefNamesByFile); - const { summary, idsByRole } = buildRoleSummary( - rows, - deadLeafRows, - typeMemberLeafRows, - roleMap, - emptySummary, - ); + const { summary, idsByRole } = buildRoleSummary(rows, typeMemberLeafRows, roleMap, emptySummary); batchUpdateRoles(db, idsByRole, () => { db.prepare('UPDATE nodes SET role = NULL').run(); @@ -1039,11 +1038,12 @@ function classifyNodeRolesIncremental( globalMedians = computed; } - // 2a. Property kind (class/struct/object fields) in affected files — dead-leaf, - // except interface/type property-signature members (#1809) — see - // classifyNodeRolesFull for the partitioning rationale. - // `parameter` is intentionally excluded (#1723) — see classifyNodeRolesFull for - // the rationale. Parameters stay unclassified (role = NULL) after the reset below. + // 2a. Property kind (class/struct/object fields) in affected files. Interface/ + // type property-signature members (#1809) get `leaf`; genuine (non-interface) + // fields get no role at all (#1810) — see classifyNodeRolesFull for the + // filtering rationale. `parameter` is intentionally excluded (#1723) — see + // classifyNodeRolesFull for the rationale. Parameters stay unclassified + // (role = NULL) after the reset below. const propertyRows = db .prepare( `SELECT n.id, n.name, n.file FROM nodes n @@ -1158,22 +1158,14 @@ function classifyNodeRolesIncremental( ); const roleMap = classifyRoles(classifierInput, globalMedians); - // Partition property rows: interface/type property-signature members (#1809) - // — see classifyNodeRolesFull for the rationale. + // Filter property rows down to interface/type property-signature members + // (#1809); non-member property rows get no role at all (#1810) — see + // classifyNodeRolesFull for the filtering rationale. const typeDefNamesByFile = computeTypeDefNamesByFile(rows); - const { deadLeafRows, typeMemberLeafRows } = partitionPropertyRows( - propertyRows, - typeDefNamesByFile, - ); + const typeMemberLeafRows = filterTypeMemberPropertyRows(propertyRows, typeDefNamesByFile); // 6. Build summary (only for affected nodes) and update only those nodes - const { summary, idsByRole } = buildRoleSummary( - rows, - deadLeafRows, - typeMemberLeafRows, - roleMap, - emptySummary, - ); + const { summary, idsByRole } = buildRoleSummary(rows, typeMemberLeafRows, roleMap, emptySummary); batchUpdateRoles(db, idsByRole, () => { // Reset roles only for affected files' nodes diff --git a/src/graph/classifiers/roles.ts b/src/graph/classifiers/roles.ts index 011bdeff6..3f4418372 100644 --- a/src/graph/classifiers/roles.ts +++ b/src/graph/classifiers/roles.ts @@ -4,7 +4,9 @@ * Roles: entry, core, utility, adapter, leaf, dead-*, test-only * * Dead sub-categories refine the coarse "dead" bucket: - * dead-leaf — properties, constants (leaf nodes by definition) + * dead-leaf — constants (leaf nodes by definition; parameters and + * genuine class/struct properties are excluded from + * classification entirely rather than landing here — see below) * dead-entry — framework dispatch: CLI commands, MCP tools, event handlers * dead-ffi — cross-language FFI boundaries (e.g. Rust napi-rs bindings) * dead-unresolved — genuinely unreferenced callables (the real dead code) @@ -16,6 +18,12 @@ * within its own function body), not a call-graph reachability question, so * "no incoming call edges" carries zero dead-code signal for it (#1723). * + * `property`-kind nodes that are genuine (non-interface) class/struct/object + * fields also never reach this module in production, for the same reason and + * with the same treatment (role left unset) — a field's liveness is a question + * of whether it's read/written anywhere in its owning class, which codegraph + * has no property-access/write edge tracking to answer (#1810). + * * `method`/`property`-kind members of an interface/type declaration (e.g. * `interface Foo { bar: string }`) DO reach this module, but are recognized by * `isTypeDeclarationMember` and classified `leaf` unconditionally — they can diff --git a/tests/unit/roles.test.ts b/tests/unit/roles.test.ts index f5b00591a..72b3352a4 100644 --- a/tests/unit/roles.test.ts +++ b/tests/unit/roles.test.ts @@ -463,10 +463,15 @@ describe('classifyNodeRoles', () => { expect(role.role).toBe('leaf'); }); - it('still classifies a genuine (non-interface) class property as dead-leaf (#1809 regression guard)', () => { + it('excludes genuine (non-interface) class property-kind nodes from role classification entirely (#1810)', () => { // A plain class field with no owning interface/type declaration in the - // file must remain dead-leaf — the #1809 partition must not promote every - // property-kind node to leaf, only ones owned by a TYPE_DEF_KINDS symbol. + // file must get no role at all — the same treatment as `parameter` (#1723). + // A field's liveness (is it read/written anywhere in its owning class) is + // a dataflow question codegraph has no property-access/write edge tracking + // to answer, so "zero inbound call edges" (guaranteed by construction) + // carries zero dead-code signal. The #1809 partition still must not + // promote every property-kind node to `leaf` — only ones owned by a + // TYPE_DEF_KINDS symbol get `leaf`; the rest get `null`, never `dead-leaf`. insertNode('src/widget.ts', 'file', 'src/widget.ts', 0); insertNode('Widget', 'class', 'src/widget.ts', 1); insertNode('Widget.label', 'property', 'src/widget.ts', 2); @@ -476,7 +481,7 @@ describe('classifyNodeRoles', () => { const role = db.prepare("SELECT role FROM nodes WHERE name = 'Widget.label'").get() as { role: string | null; }; - expect(role.role).toBe('dead-leaf'); + expect(role.role).toBeNull(); }); it('incremental path: does not classify property-kind interface members as dead (#1809)', () => { @@ -498,9 +503,28 @@ describe('classifyNodeRoles', () => { expect(role.role).toBe('leaf'); }); - it('regression: used parameters, interface members, and a genuinely dead function are classified correctly together', () => { + it('incremental path: excludes genuine (non-interface) class property-kind nodes from role classification entirely (#1810)', () => { + // Exercises classifyNodeRolesIncremental (triggered by passing changedFiles). + // A genuine class field must get no role on the incremental path just as + // on the full path — the fast-path property query must not blanket-classify + // every `kind = 'property'` row owned by a non-type-def symbol. + insertNode('src/widget.ts', 'file', 'src/widget.ts', 0); + insertNode('Widget', 'class', 'src/widget.ts', 1); + insertNode('Widget.label', 'property', 'src/widget.ts', 2); + + classifyNodeRoles(db, ['src/widget.ts']); + + const role = db.prepare("SELECT role FROM nodes WHERE name = 'Widget.label'").get() as { + role: string | null; + }; + expect(role.role).toBeNull(); + }); + + it('regression: used parameters, unused properties, interface members, and a genuinely dead function are classified correctly together', () => { // Mirrors the exact issue #1723 repro: `findChild(node, type)` in // src/extractors/helpers.ts, alongside an interface and a truly dead function. + // Also covers #1810: a genuine class property with no other graph + // connections must land alongside the parameters (no role), not dead-leaf. insertNode('src/helpers.ts', 'file', 'src/helpers.ts', 0); const findChildFn = insertNode('findChild', 'function', 'src/helpers.ts', 26); insertNode('node', 'parameter', 'src/helpers.ts', 26); @@ -508,6 +532,8 @@ describe('classifyNodeRoles', () => { insertNode('ChaContext', 'interface', 'src/helpers.ts', 18); insertNode('ChaContext.implementors', 'method', 'src/helpers.ts', 20); insertNode('trulyDeadHelper', 'function', 'src/helpers.ts', 400); + insertNode('Widget', 'class', 'src/helpers.ts', 500); + insertNode('Widget.label', 'property', 'src/helpers.ts', 501); const caller = insertNode('caller', 'function', 'other.ts', 1); insertEdge(caller, findChildFn, 'calls'); @@ -519,5 +545,6 @@ describe('classifyNodeRoles', () => { expect(getRole('type')).toBeNull(); expect(getRole('ChaContext.implementors')).toBe('leaf'); expect(getRole('trulyDeadHelper')).toBe('dead-unresolved'); + expect(getRole('Widget.label')).toBeNull(); }); }); From db082194ebb98dec0bec8d707177a4c5a6a4be15 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Tue, 7 Jul 2026 03:56:03 -0600 Subject: [PATCH 65/67] fix(resolver): scope extends/implements edges to same-file/import/language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit extends/implements heritage-clause resolution matched a bare type name against every node in the graph with that name, filtered only by kind — with no file or language scoping. Common type names (Repository, User, etc.) produced false cross-file, even cross-language, hierarchy edges whenever an unrelated declaration happened to share the name. Add resolveHierarchyTargets (shared by build-edges.ts and incremental.ts, mirrored in the native build_edges.rs emit_hierarchy_edges), following the same priority order already used for receiver-edge resolution: same-file declaration, then the file's actually-resolved import, and only as a last resort a same-language-family global-by-name match (never cross-language, matching the #1783 precedent). Fixes #1812 Impact: 19 functions changed, 12 affected --- .../graph/builder/stages/build_edges.rs | 57 ++++++- src/domain/graph/builder/call-resolver.ts | 46 ++++++ src/domain/graph/builder/incremental.ts | 34 ++++- .../graph/builder/stages/build-edges.ts | 54 ++++--- tests/fixtures/hierarchy-scoping/consumer.ts | 8 + .../hierarchy-scoping/decoy/Repository.py | 11 ++ .../hierarchy-scoping/moduleA/Base.ts | 13 ++ .../hierarchy-scoping/moduleB/Base.ts | 9 ++ tests/fixtures/hierarchy-scoping/orphan.ts | 5 + ...e-1812-hierarchy-scoped-resolution.test.ts | 140 ++++++++++++++++++ 10 files changed, 344 insertions(+), 33 deletions(-) create mode 100644 tests/fixtures/hierarchy-scoping/consumer.ts create mode 100644 tests/fixtures/hierarchy-scoping/decoy/Repository.py create mode 100644 tests/fixtures/hierarchy-scoping/moduleA/Base.ts create mode 100644 tests/fixtures/hierarchy-scoping/moduleB/Base.ts create mode 100644 tests/fixtures/hierarchy-scoping/orphan.ts create mode 100644 tests/integration/issue-1812-hierarchy-scoped-resolution.test.ts diff --git a/crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs b/crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs index 4cb2c05bb..3db41fc51 100644 --- a/crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs +++ b/crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs @@ -837,7 +837,7 @@ fn process_file<'a>( } } - emit_hierarchy_edges(ctx, file_input, fc.rel_path, edges); + emit_hierarchy_edges(ctx, file_input, fc.rel_path, &fc.imported_names, edges); } /// Callable definition kinds — only function/method bodies act as enclosing @@ -1374,9 +1374,56 @@ fn emit_receiver_edge( } } +/// Resolve extends/implements target candidates for a class hierarchy edge. +/// +/// Mirrors the JS `resolveHierarchyTargets` in `call-resolver.ts` (#1812): +/// a bare heritage-clause name previously matched every same-named node in +/// the graph regardless of file or language, producing false cross-file +/// (even cross-language) hierarchy edges for common type names. Priority: +/// 1. Same-file declaration, when `name` is not itself an import artifact. +/// 2. The file's actually-resolved import for `name` (barrel-traced). +/// 3. Last resort: a same-language-family global-by-name match (#1783), +/// first candidate only — a heritage clause names exactly one type. +fn resolve_hierarchy_targets<'a>( + ctx: &EdgeContext<'a>, + name: &str, + rel_path: &str, + imported_names: &HashMap<&str, &str>, + target_kinds: &[&str], +) -> Vec<&'a NodeInfo> { + let samefile_all: Vec<&NodeInfo> = ctx.nodes_by_name_and_file + .get(&(name, rel_path)) + .cloned().unwrap_or_default(); + let is_local_definition = !samefile_all.is_empty() && !imported_names.contains_key(name); + if is_local_definition { + return samefile_all.into_iter() + .filter(|n| target_kinds.contains(&n.kind.as_str())) + .collect(); + } + + if let Some(imported_from) = imported_names.get(name) { + let imported_candidates: Vec<&NodeInfo> = ctx.nodes_by_name_and_file + .get(&(name, *imported_from)) + .cloned().unwrap_or_default() + .into_iter() + .filter(|n| target_kinds.contains(&n.kind.as_str())) + .collect(); + if !imported_candidates.is_empty() { + return imported_candidates; + } + } + + ctx.nodes_by_name.get(name).cloned().unwrap_or_default() + .into_iter() + .filter(|n| target_kinds.contains(&n.kind.as_str()) && resolve::is_same_language_family(rel_path, &n.file)) + .take(1) + .collect() +} + /// Emit extends and implements edges for class hierarchy declarations. fn emit_hierarchy_edges( ctx: &EdgeContext, file_input: &FileEdgeInput, rel_path: &str, + imported_names: &HashMap<&str, &str>, edges: &mut Vec, ) { for cls in &file_input.classes { @@ -1387,9 +1434,7 @@ fn emit_hierarchy_edges( let Some(source) = source_row else { continue }; if let Some(ref extends_name) = cls.extends { - let targets = ctx.nodes_by_name.get(extends_name.as_str()) - .map(|v| v.iter().filter(|n| EXTENDS_TARGET_KINDS.contains(&n.kind.as_str())).collect::>()) - .unwrap_or_default(); + let targets = resolve_hierarchy_targets(ctx, extends_name, rel_path, imported_names, EXTENDS_TARGET_KINDS); for t in targets { edges.push(ComputedEdge { source_id: source.id, target_id: t.id, @@ -1399,9 +1444,7 @@ fn emit_hierarchy_edges( } } if let Some(ref implements_name) = cls.implements { - let targets = ctx.nodes_by_name.get(implements_name.as_str()) - .map(|v| v.iter().filter(|n| IMPLEMENTS_TARGET_KINDS.contains(&n.kind.as_str())).collect::>()) - .unwrap_or_default(); + let targets = resolve_hierarchy_targets(ctx, implements_name, rel_path, imported_names, IMPLEMENTS_TARGET_KINDS); for t in targets { edges.push(ComputedEdge { source_id: source.id, target_id: t.id, diff --git a/src/domain/graph/builder/call-resolver.ts b/src/domain/graph/builder/call-resolver.ts index fb97377fd..f58416f39 100644 --- a/src/domain/graph/builder/call-resolver.ts +++ b/src/domain/graph/builder/call-resolver.ts @@ -367,3 +367,49 @@ export function resolveReceiverEdge( confidence: typeConfidence ?? (typeName ? 0.9 : 0.7), }; } + +/** + * Resolve the target(s) of a class-hierarchy heritage clause (`extends X` / + * `implements Y`) to actual node candidates. + * + * Previously this resolved by a bare, unscoped name lookup across the entire + * graph, so common type names (`Repository`, `User`, …) produced false + * cross-file — even cross-language — hierarchy edges whenever an unrelated + * declaration happened to share the name (#1812). + * + * Mirrors `resolveReceiverEdge`'s priority order: + * 1. Same-file declaration, when `name` is not itself an import artifact — + * a locally-declared class/interface owns the name in its own file. + * 2. The file's actually-resolved import for `name` (barrel-traced), so + * `extends X` only links to the specific `X` this file imported. + * 3. Last resort: a same-language-family global-by-name match (never + * cross-language, per #1783) — and only the single first candidate, since + * a heritage clause names exactly one type and an unscoped match set is + * the ambiguity this function exists to eliminate. + */ +export function resolveHierarchyTargets( + lookup: CallNodeLookup, + name: string, + relPath: string, + importedNames: ReadonlyMap, + targetKinds: ReadonlySet, +): ReadonlyArray<{ id: number; file: string }> { + const sameFileAll = lookup.byNameAndFile(name, relPath); + const isLocalDefinition = sameFileAll.length > 0 && !importedNames.has(name); + if (isLocalDefinition) { + return sameFileAll.filter((n) => targetKinds.has(n.kind ?? '')); + } + + const importedFrom = importedNames.get(name); + if (importedFrom) { + const importedCandidates = lookup + .byNameAndFile(name, importedFrom) + .filter((n) => targetKinds.has(n.kind ?? '')); + if (importedCandidates.length > 0) return importedCandidates; + } + + const globalCandidates = lookup + .byName(name) + .filter((n) => targetKinds.has(n.kind ?? '') && isSameLanguageFamily(relPath, n.file)); + return globalCandidates.length > 0 ? [globalCandidates[0]!] : []; +} diff --git a/src/domain/graph/builder/incremental.ts b/src/domain/graph/builder/incremental.ts index 416414663..17d11b684 100644 --- a/src/domain/graph/builder/incremental.ts +++ b/src/domain/graph/builder/incremental.ts @@ -28,6 +28,7 @@ import { isModuleScopedLanguage, resolveCallTargets, resolveDefinePropertyAccessorTarget, + resolveHierarchyTargets, resolveReceiverEdge, resolveSameClassQualifiedMethod, } from './call-resolver.js'; @@ -236,7 +237,7 @@ function rebuildReverseDepEdges( importedNames, importedOriginalNames, ); - edgesAdded += buildClassHierarchyEdges(stmts, depRelPath, symbols); + edgesAdded += buildClassHierarchyEdges(db, stmts, depRelPath, symbols, importedNames); return edgesAdded; } @@ -513,12 +514,25 @@ const HIERARCHY_SOURCE_KINDS = new Set(['class', 'struct', 'record', 'enum']); const EXTENDS_TARGET_KINDS = new Set(['class', 'struct', 'trait', 'record']); const IMPLEMENTS_TARGET_KINDS = new Set(['interface', 'trait', 'class']); +/** + * Emit `extends`/`implements` edges for class/struct/trait heritage clauses. + * + * Target resolution goes through `resolveHierarchyTargets` (#1812) — same-file + * declaration first, then the file's actually-resolved import, only falling + * back to a same-language-family global-by-name match as a last resort — + * instead of matching the heritage name against every node in the graph + * regardless of file or language. Mirrors the full-build `buildClassHierarchyEdges` + * in build-edges.ts. + */ function buildClassHierarchyEdges( + db: BetterSqlite3Database, stmts: IncrementalStmts, relPath: string, symbols: ExtractorOutput, + importedNames: ReadonlyMap, ): number { let edgesAdded = 0; + const lookup = makeIncrementalLookup(db, stmts); for (const cls of symbols.classes) { const sourceRow = (stmts.findNodeInFile.all(cls.name, relPath) as NodeWithKind[]).find((n) => HIERARCHY_SOURCE_KINDS.has(n.kind), @@ -526,16 +540,24 @@ function buildClassHierarchyEdges( if (!sourceRow) continue; if (cls.extends) { - for (const t of (stmts.findNodeByName.all(cls.extends) as NodeWithKind[]).filter((n) => - EXTENDS_TARGET_KINDS.has(n.kind), + for (const t of resolveHierarchyTargets( + lookup, + cls.extends, + relPath, + importedNames, + EXTENDS_TARGET_KINDS, )) { stmts.insertEdge.run(sourceRow.id, t.id, 'extends', 1.0, 0); edgesAdded++; } } if (cls.implements) { - for (const t of (stmts.findNodeByName.all(cls.implements) as NodeWithKind[]).filter((n) => - IMPLEMENTS_TARGET_KINDS.has(n.kind), + for (const t of resolveHierarchyTargets( + lookup, + cls.implements, + relPath, + importedNames, + IMPLEMENTS_TARGET_KINDS, )) { stmts.insertEdge.run(sourceRow.id, t.id, 'implements', 1.0, 0); edgesAdded++; @@ -908,7 +930,7 @@ function rebuildEdgesForTargetFile( importedNames, importedOriginalNames, ); - edgesAdded += buildClassHierarchyEdges(stmts, relPath, symbols); + edgesAdded += buildClassHierarchyEdges(db, stmts, relPath, symbols, importedNames); return edgesAdded; } diff --git a/src/domain/graph/builder/stages/build-edges.ts b/src/domain/graph/builder/stages/build-edges.ts index 7e81e3c03..b7f995673 100644 --- a/src/domain/graph/builder/stages/build-edges.ts +++ b/src/domain/graph/builder/stages/build-edges.ts @@ -46,6 +46,7 @@ import { isModuleScopedLanguage, resolveCallTargets, resolveDefinePropertyAccessorTarget, + resolveHierarchyTargets, resolveReceiverEdge, resolveSameClassQualifiedMethod, } from '../call-resolver.js'; @@ -971,7 +972,7 @@ function buildCallEdgesJS( importArtifactNames, importedOriginalNames, ); - buildClassHierarchyEdges(ctx, relPath, symbols, allEdgeRows); + buildClassHierarchyEdges(lookup, relPath, symbols, importedNames, allEdgeRows); } } @@ -1933,38 +1934,51 @@ const HIERARCHY_SOURCE_KINDS = new Set(['class', 'struct', 'record', 'enum']); const EXTENDS_TARGET_KINDS = new Set(['class', 'struct', 'trait', 'record']); const IMPLEMENTS_TARGET_KINDS = new Set(['interface', 'trait', 'class']); +/** + * Emit `extends`/`implements` edges for class/struct/trait heritage clauses. + * + * Target resolution is scoped through `resolveHierarchyTargets` (#1812) — + * same-file declaration first, then the file's actually-resolved import, + * only falling back to a same-language-family global-by-name match as a + * last resort — instead of matching the heritage name against every node in + * the graph regardless of file or language. + */ function buildClassHierarchyEdges( - ctx: PipelineContext, + lookup: CallNodeLookup, relPath: string, symbols: ExtractorOutput, + importedNames: ReadonlyMap, allEdgeRows: EdgeRowTuple[], ): void { for (const cls of symbols.classes) { + const sourceRow = lookup + .byNameAndFile(cls.name, relPath) + .find((n) => HIERARCHY_SOURCE_KINDS.has(n.kind ?? '')); + if (!sourceRow) continue; + if (cls.extends) { - const sourceRow = (ctx.nodesByNameAndFile.get(`${cls.name}|${relPath}`) || []).find((n) => - HIERARCHY_SOURCE_KINDS.has(n.kind), - ); - const targetRows = (ctx.nodesByName.get(cls.extends) || []).filter((n) => - EXTENDS_TARGET_KINDS.has(n.kind), + const targets = resolveHierarchyTargets( + lookup, + cls.extends, + relPath, + importedNames, + EXTENDS_TARGET_KINDS, ); - if (sourceRow) { - for (const t of targetRows) { - allEdgeRows.push([sourceRow.id, t.id, 'extends', 1.0, 0, null, null]); - } + for (const t of targets) { + allEdgeRows.push([sourceRow.id, t.id, 'extends', 1.0, 0, null, null]); } } if (cls.implements) { - const sourceRow = (ctx.nodesByNameAndFile.get(`${cls.name}|${relPath}`) || []).find((n) => - HIERARCHY_SOURCE_KINDS.has(n.kind), - ); - const targetRows = (ctx.nodesByName.get(cls.implements) || []).filter((n) => - IMPLEMENTS_TARGET_KINDS.has(n.kind), + const targets = resolveHierarchyTargets( + lookup, + cls.implements, + relPath, + importedNames, + IMPLEMENTS_TARGET_KINDS, ); - if (sourceRow) { - for (const t of targetRows) { - allEdgeRows.push([sourceRow.id, t.id, 'implements', 1.0, 0, null, null]); - } + for (const t of targets) { + allEdgeRows.push([sourceRow.id, t.id, 'implements', 1.0, 0, null, null]); } } } diff --git a/tests/fixtures/hierarchy-scoping/consumer.ts b/tests/fixtures/hierarchy-scoping/consumer.ts new file mode 100644 index 000000000..8c2431a10 --- /dev/null +++ b/tests/fixtures/hierarchy-scoping/consumer.ts @@ -0,0 +1,8 @@ +import { type Readable, Repository } from './moduleA/Base.js'; + +// #1812: `Repository` is imported specifically from moduleA/Base.ts. The +// hierarchy resolver must link here, not to the unrelated moduleB/Base.ts or +// decoy/Repository.py declarations that happen to share the bare name. +export class UserRepository extends Repository implements Readable { + read(): void {} +} diff --git a/tests/fixtures/hierarchy-scoping/decoy/Repository.py b/tests/fixtures/hierarchy-scoping/decoy/Repository.py new file mode 100644 index 000000000..2002d8dc0 --- /dev/null +++ b/tests/fixtures/hierarchy-scoping/decoy/Repository.py @@ -0,0 +1,11 @@ +# Cross-language decoy: same bare name as the TypeScript classes/interfaces +# in moduleA/moduleB, in a completely unrelated language. A pre-#1812 +# global-by-name lookup ignored language boundaries, so a TypeScript +# `extends Repository` could resolve here too. +class Repository: + pass + + +# Cross-language decoy for the no-import fallback case (see orphan.ts). +class UniqueBase: + pass diff --git a/tests/fixtures/hierarchy-scoping/moduleA/Base.ts b/tests/fixtures/hierarchy-scoping/moduleA/Base.ts new file mode 100644 index 000000000..aba593931 --- /dev/null +++ b/tests/fixtures/hierarchy-scoping/moduleA/Base.ts @@ -0,0 +1,13 @@ +export class Repository { + getAll(): void {} +} + +export interface Readable { + read(): void; +} + +// Only same-language-family declaration of this name in the fixture (the +// decoy Python file below shares the name but is a different language). +export class UniqueBase { + identify(): void {} +} diff --git a/tests/fixtures/hierarchy-scoping/moduleB/Base.ts b/tests/fixtures/hierarchy-scoping/moduleB/Base.ts new file mode 100644 index 000000000..230aa57af --- /dev/null +++ b/tests/fixtures/hierarchy-scoping/moduleB/Base.ts @@ -0,0 +1,9 @@ +// Unrelated declaration that happens to share a name with moduleA/Base.ts. +// A pre-#1812 bare global-by-name lookup would match this too. +export class Repository { + save(): void {} +} + +export interface Readable { + scan(): void; +} diff --git a/tests/fixtures/hierarchy-scoping/orphan.ts b/tests/fixtures/hierarchy-scoping/orphan.ts new file mode 100644 index 000000000..26119f988 --- /dev/null +++ b/tests/fixtures/hierarchy-scoping/orphan.ts @@ -0,0 +1,5 @@ +// #1812: no import of `UniqueBase` at all. The only same-language-family +// (TypeScript/JS) declaration in the fixture is moduleA/Base.ts's +// `UniqueBase` — the last-resort global fallback must land there, and must +// never link to the cross-language decoy/UniqueBase.py declaration. +export class Orphan extends UniqueBase {} diff --git a/tests/integration/issue-1812-hierarchy-scoped-resolution.test.ts b/tests/integration/issue-1812-hierarchy-scoped-resolution.test.ts new file mode 100644 index 000000000..170c46f1d --- /dev/null +++ b/tests/integration/issue-1812-hierarchy-scoped-resolution.test.ts @@ -0,0 +1,140 @@ +/** + * #1812: extends/implements edges must resolve like any other symbol + * reference — same-file declaration first, then the file's actually-resolved + * import, only falling back to a same-language-family global-by-name match + * as a last resort. Before this fix, a bare heritage-clause name (`extends X` + * / `implements Y`) matched *every* node in the graph named `X`/`Y`, + * regardless of file or language, producing false cross-file (even + * cross-language) hierarchy edges for common type names. + * + * Fixture layout (tests/fixtures/hierarchy-scoping/): + * moduleA/Base.ts — Repository (class), Readable (interface), UniqueBase (class) + * moduleB/Base.ts — unrelated Repository/Readable declarations, same names + * decoy/Repository.py — unrelated Python Repository/UniqueBase, same names + * consumer.ts — imports Repository + Readable from moduleA/Base.ts; + * UserRepository extends Repository implements Readable + * orphan.ts — Orphan extends UniqueBase with NO import at all + */ + +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'; +import type { EngineMode } from '../../src/types.js'; + +const FIXTURE_DIR = path.join(import.meta.dirname, '..', 'fixtures', 'hierarchy-scoping'); + +interface HierarchyEdgeRow { + kind: string; + source_name: string; + source_file: string; + target_name: string; + target_file: string; +} + +function readHierarchyEdges(dbPath: string): HierarchyEdgeRow[] { + const db = new Database(dbPath, { readonly: true }); + try { + return db + .prepare( + `SELECT e.kind AS kind, + n1.name AS source_name, n1.file AS source_file, + n2.name AS target_name, n2.file AS target_file + FROM edges e + JOIN nodes n1 ON e.source_id = n1.id + JOIN nodes n2 ON e.target_id = n2.id + WHERE e.kind IN ('extends', 'implements') + ORDER BY n1.file, n1.name, e.kind, n2.file, n2.name`, + ) + .all() as HierarchyEdgeRow[]; + } finally { + db.close(); + } +} + +const ENGINES: EngineMode[] = ['wasm', 'native']; + +describe.each(ENGINES)('issue #1812: hierarchy edge scoping (%s)', (engine) => { + let tmpDir: string; + let hierarchyEdges: HierarchyEdgeRow[]; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), `codegraph-1812-${engine}-`)); + fs.cpSync(FIXTURE_DIR, tmpDir, { recursive: true }); + + await buildGraph(tmpDir, { incremental: false, skipRegistry: true, engine }); + hierarchyEdges = readHierarchyEdges(path.join(tmpDir, '.codegraph', 'graph.db')); + }, 60_000); + + afterAll(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('extends: resolves UserRepository -> moduleA/Base.ts Repository (import-scoped)', () => { + const matches = hierarchyEdges.filter( + (e) => e.kind === 'extends' && e.source_name === 'UserRepository', + ); + expect( + matches, + `Expected exactly one extends edge from UserRepository.\nActual:\n${JSON.stringify(hierarchyEdges, null, 2)}`, + ).toHaveLength(1); + expect(matches[0]!.target_file).toBe('moduleA/Base.ts'); + expect(matches[0]!.target_name).toBe('Repository'); + }); + + it('extends: does NOT link UserRepository -> moduleB/Base.ts Repository', () => { + const bogus = hierarchyEdges.find( + (e) => + e.kind === 'extends' && + e.source_name === 'UserRepository' && + e.target_file === 'moduleB/Base.ts', + ); + expect(bogus).toBeUndefined(); + }); + + it('extends: does NOT link UserRepository -> decoy/Repository.py Repository (cross-language)', () => { + const bogus = hierarchyEdges.find( + (e) => + e.kind === 'extends' && + e.source_name === 'UserRepository' && + e.target_file === 'decoy/Repository.py', + ); + expect(bogus).toBeUndefined(); + }); + + it('implements: resolves UserRepository -> moduleA/Base.ts Readable (import-scoped)', () => { + const matches = hierarchyEdges.filter( + (e) => e.kind === 'implements' && e.source_name === 'UserRepository', + ); + expect( + matches, + `Expected exactly one implements edge from UserRepository.\nActual:\n${JSON.stringify(hierarchyEdges, null, 2)}`, + ).toHaveLength(1); + expect(matches[0]!.target_file).toBe('moduleA/Base.ts'); + expect(matches[0]!.target_name).toBe('Readable'); + }); + + it('extends: no-import fallback resolves Orphan -> moduleA/Base.ts UniqueBase (same-language-family)', () => { + const matches = hierarchyEdges.filter( + (e) => e.kind === 'extends' && e.source_name === 'Orphan', + ); + expect( + matches, + `Expected exactly one extends edge from Orphan.\nActual:\n${JSON.stringify(hierarchyEdges, null, 2)}`, + ).toHaveLength(1); + expect(matches[0]!.target_file).toBe('moduleA/Base.ts'); + expect(matches[0]!.target_name).toBe('UniqueBase'); + }); + + it('extends: no-import fallback does NOT link Orphan -> decoy/UniqueBase.py (cross-language)', () => { + const bogus = hierarchyEdges.find( + (e) => + e.kind === 'extends' && + e.source_name === 'Orphan' && + e.target_file === 'decoy/Repository.py', + ); + expect(bogus).toBeUndefined(); + }); +}); From 79cd761c1d7f60fe5f5a1871dacceab6309eef0e Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Tue, 7 Jul 2026 04:26:35 -0600 Subject: [PATCH 66/67] fix: track inline per-specifier type-only import modifier `import { value, type Foo } from 'mod'` marks only Foo as type-only via an inline modifier, distinct from a whole-statement `import type { X }`. Both engines derived `typeOnly` per import statement, so a mixed statement's type-only names got no symbol-level `imports-type` edge and were undercounted as export consumers. Extends `Import` with a sparse `typeOnlyNames` field (mirrors the `renamedImports` convention) populated from the per-specifier `type`/ `typeof` token in the tree-sitter-typescript grammar. Propagates through the WASM/TS extractor (both query-dispatch and walk paths), the native Rust extractor, and both edge-building paths (JS full-build/incremental and native FFI/pure-native pipelines) so `emitNamedSymbolEdges` credits exactly the flagged names instead of gating on whole-statement typeOnly. Impact: 19 functions changed, 38 affected --- .../src/domain/graph/builder/pipeline.rs | 2 +- .../graph/builder/stages/build_edges.rs | 77 ++++++++++++- .../graph/builder/stages/import_edges.rs | 96 ++++++++++++++-- .../src/extractors/javascript.rs | 40 +++++-- crates/codegraph-core/src/types.rs | 12 ++ src/domain/graph/builder/incremental.ts | 25 +++- .../graph/builder/stages/build-edges.ts | 28 ++++- src/extractors/javascript.ts | 21 +++- src/types.ts | 10 ++ .../consumer.ts | 15 +++ .../issue-1813-inline-type-modifier/types.ts | 15 +++ .../issue-1813-inline-type-modifier.test.ts | 108 ++++++++++++++++++ tests/parsers/javascript.test.ts | 50 ++++++++ 13 files changed, 471 insertions(+), 28 deletions(-) create mode 100644 tests/fixtures/issue-1813-inline-type-modifier/consumer.ts create mode 100644 tests/fixtures/issue-1813-inline-type-modifier/types.ts create mode 100644 tests/integration/issue-1813-inline-type-modifier.test.ts diff --git a/crates/codegraph-core/src/domain/graph/builder/pipeline.rs b/crates/codegraph-core/src/domain/graph/builder/pipeline.rs index 17810f123..8c7fa58e7 100644 --- a/crates/codegraph-core/src/domain/graph/builder/pipeline.rs +++ b/crates/codegraph-core/src/domain/graph/builder/pipeline.rs @@ -1325,7 +1325,7 @@ fn collect_imported_names_for_file( let mut imported_names: Vec = Vec::new(); for imp in &symbols.imports { let resolved_path = import_ctx.get_resolved(abs_str, &imp.source); - for (local, original) in import_name_pairs(imp) { + for (local, original, _type_only) in import_name_pairs(imp) { // CJS require bindings are included in imported_names so the receiver-edge // resolver treats them as import artifacts (not locally-defined symbols). // We use an empty target_file so the import-aware call-target lookup diff --git a/crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs b/crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs index 3db41fc51..9d6d07851 100644 --- a/crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs +++ b/crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs @@ -1469,6 +1469,12 @@ pub struct ImportInfo { pub dynamic_import: bool, #[napi(js_name = "wildcardReexport")] pub wildcard_reexport: bool, + /// Local names (subset of `names`) marked type-only via an inline + /// per-specifier `type`/`typeof` modifier (`import { type X }`), as + /// distinct from a whole-statement `import type { X }` (already covered + /// by `type_only`, #1813). + #[napi(js_name = "typeOnlyNames")] + pub type_only_names: Vec, } #[napi(object)] @@ -1669,6 +1675,13 @@ fn is_named_reexport(imp: &ImportInfo) -> bool { imp.reexport && !imp.wildcard_reexport } +/// True when an import carries any type-only signal — a whole-statement +/// `import type { X }` or at least one inline per-specifier `type` modifier +/// (`import { type X }`, #1813). +fn has_type_only_names(imp: &ImportInfo) -> bool { + imp.type_only || !imp.type_only_names.is_empty() +} + /// For a `type` import or a named re-export targeting a barrel or resolved /// file, emit one symbol-level edge per named symbol so the target symbols /// receive fan-in credit and aren't misclassified as dead code @@ -1676,6 +1689,10 @@ fn is_named_reexport(imp: &ImportInfo) -> bool { /// precise re-export surface instead of the target's full export list /// (`reexports`, #1742). `kind` selects which edge kind to emit. /// +/// For `kind == "imports-type"`, only specifiers actually marked type-only +/// (whole-statement or inline per-specifier, #1813) get an edge — a mixed +/// `import { value, type Foo }` must not credit `value`. +/// /// `imp.names` holds the *original* declaration name for export specifiers /// (see `extractImportNames` in the JS extractor) even when renamed /// externally, so this naturally resolves `export { X as Z }` against `X`'s @@ -1694,6 +1711,12 @@ fn emit_named_symbol_edges( } for name in &imp.names { let clean_name = strip_star_as_prefix(name); + if kind == "imports-type" + && !imp.type_only + && !imp.type_only_names.iter().any(|n| n == clean_name) + { + continue; + } let barrel_target = if ctx.barrel_set.contains(resolved_path) { let mut visited = HashSet::new(); barrel_resolution::resolve_barrel_export(ctx, resolved_path, clean_name, &mut visited) @@ -1798,7 +1821,7 @@ fn process_single_import( dynamic: 0, dynamic_kind: None, }); - if imp.type_only { + if has_type_only_names(imp) { emit_named_symbol_edges(edges, file_input, imp, resolved_path, "imports-type", ctx); } if is_named_reexport(imp) { @@ -1829,6 +1852,25 @@ mod import_edge_tests { type_only, dynamic_import: dynamic, wildcard_reexport: false, + type_only_names: vec![], + } + } + + /// A mixed import (`import { value, type Foo } from 'src'`) where only + /// `type_only_names` carries the inline-modifier names (#1813). + fn make_import_with_type_only_names( + source: &str, + names: Vec<&str>, + type_only_names: Vec<&str>, + ) -> ImportInfo { + ImportInfo { + source: source.to_string(), + names: names.into_iter().map(|s| s.to_string()).collect(), + reexport: false, + type_only: false, + dynamic_import: false, + wildcard_reexport: false, + type_only_names: type_only_names.into_iter().map(|s| s.to_string()).collect(), } } @@ -1920,6 +1962,7 @@ mod import_edge_tests { type_only: false, dynamic_import: false, wildcard_reexport: true, + type_only_names: vec![], }, ], vec![])]; let resolved = vec![make_resolved("/root/src/index.ts", "./utils", "src/utils.ts")]; @@ -1988,6 +2031,38 @@ mod import_edge_tests { assert_eq!(edges[0].kind, "imports-type"); } + #[test] + fn mixed_import_inline_type_modifier_credits_only_flagged_name() { + // `import { value, type Foo } from './mixed'` — only `Foo` carries + // the inline per-specifier `type` modifier, so only `Foo` should get + // a symbol-level `imports-type` edge; `value` must not (#1813). The + // file-level edge stays `imports` since the statement as a whole + // isn't fully type-only. + let files = vec![make_file("src/app.ts", 1, vec![ + make_import_with_type_only_names("./mixed", vec!["value", "Foo"], vec!["Foo"]), + ], vec![])]; + let resolved = vec![make_resolved("/root/src/app.ts", "./mixed", "src/mixed.ts")]; + let node_ids = vec![make_node_entry("src/app.ts", 1), make_node_entry("src/mixed.ts", 2)]; + let symbol_nodes = vec![ + SymbolNodeEntry { name: "Foo".to_string(), file: "src/mixed.ts".to_string(), node_id: 50 }, + SymbolNodeEntry { name: "value".to_string(), file: "src/mixed.ts".to_string(), node_id: 51 }, + ]; + + let edges = build_import_edges( + files, + resolved, + vec![], + node_ids, + vec![], + "/root".to_string(), + Some(symbol_nodes), + ); + assert_eq!(edges.len(), 2); + assert_eq!(edges[0].kind, "imports"); + assert_eq!(edges[1].kind, "imports-type"); + assert_eq!(edges[1].target_id, 50); + } + #[test] fn dynamic_import_edge() { let files = vec![make_file("src/app.ts", 1, vec![ diff --git a/crates/codegraph-core/src/domain/graph/builder/stages/import_edges.rs b/crates/codegraph-core/src/domain/graph/builder/stages/import_edges.rs index 06cf98aad..06cd08882 100644 --- a/crates/codegraph-core/src/domain/graph/builder/stages/import_edges.rs +++ b/crates/codegraph-core/src/domain/graph/builder/stages/import_edges.rs @@ -121,23 +121,46 @@ impl BarrelContext for ImportEdgeContext { /// target-file symbol lookups must search using the *original* name — the /// renamed local alias only exists in the importing file, not in the file /// being imported from (#1730). Mirrors `importNamePairs` in build-edges.ts. -pub(crate) fn import_name_pairs(imp: &crate::types::Import) -> Vec<(String, String)> { +/// +/// Also reports, per name, whether it should be treated as type-only — +/// either because the whole statement is (`import type { X }`) or because +/// this specific specifier carries the inline modifier +/// (`import { type X }`, #1813). +pub(crate) fn import_name_pairs(imp: &crate::types::Import) -> Vec<(String, String, bool)> { let mut original_name_for: HashMap<&str, &str> = HashMap::new(); if let Some(renamed) = &imp.renamed_imports { for r in renamed { original_name_for.insert(&r.local, &r.imported); } } + let statement_type_only = imp.type_only.unwrap_or(false); + let type_only_names: HashSet<&str> = imp + .type_only_names + .as_ref() + .map(|v| v.iter().map(|s| s.as_str()).collect()) + .unwrap_or_default(); imp.names .iter() .map(|name| { let local = name.strip_prefix("* as ").unwrap_or(name); let original = original_name_for.get(local).copied().unwrap_or(local); - (local.to_string(), original.to_string()) + let type_only = statement_type_only || type_only_names.contains(local); + (local.to_string(), original.to_string(), type_only) }) .collect() } +/// True when an import carries any type-only signal — a whole-statement +/// `import type { X }` or at least one inline per-specifier `type` modifier +/// (`import { type X }`, #1813). +fn has_type_only_names(imp: &crate::types::Import) -> bool { + imp.type_only.unwrap_or(false) + || imp + .type_only_names + .as_ref() + .is_some_and(|names| !names.is_empty()) +} + /// Build the reexport map from parsed file symbols. pub fn build_reexport_map(ctx: &ImportEdgeContext) -> HashMap> { let mut reexport_map = HashMap::new(); @@ -279,11 +302,15 @@ fn collect_symbol_lookup_pairs(ctx: &ImportEdgeContext) -> HashSet<(String, Stri let abs_file = Path::new(&ctx.root_dir).join(rel_path); let abs_str = abs_file.to_str().unwrap_or(""); for imp in &symbols.imports { - if !imp.type_only.unwrap_or(false) && !is_named_reexport(imp) { + let is_reexport = is_named_reexport(imp); + if !has_type_only_names(imp) && !is_reexport { continue; } let resolved_path = ctx.get_resolved(abs_str, &imp.source); - for (_local, original) in import_name_pairs(imp) { + for (_local, original, type_only) in import_name_pairs(imp) { + if !is_reexport && !type_only { + continue; + } let mut target_file = resolved_path.clone(); if ctx.is_barrel_file(&resolved_path) { let mut visited = HashSet::new(); @@ -328,6 +355,10 @@ fn classify_import_kind(imp: &crate::types::Import) -> &'static str { /// dead (`imports-type`, #1724), or so `codegraph exports` can report the /// precise re-export surface instead of the target's full export list /// (`reexports`, #1742). `kind` selects which edge kind to emit. +/// +/// For `kind == "imports-type"`, only specifiers actually marked type-only +/// (whole-statement or inline per-specifier, #1813) get an edge — a mixed +/// `import { value, type Foo }` must not credit `value`. fn emit_named_symbol_rows( edges: &mut Vec, file_node_id: i64, @@ -337,7 +368,10 @@ fn emit_named_symbol_rows( ctx: &ImportEdgeContext, symbol_node_ids: &HashMap<(String, String), i64>, ) { - for (_local, original) in import_name_pairs(imp) { + for (_local, original, type_only) in import_name_pairs(imp) { + if kind == "imports-type" && !type_only { + continue; + } let mut target_file = resolved_path.to_string(); if ctx.is_barrel_file(resolved_path) { let mut visited = HashSet::new(); @@ -379,7 +413,7 @@ fn emit_barrel_through_rows( _ => "imports", }; let mut resolved_sources: HashSet = HashSet::new(); - for (_local, original) in import_name_pairs(imp) { + for (_local, original, _type_only) in import_name_pairs(imp) { let mut visited = HashSet::new(); let actual_source = match ctx.resolve_barrel_export(resolved_path, &original, &mut visited) { @@ -429,7 +463,7 @@ fn emit_edges_for_import( confidence: 1.0, dynamic: 0, }); - if imp.type_only.unwrap_or(false) { + if has_type_only_names(imp) { emit_named_symbol_rows( edges, file_node_id, @@ -663,4 +697,52 @@ mod tests { assert!(!ctx.is_barrel_file("src/utils.ts")); assert!(!ctx.is_barrel_file("nonexistent.ts")); } + + #[test] + fn import_name_pairs_flags_inline_per_specifier_type_only_names() { + // `import { value, type Foo } from './mixed'` — only `Foo` carries the + // inline modifier, so only its pair should report `type_only = true` (#1813). + let mut imp = Import::new( + "./mixed".to_string(), + vec!["value".to_string(), "Foo".to_string()], + 1, + ); + imp.type_only_names = Some(vec!["Foo".to_string()]); + + let pairs = import_name_pairs(&imp); + assert_eq!( + pairs, + vec![ + ("value".to_string(), "value".to_string(), false), + ("Foo".to_string(), "Foo".to_string(), true), + ] + ); + } + + #[test] + fn import_name_pairs_whole_statement_type_only_flags_all_names() { + // `import type { A, B } from './types'` — every name is type-only, + // regardless of `type_only_names` (which stays unset for this form). + let mut imp = Import::new( + "./types".to_string(), + vec!["A".to_string(), "B".to_string()], + 1, + ); + imp.type_only = Some(true); + + let pairs = import_name_pairs(&imp); + assert!(pairs.iter().all(|(_, _, type_only)| *type_only)); + } + + #[test] + fn import_name_pairs_plain_value_import_flags_no_names() { + let imp = Import::new( + "./utils".to_string(), + vec!["helper".to_string()], + 1, + ); + + let pairs = import_name_pairs(&imp); + assert!(pairs.iter().all(|(_, _, type_only)| !*type_only)); + } } diff --git a/crates/codegraph-core/src/extractors/javascript.rs b/crates/codegraph-core/src/extractors/javascript.rs index dd39aada9..1c75f53fe 100644 --- a/crates/codegraph-core/src/extractors/javascript.rs +++ b/crates/codegraph-core/src/extractors/javascript.rs @@ -1699,7 +1699,9 @@ fn handle_import_stmt(node: &Node, source: &[u8], symbols: &mut FileSymbols) { let mod_path = node_text(&source_node, source) .replace(&['\'', '"'][..], ""); let mut renamed_imports = Vec::new(); - let names = extract_import_names_with_renames(node, source, &mut renamed_imports); + let mut type_only_names = Vec::new(); + let names = + extract_import_names_with_renames(node, source, &mut renamed_imports, &mut type_only_names); let mut imp = Import::new(mod_path, names, start_line(node)); if is_type_only { imp.type_only = Some(true); @@ -1707,6 +1709,9 @@ fn handle_import_stmt(node: &Node, source: &[u8], symbols: &mut FileSymbols) { if !renamed_imports.is_empty() { imp.renamed_imports = Some(renamed_imports); } + if !type_only_names.is_empty() { + imp.type_only_names = Some(type_only_names); + } symbols.imports.push(imp); } } @@ -3184,21 +3189,25 @@ fn extract_rest_identifier(rest_node: &Node, source: &[u8], names: &mut Vec Vec { let mut names = Vec::new(); let mut renamed = Vec::new(); - scan_import_names(node, source, &mut names, &mut renamed); + let mut type_only = Vec::new(); + scan_import_names(node, source, &mut names, &mut renamed, &mut type_only); names } /// Extract import names and collect `{ local, imported }` pairs for -/// `import_specifier` nodes that rename a binding (`import { X as Y }`). -/// Mirrors `extractImportNames`'s `renamedOut` parameter in -/// src/extractors/javascript.ts (#1730). +/// `import_specifier` nodes that rename a binding (`import { X as Y }`), plus +/// the local names of specifiers carrying an inline `type`/`typeof` +/// modifier (`import { type X }`, #1813). Mirrors `extractImportNames`'s +/// `renamedOut`/`typeOnlyOut` parameters in src/extractors/javascript.ts +/// (#1730, #1813). fn extract_import_names_with_renames( node: &Node, source: &[u8], renamed_out: &mut Vec, + type_only_out: &mut Vec, ) -> Vec { let mut names = Vec::new(); - scan_import_names(node, source, &mut names, renamed_out); + scan_import_names(node, source, &mut names, renamed_out, type_only_out); names } @@ -3207,8 +3216,9 @@ fn scan_import_names( source: &[u8], names: &mut Vec, renamed_out: &mut Vec, + type_only_out: &mut Vec, ) { - scan_import_names_depth(node, source, names, renamed_out, 0); + scan_import_names_depth(node, source, names, renamed_out, type_only_out, 0); } /// Grammar note (see tree-sitter-javascript): for `import_specifier`, the @@ -3218,11 +3228,17 @@ fn scan_import_names( /// unconditionally (as this function used to, and as the `export_specifier` /// branch below still deliberately does — see its comment) silently drops the /// local alias for every renamed import: call sites use `Y`, not `X` (#1730). +/// +/// The tree-sitter-typescript grammar defines `import_specifier` as +/// `optional(choice('type', 'typeof'))` followed by the name/alias fields, so +/// an inline per-specifier type modifier (`import { type X }`) — when +/// present — is always the specifier's first child (#1813). fn scan_import_names_depth( node: &Node, source: &[u8], names: &mut Vec, renamed_out: &mut Vec, + type_only_out: &mut Vec, depth: usize, ) { if depth >= MAX_WALK_DEPTH { @@ -3234,7 +3250,8 @@ fn scan_import_names_depth( let alias_node = node.child_by_field_name("alias"); let local_node = alias_node.or(source_name_node); if let Some(local_node) = local_node { - names.push(node_text(&local_node, source).to_string()); + let local_text = node_text(&local_node, source).to_string(); + names.push(local_text.clone()); if let (Some(alias), Some(source_name)) = (alias_node, source_name_node) { let alias_text = node_text(&alias, source); let source_text = node_text(&source_name, source); @@ -3245,6 +3262,11 @@ fn scan_import_names_depth( }); } } + if let Some(modifier) = node.child(0) { + if modifier.kind() == "type" || modifier.kind() == "typeof" { + type_only_out.push(local_text); + } + } } else { names.push(node_text(node, source).to_string()); } @@ -3280,7 +3302,7 @@ fn scan_import_names_depth( } for i in 0..node.child_count() { if let Some(child) = node.child(i) { - scan_import_names_depth(&child, source, names, renamed_out, depth + 1); + scan_import_names_depth(&child, source, names, renamed_out, type_only_out, depth + 1); } } } diff --git a/crates/codegraph-core/src/types.rs b/crates/codegraph-core/src/types.rs index e41c50034..bd1cfc04e 100644 --- a/crates/codegraph-core/src/types.rs +++ b/crates/codegraph-core/src/types.rs @@ -144,6 +144,17 @@ pub struct Import { /// `Import.renamedImports`. #[napi(js_name = "renamedImports")] pub renamed_imports: Option>, + /// Local binding names (post-alias, matching entries in `names`) that + /// carry an inline per-specifier `type`/`typeof` modifier + /// (`import { type X }`), as distinct from a whole-statement + /// `import type { X }` (already covered by `type_only`). Only populated + /// for specifiers that actually use the modifier — mirrors + /// `renamed_imports`'s sparse-population convention. Lets a mixed + /// statement (`import { value, type Foo }`) still credit `Foo` with a + /// symbol-level `imports-type` edge. Mirrors TS `Import.typeOnlyNames` + /// (#1813). + #[napi(js_name = "typeOnlyNames")] + pub type_only_names: Option>, // Language-specific flags #[napi(js_name = "pythonImport")] pub python_import: Option, @@ -188,6 +199,7 @@ impl Import { reexport: None, wildcard_reexport: None, renamed_imports: None, + type_only_names: None, python_import: None, go_import: None, rust_use: None, diff --git a/src/domain/graph/builder/incremental.ts b/src/domain/graph/builder/incremental.ts index 17d11b684..f14f9f372 100644 --- a/src/domain/graph/builder/incremental.ts +++ b/src/domain/graph/builder/incremental.ts @@ -337,13 +337,25 @@ function resolveBarrelTarget( * target-file symbol lookups must search using the *original* name — the * renamed local alias only exists in the importing file, not in the file * being imported from (#1730). Mirrors `importNamePairs` in build-edges.ts. + * + * Also reports, per name, whether it should be treated as type-only — + * either because the whole statement is (`import type { X }`) or because + * this specific specifier carries the inline modifier + * (`import { type X }`, #1813). */ -function importNamePairs(imp: Import): Array<{ local: string; original: string }> { +function importNamePairs( + imp: Import, +): Array<{ local: string; original: string; typeOnly: boolean }> { const originalNameFor = new Map(); for (const r of imp.renamedImports ?? []) originalNameFor.set(r.local, r.imported); + const typeOnlyNames = new Set(imp.typeOnlyNames ?? []); return imp.names.map((name) => { const local = name.replace(/^\*\s+as\s+/, ''); - return { local, original: originalNameFor.get(local) ?? local }; + return { + local, + original: originalNameFor.get(local) ?? local, + typeOnly: imp.typeOnly === true || typeOnlyNames.has(local), + }; }); } @@ -383,6 +395,10 @@ function resolveBarrelImportEdges( * so the loop is a no-op for them; the query layer falls back to the * target's full export list for anything reached only by the file-level * edge. Mirrors `emitNamedSymbolEdges` in build-edges.ts (full-build path). + * + * For `edgeKind === 'imports-type'`, only specifiers actually marked + * type-only (whole-statement or inline per-specifier, #1813) get an edge — + * a mixed `import { value, type Foo }` must not credit `value`. */ function emitNamedSymbolEdges( db: BetterSqlite3Database | null, @@ -393,7 +409,8 @@ function emitNamedSymbolEdges( edgeKind: 'imports-type' | 'reexports', ): number { let edgesAdded = 0; - for (const { original } of importNamePairs(imp)) { + for (const { original, typeOnly } of importNamePairs(imp)) { + if (edgeKind === 'imports-type' && !typeOnly) continue; let targetFile = resolvedPath; if (db && isBarrelFile(db, resolvedPath)) { const actual = resolveBarrelTarget(db, resolvedPath, original); @@ -437,7 +454,7 @@ function emitEdgesForImport( stmts.insertEdge.run(fileNodeId, targetRow.id, edgeKind, 1.0, 0); let edgesAdded = 1; - if (imp.typeOnly) { + if (imp.typeOnly || (imp.typeOnlyNames && imp.typeOnlyNames.length > 0)) { edgesAdded += emitNamedSymbolEdges(db, stmts, imp, resolvedPath, fileNodeId, 'imports-type'); } if (imp.reexport && !imp.wildcardReexport) { diff --git a/src/domain/graph/builder/stages/build-edges.ts b/src/domain/graph/builder/stages/build-edges.ts index b7f995673..7b4fe940f 100644 --- a/src/domain/graph/builder/stages/build-edges.ts +++ b/src/domain/graph/builder/stages/build-edges.ts @@ -171,13 +171,25 @@ function importEdgeKind(imp: Import): string { * target-file symbol lookups must search using the *original* name — the * renamed local alias only exists in the importing file, not in the file * being imported from (#1730). + * + * Also reports, per name, whether it should be treated as type-only — + * either because the whole statement is (`import type { X }`) or because + * this specific specifier carries the inline modifier + * (`import { type X }`, #1813). */ -function importNamePairs(imp: Import): Array<{ local: string; original: string }> { +function importNamePairs( + imp: Import, +): Array<{ local: string; original: string; typeOnly: boolean }> { const originalNameFor = new Map(); for (const r of imp.renamedImports ?? []) originalNameFor.set(r.local, r.imported); + const typeOnlyNames = new Set(imp.typeOnlyNames ?? []); return imp.names.map((name) => { const local = name.replace(/^\*\s+as\s+/, ''); - return { local, original: originalNameFor.get(local) ?? local }; + return { + local, + original: originalNameFor.get(local) ?? local, + typeOnly: imp.typeOnly === true || typeOnlyNames.has(local), + }; }); } @@ -204,6 +216,10 @@ function importNamePairs(imp: Import): Array<{ local: string; original: string } * only gets a precise symbol-level edge when a name is actually spelled * out; the query layer falls back to the target's full export list for * anything reached only by the file-level edge (genuine wildcard semantics). + * + * For `edgeKind === 'imports-type'`, only specifiers actually marked + * type-only (whole-statement or inline per-specifier, #1813) get an edge — + * a mixed `import { value, type Foo }` must not credit `value`. */ function emitNamedSymbolEdges( ctx: PipelineContext, @@ -214,7 +230,8 @@ function emitNamedSymbolEdges( edgeKind: 'imports-type' | 'reexports', ): void { if (!ctx.nodesByNameAndFile) return; - for (const { original } of importNamePairs(imp)) { + for (const { original, typeOnly } of importNamePairs(imp)) { + if (edgeKind === 'imports-type' && !typeOnly) continue; let targetFile = resolvedPath; if (isBarrelFile(ctx, resolvedPath)) { const actual = resolveBarrelExportCached(ctx, resolvedPath, original); @@ -246,7 +263,7 @@ function emitEdgesForImport( const edgeKind = importEdgeKind(imp); allEdgeRows.push([fileNodeId, targetRow.id, edgeKind, 1.0, 0, null, null]); - if (imp.typeOnly) { + if (imp.typeOnly || (imp.typeOnlyNames && imp.typeOnlyNames.length > 0)) { emitNamedSymbolEdges(ctx, imp, resolvedPath, fileNodeId, allEdgeRows, 'imports-type'); } if (imp.reexport && !imp.wildcardReexport) { @@ -317,6 +334,8 @@ interface NativeImportInfo { typeOnly: boolean; dynamicImport: boolean; wildcardReexport: boolean; + /** Local names (subset of `names`) marked type-only via inline `type`/`typeof` modifier (#1813). */ + typeOnlyNames: string[]; } /** Native FFI input shape for a single file. */ @@ -367,6 +386,7 @@ function toNativeImportInfo(imp: Import): NativeImportInfo { typeOnly: !!imp.typeOnly, dynamicImport: !!imp.dynamicImport, wildcardReexport: !!imp.wildcardReexport, + typeOnlyNames: imp.typeOnlyNames ?? [], }; } diff --git a/src/extractors/javascript.ts b/src/extractors/javascript.ts index 935665350..c327d4318 100644 --- a/src/extractors/javascript.ts +++ b/src/extractors/javascript.ts @@ -312,13 +312,15 @@ function handleImportCapture(c: Record, imports: Import[ const isTypeOnly = impNode.text.startsWith('import type'); const modPath = c.imp_source!.text.replace(/['"]/g, ''); const renamedImports: Array<{ local: string; imported: string }> = []; - const names = extractImportNames(impNode, renamedImports); + const typeOnlyNames: string[] = []; + const names = extractImportNames(impNode, renamedImports, typeOnlyNames); imports.push({ source: modPath, names, line: nodeStartLine(impNode), typeOnly: isTypeOnly, ...(renamedImports.length > 0 ? { renamedImports } : {}), + ...(typeOnlyNames.length > 0 ? { typeOnlyNames } : {}), }); } @@ -1509,13 +1511,15 @@ function handleImportStmt(node: TreeSitterNode, ctx: ExtractorOutput): void { if (source) { const modPath = source.text.replace(/['"]/g, ''); const renamedImports: Array<{ local: string; imported: string }> = []; - const names = extractImportNames(node, renamedImports); + const typeOnlyNames: string[] = []; + const names = extractImportNames(node, renamedImports, typeOnlyNames); ctx.imports.push({ source: modPath, names, line: nodeStartLine(node), typeOnly: isTypeOnly, ...(renamedImports.length > 0 ? { renamedImports } : {}), + ...(typeOnlyNames.length > 0 ? { typeOnlyNames } : {}), }); } } @@ -4034,6 +4038,14 @@ function findParentClass(node: TreeSitterNode): string | null { * `renamedOut`, when passed, collects `{ local, imported }` pairs for * `import_specifier` nodes that rename a binding (`import { X as Y }`). * + * `typeOnlyOut`, when passed, collects the local binding name of every + * `import_specifier` carrying an inline `type`/`typeof` modifier + * (`import { type X }`) — the per-specifier form of type-only, distinct + * from a whole-statement `import type { X }` (#1813). Per the + * tree-sitter-typescript grammar, `import_specifier` is + * `optional(choice('type', 'typeof'))` followed by the name/alias fields, + * so the modifier — when present — is always the specifier's first child. + * * Grammar note (see tree-sitter-javascript): for `import_specifier`, the * `name` field is *always* present — it holds the name as declared by the * source module. `alias` is only present for `X as Y` and holds the *local* @@ -4045,6 +4057,7 @@ function findParentClass(node: TreeSitterNode): string | null { function extractImportNames( node: TreeSitterNode, renamedOut?: Array<{ local: string; imported: string }>, + typeOnlyOut?: string[], ): string[] { const names: string[] = []; function scan(n: TreeSitterNode): void { @@ -4057,6 +4070,10 @@ function extractImportNames( if (aliasNode && sourceNameNode && aliasNode.text !== sourceNameNode.text) { renamedOut?.push({ local: aliasNode.text, imported: sourceNameNode.text }); } + const modifier = n.child(0); + if (modifier && (modifier.type === 'type' || modifier.type === 'typeof')) { + typeOnlyOut?.push(localNode.text); + } } else { names.push(n.text); } diff --git a/src/types.ts b/src/types.ts index 3ce8f716f..6f3f361f8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -522,6 +522,16 @@ export interface Import { * — barrel/reexport tracing is a distinct mechanism (see resolveBarrelExport). */ renamedImports?: Array<{ local: string; imported: string }>; + /** + * Local binding names (post-alias, matching entries in `names`) that carry + * an inline per-specifier `type`/`typeof` modifier (`import { type X }`), + * as distinct from a whole-statement `import type { X }` (already covered + * by `typeOnly`). Only populated for specifiers that actually use the + * modifier — mirrors `renamedImports`'s sparse-population convention. + * Lets a mixed statement (`import { value, type Foo }`) still credit `Foo` + * with a symbol-level `imports-type` edge (#1813). + */ + typeOnlyNames?: string[]; // Language-specific flags (mutually exclusive at runtime) pythonImport?: boolean; goImport?: boolean; diff --git a/tests/fixtures/issue-1813-inline-type-modifier/consumer.ts b/tests/fixtures/issue-1813-inline-type-modifier/consumer.ts new file mode 100644 index 000000000..108a050f9 --- /dev/null +++ b/tests/fixtures/issue-1813-inline-type-modifier/consumer.ts @@ -0,0 +1,15 @@ +// Mixed statement: inline `type` modifier applied to individual specifiers +// (Repository, Widget) alongside plain value specifiers (openRepo, +// computeSize) in one import — the real-world pattern from this repo's own +// `db/index.ts` consumers (#1813). Ordering of the modifier within a +// specifier list is covered separately at the extractor unit-test level +// (tests/parsers/javascript.test.ts). +import { computeSize, openRepo, type Repository, type Widget } from './types.js'; + +export function useRepo(): Repository { + return openRepo(); +} + +export function useWidget(): Widget { + return { size: computeSize() }; +} diff --git a/tests/fixtures/issue-1813-inline-type-modifier/types.ts b/tests/fixtures/issue-1813-inline-type-modifier/types.ts new file mode 100644 index 000000000..94c16c5fe --- /dev/null +++ b/tests/fixtures/issue-1813-inline-type-modifier/types.ts @@ -0,0 +1,15 @@ +export function openRepo(): Repository { + return {} as Repository; +} + +export interface Repository { + find(id: string): unknown; +} + +export function computeSize(): number { + return 1; +} + +export interface Widget { + size: number; +} diff --git a/tests/integration/issue-1813-inline-type-modifier.test.ts b/tests/integration/issue-1813-inline-type-modifier.test.ts new file mode 100644 index 000000000..c502914f8 --- /dev/null +++ b/tests/integration/issue-1813-inline-type-modifier.test.ts @@ -0,0 +1,108 @@ +/** + * Regression for #1813: `import { value, type Foo } from 'mod'` — an inline + * per-specifier `type` modifier — was not tracked as type-only at all in + * either engine. Only a whole-statement `import type { Foo } from 'mod'` + * produced a symbol-level `imports-type` edge (#1724); a mixed statement got + * no type-only credit for `Foo`, undercounting real consumers reported by + * `codegraph exports`. + * + * Fixture: + * types.ts — defines openRepo/Repository and computeSize/Widget + * consumer.ts — a single mixed import statement from types.ts: + * import { computeSize, openRepo, type Repository, type Widget } from './types.js'; + * + * Repository and Widget should each get a symbol-level `imports-type` edge + * from consumer.ts; openRepo and computeSize (the plain value specifiers in + * the same statement) must not. (Modifier-position invariance — leading vs. + * trailing within a specifier list — is covered separately at the extractor + * unit-test level in tests/parsers/javascript.test.ts.) + */ + +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'; +import type { EngineMode } from '../../src/types.js'; + +const FIXTURE_DIR = path.join( + import.meta.dirname, + '..', + 'fixtures', + 'issue-1813-inline-type-modifier', +); + +interface EdgeRow { + kind: string; + target_name: string; + target_file: string; +} + +function readImportEdgesFromConsumer(dbPath: string): EdgeRow[] { + const db = new Database(dbPath, { readonly: true }); + try { + return db + .prepare( + `SELECT e.kind, n2.name AS target_name, n2.file AS target_file + FROM edges e + JOIN nodes n1 ON e.source_id = n1.id + JOIN nodes n2 ON e.target_id = n2.id + WHERE n1.file = 'consumer.ts' AND n1.kind = 'file' + AND e.kind IN ('imports', 'imports-type') + ORDER BY e.kind, n2.name`, + ) + .all() as EdgeRow[]; + } finally { + db.close(); + } +} + +const ENGINES: EngineMode[] = ['wasm', 'native']; + +describe.each(ENGINES)('#1813 inline per-specifier type modifier (%s)', (engine) => { + let tmpDir: string; + let edges: EdgeRow[]; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), `codegraph-1813-${engine}-`)); + fs.cpSync(FIXTURE_DIR, tmpDir, { recursive: true }); + + await buildGraph(tmpDir, { incremental: false, skipRegistry: true, engine }); + const dbPath = path.join(tmpDir, '.codegraph', 'graph.db'); + edges = readImportEdgesFromConsumer(dbPath); + }, 60_000); + + afterAll(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('credits Repository with a symbol-level imports-type edge', () => { + const hit = edges.find((e) => e.kind === 'imports-type' && e.target_name === 'Repository'); + expect(hit).toBeDefined(); + expect(hit?.target_file).toBe('types.ts'); + }); + + it('credits Widget with a symbol-level imports-type edge', () => { + const hit = edges.find((e) => e.kind === 'imports-type' && e.target_name === 'Widget'); + expect(hit).toBeDefined(); + expect(hit?.target_file).toBe('types.ts'); + }); + + it('does not credit the value specifiers sharing the same mixed statement', () => { + const wrongCredits = edges.filter( + (e) => + e.kind === 'imports-type' && + (e.target_name === 'openRepo' || e.target_name === 'computeSize'), + ); + expect(wrongCredits).toEqual([]); + }); + + it('keeps the file-level edge for the mixed statement as plain imports, not imports-type', () => { + const fileLevel = edges.filter((e) => e.target_name === 'types.ts'); + expect(fileLevel.length).toBeGreaterThan(0); + for (const e of fileLevel) { + expect(e.kind).toBe('imports'); + } + }); +}); diff --git a/tests/parsers/javascript.test.ts b/tests/parsers/javascript.test.ts index 2b01c1daf..f9da9261a 100644 --- a/tests/parsers/javascript.test.ts +++ b/tests/parsers/javascript.test.ts @@ -176,6 +176,56 @@ describe('JavaScript parser', () => { }); }); + describe('inline per-specifier type-only import modifier (#1813)', () => { + function parseTS(code) { + const parser = parsers.get('typescript'); + const tree = parser.parse(code); + return extractSymbols(tree, 'test.ts'); + } + + it('records the type-only specifier in typeOnlyNames for a mixed statement', () => { + const symbols = parseTS(`import { openRepo, type Repository } from './db';`); + expect(symbols.imports[0].names).toEqual(['openRepo', 'Repository']); + expect(symbols.imports[0].typeOnly).toBe(false); + expect(symbols.imports[0].typeOnlyNames).toEqual(['Repository']); + }); + + it('records the type-only specifier regardless of its position in the statement', () => { + const symbols = parseTS(`import { type Repository, openRepo } from './db';`); + expect(symbols.imports[0].typeOnlyNames).toEqual(['Repository']); + }); + + it('records every type-only name when multiple specifiers use the inline modifier', () => { + const symbols = parseTS(`import { type A, type B, value } from './mixed';`); + expect(symbols.imports[0].typeOnlyNames).toEqual(['A', 'B']); + }); + + it('recognizes the `typeof` modifier as well as `type`', () => { + const symbols = parseTS(`import { typeof Z, value } from './mixed';`); + expect(symbols.imports[0].typeOnlyNames).toEqual(['Z']); + }); + + it('does not set typeOnlyNames when no specifier uses the inline modifier', () => { + const symbols = parseTS(`import { foo, bar } from './baz';`); + expect(symbols.imports[0].typeOnlyNames).toBeUndefined(); + }); + + it('does not set typeOnlyNames for a whole-statement `import type` (already covered by typeOnly)', () => { + const symbols = parseTS(`import type { Foo, Bar } from './types';`); + expect(symbols.imports[0].typeOnly).toBe(true); + expect(symbols.imports[0].typeOnlyNames).toBeUndefined(); + }); + + it('records the local alias, not the source name, for a renamed type-only specifier', () => { + const symbols = parseTS(`import { type Repository as Repo, openRepo } from './db';`); + expect(symbols.imports[0].names).toEqual(['Repo', 'openRepo']); + expect(symbols.imports[0].typeOnlyNames).toEqual(['Repo']); + expect(symbols.imports[0].renamedImports).toEqual([ + { local: 'Repo', imported: 'Repository' }, + ]); + }); + }); + describe('dynamic import() destructuring through parens/as-cast wrappers (#1781)', () => { function parseTS(code) { const parser = parsers.get('typescript'); From cbfa9542bb50eb89b547268f9d3f4efc94695b01 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Tue, 7 Jul 2026 04:49:41 -0600 Subject: [PATCH 67/67] fix(native): widen NativeRepository file filters to accept string[] triage, and the unwired findNodesByScope/findNodeByQualifiedName/ listFunctionNodes/iterateFunctionNodes native bindings, forwarded the CLI's repeatable -f/--file array straight into rusqlite bindings typed for a single Rust String. triage silently swallowed the resulting napi-rs conversion error into an empty result at exit code 0; the others would crash the same way once wired up. Widen the five NativeDatabase bindings (Rust + TS declarations) to Option>, reusing the push_file_filter() helper already shared by find_nodes_with_fan_in/fn_deps, and forward the full normalized array from NativeRepository instead of truncating to the first value. Also narrow triageData's catch to only swallow the validated-input ConfigError case so a genuine internal failure propagates instead of masquerading as "no symbols match". Impact: 24 functions changed, 46 affected --- .../src/db/repository/graph_read.rs | 81 +++++++------------ src/db/repository/base.ts | 2 +- src/db/repository/in-memory-repository.ts | 5 +- src/db/repository/native-repository.ts | 36 ++++++--- src/db/repository/nodes.ts | 2 +- src/db/repository/sqlite-repository.ts | 2 +- src/domain/analysis/implementations.ts | 16 +++- src/features/triage.ts | 14 +++- src/presentation/triage.ts | 2 +- src/types.ts | 21 +++-- tests/integration/cli.test.ts | 46 +++++++++++ tests/integration/triage.test.ts | 29 +++++++ 12 files changed, 176 insertions(+), 80 deletions(-) diff --git a/crates/codegraph-core/src/db/repository/graph_read.rs b/crates/codegraph-core/src/db/repository/graph_read.rs index aa21de34e..52b216839 100644 --- a/crates/codegraph-core/src/db/repository/graph_read.rs +++ b/crates/codegraph-core/src/db/repository/graph_read.rs @@ -719,7 +719,7 @@ fn validate_triage_role(role: Option<&str>) -> napi::Result<()> { fn build_triage_query( kind: Option<&str>, role: Option<&str>, - file: Option<&str>, + file: Option<&[String]>, no_tests: bool, ) -> (String, Vec>) { let kinds_to_use: Vec<&str> = match kind { @@ -759,9 +759,7 @@ fn build_triage_query( sql.push_str(&format!(" {}", test_filter_clauses("n.file"))); } if let Some(f) = file { - sql.push_str(&format!(" AND n.file LIKE ?{idx} ESCAPE '\\'")); - param_values.push(Box::new(format!("%{}%", escape_like(f)))); - idx += 1; + push_file_filter(&mut sql, &mut param_values, &mut idx, "n.file", f); } if let Some(r) = role { if r == "dead" { @@ -1033,7 +1031,7 @@ impl NativeDatabase { &self, scope_name: String, kind: Option, - file: Option, + file: Option>, ) -> napi::Result> { let conn = self.conn()?; @@ -1048,8 +1046,7 @@ impl NativeDatabase { idx += 1; } if let Some(ref f) = file { - sql.push_str(&format!(" AND file LIKE ?{idx} ESCAPE '\\'")); - param_values.push(Box::new(format!("%{}%", escape_like(f)))); + push_file_filter(&mut sql, &mut param_values, &mut idx, "file", f); } sql.push_str(" ORDER BY file, line"); @@ -1065,53 +1062,35 @@ impl NativeDatabase { .map_err(|e| napi::Error::from_reason(format!("find_nodes_by_scope collect: {e}"))) } - /// Find nodes by qualified name with optional file filter. + /// Find nodes by qualified name with optional (multi-value) file filter. #[napi] pub fn find_node_by_qualified_name( &self, qualified_name: String, - file: Option, + file: Option>, ) -> napi::Result> { let conn = self.conn()?; + let mut sql = "SELECT * FROM nodes WHERE qualified_name = ?1".to_string(); + let mut param_values: Vec> = + vec![Box::new(qualified_name)]; + let mut idx = 2; if let Some(ref f) = file { - let pattern = format!("%{}%", escape_like(f)); - let mut stmt = conn - .prepare_cached( - "SELECT * FROM nodes WHERE qualified_name = ?1 AND file LIKE ?2 ESCAPE '\\' ORDER BY file, line", - ) - .map_err(|e| { - napi::Error::from_reason(format!( - "find_node_by_qualified_name prepare: {e}" - )) - })?; - let rows = stmt - .query_map(params![qualified_name, pattern], read_node_row) - .map_err(|e| { - napi::Error::from_reason(format!("find_node_by_qualified_name: {e}")) - })?; - rows.collect::, _>>().map_err(|e| { - napi::Error::from_reason(format!("find_node_by_qualified_name collect: {e}")) - }) - } else { - let mut stmt = conn - .prepare_cached( - "SELECT * FROM nodes WHERE qualified_name = ?1 ORDER BY file, line", - ) - .map_err(|e| { - napi::Error::from_reason(format!( - "find_node_by_qualified_name prepare: {e}" - )) - })?; - let rows = stmt - .query_map(params![qualified_name], read_node_row) - .map_err(|e| { - napi::Error::from_reason(format!("find_node_by_qualified_name: {e}")) - })?; - rows.collect::, _>>().map_err(|e| { - napi::Error::from_reason(format!("find_node_by_qualified_name collect: {e}")) - }) + push_file_filter(&mut sql, &mut param_values, &mut idx, "file", f); } + sql.push_str(" ORDER BY file, line"); + + let mut stmt = conn.prepare_cached(&sql).map_err(|e| { + napi::Error::from_reason(format!("find_node_by_qualified_name prepare: {e}")) + })?; + let params_ref: Vec<&dyn rusqlite::types::ToSql> = + param_values.iter().map(|p| p.as_ref()).collect(); + let rows = stmt + .query_map(params_ref.as_slice(), read_node_row) + .map_err(|e| napi::Error::from_reason(format!("find_node_by_qualified_name: {e}")))?; + rows.collect::, _>>().map_err(|e| { + napi::Error::from_reason(format!("find_node_by_qualified_name collect: {e}")) + }) } /// Find nodes matching a name pattern with fan-in count. @@ -1189,7 +1168,7 @@ impl NativeDatabase { &self, kind: Option, role: Option, - file: Option, + file: Option>, no_tests: Option, ) -> napi::Result> { validate_triage_kind(kind.as_deref())?; @@ -1219,7 +1198,7 @@ impl NativeDatabase { #[napi] pub fn list_function_nodes( &self, - file: Option, + file: Option>, pattern: Option, no_tests: Option, ) -> napi::Result> { @@ -1230,7 +1209,7 @@ impl NativeDatabase { #[napi] pub fn iterate_function_nodes( &self, - file: Option, + file: Option>, pattern: Option, no_tests: Option, ) -> napi::Result> { @@ -1893,7 +1872,7 @@ impl NativeDatabase { /// Shared implementation for list_function_nodes / iterate_function_nodes. fn query_function_nodes( &self, - file: Option, + file: Option>, pattern: Option, no_tests: Option, ) -> napi::Result> { @@ -1909,9 +1888,7 @@ impl NativeDatabase { let mut idx = 1; if let Some(ref f) = file { - sql.push_str(&format!(" AND n.file LIKE ?{idx} ESCAPE '\\'")); - param_values.push(Box::new(format!("%{}%", escape_like(f)))); - idx += 1; + push_file_filter(&mut sql, &mut param_values, &mut idx, "n.file", f); } if let Some(ref p) = pattern { sql.push_str(&format!(" AND n.name LIKE ?{idx} ESCAPE '\\'")); diff --git a/src/db/repository/base.ts b/src/db/repository/base.ts index 9ab137a86..9f346fe17 100644 --- a/src/db/repository/base.ts +++ b/src/db/repository/base.ts @@ -75,7 +75,7 @@ export class Repository implements IRepository { throw new Error('not implemented'); } - findNodeByQualifiedName(_qualifiedName: string, _opts?: { file?: string }): NodeRow[] { + findNodeByQualifiedName(_qualifiedName: string, _opts?: { file?: string | string[] }): NodeRow[] { throw new Error('not implemented'); } diff --git a/src/db/repository/in-memory-repository.ts b/src/db/repository/in-memory-repository.ts index 28708c2ee..05d918aa4 100644 --- a/src/db/repository/in-memory-repository.ts +++ b/src/db/repository/in-memory-repository.ts @@ -267,7 +267,10 @@ export class InMemoryRepository extends Repository { return nodes.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line); } - findNodeByQualifiedName(qualifiedName: string, opts: { file?: string } = {}): NodeRow[] { + findNodeByQualifiedName( + qualifiedName: string, + opts: { file?: string | string[] } = {}, + ): NodeRow[] { let nodes = [...this.#nodes.values()].filter((n) => n.qualified_name === qualifiedName); { diff --git a/src/db/repository/native-repository.ts b/src/db/repository/native-repository.ts index 202b3bb74..03d294304 100644 --- a/src/db/repository/native-repository.ts +++ b/src/db/repository/native-repository.ts @@ -303,37 +303,53 @@ export class NativeRepository extends Repository { } findNodesByScope(scopeName: string, opts: QueryOpts = {}): NodeRow[] { - // Unlike findNodesWithFanIn/fnDeps, this native binding only accepts a single - // file (no CLI command currently wires -f/--file to it); take the first value - // rather than crash if a caller ever passes the repeatable-flag array form. - const [file] = normalizeFileFilter(opts.file); - return this.#ndb.findNodesByScope(scopeName, opts.kind ?? null, file ?? null).map(toNodeRow); + const files = normalizeFileFilter(opts.file); + return this.#ndb + .findNodesByScope(scopeName, opts.kind ?? null, files.length > 0 ? files : null) + .map(toNodeRow); } - findNodeByQualifiedName(qualifiedName: string, opts: { file?: string } = {}): NodeRow[] { - return this.#ndb.findNodeByQualifiedName(qualifiedName, opts.file ?? null).map(toNodeRow); + findNodeByQualifiedName( + qualifiedName: string, + opts: { file?: string | string[] } = {}, + ): NodeRow[] { + const files = normalizeFileFilter(opts.file); + return this.#ndb + .findNodeByQualifiedName(qualifiedName, files.length > 0 ? files : null) + .map(toNodeRow); } listFunctionNodes(opts: ListFunctionOpts = {}): NodeRow[] { + const files = normalizeFileFilter(opts.file); return this.#ndb - .listFunctionNodes(opts.file ?? null, opts.pattern ?? null, opts.noTests ?? null) + .listFunctionNodes( + files.length > 0 ? files : null, + opts.pattern ?? null, + opts.noTests ?? null, + ) .map(toNodeRow); } iterateFunctionNodes(opts: ListFunctionOpts = {}): IterableIterator { + const files = normalizeFileFilter(opts.file); const rows = this.#ndb - .iterateFunctionNodes(opts.file ?? null, opts.pattern ?? null, opts.noTests ?? null) + .iterateFunctionNodes( + files.length > 0 ? files : null, + opts.pattern ?? null, + opts.noTests ?? null, + ) .map(toNodeRow); return rows[Symbol.iterator](); } findNodesForTriage(opts: TriageQueryOpts = {}): TriageNodeRow[] { try { + const files = normalizeFileFilter(opts.file); return this.#ndb .findNodesForTriage( opts.kind ?? null, opts.role ?? null, - opts.file ?? null, + files.length > 0 ? files : null, opts.noTests ?? null, ) .map(toTriageNodeRow); diff --git a/src/db/repository/nodes.ts b/src/db/repository/nodes.ts index cab617a5d..b4f12e6e3 100644 --- a/src/db/repository/nodes.ts +++ b/src/db/repository/nodes.ts @@ -324,7 +324,7 @@ export function findNodesByScope( export function findNodeByQualifiedName( db: BetterSqlite3Database, qualifiedName: string, - opts: { file?: string } = {}, + opts: { file?: string | string[] } = {}, ): NodeRow[] { const fc = buildFileConditionSQL(opts.file ?? '', 'file'); if (fc.sql) { diff --git a/src/db/repository/sqlite-repository.ts b/src/db/repository/sqlite-repository.ts index 1ef1210f3..126d69b21 100644 --- a/src/db/repository/sqlite-repository.ts +++ b/src/db/repository/sqlite-repository.ts @@ -128,7 +128,7 @@ export class SqliteRepository extends Repository { return findNodesByScope(this.#db, scopeName, opts); } - findNodeByQualifiedName(qualifiedName: string, opts?: { file?: string }): NodeRow[] { + findNodeByQualifiedName(qualifiedName: string, opts?: { file?: string | string[] }): NodeRow[] { return findNodeByQualifiedName(this.#db, qualifiedName, opts); } diff --git a/src/domain/analysis/implementations.ts b/src/domain/analysis/implementations.ts index ea6703839..2c9ca2092 100644 --- a/src/domain/analysis/implementations.ts +++ b/src/domain/analysis/implementations.ts @@ -12,7 +12,13 @@ import { findMatchingNodes } from './symbol-lookup.js'; export function implementationsData( name: string, customDbPath: string, - opts: { noTests?: boolean; file?: string; kind?: string; limit?: number; offset?: number } = {}, + opts: { + noTests?: boolean; + file?: string | string[]; + kind?: string; + limit?: number; + offset?: number; + } = {}, ) { return withRepo(customDbPath, (repo) => { const noTests = opts.noTests || false; @@ -49,7 +55,13 @@ export function implementationsData( export function interfacesData( name: string, customDbPath: string, - opts: { noTests?: boolean; file?: string; kind?: string; limit?: number; offset?: number } = {}, + opts: { + noTests?: boolean; + file?: string | string[]; + kind?: string; + limit?: number; + offset?: number; + } = {}, ) { return withRepo(customDbPath, (repo) => { const noTests = opts.noTests || false; diff --git a/src/features/triage.ts b/src/features/triage.ts index 95e7d1559..b102d52dd 100644 --- a/src/features/triage.ts +++ b/src/features/triage.ts @@ -4,6 +4,7 @@ import { DEFAULT_WEIGHTS, scoreRisk } from '../graph/classifiers/risk.js'; import { loadConfig } from '../infrastructure/config.js'; import { warn } from '../infrastructure/logger.js'; import { isTestFile } from '../infrastructure/test-filter.js'; +import { ConfigError } from '../shared/errors.js'; import { paginateResult } from '../shared/paginate.js'; import type { CodegraphConfig, Role, TriageNodeRow } from '../types.js'; @@ -108,7 +109,7 @@ interface TriageDataOpts { sort?: string; config?: CodegraphConfig; weights?: Partial; - file?: string; + file?: string | string[]; kind?: string; role?: Role; limit?: number; @@ -163,8 +164,15 @@ export function triageData( role: opts.role || undefined, }); } catch (err: unknown) { - warn(`triage query failed: ${(err as Error).message}`); - return { items: [], summary: EMPTY_SUMMARY(weights) }; + // Only invalid kind/role (validated inside findNodesForTriage, surfaced as + // ConfigError) degrade gracefully to an empty result — every other failure + // (e.g. an internal query bug) must propagate rather than masquerade as + // "no symbols match" with exit code 0. + if (err instanceof ConfigError) { + warn(`triage query failed: ${err.message}`); + return { items: [], summary: EMPTY_SUMMARY(weights) }; + } + throw err; } const filtered = noTests ? rows.filter((r) => !isTestFile(r.file)) : rows; diff --git a/src/presentation/triage.ts b/src/presentation/triage.ts index 2b8b5e472..26587ab17 100644 --- a/src/presentation/triage.ts +++ b/src/presentation/triage.ts @@ -10,7 +10,7 @@ interface TriageOpts { sort?: string; kind?: string; role?: string; - file?: string; + file?: string | string[]; minScore?: number; limit?: number; offset?: number; diff --git a/src/types.ts b/src/types.ts index 6f3f361f8..3654a426f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -272,7 +272,8 @@ export interface QueryOpts { /** Options for listFunctionNodes / iterateFunctionNodes. */ export interface ListFunctionOpts { - file?: string; + /** Partial-match file filter. Repeatable on the CLI (`-f/--file`), so a single value arrives as a one-element array. */ + file?: string | string[]; pattern?: string; noTests?: boolean; } @@ -282,7 +283,8 @@ export interface TriageQueryOpts { kind?: string; role?: Role; noTests?: boolean; - file?: string; + /** Partial-match file filter. Repeatable on the CLI (`-f/--file`), so a single value arrives as a one-element array. */ + file?: string | string[]; } /** @@ -303,7 +305,7 @@ export interface Repository { bulkNodeIdsByFile(file: string): NodeIdRow[]; findNodeChildren(parentId: number): ChildNodeRow[]; findNodesByScope(scopeName: string, opts?: QueryOpts): NodeRow[]; - findNodeByQualifiedName(qualifiedName: string, opts?: { file?: string }): NodeRow[]; + findNodeByQualifiedName(qualifiedName: string, opts?: { file?: string | string[] }): NodeRow[]; listFunctionNodes(opts?: ListFunctionOpts): NodeRow[]; iterateFunctionNodes(opts?: ListFunctionOpts): IterableIterator; findNodesForTriage(opts?: TriageQueryOpts): TriageNodeRow[]; @@ -2563,9 +2565,12 @@ export interface NativeDatabase { findNodesByScope( scopeName: string, kind: string | null | undefined, - file: string | null | undefined, + file: string[] | null | undefined, + ): NativeNodeRow[]; + findNodeByQualifiedName( + qualifiedName: string, + file: string[] | null | undefined, ): NativeNodeRow[]; - findNodeByQualifiedName(qualifiedName: string, file: string | null | undefined): NativeNodeRow[]; findNodesWithFanIn( namePattern: string, kinds: string[] | null | undefined, @@ -2574,16 +2579,16 @@ export interface NativeDatabase { findNodesForTriage( kind: string | null | undefined, role: string | null | undefined, - file: string | null | undefined, + file: string[] | null | undefined, noTests: boolean | null | undefined, ): NativeTriageNodeRow[]; listFunctionNodes( - file: string | null | undefined, + file: string[] | null | undefined, pattern: string | null | undefined, noTests: boolean | null | undefined, ): NativeNodeRow[]; iterateFunctionNodes( - file: string | null | undefined, + file: string[] | null | undefined, pattern: string | null | undefined, noTests: boolean | null | undefined, ): NativeNodeRow[]; diff --git a/tests/integration/cli.test.ts b/tests/integration/cli.test.ts index 28c3a0a76..971aa6f0a 100644 --- a/tests/integration/cli.test.ts +++ b/tests/integration/cli.test.ts @@ -243,6 +243,52 @@ describe('CLI smoke tests', () => { expect(data.level).toBe('directory'); }); + // Regression tests for #1815: `-f/--file` is a repeatable Commander option + // (collectFile) that always produces a string[], even for a single use. + // triage's native composite path (findNodesForTriage) used to forward that + // array straight into a napi binding typed for a single String; the failure + // was silently swallowed into an empty result with exit code 0 instead of + // crashing or surfacing an error (see query -f test above for #1726 context + // on the underlying array-vs-String bug class). + test('triage -f scopes results to a single file without crashing or silently emptying', () => { + const out = run('triage', '-f', 'math.js', '--db', dbPath, '--json'); + const data = JSON.parse(out); + expect(data.items.length).toBeGreaterThan(0); + for (const it of data.items) expect(it.file).toContain('math.js'); + }); + + test('triage with a single -f excludes non-matching files', () => { + const out = run('triage', '-f', 'math.js', '--kind', 'function', '--db', dbPath, '--json'); + const data = JSON.parse(out); + const names = data.items.map((it) => it.name); + expect(names).toContain('add'); + expect(names).not.toContain('sumOfSquares'); + }); + + test('triage supports repeated -f (multi-file scoping)', () => { + const out = run('triage', '-f', 'math.js', '-f', 'utils.js', '--db', dbPath, '--json'); + const data = JSON.parse(out); + const files = new Set(data.items.map((it) => it.file)); + expect([...files].some((f) => f.includes('math.js'))).toBe(true); + expect([...files].some((f) => f.includes('utils.js'))).toBe(true); + expect([...files].some((f) => f.includes('index.js'))).toBe(false); + }); + + // ─── Interfaces / Implementations ─────────────────────────────────── + test('interfaces -f does not crash when scoping by file', () => { + const out = run('interfaces', 'Calculator', '-f', 'utils.js', '--db', dbPath, '--json'); + const data = JSON.parse(out); + expect(data).toHaveProperty('results'); + expect(Array.isArray(data.results)).toBe(true); + }); + + test('implementations -f does not crash when scoping by file', () => { + const out = run('implementations', 'Calculator', '-f', 'utils.js', '--db', dbPath, '--json'); + const data = JSON.parse(out); + expect(data).toHaveProperty('results'); + expect(Array.isArray(data.results)).toBe(true); + }); + // ─── Audit --quick (formerly explain) ────────────────────────────── test('audit --quick --json returns structural summary', () => { const out = run('audit', 'math.js', '--quick', '--db', dbPath, '--json'); diff --git a/tests/integration/triage.test.ts b/tests/integration/triage.test.ts index e7bdcb864..89924643a 100644 --- a/tests/integration/triage.test.ts +++ b/tests/integration/triage.test.ts @@ -5,7 +5,9 @@ */ import { beforeAll, describe, expect, test } from 'vitest'; +import { InMemoryRepository } from '../../src/db/repository/in-memory-repository.js'; import { triageData } from '../../src/features/triage.js'; +import { ConfigError } from '../../src/shared/errors.js'; import { createTestRepo } from '../helpers/fixtures.js'; // ─── Fixture ────────────────────────────────────────────────────────── @@ -263,4 +265,31 @@ describe('triage', () => { expect(core.roleWeight).toBe(1.0); expect(leaf.roleWeight).toBe(0.2); }); + + // Regression tests for #1815: findNodesForTriage failures used to be + // unconditionally swallowed into an empty result (exit 0), masking real + // internal errors (e.g. the native array-vs-String crash) as "no symbols + // match". Only the validated-input ConfigError case should degrade + // gracefully; anything else must propagate. + test('propagates a non-ConfigError failure from findNodesForTriage instead of swallowing it', () => { + class BrokenRepo extends InMemoryRepository { + override findNodesForTriage(): never { + throw new Error('boom: internal query failure'); + } + } + expect(() => triageData(null, { repo: new BrokenRepo(), limit: 100 })).toThrow( + 'boom: internal query failure', + ); + }); + + test('degrades gracefully (empty result) on a ConfigError from findNodesForTriage', () => { + class InvalidOptsRepo extends InMemoryRepository { + override findNodesForTriage(): never { + throw new ConfigError('Invalid kind: bogus (expected one of function, method, class)'); + } + } + const result = triageData(null, { repo: new InvalidOptsRepo(), limit: 100 }); + expect(result.items).toEqual([]); + expect(result.summary.total).toBe(0); + }); });