Skip to content

trace_path data_flow mode doesn't surface arg expressions; NestJS DI patterns defeat ~70% of caller resolution #514

Description

@thaoula

Version

codebase-memory-mcp 0.8.1 (also reproduced after re-index in full mode)

Platform

macOS (Apple Silicon)

Install channel

GitHub release archive / install.sh / install.ps1

Binary variant

standard

What happened, and what did you expect?

Two related gaps observed on a NestJS 11 + TypeScript monorepo (~47k nodes, 176k edges after full index).

Gap 1 — arg_expressions advertised but never populated

The trace_path tool description promises: data_flow (follow CALLS+DATA_FLOWS with arg expressions). In practice:

  • mode: 'calls' → 22 callers returned, r.arg_expressions blank on every CALLS edge in Cypher
  • mode: 'data_flow' with parameter_name: 'delayInMs' → 18 callers (includes hop-2), but the response has no arg expression field at all — the schema doesn't surface one

Concrete repro — trying to find which callers of RabbitMqTenantPublisher.publish(topic, data, action?, delayInMs?) pass a non-zero delayInMs:

trace_path(
  function_name="…RabbitMqTenantPublisher.publish",
  mode="data_flow",
  parameter_name="delayInMs",
  direction="inbound"
)

Expected: per-caller arg expression for delayInMs (or at minimum a flag indicating which callers pass a non-literal-zero value).
Actual: caller list only, zero argument values surfaced. Had to fall back to rg multiline search + per-file source reads.

The README edge-type list also advertises DATA_FLOWS with arg-to-param mapping + field access chains — but DATA_FLOWS edges do not appear in the response for this call. Either the indexer is not extracting them for TS, or the tool is not returning them.

Gap 2 — NestJS DI defeats LSP caller resolution (~70% miss)

Same publisher, grep finds ~70 callers across the repo. trace_path returns 22. The 48 missed callers all use one of these NestJS patterns:

  1. Token-based injection — @Inject(Tokens.X) private publisher: RabbitMqTenantPublisher. LSP sees the token type, not the runtime-bound concrete class, so no CALLS edge.
  2. ModuleRef.get(RabbitMqTenantPublisher) — pipeline tasks resolve dependencies at runtime (const tenantPublisher = ctx.get(RabbitMqTenantPublisher)). No static edge for the LSP to follow.
  3. Varying field names — this.publisher vs this.tenantPublisher vs this.rabbitPublisher across consumers. Cross-file type resolution fails on the varying declaration sites even when the type is identical.
  4. Interface/base-typed fields — private publisher: RabbitMqPublisherBase (abstract base). Edge resolves to the base method, not the concrete subclass override.

Combined, these describe most of the missed callers. NestJS DI design is structurally hostile to static call-graph analysis — but the tool markets itself as NestJS-compatible via TS Hybrid LSP, so the gap is worth documenting.

Impact

  • Agents cannot trust trace_path for "which callers pass argument N" — they must grep + read source. This is the exact use case data_flow mode is advertised for.
  • Agents cannot trust trace_path for complete caller inventory on NestJS codebases — ~70% miss rate on DI-injected services is too high for impact analysis.
  • Both gaps force fallback to grep, which negates the 120x token-efficiency claim for these query types.

Reproduction

NestJS 11 monorepo, TypeScript. Index with full mode. Then:

trace_path(
  function_name="<qualified_name>.RabbitMqTenantPublisher.publish",
  direction="inbound",
  mode="data_flow",
  parameter_name="delayInMs"
)

Compare caller count to rg "\.publish\(" --type ts over the same project — grep finds ~3x more callers, and is the only way to identify which ones pass a non-zero delay.

Project scale

~47,623 nodes, 176,553 edges after full index (NestJS 11 + TypeScript monorepo, ~1.5M LOC)

Confirmations

  • I searched existing issues and this is not a duplicate.
  • Reproduction is on a proprietary codebase, but the patterns (NestJS @Inject, ModuleRef.get, base-typed fields) are universal to any NestJS project.

Activity

  1. DeusData commented on Jun 21, 2026

    @DeusData
    Owner

    Hey, thx for reporting. Will check ASAP

  2. added
    bugSomething isn't working
    parsing/qualityGraph extraction bugs, false positives, missing edges
    on Jun 23, 2026
  3. DeusData commented on Jun 28, 2026

    @DeusData
    Owner

    This issue now has a first-class, durable reproduction in the cumulative bug-reproduction suite landing in #667 — a test that pins the exact failing behaviour, so it's tracked and guarded against silent regression. Where #667 also lands the root-cause fix, that's called out in the PR; otherwise the reproduction makes the eventual fix straightforward and verifiable.

    The reproduction goes live once #667 merges. (Posted from the release branch's QA pass.)

  4. added
    priority/normalStandard review queue; useful PR with ordinary maintainer urgency.
    on Jun 30, 2026
  5. added this to the 0.9.1-rc milestone on Jul 8, 2026
  6. Edwin-Barlow commented on Aug 18, 2026

    @Edwin-Barlow

    Hit this too, on 0.10.6. I think the root cause is broader than the four mechanisms in the description — plain constructor injection with a concrete class type doesn't resolve either, so none of token binding, ModuleRef.get(), differing field names, or interface-typed fields is required to trigger it.

    Minimal repro (stock NestJS, nothing dynamic):

    // cat.service.ts
    @Injectable()
    export class CatService {
      findAll(): string[] {
        return ["a"];
      }
    }
    
    // cat.controller.ts
    @Controller("cats")
    export class CatController {
      constructor(private readonly catService: CatService) {}
    
      @Get()
      findAll() {
        return this.catService.findAll(); // <-- no CALLS edge is created
      }
    }

    trace_path(function_name="CatService.findAll", direction="inbound") returns callers_total: 0.

    Note the field name here is the exact camelCase of its type (catService / CatService), the type is a concrete class, and there is no @Inject and no ModuleRef — so cause 3 ("varying field names") and cause 4 ("interface/base-typed fields") don't apply. It looks like injected-property call resolution isn't happening at all, rather than degrading on the harder cases.

    Two extra observations from a ~2.5k-node TypeScript/NestJS index (mode: "full", 0 skipped, 0 parse_partial):

    1. Every CALLS edge into an injected class originates inside that class, and several are self-loops of the form postCustomerDevice CALLS postCustomerDevice. That smells like the resolver falling back to the enclosing method's name when it can't resolve the receiver, rather than emitting no edge.

    2. check_index_coverage reports status: "no_recorded_issue" / freshness: "metadata_match" for the files containing those unresolved calls. Since the docs say to use it to decide whether to trust the graph on a file, it would help a lot if unresolved-receiver call sites were recorded as a coverage gap — right now an empty trace_path result is indistinguishable from "genuinely no callers", which is the failure mode that actually bites, because it's silent.

    Happy to test a patch against a real NestJS codebase if that's useful.

  7. added
    priority/highNeeds near-term maintainer attention; high-impact bug, regression, safety issue, or release blocker.
    and removed
    priority/normalStandard review queue; useful PR with ordinary maintainer urgency.
    on Sep 5, 2026
  8. DeusData commented on Sep 25, 2026

    @DeusData
    Owner

    Thank you, @thaoula, for the thorough write-up, and @Edwin-Barlow for the minimal repro that let us pin this down exactly!

    Gap 1 is fixed in v0.11.0: trace_path with mode: "data_flow" now returns each caller's argument expressions.

    For Gap 2, your repro still gives zero callers on current main. The cause is specific: TypeScript constructor parameter properties (constructor(private readonly catService: CatService)) aren't registered as class fields, so this.catService.findAll() can't be resolved. A normally declared field resolves correctly. That fix is next. The harder DI cases (@Inject(token), ModuleRef.get, base-typed dispatch) and reporting unresolved receivers as a coverage gap are separate design questions we'll follow up on. @Edwin-Barlow, we'd gladly take you up on testing the fix against a real NestJS codebase.

  9. antespi commented on Oct 2, 2026

    @antespi

    @DeusData I've created this PR to solve the issue. Let me know if you can merge it or any change is needed. Thanks
    #2402

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingparsing/qualityGraph extraction bugs, false positives, missing edgespriority/highNeeds near-term maintainer attention; high-impact bug, regression, safety issue, or release blocker.

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions