Repository navigation
Description
Describe the bug
PROBLEM
The TypeScript backend reuses a cached analysis.json whenever one exists and eager=False, with NO check that anything which affects the output has changed. cldk/analysis/typescript/codeanalyzer/codeanalyzer.py:168:
needs_run = self.eager_analysis or not analysis_json_file.exists() or bool(self.target_files)
File existence is the only freshness signal. Three distinct inputs can change without busting the cache:
-
Source content — after any edit to the analyzed project, re-analysis with the default
eager=Falsesilently returns the PRIORanalysis.json: stale symbol table, call graph, and (schema v2) L3/L4 body/edge data. The analyzer subprocess never runs, so the analyzer's own content-hash cache never gets a chance to detect the change. The only escape iseager=Trueon every call, which defeats caching entirely. -
Resolver mode (
tsc_only) — folded in from Cached analysis.json not invalidated when tsc_only (resolver mode) changes — stale call graph returned #177. The flag materially changes output (the tsc resolver produces a different call graph and an emptysynthesized_callables, vs. the Jelly default) but is not in the predicate.bool(self.target_files)is in there precisely because it changes what the analyzer produces;self.tsc_onlyhas the same property and is missing. Symmetric in both directions: a tsc-only cache served to a caller who asked for Jelly, or vice versa. No error, no warning. -
analysis_level— the pre-existing parameter has the same gap: it is passed in the CLI args but absent fromneeds_run.
The cache file is a fixed analysis.json with no encoding of the mode or level it was produced under — no sidecar manifest, no fingerprint. This is not an opt-in path: typescript_analysis.py resolves a default cache dir (<project>/.codeanalyzer/typescript) even when cache_dir=None, so every TypeScript run hits it.
Contrast: the Python backend (codeanalyzer.py:239) always invokes analyzer.analyze() and delegates freshness to codeanalyzer-python's content-hash cache, so Python invalidates correctly. TypeScript is the odd one out.
WHY IT MATTERS
Under schema v2, sub-callable vertices (statements, CFG/DDG nodes) are addressed by ORDINAL ids (<callable-id>@line:col) that are deliberately NOT durable across edits. Any L3/L4 consumption API that seeds queries by source location (slice_backward("src/util.ts:42"), flows-to, def-use) relies on cache invalidation to keep line:col honest. With this bug a stale cache returns vertices pointing at OLD line numbers — a silent wrong answer, the worst failure mode for slice/flow evidence.
SCOPE BOUNDARY
The TypeScript backend's freshness predicate. Not the analyzer's internal caching, and not the Python backend (which already behaves correctly and serves as the reference).
GOALS
Gate needs_run on the inputs that actually affect output, not on file existence:
- A content signal for sources — compare a hash/mtime of the project's TS sources (or the top-level
content_hashthe analyzer already emits per module) against what produced the cachedanalysis.json. - The output-affecting flags (
tsc_only,analysis_level) recorded alongside the cache, via any of: ananalysis.json.manifestsidecar compared on load; namespacing the cache file by mode (analysis.json/analysis.tsc_only.json); or hashing the output-affecting args into the cache file name. - Or: always invoke the analyzer and let its content-hash cache decide, for parity with the Python path — measure cold-vs-warm cost first, since that removes the SDK-level short-circuit entirely.
CAVEATS AND KNOWN RISKS
- Always invoking the analyzer (the Python-parity option) removes the SDK-level short-circuit entirely. Measure cold-vs-warm cost before choosing it — trading a wrong-answer bug for a latency regression on every call is not obviously a win.
- A content hash over the project's TS sources must cover what the analyzer actually reads:
tsconfig.json, resolver settings and dependency versions change output without any.tsfile changing. Hashing only source files reintroduces the same class of staleness at a smaller scale. - The three triggers are one root cause but not one fix — a source-content signal does nothing for a
tsc_onlyflip, which is a flag, not a file. Both mechanisms are needed. analysis_levelinteracts with monotonicity: a cache produced at L4 legitimately satisfies an L1 request. Busting on any level change is correct but wasteful; treating lower-level requests as cache hits is an optimisation with a sharp edge.
DEFINITION OF DONE
- Regression test: analyze a fixture, mutate a source file, re-analyze with
eager=False, assert the result reflects the change (a renamed method appears; a moved statement's line updates). - Regression test: analyze with
tsc_only=False, re-analyze the same project withtsc_only=True, assert the resolver actually changed (emptysynthesized_callables) rather than the cached Jelly graph being returned. And the reverse direction. - Regression test: the same for a changed
analysis_level. - Python-backend behaviour unchanged — verify it already passes the same tests.
Merged 2026-08-03: #177 (resolver-mode invalidation) folded in here as case 2. It was a special case of this same root cause at :168, as noted in the original text of both issues.
To Reproduce
Not stated in the original issue.
Expected behavior
Not stated in the original issue.
Activity
[-]TypeScript backend does not invalidate cached analysis.json on source change — stale results unless eager=True[/-][+]TypeScript backend never invalidates cached analysis.json — stale results on source, tsc_only, and analysis_level changes[/+]