You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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=False silently returns the PRIOR analysis.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 is eager=True on 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 empty synthesized_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_only has 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 from needs_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).
FIX DIRECTION
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_hash the analyzer already emits per module) against what produced the cached analysis.json.
The output-affecting flags (tsc_only, analysis_level) recorded alongside the cache, via any of: an analysis.json.manifest sidecar 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.
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 with tsc_only=True, assert the resolver actually changed (empty synthesized_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.
PROBLEM
The TypeScript backend reuses a cached
analysis.jsonwhenever one exists andeager=False, with NO check that anything which affects the output has changed.cldk/analysis/typescript/codeanalyzer/codeanalyzer.py:168: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.jsonwith 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.pyresolves a default cache dir (<project>/.codeanalyzer/typescript) even whencache_dir=None, so every TypeScript run hits it.Contrast: the Python backend (
codeanalyzer.py:239) always invokesanalyzer.analyze()and delegates freshness tocodeanalyzer-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 keepline:colhonest. 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).
FIX DIRECTION
Gate
needs_runon the inputs that actually affect output, not on file existence:content_hashthe analyzer already emits per module) against what produced the cachedanalysis.json.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.DEFINITION OF DONE
eager=False, assert the result reflects the change (a renamed method appears; a moved statement's line updates).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.analysis_level.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.