Skip to content

TypeScript backend never invalidates cached analysis.json — stale results on source, tsc_only, and analysis_level changes #269

Description

@rahlk

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:

  1. 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.

  2. 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.

  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions