Skip to content

Parallel pipeline drops all JSX-via-alias CALLS edges (21% of call graph); repros on this repo's own source #1085

Closed

Description

@itayost

Summary

The parallel pipeline silently drops CALLS edges. On a 122-file Next.js/TypeScript repo it loses 21% of the call graph (519 -> 408), and the default configuration is the broken one.

You already know the two pipelines disagree — d9ea73f says so:

a new deliberately-RED repro_seq_parallel_equivalence tracks the remaining OPEN bug that the sequential and parallel pipelines systematically disagree (~3459 USAGE / ~1666 WRITES lines on xfs) -- separate code paths, fix deferred.

I could not find a GitHub issue for it, so here is one — with the exact failure rule, a public corpus that reproduces it, and the file-count threshold below which it is invisible.

tests/repro/repro_parallel_determinism.c:54 hardcodes RPD_CORPUS "/Users/martinvogel/perf-bench/linux/fs/xfs" and SKIPs when absent, so CI never runs it. The header notes synthetic corpora failed to reproduce. Both problems are solved below.

Version: v0.9.0, macOS arm64.

The failure rule

An edge is dropped if and only if the target is a JSX component imported through a tsconfig paths alias.

I classified all 519 sequential CALLS edges by import style x usage. The confusion matrix is perfect:

import style usage kept lost loss
@/ tsconfig alias JSX 0 122 100%
@/ tsconfig alias call 115 0 0%
relative ./ JSX 37 0 0%
relative ./ call 4 0 0%
external (npm) call 14 0 0%

Alias-imported function calls resolve fine. Relative-imported JSX resolves fine. Only the intersection fails. The parallel import_map heuristic appears to apply alias mapping to call expressions but not to JSX element references.

Concrete case — src/app/(dashboard)/_components/dashboard-kpis.tsx:

import { KpiCard } from "@/components/ui/kpi-card";   // alias import
...
<KpiCard ... />   // rendered at lines 32, 40, 47
sequential: DashboardKpis -> KpiCard   (strategy=unique_name)
parallel:   (no edge)

USAGE does not compensate. So on a default index, "who renders <KpiCard>?" silently returns nothing. For a React/Next.js codebase, where most of the call graph is JSX composition through aliases, this removes the majority of the component graph.

It is a code-path split, not a race

Do not look for a lock. I fingerprinted the full CALLS edge set (src,tgt,line,strategy -> sha256) across 21 runs (workers 1,2,3,4,6,8,12 x 3):

workers CALLS (3 runs) CALLS fingerprint Routes self-loops
1 519 519 519 30eff468830d627a 1 0
2 408 408 408 d704b71f31522558 5 8
3 408 408 408 d704b71f31522558 5 8
4 408 408 408 d704b71f31522558 5 8
6 408 408 408 d704b71f31522558 5 8
8 408 408 408 d704b71f31522558 5 8
12 408 408 408 d704b71f31522558 5 8

Every parallel run is bit-identical, at every worker count. Two workers is already fully broken; twelve is no worse. A data race cannot produce that. This is consistent with pipeline.c:1336-1337 dispatching to two separate implementations:

? run_parallel_pipeline(p, ctx, files, file_count, worker_count, &t)
: run_sequential_pipeline(p, ctx, files, file_count, &t);

Nothing resource-related helps, which fits: at workers=8, all of CBM_TS_TYPE_BUDGET=100000000, CBM_MEM_BUDGET_MB=12000, CBM_RETAIN_TOTAL_MB=8000, CBM_WALK_DEFS_MAX=1000000, and CBM_INDEX_SUPERVISOR=0 still yield exactly 408.

Threshold: the parallel path engages at 51 File nodes

Bisected: 50 files -> sequential path, 51 files -> parallel path. Below 51 the bug is invisible.

This is almost certainly why the synthetic corpora in the test header failed to reproduce. A small fixture will pass and look fixed. Any regression test must exceed 51 files.

Minimal shape: one Consumer.tsx importing three things — a JSX component via alias, a JSX component via relative path, and a plain function via alias — plus >=48 padding files.

files workers AliasCard (JSX+alias) RelCard (JSX+rel) aliasHelper (call+alias)
<=52 1 or 8 unique_name lsp_ts_jsx_import lsp_ts_import
>=53 1 unique_name lsp_ts_jsx_import lsp_ts_import
>=53 8 LOST import_map import_map

Public corpus: this repository reproduces it

No local checkout needed — the tool reproduces the divergence on its own source:

git clone https://github.com/DeusData/codebase-memory-mcp
for w in 1 8; do
  CBM_WORKERS=$w codebase-memory-mcp cli index_repository \
    --repo-path ./codebase-memory-mcp --mode full --name "si-$w"
  codebase-memory-mcp cli query_graph --project "si-$w" \
    --query "MATCH ()-[r:CALLS]->() RETURN count(*)"
done
workers CALLS (3 runs)
1 35534 / 35534 / 35534
8 35510 / 35510 / 35510

Deterministic, 24 edges lost, on a corpus already present in CI. This can replace the hardcoded RPD_CORPUS.

Fabricated edges and nodes (parallel only)

519 -> 408 decomposes as 391 survive, 128 lost, 17 fabricated.

8 phantom self-loops (f -> f), all strategy=callee_suffix, confidence=0.50, candidates=0. Every callee is an unresolvable builtin/library member call — templateCache.get, formData.get, request.headers.get, response.headers.get, promptMap.get, Drawer.Handle, test.use. The resolver finds zero candidates and still emits an edge, pointing at the enclosing function itself. This sets Function.recursive = true on 7 functions in a repo with zero recursive functions (all with self_recursive: false).

Fix: on an empty candidate set, emit no edge. The README already states this invariant for the Perl resolver ("unresolved receivers emit no edge (zero-edge guarantee)"); the TS path does not honor it.

4 fabricated Route nodes (/api/sendText, /api/sessions, /api/sessions/:name, /api/:session/auth/qr) with empty source and empty file_path. These are outbound URLs passed to a local helper: request("POST", "/api/sendText", payload). Sequentially this resolves to a normal CALLS edge and creates no Route. In parallel the call goes unresolved and a heuristic promotes the path-like string literal into a Route the project supposedly serves.

Mode does not interact

Constant -111 CALLS penalty in every mode. Route fabrication and self-loops appear in all three.

mode workers=1 workers=8
fast 431 320
moderate 431 320
full 519 408

Sequential is not a perfect oracle either

Worth knowing before you "fix" parallel by making it match sequential: parallel actually corrects 9 edges that sequential gets wrong.

Sequential's lsp_ts_namespace collapses multiple call sites sharing a method name into a single edge, keeping the first target and the last line. In src/actions/appointments.ts, appointmentRepository.create (L58) and reminderRepository.create (L62, L63) collapse into ONE edge pointing at appointment-repository.create at line 63 — wrong receiver, wrong line — and L58 is dropped. Parallel resolves both correctly. Same pattern for findAll in src/app/(dashboard)/page.tsx:31,32.

So the correct framing is: sequential is correct for JSX/alias resolution; parallel is correct for same-named-method receiver disambiguation. Neither is a superset. Converging the paths should take the union, not simply adopt one.

Two adjacent traps

CBM_INDEX_SINGLE_THREAD is strict-parsed as "1". =true is accepted without error and silently does nothing:

CBM_INDEX_SINGLE_THREAD=1      -> CALLS=519   (also overrides CBM_WORKERS=8)
CBM_INDEX_SINGLE_THREAD=true   -> CALLS=408   (silently ignored)

Re-indexing an existing project is a silent no-op. index_repository --mode full on an unchanged repo returns the existing graph without re-resolving. Re-indexing a workers=8 project (408) with CBM_WORKERS=1 still yields 408; you must delete_project first. Anyone trying the single-threaded workaround on an already-corrupted index will see no change and conclude it does not work.

Impact

trace_path is the flagship feature, and under the default configuration it silently omits ~21% of call relationships on real TypeScript — including essentially the entire JSX component graph. There is no signal to the user: node counts are identical, nothing is logged, and the bad edges carry confidence: 0.95 (import_map), so confidence cannot be used to detect a degraded graph either.

Suggestions

  1. Fix alias resolution for JSX element references on the parallel path (the whole 21%).
  2. On candidates=0, emit no edge — kills the phantom self-loops, the false recursive flags, and the fabricated Routes.
  3. Unpin RPD_CORPUS; this repository reproduces it. Ensure any regression fixture exceeds 51 files.
  4. Accept true/yes/on for CBM_INDEX_SINGLE_THREAD, or error on unparseable values.
  5. Until the paths converge, consider defaulting to sequential or logging that the parallel graph is known-lossy. Serial costs 0.43s vs 0.33s on 122 files.

Separate, lower severity

SEMANTICALLY_RELATED is nondeterministic across identical runs at every worker count including 1 (observed 18-22 on repeated full indexes of one tree). All structural edges and node counts are bit-stable. This is not the 519-vs-408 bug and looks like #998, whose fix (#1014) appears not to have shipped in v0.9.0.

Activity

  1. added this to the 0.9.1-rc milestone on Jul 14, 2026
  2. added
    bugSomething isn't working
    priority/highNeeds near-term maintainer attention; high-impact bug, regression, safety issue, or release blocker.
    on Jul 14, 2026
  3. DeusData commented on Jul 14, 2026

    @DeusData
    Owner

    Thanks for the precise public repro and the sequential/parallel comparison. This is a high-priority 0.9.1-rc parsing-correctness bug because the default pipeline silently loses a material part of the call graph. The fix needs a portable regression that exercises the JSX alias case without a machine-local corpus path and proves sequential/parallel edge equivalence for this class.

  4. zavyrylin commented on Jul 15, 2026

    @zavyrylin

    Looks wider than JSX — I'm hitting this in Python too

    Ran into this on v0.9.0 (macOS), mixed Python/TS repo, ~8.6k nodes. Did an A/B to be sure: same commit, clean tree, same mode, fresh index each time (separate --name, so nothing merges into an existing graph). Only thing that changes is CBM_INDEX_SINGLE_THREAD.

    parallel (default) CBM_INDEX_SINGLE_THREAD=1
    edges 27,661 29,967
    CALLS 4,389 5,860 (−25%)

    The interesting part: the LSP tier comes out identical. lsp_direct 907/907, lsp_method 781/781, lsp_ts_local 315/315, lsp_builtin 290/290, same_module 196/196. Not one edge lost there. Everything that goes missing is the registry/fallback tier:

    • unique_name 1880 → 248 (−87%)
    • suffix_match 587 → 291
    • lsp_ts_import 277 → 92
    • fastapi_depends 29 → 0
    • lsp_ts_namespace 59 → 0

    And they don't just differ in counts, they emit different strategies altogether. Parallel is the only one producing import_map (586), callee_suffix (49), field_type_hint (13), plus 117 edges with an empty strategy. Sequential is the only one producing lsp_ts_namespace and fastapi_depends. Reads like two code paths to me, not a race.

    Smallest repro I've got — plain relative import, nothing aliased, no JSX anywhere near it:

    # api.py:53
    from .proxygraphic import decode_proxy_graphic
    # api.py:808
    prims = decode_proxy_graphic(blob, self.version)
    trace_path(function_name='decode_proxy_graphic', direction='inbound')
      parallel   -> {"callers": []}
      sequential -> _proxy_entities -> _collect_entity -> collect_entities
    

    The IMPORTS edge is there in both, it's the CALLS edge that goes missing. Import isn't aliased, so I don't think #875/#979/#1049 cover this one.

    What makes it nasty is how quiet it is. trace_path hands you an empty callers list instead of erroring, so anything over the 50-file threshold will cheerfully tell you a function has no callers when it does.

    Can post full histograms or test a patch if that's useful.

  5. DeusData commented on Jul 15, 2026

    @DeusData
    Owner

    Thanks, this confirms that the defect is broader than JSX aliases and strengthens the two-code-path diagnosis. The clean A/B comparison with fresh project names is exactly the evidence we need.

    Please post the full per-strategy histograms. If you can test a patch, the regression fixture should remain above the 50-file parallel-pipeline threshold and assert both Python relative-import CALLS and TypeScript JSX-alias CALLS. We should require sequential and parallel output to converge through a shared pipeline-correctness fix rather than patching only the JSX heuristic.

  6. DeusData commented on Jul 15, 2026

    @DeusData
    Owner

    Fixed on main by PR #1106 (merged in 548927a), shipping in v0.9.1.

    Your diagnosis was exactly right, and I pinned it to the precise line: the parallel resolver drops a CALLS edge whenever the LSP produces a resolution whose callee_qn isn't a node in the graph buffer — the registry fallback lived in an else that ran only when there was no LSP resolution at all. So for an alias-imported JSX component, the TS LSP resolves <KpiCard> to an alias-path QN that never matches a def node (lsp_target == NULL), and the parallel path discarded the edge instead of falling through to the registry's import_map/unique_name resolution — which is exactly what the sequential path does. That's the seq/parallel divergence.

    The fix runs the registry fallback whenever the LSP didn't yield a gbuf-resolvable target, restoring parity. Verified on a 55-file alias+JSX fixture: 0 → 55 CALLS edges on the parallel path, and the reproduce-first guard (calls_jsx_component_via_tsconfig_alias_parallel_issue1085) pads past the 50-file threshold so it actually exercises the parallel path — RED before, GREEN after.

    Thanks for the exceptional report — the confusion matrix (alias×JSX = 100% loss, every other cell 0%) and the ">51 files" threshold note are what made this fast to isolate. Retest on v0.9.1 welcome.

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.ux/behaviorDisplay bugs, docs, adoption UX

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions