Skip to content

fix(fn-impact): add direct and transitive summary fields to JSON output#1603

Merged
carlos-alm merged 9 commits into
mainfrom
fix/issue-1587
Jun 18, 2026
Merged

fix(fn-impact): add direct and transitive summary fields to JSON output#1603
carlos-alm merged 9 commits into
mainfrom
fix/issue-1587

Conversation

@carlos-alm

Copy link
Copy Markdown
Contributor

Summary

  • fn-impact --json was missing direct and transitive shorthand counts in each result, making it difficult for tools (GAUNTLET, manifesto rules, external scripts) to assess blast radius without parsing the full levels object
  • Adds two derived fields to each fnImpactData result:
    • direct: count of level-1 callers (functions that call the target directly)
    • transitive: totalDependents - direct (callers at depth 2+)
  • Both fields are computed from the existing BFS levels data with no additional DB queries; existing fields (levels, totalDependents) are unchanged — fully backward-compatible

Test plan

  • New integration tests: result includes direct and transitive summary fields verifies direct == level[1].length, transitive == totalDependents - direct, and direct + transitive == totalDependents
  • Existing 85 integration tests in queries.test.ts still pass (87 total with 2 new)
  • Manual verification: buildFileCallEdges now reports direct: 1, transitive: 5, totalDependents: 6 matching the actual call graph
  • Non-JSON display output (codegraph fn-impact) unchanged

Closes #1587

… cross-file edges

The role classifier inferred "is exported" purely from cross-file
calls/imports-type edges and barrel reexports. This missed symbols that
are declared with the `export` keyword but are only used as type
annotations within the same file — no edge is produced for same-file
type usage, so the symbol's fan-in stayed 0 and isExported=false,
yielding a spurious dead-unresolved classification.

Fix: after the existing cross-file-edge and reexport-barrel checks,
query `WHERE exported = 1` and add those IDs to exportedIds. Applied to
all four classification paths: classifyNodeRolesFull and
classifyNodeRolesIncremental in TypeScript, and do_classify_full and
do_classify_incremental in the Rust native engine.

docs check acknowledged

Closes #1583
…ables

Rust struct/enum/trait definitions (and equivalent type-definition kinds in
other languages) have fan_in=0 by design — they are consumed via type
annotations and struct literals, neither of which produces a call edge.
The classifier previously sent these through `classifyDeadSubRole`, which
returned `dead-ffi` for any `.rs` file (FFI extension check).

Fix: extend the existing `constant` heuristic to cover all annotation-only
kinds (`struct`, `enum`, `trait`, `type`, `interface`, `record`). When the
same file has at least one active callable (function/method with fanIn > 0
or fanOut > 0), these type definitions are almost certainly live — classify
them as `leaf` instead of dead.

Changes:
- `src/graph/classifiers/roles.ts`: add TYPE_DEF_KINDS set; check it in
  classifyUnreferencedNode alongside the existing constant check
- `src/features/structure.ts`: extract ANNOTATION_ONLY_KINDS constant;
  use it in buildActiveFilesSet (exclude type defs from "active" count)
  and buildClassifierInput (pass hasActiveFileSiblings for type defs)
- `crates/codegraph-core/src/graph/classifiers/roles.rs`: mirror both
  changes in the Rust native classifier (TYPE_DEF_KINDS constant,
  classify_node guard, compute_active_files exclusion, classify_rows
  is_annotation_only check)
- `tests/unit/roles.test.ts`: two new tests for #1584 — one verifying
  struct/enum/trait classify as leaf with active siblings, one verifying
  they remain dead with no active siblings

docs check acknowledged

Closes #1584
…not dead-entry

Methods named `execute` and `validate` in files matching ENTRY_PATH_PATTERNS
(cli/commands/, routes/, handlers/, mcp/, middleware/) are Commander.js dispatch
entry points confirmed at the framework level. Classifying them as `dead-entry`
caused them to appear in `--role dead` output, producing ~12,000+ false positives
and making dead-code analysis unactionable on Commander-based CLIs.

The fix adds COMMANDER_DISPATCH_NAMES (execute, validate) and promotes matching
methods directly to `entry` when their file path confirms they are in a framework
dispatch directory. An `execute` in a non-framework directory (src/utils/) is
still classified as dead-unresolved.

Both the TypeScript classifier and the mirrored Rust classifier are updated.

Closes #1585
Three call-site patterns cause false-positive dead-unresolved classification
when codegraph cannot trace the call relationship:

1. Interface dispatch via conditional property access:
   `if (v.enterFunction) v.enterFunction(fn, ctx)` — the call resolves to
   the property accessor, not the concrete method implementation, so the
   method gets fanIn=0. Affects enterFunction/exitFunction/enterNode/exitNode/
   finish methods in cfg-visitor.ts, complexity-visitor.ts, dataflow-visitor.ts.

2. Logical-or function defaults:
   `const fn = options._fetchLatest || fetchLatestVersion` — the function
   reference is a value reference, not a call site, so no call edge is produced.
   Requires fanOut>0 as evidence the function is non-trivial. Affects
   fetchLatestVersion in update-check.ts.

3. Handler-table object property callbacks:
   `{ handle: handleReturn }` — the function stored as a property value
   generates no call edge when dispatched via `h.handle(...)`.

Fix: Extend the existing hasActiveFileSiblings heuristic (previously only
applied to constant kind) to also cover method and function kinds. When the
same file contains at least one callable connected to the graph:
  - method kinds: always classified as leaf (interface implementations)
  - function kinds with fanOut>0: classified as leaf (non-trivial helpers
    used as value references)

The fanOut>0 guard for function kinds prevents genuinely inert dead functions
(fanOut=0) from being promoted to leaf, preserving dead-code detection for
the common case.

Both the TypeScript classifier and the mirrored Rust classifier are updated.
Also updates a stale unit test that expected dead-entry for execute() in CLI
files, which was corrected to entry by #1585.

Closes #1586

-- docs check acknowledged
fn-impact --json was missing direct/transitive shorthand counts, making
it difficult for tools (GAUNTLET, manifesto rules, scripts) to assess
blast radius without parsing the full levels object.

Adds two computed fields to each fnImpactData result:
- direct: count of level-1 callers (functions that directly call the target)
- transitive: totalDependents - direct (callers at depth 2+)

These are derived from the existing BFS levels data and add no extra
DB queries. Fully backward-compatible: existing fields are unchanged.

Closes #1587
@greptile-apps

greptile-apps Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds direct and transitive shorthand count fields to fnImpactData JSON output, computed from the existing BFS levels data with no extra DB queries. It bundles several role-classifier improvements: type-definition leaf-promotion (TYPE_DEF_KINDS), Commander.js dispatch-method entry-promotion (execute/validate), an exported-flag fix for same-file-only type annotations, and a new heuristic promoting unreferenced functions/methods with fanOut > 0 in active files to leaf.

Confidence Score: 5/5

Safe to merge; the fn-impact output change is additive and the classifier fixes are well-tested with positive and boundary cases.

The core change (adding direct/transitive fields) is a pure derivation from existing BFS data and cannot corrupt existing fields. The classifier changes are broader but each comes with dedicated unit tests and an explicit reasoning comment. No existing fields are mutated.

src/graph/classifiers/roles.ts — the new function leaf-promotion heuristic (fanOut > 0 in active file) is intentionally broad and may reduce visibility of some genuinely dead functions in --role dead output.

Important Files Changed

Filename Overview
src/domain/analysis/fn-impact.ts Adds direct (level-1 count) and transitive (totalDependents − direct) fields to fnImpactData results; computation is mathematically sound and consistent with the existing BFS.
src/graph/classifiers/roles.ts Adds TYPE_DEF_KINDS leaf-promotion, COMMANDER_DISPATCH_NAMES entry-promotion, and a new function/method leaf heuristic (fanOut > 0 in active file); the function heuristic is intentionally broad — any dead function with outgoing calls in a busy file becomes leaf.
src/features/structure.ts Extends hasActiveFileSiblings propagation to method/function kinds and adds exported=1 nodes to exportedIds; mirrors the roles.ts logic correctly.
crates/codegraph-core/src/graph/classifiers/roles.rs Rust mirror of TypeScript classifier changes: TYPE_DEF_KINDS, COMMANDER_DISPATCH_NAMES, exported=1 fix, and symmetric method/function leaf-promotion with fanOut > 0 guard.
tests/integration/queries.test.ts Two new integration tests added: one verifies direct/transitive consistency against BFS levels data, the other uses an actual zero-caller function (handleRoute) and checks all three fields correctly.
tests/graph/classifiers/roles.test.ts Good test coverage for the new leaf-promotion cases; Commander dispatch, interface-dispatch methods, and logical-or function patterns all have positive and boundary test cases.
tests/unit/roles.test.ts Integration-level unit tests for the DB-backed classifier; covers exported interface fix, struct/enum/trait leaf promotion, and Commander execute/validate entry promotion with clear boundary cases.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[bfsTransitiveCallers] --> B[levels + totalDependents]
    B --> C[direct = level-1 count]
    B --> D[transitive = total - direct]
    C --> E[fnImpactData result]
    D --> E

    F[classifyNodeRole fanIn=0, not exported] --> G{Commander name + entry path?}
    G -- yes --> H[entry]
    G -- no --> I[classifyUnreferencedNode]
    I --> J{hasActiveFileSiblings?}
    J -- no --> K[classifyDeadSubRole]
    J -- yes --> L{kind}
    L -- constant --> M[leaf]
    L -- TYPE_DEF_KINDS --> M
    L -- method + fanOut gt 0 --> M
    L -- function + fanOut gt 0 --> M
    L -- other --> K
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[bfsTransitiveCallers] --> B[levels + totalDependents]
    B --> C[direct = level-1 count]
    B --> D[transitive = total - direct]
    C --> E[fnImpactData result]
    D --> E

    F[classifyNodeRole fanIn=0, not exported] --> G{Commander name + entry path?}
    G -- yes --> H[entry]
    G -- no --> I[classifyUnreferencedNode]
    I --> J{hasActiveFileSiblings?}
    J -- no --> K[classifyDeadSubRole]
    J -- yes --> L{kind}
    L -- constant --> M[leaf]
    L -- TYPE_DEF_KINDS --> M
    L -- method + fanOut gt 0 --> M
    L -- function + fanOut gt 0 --> M
    L -- other --> K
Loading

Reviews (3): Last reviewed commit: "fix(roles): add fanOut > 0 guard to meth..." | Re-trigger Greptile

Comment thread src/graph/classifiers/roles.ts Outdated
Comment on lines +135 to +143
if (node.kind === 'method') {
// Methods implementing interfaces are dispatched via conditional property
// access e.g. `if (v.enterFunction) v.enterFunction(...)`. Codegraph
// resolves the call to the property accessor rather than to the concrete
// method implementation, so the method has no inbound call edge. All
// methods with active file siblings are almost certainly interface
// implementations — classify as leaf.
return 'leaf';
}

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 Method leaf-promotion is asymmetric with the function case

Functions require fanOut > 0 as evidence of non-triviality before being promoted to leaf when hasActiveFileSiblings is true. Methods have no such guard — any method with fanIn === 0 in a file with active callables is immediately classified leaf. A genuinely dead helper method (e.g., a private method with no callers and no outgoing calls that was accidentally left behind) in a busy file will be silently excluded from --role dead output. Adding the same node.fanOut > 0 guard as the function case would preserve the intent (interface dispatch methods do call out) while keeping trivially-inert dead methods visible.

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 fanOut > 0 guard to the method case in both roles.ts and roles.rs, making it symmetric with the function case. A trivially-inert method (fanOut === 0, no callers) in a busy file now surfaces as dead-unresolved. Added a new test case to verify this behavior.

@github-actions

github-actions Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Codegraph Impact Analysis

3 functions changed9 callers affected across 5 files

  • classify_node in crates/codegraph-core/src/graph/classifiers/roles.rs:129 (3 transitive callers)
  • fnImpactData in src/domain/analysis/fn-impact.ts:265 (2 transitive callers)
  • classifyUnreferencedNode in src/graph/classifiers/roles.ts:129 (4 transitive callers)

@carlos-alm

Copy link
Copy Markdown
Contributor Author

Addressed both Greptile P2 findings:

  1. Test doesn't exercise its stated assertion (queries.test.ts): Replaced the nonexistent-function lookup with a lookup on 'handleRoute' — the actual root of the fixture call graph with zero callers. The test now asserts direct === 0, transitive === 0, and totalDependents === 0, exercising the real zero-caller code path.

  2. Method leaf-promotion asymmetric with function case (roles.ts:135): Added fanOut > 0 guard to the method case in both roles.ts and the Rust mirror in roles.rs. Trivially-inert dead helper methods (fanOut === 0, no callers) in busy files now surface as dead-unresolved instead of being silently promoted to leaf. Added a unit test covering this exact scenario.

@carlos-alm

Copy link
Copy Markdown
Contributor Author

@greptileai

@carlos-alm carlos-alm merged commit 0864c01 into main Jun 18, 2026
33 checks passed
@carlos-alm carlos-alm deleted the fix/issue-1587 branch June 18, 2026 09:27
@github-actions github-actions Bot locked and limited conversation to collaborators Jun 18, 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.

bug(fn-impact): returns direct:0 transitive:0 for indirectly-called functions in the builder pipeline

1 participant