Skip to content

Unresolvable calls are bound to any same-named symbol (Jest describe, RxJS take) and trace_path exposes no confidence/strategy #1355

Description

@artaommahe

Version

codebase-memory-mcp 0.9.1-rc.1

Platform

macOS (Apple Silicon)

Install channel

GitHub release archive / install.sh / install.ps1

Binary variant

standard

What happened, and what did you expect?

When a called name can't be resolved to a definition, the unique_name / qualified_suffix / suffix_match fallbacks bind it to any same-named symbol anywhere in the project — including test-runner globals and library operators that have no relationship to the matched symbol. trace_path then reports these as callers with no confidence or strategy column, so the caller list reads as fact.

Minimal case: a Jest/Jasmine describe('...', () => {}) block in a .spec.ts file becomes a CALLS edge to an application method named describe in an unrelated file, at confidence 0.75.

At scale on a private Angular monorepo (11,788 files, 63,840 nodes / 120,805 edges), the entire top of the inbound-call ranking is fabricated:

callee reported by the graph inbound CALLS what the call sites actually are
ScheduleOpenShiftViewComponent.take 457 the RxJS take(n) pipe operator (486 take( occurrences repo-wide; take imported from rxjs/operators in 326 files)
ReportService.describe 377 the Jest describe() global (794 spec files)
ReportApi.describe 275 same
PersonalScheduleComponent.date 264 see below
PermissionDirective.permission 207 unrelated permission references

Filtering on confidence does not rescue it: with WHERE r.confidence >= 0.9, the top callee is still PersonalScheduleComponent.date with 250 inbound edges.

Two of those recorded "call sites" are not calls at all. I opened the source and verified both:

  • An @Input() set date(date: string | Date) { ... } setter declaration in file A is recorded as a CALLS edge (strategy import_map) to a set date(...) setter in unrelated file B. The edge's line property points at the setter's own declaration line, not at any call.
  • lazySelect(getTimesheet(this.timesheet.id)) — where this.timesheet is a property access — is recorded as a CALLS edge to a method named timesheet on an unrelated API class.

I could not reduce those last two to dummy code (plain and @Input()-decorated setters in two files did not reproduce them in isolation), so I report them as observed-and-source-verified rather than minimally reduced. The test-global case reduces cleanly and is in the repro repo.

Expected, in rough priority order:

  1. Prefer no edge over a guessed one when the callee is unresolvable — especially for known test-runner and library globals.
  2. Never emit a CALLS edge for a setter declaration or a property access.
  3. Surface strategy and confidence in trace_path output (a column, or a --min-confidence filter). That data exists in the store but today is only reachable by hand-writing query_graph, so the default path for an agent is the unfiltered, unlabelled one.

Reproduction

Public repro repo: https://github.com/artaommahe/codebase-memory-mcp-rc-repros — see Case B.

git clone https://github.com/artaommahe/codebase-memory-mcp-rc-repros
cd codebase-memory-mcp-rc-repros
codebase-memory-mcp cli index_repository --repo-path "$PWD" --mode full --name cbm-rc-repros

codebase-memory-mcp cli query_graph --project cbm-rc-repros \
  --query "MATCH (a)-[r:CALLS]->(b) RETURN a.file_path AS caller, b.qualified_name AS callee, r.strategy AS s, r.confidence AS c, r.line AS line LIMIT 10"

src/app/report.spec.ts contains only Jest describe/it/expect globals. src/app/report.service.ts defines an unrelated method named describe. Observed (row 2 of 5):

  src/app/report.spec.ts cbm-rc-repros.src.app.report.service.ReportService.describe unique_name "0.75" "9"

Line 9 is the describe('another suite', ...) block in the spec file. trace_path --function-name describe --direction inbound --include-tests true then lists the spec as a caller with no indication that the binding is a guess.

Project scale (if relevant)

Aggregate figures are from a private Angular monorepo: 11,788 files, 63,840 nodes / 120,805 edges, --mode full, 10,175 .ts files, 794 .spec.ts files. CALLS strategy mix across all 15,837 edges: unique_name 5,923, lsp_ts_method 3,051, suffix_match 2,147, import_map 1,923, same_module 1,490, qualified_suffix 707, lsp_ts_local 408, remainder <100 each. Roughly 5,500 edges carry confidence below 0.5. The linked repro is self-contained dummy code.

Confirmations

  • I searched existing issues and this is not a duplicate.
  • My reproduction uses shareable code (a dummy snippet or a public OSS repository), not proprietary code.

Posted by Claude Code on behalf of @artaommahe

Activity

  1. artaommahe commented on Jul 30, 2026

    @artaommahe
    Author

    I need to correct two bullets in the body that I got wrong, and in the process I found what I think is the actual root cause — it's more specific and more actionable than what I originally filed.

    The headline holds up

    Re-verified independently: of 40 sampled inbound edges to …ScheduleOpenShiftViewComponent.take, 40/40 land on a source line literally containing take(, in files importing take from rxjs. Of 30 sampled edges into the two describe methods, 30/30 land on a describe( line in a spec file. So the misattribution is real and not a misreading of the call sites. (Two small figure corrections: take is imported from 'rxjs/operators' in 228 files and from any 'rxjs*' specifier in 327; there are 796 .spec.ts files, not 794.)

    trace_path exposing no confidence or strategy is also confirmed — no column in tree or json format, no --min-confidence flag.

    Correction 1: my two "not calls at all" bullets are wrong about the mechanism

    I claimed an @Input() set date(...) setter declaration was recorded as a call, with the edge's line pointing at the declaration. That is not what happens. In the case I looked at, line 98 is the setter declaration and line 99 — the line the edge actually points to — is a real call, const updatedDate = parseDate(date);. I misread the offset and then generalised from it. Same error on the timesheet bullet: pulling r.callee for all edges into nodes named timesheet gives real function names (getTimesheet 6, hasTimesheetPermission 3, sumApprovedTimesheets 3, …), all import_map @ 0.95 — not the property access I described.

    Please disregard both bullets, and my "Expected #2" ("never emit a CALLS edge for a setter declaration or a property access") — it asks for the wrong fix.

    There is a smaller property-access class, but it's a different strategy and I should have separated it: of the 264 edges into PersonalScheduleComponent.date, 19 have r.callee = "this.date" via suffix_match @ 0.55, and one of the timesheet edges is r.callee = "this.timesheet", also suffix_match @ 0.55. So bare this.<prop> receivers do become call edges, but at low confidence and in small numbers — not the main event.

    The actual root cause: import_map discards the imported identifier

    This is the part worth acting on. import_map appears to reduce the import target to a path segment — a directory name, or a dot-segment of a dotted filename — and then binds the imported symbol to any node whose qualified name ends in that segment, ignoring which identifier was actually imported. Grouping all import_map edges by r.callee against the node they landed on:

    imported symbol (r.callee) node it was bound to edges
    parseDate date 145
    hasPermission permission 136
    getDayListWithoutTotals employee 41
    permissionDepartments permission 24
    getTimesheet timesheet 6
    AppStateBuilder AppStateBuilder 102 ✅
    mergeEntities mergeEntities 32 ✅

    Where the imported name happens to coincide with the segment the edge is correct; otherwise the call is attributed to an unrelated symbol that merely shares a name with a directory or a *.helper.ts / *.service.ts filename stem.

    Why this matters more than the unique_name cases I led with:

    1. import_map fires at confidence 0.95, which is exactly why filtering on confidence doesn't clean up the ranking — the date cluster survives >= 0.9.
    2. It's triggered by ordinary Angular file-naming conventions (date.helper.ts, permission-util.ts, feature folders), so it will hit essentially every Angular codebase.

    Revised ask, replacing my original #2: import_map should key on the imported identifier and must not reduce a module path to a bare directory or dot-segment.

    Correction 2: "the entire top of the inbound-call ranking is fabricated" overstates it

    The real unfiltered top 10 is:

    project.inject                                            1627
    ScheduleOpenShiftViewComponent.take                        457
    integrations-ui.component.stories.Input                    383
    ReportService.describe                                     377
    no-readonly.filter                                         352
    provideTranslateServiceTesting                             345
    ReportApi.describe                                         275
    project.input                                              265
    PersonalScheduleComponent.date                             264
    PermissionDirective.permission                             207
    

    provideTranslateServiceTesting is a genuinely-called test helper, and I haven't characterised project.inject / project.input. "Several of the top 10", not "the entire top" — I'd filtered the ranking and then described the filtered result as though it were the whole.

    On my third ask

    Exposing strategy/confidence through trace_path overlaps #786 (MCP freshness/provenance evidence for client trust decisions) — happy to have it folded in there rather than tracked here.

    Likely shared code site with #1354

    cbm_tsjs_suppress_weak_method_match(is_tsjs, is_method, strategy) in src/pipeline/registry.c (called from pass_calls.c / pass_parallel.c) is asserted in tests/test_registry.c to return true for ("unique_name", is_method=true) — i.e. weak matches should already be suppressed for TS member calls. Yet the cross-file Method→Method population contains 557 unique_name + 215 suffix_match edges. Either those sites aren't flagged is_method or the guard isn't reached, which would explain both the fabricated edges here and the missing ones in #1354.


    Posted by Claude Code on behalf of @artaommahe

  2. added
    bugSomething isn't working
    priority/highNeeds near-term maintainer attention; high-impact bug, regression, safety issue, or release blocker.
    and removed
    windowsWindows-specific issues
    on Aug 3, 2026
  3. added this to the 0.9.1-rc milestone on Aug 3, 2026
  4. DeusData commented on Aug 3, 2026

    @DeusData
    Owner

    Thank you for refining the diagnosis from namespace matching to the discarded imported identifier and generic same-name fallback. Current main now exposes edge provenance when evidence is requested, which addresses the missing-strategy visibility portion, but it does not eliminate the false CALLS edges. PR #1386 is a useful partial fix for weak generic names; the broader binding bug remains open at high priority in 0.9.1-rc.

  5. maheshmirchandani commented on Aug 10, 2026

    @maheshmirchandani

    Environment: v0.9.0 (also reproduced after the 2026-08-10 18:05 self-update), Claude Code 2.1.220 installMethod: native, macOS 26.5.2 (25F84) arm64.

    Adding a third language to this pattern, plus a detail on how the arbitrary pick behaves at scale.

    C#: XDocument.Save bound to a controller action

    Project: 193 .cs files, 2,391 nodes / 5,768 edges, indexed at mode: moderate.

    trace_path(function_name="GenerateXmlFiles", mode="calls", direction="both", depth=3)
    

    returns FinanceValidationController.Save as a hop-2 callee. No such call exists.

    Ground truth: ConvertXMLService.cs:95 calls doc.Save(writer) — XDocument.Save from System.Xml.Linq. The repository defines 30 methods named Save, almost all public IActionResult Save([FromBody] …) across controllers. The extractor could not resolve the external member and bound the edge to one of the 30.

    Confirmed directly:

    MATCH (a)-[r:CALLS]->(b) WHERE b.name = 'Save'
    RETURN a.qualified_name, a.file_path, b.qualified_name, b.file_path
    

    One row: ConvertXMLService.GenerateXmlForBatch → FinanceValidationController.Save. Cross-file, and fabricated.

    So the same failure appears in C# (this), JS/TS (this issue's Jest describe and RxJS take), Python (#1276) and PHP (#1186). It looks like a resolver-wide behaviour rather than a per-language quirk.

    On the "no confidence/strategy" half of this issue

    Two adjacent behaviours already work correctly and suggest a consistent fix:

    • trace_path on an ambiguous bare name returns status: ambiguous with a list of qualified names rather than guessing. On a symbol with 66 definitions it refused to merge them.
    • Same-file calls bind to the calling file's own definition and are correct.

    Neither protects against the external-member case, but the first is arguably the right model for it. Where an unresolved call has more than one local candidate, the same ambiguous treatment would be consistent with existing behaviour rather than new machinery.

    The schema also already carries CALL_REFERENCE alongside CALLS. An unresolved target emitted as CALL_REFERENCE, or as no edge, would let a consumer distinguish "resolved" from "guessed" without a new confidence field.

    Why this is worth more than a missing edge

    A missing edge sends the agent back to grep, which is slower. A fabricated edge arrives structured, with a qualified name and a file path, looking exactly like a finding. In our case it survived several minutes of review before someone checked the source.

    A cheap detection heuristic for users, until this is fixed

    Risk scales with the intersection of local method names and the member names the code calls on imported objects. Anyone can compute it without the graph:

    MATCH (a)-[r:CALLS]->(b) WHERE b.name IN [<library member names you actually call>]
    RETURN b.name, a.file_path, b.file_path
    

    Cross-file rows are candidates for fabrication. Same-file rows are usually genuine.

  6. DeusData commented on Aug 10, 2026

    @DeusData
    Owner

    Thank you for adding the C# reproduction. The current resolver still includes suffix and same-name fallback paths, so this is useful evidence that the false binding is cross-language rather than isolated to JavaScript or TypeScript. I have linked the issue under the graph-query and resolution epic and retained high priority in the immediate bug train.

  7. bearluo commented on Aug 19, 2026

    @bearluo

    Still reproduces on 0.10.8 (this issue is milestoned 0.10.0-rc). Adding two TypeScript sub-cases that reduce more sharply than the test-runner-global case, and one filtering result that cuts against the obvious workaround.

    Sub-case 1: the callee is a parameter of the enclosing function

    The tightest form of this bug I've been able to produce. No globals, no library, no imports — the callee is a Promise executor parameter, declared two lines above the call site, lexically in scope:

    // src/loader.ts — no import of `reject` anywhere in the project
    export function loadText(url: string): Promise<string> {
      return new Promise<string>((resolve, reject) => {
        if (!url) {
          reject(new Error('empty url'));   // line 5
          return;
        }
        resolve(url);
      });
    }
    // src/view-model.ts — the ONLY declaration named `reject` in the project
    export class LoginViewModel {
      private reject(msg: string): Promise<void> {
        console.error(msg);
        return Promise.resolve();
      }
    
      submit(name: string): Promise<void> {
        if (!name) return this.reject('name required');
        return Promise.resolve();
      }
    }

    Observed:

      cbm-repro.src.loader.loadText  reject  cbm-repro.src.view-model.LoginViewModel.reject  unique_name  "0.75"  line 5
    

    The executor's parameters are not represented in the graph at all:

    MATCH (n) WHERE n.name IN ['reject','resolve'] RETURN labels(n), n.qualified_name
      ["Method"]  cbm-repro.src.view-model.LoginViewModel.reject
    total: 1
    

    and loadText's own param_names is ["url"] — the arrow function's (resolve, reject) are nowhere. So reject has nothing in scope to bind to, and the fallback reaches across the project into a private method of an unrelated class. Everything needed to not do that was in the same function body.

    Note the third edge in the same repro resolves correctly: submit → this.reject via lsp_ts_method @ 0.95. So the LSP pass is running on these files; the fallback simply fires anyway once the LSP declines to bind.

    Sub-case 2: qualified callee whose qualifier is ambient, bound to an interface

    // node_modules/@fake/engine-types/index.d.ts — ambient, outside indexed source
    declare namespace native {
      class Manifest { constructor(content: string, root?: string); }
    }
    // src/manifest-type.ts — the ONLY declaration named `Manifest` in indexed source
    export interface Manifest { version: string; assets: Record<string, string>; }
    // src/updater.ts — calls the ambient global, unrelated to ./manifest-type
    export function makeManifest(content: string): unknown {
      return new native.Manifest(content, '/tmp');
    }

    Observed:

      cbm-repro.src.updater.makeManifest  native.Manifest  cbm-repro.src.manifest-type.Manifest  unique_name  "0.75"  line 3
    

    Two things here:

    1. r.callee is recorded as native.Manifest, i.e. the resolver knows the call was qualified — and still drops the qualifier to match a bare name. If the qualifier can't be resolved, the bare-name match on the last segment should not be attempted.
    2. The target is an Interface node. A CALLS edge into a type declaration can never be a real call in any language the tool indexes. That looks like a cheap structural invariant to enforce at edge-write time, independent of how the binding bug is ultimately fixed — it would also have caught the same class of edge in the C# and Python reports above without any resolver change.

    Control: moving that declare namespace native into the indexed source changes the edge to qualified_suffix @ 0.90 pointing at the ambient declaration, which is correct. So the failure is specifically "qualifier resolves to nothing indexable".

    Repro for both:

    mkdir -p repro/src repro/node_modules/@fake/engine-types && cd repro
    printf '{ "name": "cbm-repro", "private": true, "type": "module" }\n' > package.json
    printf '{ "compilerOptions": { "target": "ES2021", "module": "ESNext", "moduleResolution": "Bundler", "strict": true, "types": ["@fake/engine-types"] } }\n' > tsconfig.json
    printf '{ "name": "@fake/engine-types", "version": "1.0.0", "types": "index.d.ts" }\n' > node_modules/@fake/engine-types/package.json
    # ...then the four .ts files above, plus the .d.ts at node_modules/@fake/engine-types/index.d.ts
    codebase-memory-mcp cli index_repository --repo-path "$PWD" --name cbm-repro --mode full
    codebase-memory-mcp cli query_graph --project cbm-repro \
      --query "MATCH (a)-[r:CALLS]->(b) RETURN a.qualified_name, r.callee, b.qualified_name, r.strategy, r.confidence, r.line"

    Scale, and why confidence filtering fails in both directions

    Private TS pnpm monorepo: 321 files, 4,548 nodes / 9,822 edges, --mode full. 319 of 1,190 CALLS edges (27%) are unique_name (271) or suffix_match (48).

    Cross-checking each of those against the calling file's actual import statements in source — not against the graph's IMPORTS edges, which are themselves incomplete in this repo (filed separately as #1732) — 69 bind to a name the calling file never imports. They cluster exactly on generic identifiers:

    13 resolve   13 reject    7 off        5 stop        5 dispose
     3 onChange   3 factory   3 Manifest   2 onProgress  2 Item
    

    resolve and reject are Promise executor parameters (sub-case 1). off / stop / dispose are lifecycle methods invoked on receivers the resolver could not type. Manifest is sub-case 2.

    LoginVM.reject ranks #5 by inbound CALLS (14) in this repo. Thirteen are fabricated, every one from a new Promise((resolve, reject) => …) body in a different package; the fourteenth is the genuine intra-class this.reject call, correctly resolved via lsp_ts_method @ 0.95. KitContext.resolve ranks #6 with 13 and behaves identically, but stays inside one package — so it produces no layering violation to tip anyone off. We only found it by auditing on strategy.

    @artaommahe showed that confidence >= 0.9 fails to remove false edges. In our repo it also removes true ones: getRootContainer has 67 inbound CALLS edges across 48 distinct caller files, and I confirmed every one of those files contains a real getRootContainer() call — yet 64 of the 67 are unique_name @ 0.75/0.38, because the workspace-package imports that would have resolved them never produced IMPORTS edges. Filtering at 0.9 drops 64 of 67 and the top of our fan-in ranking empties out.

    So in a workspace monorepo confidence is not separable into signal and noise in either direction — false edges sit above the threshold and true edges sit below it. strategy is the usable discriminator today; confidence is not.


    Posted by Claude Code on behalf of @bearluo

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 workingeditor/integrationEditor compatibility and CLI integrationparsing/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