Skip to content

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

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    parsing/qualityGraph extraction bugs, false positives, missing edgesux/behaviorDisplay bugs, docs, adoption UX

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions