Skip to content

fix: recompute directory structure metrics for affected directories on incremental rebuild#1840

Merged
carlos-alm merged 17 commits into
mainfrom
fix/issue-1738-structure-metrics-incremental-stale
Jul 6, 2026
Merged

fix: recompute directory structure metrics for affected directories on incremental rebuild#1840
carlos-alm merged 17 commits into
mainfrom
fix/issue-1738-structure-metrics-incremental-stale

Conversation

@carlos-alm

Copy link
Copy Markdown
Contributor

Summary

  • The incremental build's "small fast path" (updateChangedFileMetrics, fires for ≤5 changed files on repos >20 files — i.e. almost every normal edit) only updated per-file node_metrics rows. It never touched directories at all: no directory-metrics recompute, no contains edge for a new file, no directory node for a brand-new directory. The non-fast-path incremental branch was already correct in both engines.
  • Added refreshAffectedDirectoryMetrics/refresh_affected_directory_metrics (both engines) to the fast path: recomputes metrics for ancestor directories of touched files, lazily creates missing directory nodes/contains edges, and expands one hop via live import edges to catch cross-directory fan-in/fan-out shifts (mirrors the neighbor-expansion pattern classifyNodeRolesIncremental already uses for roles).

Closes #1738

Test plan

  • npm test — full suite green (3444 passed, 0 failed)
  • npm run lint / npx tsc --noEmit — clean
  • cargo check / cargo test -p codegraph-core — clean
  • Native addon rebuilt, codesigned, verified directly
  • Drove the real buildGraph pipeline end-to-end (not just unit tests) on both engines: add-a-file, remove-a-file, new-nested-directory, and cross-directory-import scenarios, each diffed against an independent from-scratch full rebuild as ground truth
  • Test validity confirmed by disabling each fix individually and observing the corresponding test(s) fail

Filed #1839 for a narrower residual gap (a directory whose only link to the touched set was via a file that was itself just removed — needs pre-purge capture logic, a materially larger separate change).

Stacked on #1837 (base branch fix/issue-1736-update-graph-hook-extensions) — only the structure/pipeline/constants/test diff is this issue's change.

carlos-alm added 13 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
@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Codegraph Impact Analysis

3 functions changed8 callers affected across 3 files

  • buildDirectoryStructure in src/domain/graph/builder/stages/build-structure.ts:51 (3 transitive callers)
  • refreshAffectedDirectoryMetrics in src/domain/graph/builder/stages/build-structure.ts:293 (3 transitive callers)
  • getAncestorDirs in src/shared/constants.ts:89 (6 transitive callers)

@greptile-apps

greptile-apps Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a long-standing staleness bug (#1738) where the small-incremental fast path (updateChangedFileMetrics / update_changed_file_metrics) only updated per-file node_metrics rows and never touched directory nodes, contains edges, or directory-level aggregates. The fix adds refreshAffectedDirectoryMetrics / refresh_affected_directory_metrics to both the TypeScript and Rust engines, called immediately after the existing per-file update in the fast path.

  • Core fix: recomputes fileCount, symbolCount, fanIn, fanOut, and cohesion for all ancestor directories of any added/removed/modified file, lazily creates missing directory nodes and contains edges for brand-new directories, and expands one hop via live import edges to catch cross-directory fan-in/fan-out shifts — seeded from leaf directories only (not the full ancestor chain) to avoid a 70-90 ms regression on near-root containers like src/.
  • Rust pipeline plumbing: removed_files is threaded through run_structure_phase as a new parameter so the directory refresh covers deletions (zero re-parsed files still satisfies the ≤5 gate).
  • getAncestorDirs promoted from a private helper in src/features/structure.ts to a shared export in src/shared/constants.ts, used by both the full rebuild path and the new fast-path refresh.

Confidence Score: 5/5

Safe to merge — the fast-path fix is well-scoped, both engines are updated in lockstep, and the integration tests validate all four key scenarios (add, remove, new nested dir, cross-directory fan-in) against independent full rebuilds as ground truth.

The change is additive: it only adds work to the fast path that the slow path already did correctly. The SQL metric formulas are verified to match the existing full-rebuild query in computeImportEdgeMaps. The one open finding is a stale inline comment in structure.rs (the Rust mirror of a comment already corrected in the TS side), which has no effect on behavior.

The stale comment in crates/codegraph-core/src/features/structure.rs (line 264) is the only item worth a second look; all other files are clean.

Important Files Changed

Filename Overview
crates/codegraph-core/src/domain/graph/builder/pipeline.rs Threads removed_files into run_structure_phase and calls refresh_affected_directory_metrics in the fast path — minimal, correct plumbing change.
crates/codegraph-core/src/features/structure.rs Adds find_neighbor_files and refresh_affected_directory_metrics. Logic is correct and consistent with the TS mirror; one backwards inline comment (// 2. Wire dir -> parent-dir) that was fixed in the TS side was not updated here.
src/domain/graph/builder/stages/build-structure.ts Calls refreshAffectedDirectoryMetrics after updateChangedFileMetrics in the fast path; SQL queries verified to match the full-rebuild metrics formula in src/features/structure.ts.
src/features/structure.ts Removes the now-shared getAncestorDirs private function; no behavioral change, confirmed the remaining computeImportEdgeMaps query is consistent with the new incremental SQL.
src/shared/constants.ts Promotes getAncestorDirs to a shared export with a widened Iterable<string> signature (backward-compatible for existing string[] callers).
tests/integration/issue-1738-structure-metrics-incremental.test.ts Comprehensive regression suite with four scenarios (add file, remove file, brand-new nested dir, cross-directory fan-in shift); each diffs incremental output against an independent full rebuild as ground truth.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Incremental build triggered] --> B{useSmallIncrementalFastPath?}
    B -- No --> C[Full / medium incremental structure rebuild]
    B -- Yes --> D[updateChangedFileMetrics\nper-file node_metrics rows]
    D --> E[refreshAffectedDirectoryMetrics\n— NEW —]
    E --> F[Collect ancestor dirs of\nchanged + removed files]
    F --> G[Expand via leaf-dir import edges\none hop to neighbor dirs]
    G --> H[DB transaction]
    H --> H1[1 · INSERT OR IGNORE directory nodes]
    H1 --> H2[2 · Wire parent→child contains edges]
    H2 --> H3[3 · Wire dir→file contains edges\nfor added/modified files]
    H3 --> H4[4 · Recompute fileCount / symbolCount\nfanIn / fanOut / cohesion per dir]
    H4 --> I[Commit]
    C --> I
    I --> J[Continue pipeline]
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[Incremental build triggered] --> B{useSmallIncrementalFastPath?}
    B -- No --> C[Full / medium incremental structure rebuild]
    B -- Yes --> D[updateChangedFileMetrics\nper-file node_metrics rows]
    D --> E[refreshAffectedDirectoryMetrics\n— NEW —]
    E --> F[Collect ancestor dirs of\nchanged + removed files]
    F --> G[Expand via leaf-dir import edges\none hop to neighbor dirs]
    G --> H[DB transaction]
    H --> H1[1 · INSERT OR IGNORE directory nodes]
    H1 --> H2[2 · Wire parent→child contains edges]
    H2 --> H3[3 · Wire dir→file contains edges\nfor added/modified files]
    H3 --> H4[4 · Recompute fileCount / symbolCount\nfanIn / fanOut / cohesion per dir]
    H4 --> I[Commit]
    C --> I
    I --> J[Continue pipeline]
Loading

Reviews (3): Last reviewed commit: "fix: resolve merge conflicts with main (..." | Re-trigger Greptile

Comment on lines +381 to +382
// Wire dir -> parent-dir contains edges for the chain.
for (const dir of affectedDirs) {

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 The comment says "dir → parent-dir contains edges" but the edge inserted is parent_id → child_id (i.e., parent contains child). The variable names parentRow / childRow make the intended direction clear, but the comment points the wrong way and could mislead a future reader tracing the containment graph.

Suggested change
// Wire dir -> parent-dir contains edges for the chain.
for (const dir of affectedDirs) {
// Wire parent-dir -> child-dir contains edges for the chain.
for (const dir of affectedDirs) {

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 — comment now reads "Wire parent-dir -> child-dir contains edges for the chain.", matching the actual parentRow.id -> childRow.id insert.

Comment on lines +369 to +387
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) {

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 neighborFiles includes intra-directory edges

Neither the first half (n1.file >= @lo … n1.file < @hi, returns n2.file) nor the second half (returns n1.file) of the UNION restricts the other file to be outside the queried directory. A pair of files that both live inside dir but import each other would have each file appear as a "neighbour" of the other. getAncestorDirs on those intra-dir paths produces ancestors that are already in affectedDirs, so correctness is unaffected (the Set deduplicates). The same query pattern exists in the Rust find_neighbor_files. Worth noting as a latent source of minor extra work for repos with many intra-package cross-file imports, though the real cost is bounded and likely negligible in practice.

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.

Good catch — fixed rather than left as a note, since it's the same function I was already touching for the perf fix. Added NOT (other-side range) to both halves of the UNION so intra-directory pairs no longer surface as cross-directory neighbours at all, in both engines (TS neighborFiles and Rust find_neighbor_files).

Fixes the CI-caught "1-file rebuild" regression (+76-79% on both engines):
seeding the cross-directory neighbor lookup from every ancestor directory
(including broad, near-root containers like `src`) made a single touched
file expand to 50+ affected directories on this repo's own tree, since
find_neighbor_files/neighborFiles is bounded by edges crossing ONE
directory's boundary — for `src` that's effectively the whole repo, not
"path depth" as the original comment assumed. Verified empirically with
debug instrumentation (removed) against a local native build: before the
fix, touching one file in src/features/ expanded to 52 affected dirs via
a 519-file neighbor scan on `src` alone; after, none.

Scoped the neighbor-discovery seed to just the touched files' own leaf
directories instead — the ancestor-chain rollup (directory node creation,
contains edges, metrics recompute for every ancestor) is unchanged, only
the expensive neighbor lookup is scoped down. All 10 cases in
tests/integration/issue-1738-structure-metrics-incremental.test.ts still
pass on both engines, including the cross-directory-neighbor scenario
this logic exists for.

Mirrored in both engines per this repo's dual-engine parity requirement.

Impact: 1 functions changed, 3 affected
…1738)

- Wire parent-dir -> child-dir, not dir -> parent-dir; the code already
  inserts parentRow.id -> childRow.id, the comment pointed the wrong way.
- Exclude intra-directory file pairs from neighborFiles so files that
  import each other from within the same queried directory no longer
  spuriously surface as cross-directory neighbours.
…1738)

Mirrors the TS-side fix — files that import each other from within the
same queried directory no longer surface as cross-directory neighbours.
@carlos-alm

Copy link
Copy Markdown
Contributor Author

@greptileai

Base automatically changed from fix/issue-1736-update-graph-hook-extensions to main July 6, 2026 05:46
@carlos-alm carlos-alm merged commit 2e1f6e1 into main Jul 6, 2026
74 of 80 checks passed
@carlos-alm carlos-alm deleted the fix/issue-1738-structure-metrics-incremental-stale branch July 6, 2026 06:54
@github-actions github-actions Bot locked and limited conversation to collaborators Jul 6, 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.

codegraph structure: directory fileCount/cohesion metrics go stale on incremental rebuild after adding a new file

1 participant