Repository navigation
TypeScript call resolution: ~3,000 lines of fixes ready (0 invented CALLS edges, full recall on a NestJS backend) — how would you like them split? #2789
Description
Version
main @ 10ab613 (v0.12.1 + 281 commits), plus the changes described below
Platform
Windows (x64)
Install channel
GitHub release archive / install.sh / install.ps1
Binary variant
standard
What happened, and what did you expect?
This is actually my first attempt at contributing to an open source project ever! I'm very excited. I built this 100% using claude code and I had it prep a post here cause it's a huge chunk and I feel like we need to be strategic on how I partition up so many lines of code.
The background for this update is that I use Nest.JS and it uses dependency injection and the default codebase memory mcp project seemed to have not found all the nodes/edges and it was inventing nodes/edges. When I started about a month ago, codebase only found about 15% of the nodes/edges. Personally I'm not an expert at static analysis but I had claude doing a lot of checking and it seems extremely good with 100% found (with my particular code base) and 0 invented. There's probably still edge cases that my updates do not handle. Anyway, here's claude's post and I'm ok in doing whatever it takes to get this into the mix, but I want to avoid a ton of complicated stacked PRs.
What problem does this solve?
On TypeScript, and NestJS in particular, the graph both misses real calls and invents calls that
don't exist. "Who calls this?" questions come back incomplete in ways that look complete. For example, a
query for dead code would list live code that is only reached through dependency injection.
We measured against ground truth from the TypeScript compiler's own checker (ts.createProgram +
getSymbolAtLocation) on a production NestJS backend: 641 .ts files, one fixed commit, same scorer
for every build:
| v0.12.1 release | main @ 10ab613d |
our branch | |
|---|---|---|---|
| production calls found (recall) | 4,052 / 4,136 | 4,031 / 4,136 | 4,136 / 4,136 |
| production edges invented | 633 | 70 | 0 (of 3,627) |
| test-file edges invented | 211 | 189 | 0 (of 934) |
| calls made by functions held in object/array literals in tests | 0 / 1,585 | 3 / 1,585 | 1,585 / 1,585 |
On the same 641-file repo, our Windows build (MSYS2 clang, 4 workers) produces a CALLS edge set
identical to a Linux build of the same commit: 4,768 edges, 0 differences either way.
Proposed solution
The branch is 34 commits on main @ 10ab613d, across 23 files:
- +3,080 / −83 lines of source. Mostly
internal/cbm/lsp/ts_lsp.c(+1,404) andinternal/cbm/helpers.c
(+628), plus the extract and pipeline passes. - +3,854 lines of tests. Mostly new probes in
tests/test_lsp_resolution_probe.c, plus
test_pipeline.c,test_registry.candtest_ts_lsp.c.
Each fix has a probe that fails without it. The key ones were mutation-checked: the fix was reverted, the
probe went red for the stated reason, and the fix was restored.
It is too big for one PR under your 500-line guideline, and I don't want to dump 15 PRs on you
unannounced. So here is the shape. I'm asking how you'd like to receive it.
The PRs are not all dependent. I mapped the commits by which earlier commits' lines each one edits,
and which earlier commits' new functions it calls. That gives a dependency graph six levels deep, not a
chain of 15:
| PR | What it fixes | src / test lines | Needs |
|---|---|---|---|
| A | Receiver types imported through a barrel re-export | 94 / 66 | — |
| B | Generic calls misparsed under a prefix operator (!foo<T>(), await, …) |
213 / 238 | — |
| C | Calls in module-level code and top-level arrow functions | 78 / 197 | — |
| D | Calls in a class's helpers and fields credited to the right owner (incl. namespaces) | 98 / 171 | — |
| E | Calls through functions held in a class field's object | 297 / 341 | — |
| F | Stop name-only guesses and builtin calls binding to project symbols | 186 / 439 | B |
| G | Type services a test gets from a factory or DI container | 81 / 141 | B |
| H1 | Calls through typed object literals and their interfaces | 405 / 249 | B |
| H2 | Namespace import vs declared type; implementation dispatch on incremental runs; intersection aliases | 238 / 328 | H1 |
| I | Return type of helpers declared inside a test block | 163 / 219 | B, G |
| J | Remaining invented edges, stopped at their cause | 275 / 300 | B, C, F, G |
| K | Name a typed literal's functions after the variable that holds them | 399 / 209 | E, F, H2, J |
| L1 | Name each typed list entry apart, credit its calls to it (+ index version bump) | 689 / 551 | C, E, H1, J, K |
| L2 | Same for untyped file-level literals; tightening | 386 / 574 | E, I, K, L1 |
| M | A workspace sibling package's import stays a project binding | 38 / 49 | F, J |
- Wave 1 (A–E): five independent PRs, each off
main. They can all be open and reviewed at once. - Then: F, G, H1 → H2, I, J → K, M → L1 → L2.
Why not one linear stack of ~14 PRs: in a strict chain, PR k can't merge until 1…k−1 have. Every
change requested on an early PR forces a rebase of everything above it, which is up to 14 × 13 / 2 = 91
rebases in the worst case. With the graph above, the critical path is 6 merges, and a change to a wave-1
PR only ripples into its own descendants.
Caveats, so you can weigh this:
- The map is textual plus symbol-level. I have not yet built and tested each PR on its own. I'd do that
before opening any of them. - Most probes live in one file (
test_lsp_resolution_probe.c). PRs touching it will conflict on
append-only hunks: mechanical, but present. - K, L1 and L2 change stored qualified names, so they bump
CBM_SEMANTIC_INDEX_VERSION. I'd keep one bump
per PR that needs it rather than carry one early. - H1, H2, L1 and L2 are still over 500 lines. They are one rule applied to several syntax shapes.
I can split further by shape if you prefer.
Alternatives considered
- The wave plan above: 15 PRs, 5 open at a time.
- Fewer, larger PRs, one per wave (~600–1,800 lines each), if you'd rather review fewer threads.
- You take the findings and tests and implement it your way. We'd hand over the probes, the
compiler-checked oracle and the scoring script. The probes are the part that's most useful on its own.
Which do you prefer? Or is there a shape that fits your review flow better?
Separate, found on the way: MinGW gcc 13.2 build deadlocks at 4 workers
Happy to open this as its own issue if you'd like.
- What happens. Built with Strawberry Perl's MinGW-w64 gcc 13.2, indexing hangs forever at
parallel.resolve.startwith ~0 CPU when run with 4 workers (3 of 3 runs). 1 and 2 workers finish. The
MSYS2 clang build of the same tree runs fine at 4 workers. - Backtrace (gdb). An exiting worker is stuck in winpthreads TLS cleanup
(__dyn_tls_pthread→pthread_mutex_lock) while holding the loader lock. A new thread waits on the
loader lock, and main sits incbm_thread_join. - Likely cause. gcc 13.2 on MinGW compiles
_Thread_local(CBM_TLS,src/foundation/compat.h:26) as
emulated TLS through winpthreads: 166__emutlssymbols in the binary, and this gcc has no
-fno-emulated-tls. The clang build has none. - Not proven: I haven't built plain
mainwith the same gcc. The CI's gcc check cross-compiles from
Linux and runs--versionunder Wine, so it can't catch a runtime deadlock. - Two smaller build notes. gcc on MinGW checks
format(printf)attributes against Microsoft's printf
regardless of__USE_MINGW_ANSI_STDIO, so 7%zu/ extra-arg sites fail-Werror=format. Also,
scripts/build.shsetsCFLAGS_EXTRAitself, so a caller'sCFLAGS_EXTRA=only takes effect if it
comes later on the command line.
Reproduction
Measured 2026-10-25: v0.12.1 Linux release binary vs our branch (integration/ts-resolution-v3 @ 26203c64).
Plain main @ 10ab613d was NOT run against this fixture.
Minimal repro
Four files:
src/users/users.repository.ts
export class UsersRepository {
findById(id: string): string {
return `user-${id}`;
}
get(key: string): string {
return key;
}
}src/users/index.ts
export * from './users.repository';src/util.ts
export function isPresent<T>(value: T | undefined): value is T {
return value !== undefined;
}src/orders/orders.service.ts
import { UsersRepository } from '../users';
import { isPresent } from '../util';
export class OrdersService {
constructor(private readonly users: UsersRepository) {}
// 1. Method call on an injected service whose class is imported through a barrel (index.ts)
ownerOf(orderId: string): string {
return this.users.findById(orderId);
}
// 2. Generic function call under a prefix operator
requireId(id: string | undefined): string {
if (!isPresent<string>(id)) throw new Error('missing id');
return id;
}
}(plus a one-line tsconfig.json: { "compilerOptions": { "target": "ES2022", "module": "commonjs", "strict": true } })
Commands
cd /tmp/cbm-example && git init -q && git add -A && git commit -qm fixture
codebase-memory-mcp cli index_repository '{"repo_path":"/tmp/cbm-example"}'
codebase-memory-mcp cli query_graph '{"project":"tmp-cbm-example","query":"MATCH (a)-[r:CALLS]->(b) RETURN a.name, b.qualified_name"}'Actual (v0.12.1)
rows: 0 (cols: a.name b.qualified_name)
total: 0
Expected (and what our branch returns)
rows: 2 (cols: a.name b.qualified_name)
ownerOf tmp-cbm-example.src.users.users.repository.UsersRepository.findById
requireId tmp-cbm-example.src.util.isPresent
total: 2
Control: each miss comes from one pattern
Change the import to from '../users/users.repository' (no barrel), and change the guard to
const ok = isPresent<string>(id); if (!ok) ... (no prefix operator). v0.12.1 then returns both edges.
So the first miss comes from the barrel re-export (PR A) and the second from the ! before a generic call
(PR B). These are the two smallest PRs in wave 1.
Duplicate check (2026-10-25): upstream issues and PRs searched, about 30 queries
Nothing duplicates the proposal. Related items to link in the issue:
- TypeScript: a method call on an object imported through a re-export barrel produces no CALLS edge #2718 (open): calls through a barrel re-export. Same root cause as PR A, different shape: they
re-export a value (an objectconstand a function) and call it directly. PR A fixes a re-exported
class used as a type (constructor(private x: Foo)), through bothexport *andexport { Foo } from.
Measured: our branch does NOT fix TypeScript: a method call on an object imported through a re-export barrel produces no CALLS edge #2718's repro (identical output to v0.12.1). Say "related", never "fixes". - TypeScript: awaited generic calls omit CALLS edges #2210 (closed, fixed in v0.12.0 by PR fix(typescript): preserve awaited generic call edges #2242):
await foo<T>()only. PR B generalises it to the other
prefix operators.!foo<T>()is still missing on v0.12.1 (the fixture above). - TypeScript: method calls on a receiver typed by inference (const x = new Foo()) produce no CALLS edge #1974 / PR fix(ts-lsp): type imported class receivers for cross-file method calls (#1354) #2407, PR fix(typescript): resolve constructor properties and new-initialized fields #2523 (merged): inferred receivers and constructor parameter properties. Our branch builds on them.
- Calls inside a closure/lambda are attributed to the enclosing named function, producing false CALLS edges (C++, JS, Python, Rust) #2293 (open, waiting on a maintainer design decision): calls inside a closure credited to the enclosing
function. Also Enhancement: index inner declarations of factory/setup callbacks — Vue Pinia setup stores, composables & React hooks (the Vue/React twin of the tracked Zustand gap) #415 / AST: Zustand factory-returning-object pattern — inner object-literal methods not extracted as Function nodes #341: functions inside object literals. PRs K, L1 and L2 make that naming decision
for TS literals, so ask the maintainer whether that representation is acceptable. - Name-only resolution is emitted as
CALLSrather thanCALL_REFERENCE#2126, BCL/external calls fabricate CALLS edges to unrelated internal classes (phantom callers; e.g. int.TryParse → internal TryParse) #2121, PR fix(registry): restrict same-module and unique-name suffix matches on receivers #1128 (open): name-only guesses emitted as CALLS. Related to PR F, which handles the TS side only. - Windows/MSYS2-gcc15: self-built main hangs at scale; self-built v0.11.0 crashes (mimalloc corrupted free list) — released binary is fine #2606 (open): self-built MinGW gcc 15 hangs on main. Probably the same family as our gcc 13.2 deadlock. Link it.
- Not overlapping: TypeScript: calls through a renamed named import (import { foo as bar }) produce no CALLS edge #2297 / PR fix(ts-lsp): resolve calls through renamed named imports #2746 (renamed imports, not in our branch); PR fix(514): Support NestJS DI #2402 (constructor parameter
properties, which merged PR fix(typescript): resolve constructor properties and new-initialized fields #2523 appears to cover already); trace_path reports callers_total: 0 with relation: eq for injected-receiver calls; the resolver already records the unresolved site but drops it before any output #2265, TypeScript: "who uses X" returns 0 callers or only the definition for constants, fields, types and string values, so agents fall back to grep (field data from 135 sessions) #2709 / PR docs: propose phased TypeScript reference improvements (#2709) #2721 (showing which calls went unresolved).
Logs
Diagnostics trajectory (memory / performance / leak issues)
Project scale (if relevant)
No response
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.
Activity
DeusData commentedon Oct 25, 2026
Thank you for the careful dependency map, the paired fixtures and the candid limits on the measurements—and for bringing this before opening the whole set. The independent barrel and prefix-operator cases give us concrete starting points to assess. The delivery shape and stored-name changes still need a maintainer decision, so please hold further splitting work for that response. I’ll follow up soon.
scottmurawski commentedon Oct 25, 2026
Thanks! It turns out my post was a bit premature and I’m indexing other code bases and found more gaps and currently fixing.
I haven’t actually split anything yet… that’s all pondering. I have the entire thing in one big chunk.
What is a good plan? Should I try to make a sample typescript with as many issues as I can get Claude to make based on all the fixes I have?
I really don’t know if my fixes are in the project’s style. One possibility is to use a giant chunk of a PR as “raw materials” along with ideally a full suite of code samples and then you guys can identify your preferred way to fix?
I may still be working on fixes for another couple of days for the rest of the things I found. I’m pretty obsessed with trying to get it to 100% for typescript / JavaScript (or as close as possible anyway).
Whatever you guys want is good with me :)