Skip to content

fix(extract): invalidate tsconfig alias and baseUrl caches on edit (#2917) - #2940

Open
ousamabenyounes wants to merge 1 commit into
Graphify-Labs:v8from
ousamabenyounes:fix/issue-2917
Open

fix(extract): invalidate tsconfig alias and baseUrl caches on edit (#2917)#2940
ousamabenyounes wants to merge 1 commit into
Graphify-Labs:v8from
ousamabenyounes:fix/issue-2917

Conversation

@ousamabenyounes

Copy link
Copy Markdown
Contributor

Fixes #2917.

The bug

_TSCONFIG_ALIAS_CACHE / _TSCONFIG_BASEURL_CACHE are keyed on the config path
string, with no mtime component and no invalidation anywhere in the package —
unlike _WORKSPACE_PACKAGE_CACHE, which is both mtime-keyed and cleared per run.
So for any process that calls extract() more than once (graphify watch, the
MCP server, library loops), an edit to compilerOptions.paths or baseUrl is
never observed again. It fails silently: the import edges still exist and still
look plausible, they just point at the previous target.

The issue's repro, verbatim, on v8 (b2cd362):

alias -> src/*: ['src_target_hit']
alias -> lib/*: ['src_target_hit']      <-- tsconfig now says lib/*

and after this PR:

alias -> src/*: ['src_target_hit']
alias -> lib/*: ['lib_target_hit']

The fix

Two layers, because the two callers differ:

  • Cache entries key on the config's mtime, mirroring the manifest-mtime key
    _load_workspace_packages already uses in the same module. This covers
    extract_js(), which reads both caches directly and never goes through
    extract().
  • extract() clears both caches beside the workspace/XAML/markdown caches.
    This is not redundant: an alias inherited through an extends chain is keyed
    on the leaf config only, so an edit to the base config needs the run
    boundary. That leaf-only key is the same scope _load_workspace_packages has
    (root manifests, not transitive includes) — happy to widen the key to the
    whole extends chain if you'd rather close that case too.

A stat() on a config that vanished between the exists() probe and the key
read falls back to a distinct sentinel key, so the miss is recomputed rather
than served stale.

Test verification (RED → GREEN)

RED — the three new tests against unmodified v8, production files stashed:

$ git stash push graphify/extract.py graphify/extractors/resolution.py
$ uv run --frozen pytest tests/test_jsconfig_baseurl.py -q --tb=line
FAILED tests/test_jsconfig_baseurl.py::test_tsconfig_paths_alias_edit_is_seen_by_a_second_extract
FAILED tests/test_jsconfig_baseurl.py::test_tsconfig_baseurl_edit_is_seen_by_a_second_extract
FAILED tests/test_jsconfig_baseurl.py::test_tsconfig_alias_edit_is_seen_without_a_full_extract
3 failed, 11 passed, 1 warning in 0.47s

with the leak visible in the assertion:

E   AssertionError: assert 'b_root_mods_w' in {'a_root_mods_w'}

GREEN — same file with the fix:

$ uv run --frozen pytest tests/test_jsconfig_baseurl.py -q
14 passed, 1 warning in 0.50s

Full suite, unchanged against the v8 baseline (the 4 failures are pre-existing
in my local environment — 3 test_ollama backend-detection tests read env vars I
have set, plus test_collect_files_skips_hidden — all 4 fail identically on
clean v8):

$ uv run --frozen pytest tests/ -q
4 failed, 4880 passed, 11 skipped, 4 warnings in 136.95s

ruff check clean on graphify/ and the touched test file; the five
tools.skillgen validators and graphify --help all pass.

The two existing per-run alias tests (test_tsconfig_paths_alias_unchanged,
test_tsconfig_wins_when_both_configs_present) are untouched and still pass, so
alias precedence (#1269, #927, #1531, #2153) is unaffected.

…raphify-Labs#2917)

`_TSCONFIG_ALIAS_CACHE` and `_TSCONFIG_BASEURL_CACHE` were keyed on the config
path alone, with no mtime component and no invalidation anywhere. Anything that
calls `extract()` more than once in one process — `graphify watch`, the MCP
server, library loops — kept resolving imports against the `compilerOptions`
read on the first run, so retargeting a `paths` alias mid-session silently wired
every subsequent rebuild to the previous directory.

Both caches now key on the config's mtime, mirroring the manifest-mtime key
`_load_workspace_packages` already uses, so direct `extract_js()` callers expire
too; and `extract()` clears them beside the workspace cache, which covers an
alias inherited through an `extends` chain, whose base config is not in the
leaf's key.

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Graphify reviewed this change.

Worth a look — the grounded gate found no coupling regressions or blocking issues, but 1 advisory finding(s) below merit a look before merge.

Formal verification. No changes could be formally verified in this run.


Graphify review — findings

Keys the tsconfig/jsconfig paths and baseUrl caches (_TSCONFIG_ALIAS_CACHE, _TSCONFIG_BASEURL_CACHE) by config path + mtime via a new _js_config_cache_key, and clears both at the start of extract() so an extends base-config edit invalidates too. This lets graphify watch, the MCP server, and repeated extract()/extract_js() calls pick up an edited config instead of pinning imports to the previous alias/baseUrl target for the life of the process (#2917). Adds tests covering alias and baseUrl retargeting across a second extract and directly through the cache without a full extract.

Worth a look

  • extends-base config edit not caught by mtime cache key without per-run cleargraphify/extractors/resolution.py:210 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 1956 functions depend on the 553 functions this change touches.

Health — this change adds coupling hotspots:

  • new: extract() — 478 callers, 42 callees
  • new: _rebuild_code() — 98 callers, 50 callees
  • new: _extract_generic() — 18 callers, 24 callees
  • new: extract_xaml() — 19 callers, 17 callees
  • new: extract_objc() — 27 callers, 9 callees
  • new: extract_js() — 80 callers, 3 callees
  • new: dispatch_command() — 2 callers, 119 callees
  • new: _resolve_js_module_path() — 27 callers, 6 callees
  • …and 41 more — each is listed as a finding

Verification — 1956 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 1808 function(s) in the blast radius were not formally verified this run

Formal verification

Could not verify: Could not verify extract.

The verifier did not have enough to check extract, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `cache_root` is annotated `Path | None` — outside the synthesizable primitive/collection set

Could not verify: Could not verify \_load\_tsconfig\_aliases.

The verifier did not have enough to check \_load\_tsconfig\_aliases, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `start_dir` is annotated `Path` — outside the synthesizable primitive/collection set

Could not verify: Could not verify \_load\_tsconfig\_base\_url.

The verifier did not have enough to check \_load\_tsconfig\_base\_url, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `start_dir` is annotated `Path` — outside the synthesizable primitive/collection set

· 1 grounded finding(s) anchored inline below; 48 more finding(s) on lines outside this diff (see the check run).

return str((str(config), mtime))


def _load_tsconfig_aliases(start_dir: Path) -> dict[str, list[str]]:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression_load_tsconfig_aliases()

8 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

JS/TS: tsconfig alias and baseUrl caches are never invalidated, so an edit to compilerOptions is invisible for the life of the process (watch / MCP)

1 participant