Skip to content

id-375 research — fixCache

The TECH.md per-file facts cache does not serve the tool as built: 11 of 12 queries walk the live type-checked ts-morph AST (findReferences(), getType(), module resolution), and no code path consumes “extracted facts” — only string-literal-uses is purely syntactic. Measurement shows the cost is dominated by per-invocation Project construction (~5 s) plus first checker build (~2.6–3.5 s), while a warm in-process re-query costs 80–240 ms and a single-file in-memory edit re-query costs ~1.6–2.1 s — a 5–100× amortisation that only a long-lived process can capture. The id-191 activation trigger has fired (importers runs 10.5 s cold-CLI / 18–20 s even warm, breaching its 5 s P-19 budget; rename-sweep chains 5+ cold CLI calls at 7–16 s each). Recommendation: drop the LMDB/facts cache (Option A), ship a warm MCP stdio server holding the Project with per-file mtime+hash staleness refresh (Option B, ~4 h — closes PRODUCT inv 28 / OQ-R4 simultaneously), plus an algorithmic fix to importers (~1–2 h). Option C tricks are a wash: skipFileDependencyResolution silently drops 60 dependency-resolved files (all of scripts/) from corpus-iterating queries. Of id-191 scope (a)–(f): (a),(b) drop; (c),(e),(f) reinterpret against the warm process; (d) keeps --corpus-info, drops --reset-cache. CI determinism improves — with no disk cache there is nothing to turn off.

1. Where the time actually goes (measured, darwin arm64, bun 1.3.4, ts-morph 28.0.0, corpus = 1,969 source files)

Section titled “1. Where the time actually goes (measured, darwin arm64, bun 1.3.4, ts-morph 28.0.0, corpus = 1,969 source files)”

Cold CLI end-to-end (/usr/bin/time, repo root):

QueryWalldurationMs (query body)Construction + startup
callers --symbol lib/supabase/safe.ts:sb7.16 s1,803 ms~5.3 s (75% of wall)
string-literal-uses --value '@/lib/supabase/safe'15.5 s10,406 ms~5.1 s
importers --module '@/lib/supabase/safe'16.4 s10,507 ms~5.9 s

durationMs excludes Project construction: the Project is built in main() at tools/ast-dataflow/cli.ts:371-373 before any query runs; each query starts its own clock (tools/ast-dataflow/queries/callers.ts:51). There is no other instrumentation.

In-process phase breakdown (instrumented script, 2–3 samples):

PhaseCost
new Project({ tsConfigFilePath }) (index.ts:46-53, skipAddingFilesFromTsConfig: false)4.5–10.2 s (median ~5 s)
resolveSymbol (first file parse)~1.7 s
First findReferences() (program + checker build)2.6–3.5 s
Warm repeat, same symbol118–239 ms
Warm, different symbol (resolve + findReferences)73–98 ms
Single-file in-memory edit (replaceWithText)24–29 ms
First findReferences() after that edit (incremental rebuild)1.6–2.1 s
Next query after that73–80 ms
string-literal-uses warm full-corpus walk3.9–5.5 s (does NOT amortise to ms — re-walks all descendants each call)
column-reads warm3.2–5.6 s
importers warm18–20.5 s (slowest query in any state)
Held-Project RSS after checker build~1.7 GB
mtime stat() sweep over all 1,969 corpus files8 ms

The id-191 activation trigger has fired, on both arms. The task file says: implement “when a smoke query breaches budget OR when iterative CLI invocations… become a bottleneck for agent workflows.” importers is a resolution-based query (5 s P-19 budget per PRODUCT.md:247-253) at 10.5 s cold / 18–20 s warm; and the ast-dataflow-rename-sweep skill chains 5 CLI invocations per sweep (string-literal-uses ×2, importers ×2, references ×1 — .claude/skills/ast-dataflow/ast-dataflow-rename-sweep/SKILL.md:67-130), i.e. ~60–90 s of wall time per sweep. The ROADMAP R-WP9 baseline (column-reads 1.8 s, dead-exports 4.3 s — ROADMAP.md:189) is stale; the corpus has grown.

2. Option A — TECH.md facts cache: serves ~1 of 12 queries

Section titled “2. Option A — TECH.md facts cache: serves ~1 of 12 queries”

Checker/type-system usage per query (grep evidence):

  • Cannot run from cached per-file facts (live language service / checker required): callers (callers.ts:71 findReferences), references (references.ts:151), type-evolution (type-evolution.ts:190,348), enum-uses (enum-uses.ts:163), dead-exports (dead-exports.ts:49), flow-trace (flow-trace.ts:188,413 getTypeChecker/getType), column-reads/column-writes/supabase-shared (column-reads.ts:266, supabase-shared.ts:54getType().getSymbol() for typed-client detection), reexport-chain (reexport-chain.ts:144-261 — module resolution), type-drift-detect.
  • Partially: importers — import specifiers are per-file syntactic facts, but the implementation needs project-level module resolution (importers.ts:107) and uses findReferencesAsNodes() per named import for used/unused detection (importers.ts:185).
  • Fully cacheable: string-literal-uses only — zero checker hits.

Critically, no query has a code path that consumes “extracted facts” — TECH.md §Cache strategy (TECH.md:211-248) describes caching “declarations, exports, imports, call sites with raw resolution attempts”, but implementing it means writing a second, facts-driven implementation of each query alongside the live one, plus solving cross-file invalidation. TECH.md’s own risk table concedes this (TECH.md:1152): a per-file cache needs per-result dependency sets and re-derivation when any dependency hash changes — that is an incremental-computation engine, not a ~3 h task. Honest estimate: 12–20 h for a benefit confined to ~2/12 queries (2 of the 5 rename-sweep invocations), while callers/references still pay full Project+checker cost. Option A as specced does not serve these queries; the win must come from amortising Project construction.

3. Option B — warm long-lived process (MCP server): captures the whole win and closes inv 28 / OQ-R4

Section titled “3. Option B — warm long-lived process (MCP server): captures the whole win and closes inv 28 / OQ-R4”
  • TECH.md already anticipates exactly this: “The MCP server (Phase 2) holds the Project in memory across queries; the CLI rebuilds per invocation” (TECH.md §Index lifecycle point 3, ~line 666-668), and sketches the registration (scripts/ast-dataflow-mcp.ts, TECH.md ~line 686-700).
  • PRODUCT inv 28 (PRODUCT.md:310-315) permits an MCP add-on exposing the same catalogue; OQ-R4 (ROADMAP.md:296) left the shape open pending “observed agent friction” — the rename-sweep latency IS that friction, and agents are the tool’s stated users (PRODUCT.md:53-58).
  • ts-morph 28.0.0 supports per-file refresh: SourceFile.refreshFromFileSystem()/Sync() (node_modules/ts-morph/lib/ts-morph.d.ts:7710,7717). Measured: in-memory single-file edit 24 ms; next checker query 1.6–2.1 s (incremental rebuild, vs 2.6–3.5 s cold build); subsequent queries ~80 ms.
  • Steady-state warm latency 0.08–2.1 s satisfies inv 19 (5 s P95 / 2 s P50) for all resolution-based queries except importers, which needs the algorithmic fix below regardless of caching.
  • All query functions already take (args, project, repoRoot) — zero refactor needed to host them in a server; @modelcontextprotocol/sdk@^1.29.0 is already a dependency (package.json:94).
  • Invariant compliance: inv 21 (“never blocks waiting for a separate long-running daemon”) holds because the CLI remains the always-available cold path and the MCP server is a client-owned per-session subprocess, not a shared daemon; inv 30 holds trivially (the server writes nothing to disk at all).
  • Cost to flag: ~1.7 GB RSS per warm server; 3 parallel worktree sessions ≈ 5 GB.

4. Option C — cold-start tricks: a wash, one is a correctness landmine

Section titled “4. Option C — cold-start tricks: a wash, one is a correctness landmine”
  • skipFileDependencyResolution: true: construction drops 5 s → 2 s but the first query absorbs the deferred work (6.4 s vs ~5.2 s) — net ~1 s, within run variance. Landmine: it removes 60 dependency-resolved files from project.getSourceFiles() (1,909 vs 1,969; all 51 scripts/ files vanish — they enter the corpus only as dependencies of __tests__ because tsconfig excludes scripts at tsconfig.json:66-73 while PRODUCT.md:51 requires scripts/*.ts in the corpus). findReferences still finds them (program-level resolution → identical 167 rows), but corpus-iterating queries (importers, string-literal-uses, dead-exports, walkBarrelChain at resolve.ts:405-465) would silently skip them. Reject.
  • tsbuildinfo/incremental (already on, tsconfig.json:34): feeds emit/diagnostics only; the language service’s findReferences cannot consume it. No win.
  • skipLibCheck already on (tsconfig.json:21). Bun startup is ~50 ms — negligible.
  • Ceiling of Option C ≈ 1–2 s off a 7–16 s invocation. Not the answer.

5. Independent finding — importers is algorithmically slow, not cache-starved

Section titled “5. Independent finding — importers is algorithmically slow, not cache-starved”

resolveTargetPath (importers.ts:99-128) iterates every file × every import declaration, calling module resolution (getModuleSpecifierSourceFile()) on every specifier containing the target’s last path segment; then isImportUnused (importers.ts:166-202) invokes language-service findReferencesAsNodes() per named import per matching importer. That is why it runs 10.5–20 s in any state. A syntactic same-file identifier scan for used/unused plus trying the direct-path fallback (importers.ts:130-144) before the corpus loop would fix the P-19 breach without any cache.

Every test constructs a fresh Project from a fixture tsconfig (tools/ast-dataflow/__tests__/callers.test.ts:15, performance.test.ts:22-26; fixtures at __tests__/fixtures/* are excluded from the main tsconfig at tsconfig.json:72). Under Option B there is no on-disk cache, so no cache-off mode is needed — a strict simplification versus Option A, which would have required cache-off plumbing in CI. performance.test.ts already measures second-run-on-warm-project, which is exactly the warm-process semantics.

Note: .gitignore:122-123 already contains .ast-dataflow/** (a different directory used by the skill for reports) — id-191 scope (b)‘s .ast-dataflow-cache/ entry was never added and is now moot.

Section titled “Recommended path: staged Option B + targeted query fix; drop Option A”

Stage 1 — Warm MCP server holding the Project (~4 h). New tools/ast-dataflow/mcp-server.ts + package script (e.g. ast-dataflow-mcp), stdio transport via the existing @modelcontextprotocol/sdk@^1.29.0. Shape:

  • One dispatch tool ast_dataflow({ query, args }) (inv 28 explicitly allows this shape) rather than 12 tools — extract the cli.ts switch (cli.ts:375-841) into a shared dispatch(query, args, project, repoRoot) consumed by both CLI and server, keeping CLI/MCP parity structural.
  • State: { project, repoRoot, known: Map<absPath, {mtimeMs, size}> }, built lazily on first tool call.
  • Per-call staleness protocol (measured 8 ms for the stat sweep): (1) re-enumerate the corpus file set and diff against knownproject.addSourceFileAtPath() for new, sf.forget() for deleted; (2) mtime+size mismatch → sf.refreshFromFileSystem() (ts-morph.d.ts:7710); (3) run the query. First checker query after a refresh pays ~1.6–2.1 s incremental rebuild; steady state 80–240 ms.
  • Stale-loud (inv 22): add meta: { refreshedFiles, addedFiles, removedFiles, staleFiles } to the response envelope; a file whose refresh throws goes in staleFiles.
  • Concurrency (inv 21): serialise tool calls through a promise chain (ts-morph is single-threaded); one server per Claude Code session (client-owned subprocess) — no shared daemon, no lock protocol, CLI untouched as the cold path. Registration per TECH.md’s own sketch (mcpServers entry in .mcp.json / settings).
  • Memory: document ~1.7 GB RSS per warm server; optionally add an idle self-release (drop the Project after N min idle, rebuild lazily) if parallel-worktree memory pressure surfaces.

Stage 2 — importers algorithmic fix (~1–2 h, independent of caching). Replace isImportUnused’s per-import findReferencesAsNodes() (importers.ts:185) with a syntactic same-file identifier scan, and try the direct-path resolution fallback before the all-files × all-imports loop in resolveTargetPath (importers.ts:99-144). This is the actual P-19 breach; no cache design fixes it.

Stage 3 — Spec + ledger updates (~1 h, needs Liam’s ratification since PRODUCT is the invariant spec).

  • TECH.md §Cache strategy: rewrite as §Warm process — record that the facts cache was rejected by measurement (queries consume the live type-checked AST; construction+checker amortisation is the win).
  • PRODUCT inv 19: “warm-cache invocation” → “warm-process invocation (MCP server or persistent process)”; inv 20 reinterpreted as per-file refreshFromFileSystem (branch switch = N files refreshed, no flush — semantics preserved); inv 28 flips DEFERRED → shipped-optional. The 12-query surface is untouched.
  • ROADMAP: resolve OQ-R3 as “rejected — facts cache serves ~1/12 queries” and OQ-R4 as “single dispatch tool, stdio, per-session”.

id-191 scope (a)–(f) disposition:

ItemVerdict
(a) lib/ast-dataflow/cache.ts reader/writerDROP — replaced by tools/ast-dataflow/mcp-server.ts (project holder + staleness sweep)
(b) .ast-dataflow-cache/ gitignoreDROP — no on-disk artefact exists
(c) cache.test.ts (inv 20/21/22)REINTERPRETstaleness.test.ts against the server module: edit fixture file → only that file refreshed and answers update (inv 20); CLI remains daemon-free + server serialises calls (inv 21); failed refresh surfaces in meta.staleFiles (inv 22)
(d) --reset-cache + --corpus-infoSPLIT--corpus-info survives (dump resolved file set + count; also useful for the skipFileDependencyResolution class of corpus bugs); --reset-cache drops (nothing to reset; server restart is the reset)
(e) warm latency targets in performance.test.tsSURVIVES, reinterpreted — warm-process P95 on the fixture corpus (already the test’s structure); optionally a local full-corpus smoke script, not CI
(f) ts-morph version + per-file SHA-256 keysREINTERPRET — mtime+size (optionally hash-confirm) staleness keys held in memory; ts-morph version key is moot with no persisted artefacts

Rejected: Option A LMDB/facts cache (12–20 h honest cost for ~2/12 query coverage and an unsolved cross-file invalidation problem TECH.md:1152 itself flags); skipFileDependencyResolution (silent 60-file corpus loss for iterating queries); a standalone unix-socket daemon (MCP covers the agent use case without inventing a lock/staleness protocol against inv 21).

Total effort ≈ 6–7 h across the three stages, versus the original ~3 h estimate that assumed the facts cache was a drop-in — it is not.