Skip to content

fix: classify destructured bindings as constant, not function#1902

Merged
carlos-alm merged 49 commits into
mainfrom
fix/issue-1773-destructured-binding-kind
Jul 8, 2026
Merged

fix: classify destructured bindings as constant, not function#1902
carlos-alm merged 49 commits into
mainfrom
fix/issue-1773-destructured-binding-kind

Conversation

@carlos-alm

Copy link
Copy Markdown
Contributor

Summary

  • Both engines' destructured object-pattern extraction (extractDestructuredBindings/extract_destructured_bindings) unconditionally assigned kind: 'function' to every binding target, regardless of the destructured value's actual nature. Since these bindings have no call-graph edges pointing at them by name under that wrong kind, roles --role dead flagged them dead-unresolved even when read repeatedly.
  • Fixed to kind: 'constant' (verified against shared/kinds.ts as the already-used correct kind for JS/TS const bindings — all three extraction call sites are unconditionally gated on const) in both engines. Confirmed this doesn't break call-target resolution: 'constant' is already in NODE_KIND_FILTER_SQL/TOP_LEVEL_BINDING_KINDS, so the flags -> parseArgs call-attribution edge survives via the correct tier instead of the old bogus "function scope" tier.
  • Checked array-pattern destructuring for the same bug class: TS/WASM already correctly emits kind: 'constant'; native emits no definition at all for array-pattern const declarations — a different bug shape (missing node, not wrong kind) and a genuine engine-parity gap. Filed separately as Engine parity: native JS extractor emits no definition for const array-pattern destructuring #1901, out of scope for this fix.

Closes #1773

Test plan

  • npm test — full suite green (3564 passed, 0 failed)
  • npm run lint — clean
  • cargo test --package codegraph-core — 466 passed
  • Native addon rebuilt, codesigned, verified directly
  • Resolution benchmark run twice (fix applied vs. both extractor files temporarily reverted, native rebuilt each time) — zero differences in precision/recall/TP/FP/FN across all 40 fixture languages
  • Both exact repros confirmed fixed: flags now kind: constant, role leaf (not dead); dbPath/name/force now kind: constant, role dead-leaf (not the misleading dead-unresolved) — verified this matches how any isolated referenced-but-unproven-live constant is classified via a control test

Stacked on #1900 (base branch fix/issue-1772-dedupe-callref-tests-rendering) — only the extractor/test diff is this issue's change.

carlos-alm added 30 commits July 5, 2026 11:24
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 <file> 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
…ssification

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
codegraph exports <file> --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 <file>` 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
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<String> 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
… exports

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
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
codegraph exports <file> 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
… builds

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
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
…g WASM grammars

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
`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
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
…n 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
…n DEFAULTS

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
…ng fan-in

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
…g callees

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
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
`codegraph exports <file>` 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
… 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
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
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
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
…onfig.ts

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
… check acknowledged)

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
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 (8f23020); 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
…ng them

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
…oseDbDeferred

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
…arations 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
(#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
…sis.pointsToMaxIterations

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
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
carlos-alm added 17 commits July 6, 2026 04:48
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
…pact commands

`renderAuditFunction` (presentation/audit.ts) and `printFnImpactLevels`
(presentation/queries-cli/impact.ts) both rendered the same
`Record<number, ImpactLevelEntry[]>` 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 #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,
`^ <icon> <name>  <file>:<line>` 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
…ken-benchmark results

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
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
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
… 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
…y call sites

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
…n (docs check acknowledged)

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
…ocs 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 <CallerClass>.<callName> 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
…ween build paths

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
… structure

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<chunkSize, stmt>) and its per-database
wrapper getOrCreatePerDbChunkStmt (WeakMap<db, Map<chunkSize, stmt>>).
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
…Insert chunk loop

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
…tance (docs check acknowledged)

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
…thods

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
…eral values

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
… inspect commands

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
…heck acknowledged)

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 = <literal>` 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
@greptile-apps

greptile-apps Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a bug where destructured object-pattern bindings (const { a, b } = expr) were classified with kind: 'function' instead of kind: 'constant' in both the TypeScript (WASM) and Rust (native) JavaScript extractors. The misclassification caused the dead-code classifier to emit the misleading dead-unresolved role for any binding whose destructured value was not a callable.

  • Core fix (src/extractors/javascript.ts, crates/codegraph-core/src/extractors/javascript.rs): two one-line changes per engine swap 'function''constant' in extractDestructuredBindings/extract_destructured_bindings. Both call sites are already gated on const declarations, so 'constant' is unconditionally correct.
  • Tests (tests/parsers/javascript.test.ts, tests/integration/issue-1773-destructured-binding-kind.test.ts): existing unit tests updated to match the corrected kind; new unit tests guard the non-renamed case from workerData-style RHS; new integration tests verify both WASM and native engines emit the right kind/role and that call-edge attribution (flags → parseArgs) survives the change.

Confidence Score: 5/5

Safe to merge — the change is two symmetric one-line fixes applied consistently across both engines, with comprehensive unit and integration coverage confirming correct kind emission and call-edge preservation.

Both the TypeScript and Rust extractors receive the same minimal, correct fix. All existing tests were updated to match the new kind, new unit tests cover the non-renamed/non-call-RHS case, and new integration tests verify end-to-end behavior (kind, role, and call attribution) against both WASM and native engines. No regressions were introduced in call-target resolution, which is kind-agnostic by design.

No files require special attention.

Important Files Changed

Filename Overview
src/extractors/javascript.ts Two one-line kind fixes in extractDestructuredBindings (shorthand and renamed binding branches); updated doc-comment with thorough rationale. Change is correct and minimal.
crates/codegraph-core/src/extractors/javascript.rs Two one-line kind fixes in extract_destructured_bindings (Rust mirror of TS extractor); updated doc-comment; Rust unit tests updated and new regression test added.
tests/integration/issue-1773-destructured-binding-kind.test.ts New integration test file covering both WASM and native engines; validates kind, role, and call-edge preservation for renamed and non-renamed destructured bindings.
tests/parsers/javascript.test.ts Existing kind assertions updated to 'constant'; new test for non-renamed/non-call RHS case (workerData); renamed-binding test upgraded with kind assertion.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["const { a, b } = expr()"] --> B{extract_destructured_bindings}
    B --> C["shorthand_property_identifier\ne.g. { a }"]
    B --> D["pair_pattern\ne.g. { original: renamed }"]
    C -->|"before #1773"| E["kind: 'function' ❌"]
    C -->|"after #1773"| F["kind: 'constant' ✅"]
    D -->|"before #1773"| G["kind: 'function' ❌"]
    D -->|"after #1773"| H["kind: 'constant' ✅"]
    E --> I["dead-code classifier\n→ dead-unresolved 😞"]
    F --> J["dead-code classifier\n→ leaf / dead-leaf 😊"]
    G --> I
    H --> J
    J --> K["call-target resolution\nkind-agnostic lookup ✅"]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A["const { a, b } = expr()"] --> B{extract_destructured_bindings}
    B --> C["shorthand_property_identifier\ne.g. { a }"]
    B --> D["pair_pattern\ne.g. { original: renamed }"]
    C -->|"before #1773"| E["kind: 'function' ❌"]
    C -->|"after #1773"| F["kind: 'constant' ✅"]
    D -->|"before #1773"| G["kind: 'function' ❌"]
    D -->|"after #1773"| H["kind: 'constant' ✅"]
    E --> I["dead-code classifier\n→ dead-unresolved 😞"]
    F --> J["dead-code classifier\n→ leaf / dead-leaf 😊"]
    G --> I
    H --> J
    J --> K["call-target resolution\nkind-agnostic lookup ✅"]
Loading

Reviews (4): Last reviewed commit: "test: add native call-edge preservation ..." | Re-trigger Greptile

Comment on lines +142 to +163
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'));
});
},
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Native describe block missing call-edge preservation test

The WASM describe block has two assertions — kind correctness (expectFixedKindAndRole) and a flags → parseArgs call-edge preservation check — but the native block only covers the first. If a future change to the native engine's call-attribution for top-level const bindings silently drops that edge, this test suite won't catch it. Parity with the WASM block's second it(...) would close the gap.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — added the matching flags → parseArgs call-edge preservation test to the native describe block in 156116b8.

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Codegraph Impact Analysis

2 functions changed7 callers affected across 1 files

  • extractDestructuredBindings in src/extractors/javascript.ts:1133 (6 transitive callers)
  • handleConstObjectPatternAssignment in src/extractors/javascript.ts:1271 (3 transitive callers)

Base automatically changed from fix/issue-1772-dedupe-callref-tests-rendering to main July 8, 2026 06:07
docs check acknowledged — conflict resolution only, no new functionality.

Impact: 45 functions changed, 181 affected
docs check acknowledged — test-only change, no behavior affected.
@carlos-alm

Copy link
Copy Markdown
Contributor Author

@greptileai

@carlos-alm carlos-alm merged commit bad36cb into main Jul 8, 2026
33 checks passed
@carlos-alm carlos-alm deleted the fix/issue-1773-destructured-binding-kind branch July 8, 2026 08:06
@github-actions github-actions Bot locked and limited conversation to collaborators Jul 8, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Destructured object-pattern bindings (const { x: renamed } = fn()) misclassified as kind=function, causing false dead-unresolved flags

1 participant