Skip to content

fix(classifiers): detect transitively-unreachable dead code, not just fanIn===0 - #2258

Merged
carlos-alm merged 3 commits into
mainfrom
fix/issue-2032-transitive-dead-code-reachability
Aug 4, 2026
Merged

fix(classifiers): detect transitively-unreachable dead code, not just fanIn===0#2258
carlos-alm merged 3 commits into
mainfrom
fix/issue-2032-transitive-dead-code-reachability

Conversation

@carlos-alm

@carlos-alm carlos-alm commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Summary

  • roles --role dead treated any inbound calls edge as sufficient evidence of liveness, without checking whether the caller itself is reachable from a confirmed-live root. A function called only by another unreachable function kept a non-dead role forever.
  • Adds a worklist/BFS transitive-reachability pass (applyReachabilityDowngrade in src/graph/classifiers/roles.ts, mirrored as apply_reachability_downgrade in crates/codegraph-core/src/graph/classifiers/roles.rs) that runs after the existing per-node classification. It is strictly downgrading — never upgrades a verdict, never touches entry/test-only/hasActiveFileSiblings rescues/interface-type-members — it only reconsiders function/method nodes whose core/utility/adapter/leaf verdict came from direct fan-in (classifyByFanShape).
  • Roots: function/method nodes on the genuinely public surface (explicit export keyword, or the SPECIFIC symbol named in a confirmed production-reachable reexport, or every symbol in a file reached via a genuine export * from — deliberately NOT the broader "some cross-file caller exists" signal, nor "shares a file with a named-reexported sibling", see Greptile fixes below) / framework-dispatched (route:/event:/command:) / Commander-dispatch, plus every calls-edge SOURCE that isn't itself a function/method (module-top-level constant declarations, bare file-scope assignments like Lua's require = tracedFn) and method-kind interface-dispatch implementations whose name matches a declared interface/type member (Visitor-pattern methods invoked only via generic property access) — none of these can ever be the target of a calls edge by construction, so they must count as unconditionally live.
  • Deliberately does not promote function-kind logical-or-fallback rescues to roots — doing so would silently re-rescue genuinely-dead intermediate functions, defeating the fix for the common case (see code comments for the reasoning and a regression test guarding it).

Scope decision

Implemented for the full-classification path only (classifyNodeRolesFull / Rust do_classify_full). The incremental path cannot safely compute whole-graph reachability from a changed-files-scoped window — a node's only live path in can run through files entirely outside that window — without either reintroducing a full-graph scan on every incremental build (undermining the perf work that path was built around) or a real incremental-reachability-maintenance design (handles edge additions fine, edge removals need decremental reachability). Filed as a tracked follow-up: #2255.

Root-identification bugs found and fixed

Running codegraph roles --role dead -T on this repo's own src/ before/after (each fix re-validated the same way) surfaced real root-identification bugs, all fixed before landing:

  1. Interface/type method-signature members (e.g. NativeDatabase.countNodes in src/types.ts) get 'leaf' unconditionally from isTypeDeclarationMember, independent of fanIn — indistinguishable from classifyByFanShape's 'leaf' by role string alone. Fixed by explicitly excluding type-declaration members from the downgrade pass.
  2. method-kind interface-dispatch implementations (e.g. enterNode/exitNode in src/ast-analysis/visitors/*.ts, dispatched via if (v.enterNode) v.enterNode(...)) can never be the target of a calls edge by construction, so they could never be BFS-reachable — wrongly starving their own callees. Fixed by adding isInterfaceDispatchMethodRoot.
  3. (Greptile review) isLiveRoot used the broad isExported flag, which also fires when some caller in a different file calls a symbol regardless of whether that caller is itself reachable — letting a cross-file dead call chain evade the whole check. Fixed with a narrower isPublicSurface (explicit export / confirmed reexport chain only).
  4. (Greptile review) isInterfaceDispatchMethodRoot promoted any method with fanIn===0, fanOut>0, and an active file sibling — indistinguishable from an ordinary dead class method that happens to call a helper. Fixed by requiring the method's bare name to match an actual declared interface/type member somewhere in the codebase.
  5. (Greptile review) publicSurfaceIds reused the pre-existing whole-file reexport query, which treats ANY reexports edge (named or wildcard) as "every symbol in the target file is exported". Fixed by using the symbol-level reexports edge a named reexport already gets, falling back to whole-file marking only for a genuine export * from (reexports-wildcard). Caught a real live instance in this repo's own self-build (src/extractors/groovy.ts's AST-node-dispatch handlers).

Re-validating after fixes 3-5 surfaced two further narrow, out-of-scope gaps — filed as #2259 and #2260 rather than solved inline.

Before/after on this repo's own self-build (src/, -T)

origin/main this PR
dead-entry 61 61
dead-ffi 17 60
dead-leaf 91 91
dead-unresolved 464 615
total 633 827

+194 delta. Diffed the actual symbol lists (not just totals) at every step of the fix, not just the final one: the large majority are pre-existing hand-authored parser/resolution test fixtures under tests/benchmarks/** / tests/fixtures/** that -T's filename-only heuristic doesn't exclude (tracked as #2256) — these grew once the cross-file-root fix (#3 above) closed a loophole those fixtures relied on especially heavily (they're full of cross-file call-resolution test cases with no genuine exported entry point). The remaining real src/ findings are the already-tracked #2257 (2 cases), #2259 (1 case, 4 downstream nodes), and #2260 (1 case, 4 downstream nodes — caught and fixed by review round 3, then re-surfaced as a distinct root cause once the reexport-scoping bug was corrected).

Testing

  • npm run lint — clean
  • npm test — 271 files / 4395 tests pass (added TS unit + integration tests for the reachability pass, including regression tests for all three Greptile findings, + updated pre-existing fixtures whose only "root" was one of the now-narrower signals, to keep testing what they originally intended)
  • cargo test -p codegraph-core --lib — 741 tests pass (mirrored Rust unit tests using an in-memory NativeDatabase)
  • Verified against the issue's own repro shape (an equivalent one, since the literal snippet in the issue doesn't create a separate node for the nested arrow-function closure — codegraph attributes its call to the enclosing named function by design) with both engines
  • codegraph diff-impact --staged -T: small, expected blast radius scoped to the classifier module

Follow-up issues filed

Closes #2032

… fanIn===0

roles --role dead treated any inbound `calls` edge as sufficient evidence
of liveness, without checking whether the caller itself is reachable from
a confirmed-live root. A function called only by another unreachable
function kept a non-dead role forever (#2032).

Add a worklist/BFS reachability pass (both engines) that runs after the
existing per-node classification and can only downgrade a fan-in-based
verdict to dead, never upgrade one — entry/test-only/hasActiveFileSiblings
rescues/interface-type-members are untouched. Roots are exported/
framework-dispatched/Commander-dispatch function/method nodes, plus every
`calls`-edge source that isn't itself a function/method (module-level
constants, file-scope bare assignments) and `method`-kind interface-
dispatch implementations, which can never be call-edge targets by
construction.

Scoped to the full-classification path only: the incremental path cannot
safely compute whole-graph reachability from a changed-files-scoped
window without reintroducing the full-scan cost its caching was built to
avoid (tracked in #2255, along with two other follow-ups filed from
self-build validation: #2256, #2257).

docs check acknowledged: internal classifier fix, no README/CLAUDE.md/ROADMAP.md changes needed.

Impact: 6 functions changed, 5 affected
@greptile-apps

greptile-apps Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR changes full role classification to downgrade functions and methods that have direct fan-in but are unreachable from confirmed-live roots.

  • Adds matching TypeScript and Rust reachability traversals over calls edges.
  • Narrows root selection to explicit exports and production-reachable named or wildcard reexports.
  • Preserves interface-dispatch, framework-entry, and non-callable-source roots.
  • Adds unit and integration coverage for unreachable chains and the previously reported root-selection defects.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains in the current fixes for the previously reported threads.

Important Files Changed

Filename Overview
src/graph/classifiers/roles.ts Adds the TypeScript reachability pass and narrowed root rules; the fixes for the prior classifier threads are present.
src/features/structure.ts Builds the narrowed public-surface set and supplies full call adjacency to full classification.
crates/codegraph-core/src/graph/classifiers/roles.rs Mirrors the TypeScript reachability and public-surface behavior in the native classifier.
tests/graph/classifiers/roles.test.ts Covers reachability behavior and the previously reported cross-file and ordinary-method cases.
tests/integration/roles.test.ts Exercises named and wildcard reexport classification through persisted graph data.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Classify nodes by fan shape] --> B[Collect confirmed-live roots]
  B --> C[Traverse calls edges]
  C --> D{Fan-shape function or method reachable?}
  D -->|Yes| E[Keep existing role]
  D -->|No| F[Downgrade to dead sub-role]
Loading

Reviews (3): Last reviewed commit: "fix(classifiers): scope reexport-derived..." | Re-trigger Greptile

if (node.kind !== 'function' && node.kind !== 'method') return false;
if (node.isExported) return true;
return !!(
node.file &&

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.

P1 Cross-file references become roots

When an unexported function is called across files only by an unreachable function, the existing cross-file-reference heuristic sets isExported, and isLiveRoot consequently seeds the callee as a BFS root. The callee and its descendants retain live fan-shape roles instead of being classified as dead, so cross-file dead call chains remain undetected.

Knowledge Base Used:

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.

Valid, fixed in 7360d4f. Added a narrower isPublicSurface field (explicit export keyword + confirmed production-reachable reexport chains only), and switched isLiveRoot to check that instead of the broad isExported (which also fires from an unverified cross-file caller). Mirrored in the Rust classifier. Added regression tests in both engines confirming a symbol reachable only through an unreachable cross-file caller now correctly stays dead.

Comment thread src/graph/classifiers/roles.ts Outdated
Comment on lines +467 to +473
return (
node.kind === 'method' &&
node.fanIn === 0 &&
node.fanOut > 0 &&
!!node.hasActiveFileSiblings &&
!isTypeDeclarationMember(node, typeDefNamesByFile)
);

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.

P1 Ordinary methods become live roots

When an unused class method calls a helper and shares its file with another called symbol, it satisfies this predicate because class methods use kind: 'method', receive hasActiveFileSiblings, and are not type-declaration members. The method is therefore treated as an interface-dispatch root, causing helpers reachable only through that dead method to retain live roles.

Knowledge Base Used:

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.

Valid, fixed in 7360d4f. isInterfaceDispatchMethodRoot now additionally requires the candidate method's bare name to match an actual interface/type declaration member somewhere in the codebase (e.g. enterNode against interface Visitor { enterNode?(...): ...; } in src/types.ts), not just the fanIn=0/fanOut>0/hasActiveFileSiblings shape alone. An unrelated dead class method now needs a coincidental name collision with some interface's member to slip through, instead of qualifying by default. Mirrored in Rust. Added regression tests in both engines for both the still-correct Visitor-pattern case and the now-fixed ordinary-dead-method case. (Re-validating against this repo's own self-build surfaced one further narrow gap this uncovers — genuine EventEmitter-style method callbacks with no matching interface declaration — filed as #2259 rather than trying to solve it inline.)

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Codegraph Impact Analysis

9 functions changed9 callers affected across 4 files

  • buildClassifierInput in src/features/structure.ts:678 (5 transitive callers)
  • classifyNodeRolesFull in src/features/structure.ts:839 (5 transitive callers)
  • classifyNodeRolesIncremental in src/features/structure.ts:1179 (5 transitive callers)
  • isLiveRoot in src/graph/classifiers/roles.ts:416 (4 transitive callers)
  • computeReachableIds in src/graph/classifiers/roles.ts:437 (4 transitive callers)
  • computeInterfaceMemberBareNames in src/graph/classifiers/roles.ts:480 (4 transitive callers)
  • isInterfaceDispatchMethodRoot in src/graph/classifiers/roles.ts:528 (4 transitive callers)
  • applyReachabilityDowngrade in src/graph/classifiers/roles.ts:607 (4 transitive callers)
  • classifyRoles in src/graph/classifiers/roles.ts:662 (5 transitive callers)

…s grant roots

Two root-identification gaps found by Greptile review:

1. isLiveRoot checked the broad isExported flag, which also fires when
   SOME caller in a different file calls a symbol, regardless of whether
   that caller is itself reachable. A cross-file dead call chain could
   therefore evade the whole reachability check. Added a narrower
   isPublicSurface (explicit `export` keyword + confirmed
   production-reachable reexport chains only) and switched isLiveRoot to
   use it instead.

2. isInterfaceDispatchMethodRoot promoted any method with fanIn===0,
   fanOut>0, and an active file sibling — indistinguishable from an
   ordinary dead class method that happens to call a helper. Added a
   requirement that the method's bare name match an actual declared
   interface/type member somewhere in the codebase (e.g. `enterNode`
   against `interface Visitor { enterNode?(...): ...; }`), so an
   unrelated dead method needs a coincidental name collision to slip
   through instead of qualifying by default.

Both fixes mirrored in crates/codegraph-core/src/graph/classifiers/roles.rs.
Re-validated against this repo's own self-build; filed #2259 for a
narrower remaining gap (EventEmitter-style method callbacks with no
matching interface declaration).

docs check acknowledged: internal classifier fix, no README/CLAUDE.md/ROADMAP.md changes needed.

Impact: 8 functions changed, 5 affected
@carlos-alm

Copy link
Copy Markdown
Contributor Author

@greptileai

Comment thread src/features/structure.ts Outdated
// `isPublicSurface` doc comment for why that component must not grant
// automatic root status).
const publicSurfaceIds = new Set<number>();
for (const r of reexportExported) publicSurfaceIds.add(r.id);

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.

P1 Private reexport targets become roots

When a production-reachable barrel names one export from a module that also contains private functions, reexportExported includes every eligible symbol in that module and this loop adds them all to publicSurfaceIds. Those private functions become unconditional BFS roots, causing unreachable call chains beneath them to retain live roles instead of being classified as dead.

Knowledge Base Used:

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.

Valid, fixed in d08ffe7. publicSurfaceIds no longer reuses the whole-file reexportExported query. Named reexports already get their own symbol-level 'reexports' edge (target = the specific symbol, not the file) — now used directly; whole-file marking is scoped to a genuine export * from (kind 'reexports-wildcard') only. The pre-existing exportedIds/entry-classification query (#837) is untouched. Mirrored in Rust. Added regression tests in both engines (a named-reexport fixture where a private sibling reachable only through an unreachable caller correctly stays dead despite sharing a file with the re-exported symbol) — validating against this repo's own self-build actually caught a real live instance of exactly this bug (src/extractors/groovy.ts's AST-dispatch-table handlers), now fixed; filed #2260 for the underlying computed-dispatch-table extraction gap that instance also exposed.

…named symbol

publicSurfaceIds reused the pre-existing whole-file reexport query, which
treats ANY 'reexports' edge as "every symbol in the target file is
exported" — correct for `export * from './b'` but wrong for `export {
specificThing } from './b'`, which only re-exports specificThing. A
private, unreachable sibling in that same file (Greptile review: e.g.
groovy.ts's AST-node-type dispatch handlers, previously masked by this
exact bug) was becoming an automatic BFS root merely by sharing a file
with the one actually re-exported symbol.

Named reexports already get their own symbol-level 'reexports' edge
(target = the specific symbol, not the file); use that directly for
publicSurfaceIds, and only fall back to whole-file marking for a genuine
`export * from` (kind 'reexports-wildcard'). The pre-existing whole-file
exportedIds query (used for classifyNodeRole's own `entry` classification,
#837) is left untouched — this only narrows the new #2032 root signal.

Mirrored in crates/codegraph-core/src/graph/classifiers/roles.rs.
Re-validated against this repo's own self-build; filed #2260 for the
underlying gap this uncovered (computed/bracket-access dispatch-table
lookups still lack a real calls edge, unlike dot-property value-refs).

docs check acknowledged: internal classifier fix, no README/CLAUDE.md/ROADMAP.md changes needed.

Impact: 2 functions changed, 1 affected
@carlos-alm

Copy link
Copy Markdown
Contributor Author

@greptileai

@carlos-alm
carlos-alm merged commit cf30e74 into main Aug 4, 2026
49 of 54 checks passed
@carlos-alm
carlos-alm deleted the fix/issue-2032-transitive-dead-code-reachability branch August 4, 2026 03:28
@github-actions github-actions Bot locked and limited conversation to collaborators Aug 4, 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.

roles --role dead: non-transitive fan-in means a function called only by another dead function is never flagged dead

1 participant