From 5e92f8df4a6cb4e256e92002d836ba7a73fbdbf8 Mon Sep 17 00:00:00 2001 From: sashankh Date: Fri, 21 Aug 2026 03:41:34 +0530 Subject: [PATCH] fix(extract): invalidate tsconfig alias/baseUrl caches per run (#2917) extract() already resets the process-global caches whose backing files can change between runs, and _MD_LINK_INDEX_CACHE states the contract: cleared at the start of each run 'so a serial rerun in one process sees files created since the last run'. _TSCONFIG_ALIAS_CACHE and _TSCONFIG_BASEURL_CACHE are the same kind of state - keyed on the config path string, no mtime component, no invalidation anywhere - but were not in that block. A tsconfig.json is at least as mutable as the workspace manifests the neighbouring comment was written for, so an edit to compilerOptions.paths or baseUrl was never observed again for the life of the process. graphify watch, the MCP server, and any library caller looping over extract() kept resolving imports to the previous target directory, silently: the edges still exist and still look plausible. Clearing per run rather than per file leaves the within-run caching these exist for fully intact, so the cost is the same as _WORKSPACE_PACKAGE_CACHE's. The existing suite could not see this - every test in test_jsconfig_baseurl.py calls extract() once under its own tmp_path, so each gets a distinct config path and a fresh cache key. The two new tests run extract() twice against one config path, and both fail on v8 without this change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- graphify/extract.py | 9 +++++ tests/test_jsconfig_baseurl.py | 60 ++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/graphify/extract.py b/graphify/extract.py index ffc6153f82..3086721076 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -70,6 +70,7 @@ _JS_PRIMITIVE_TYPES, _JS_RESOLVE_EXTS, _TSCONFIG_ALIAS_CACHE, + _TSCONFIG_BASEURL_CACHE, _VUE_SCRIPT_LANG_RE, _VUE_SCRIPT_RE, _WORKSPACE_MANIFEST_NAMES, @@ -5523,6 +5524,14 @@ def extract( _raise_recursion_limit() # Workspace package manifests/globs can change during watch or repeated extraction. _WORKSPACE_PACKAGE_CACHE.clear() + # tsconfig/jsconfig compilerOptions are the same kind of state: keyed by + # config path with no mtime component, so an edit to `paths` or `baseUrl` + # was never observed again for the life of the process. `graphify watch` + # and the MCP server both call extract() repeatedly in one process, so + # every rebuild after the edit kept resolving through the stale alias map. + # Clearing per run, not per file, leaves within-run caching intact. + _TSCONFIG_ALIAS_CACHE.clear() + _TSCONFIG_BASEURL_CACHE.clear() _XAML_CSHARP_CLASS_CACHE.clear() _MD_LINK_INDEX_CACHE.clear() diff --git a/tests/test_jsconfig_baseurl.py b/tests/test_jsconfig_baseurl.py index c99d07950e..e5a171541d 100644 --- a/tests/test_jsconfig_baseurl.py +++ b/tests/test_jsconfig_baseurl.py @@ -191,3 +191,63 @@ def test_tsconfig_wins_when_both_configs_present(tmp_path): targets = _targets(r) assert _cid(tmp_path, ts_hit) in targets assert _cid(tmp_path, tmp_path / "js_root" / "mods" / "W.js") not in targets + + +# --- config edits must survive the per-process caches (#2917) --------------- +# +# `_TSCONFIG_ALIAS_CACHE` and `_TSCONFIG_BASEURL_CACHE` are keyed on the config +# path with no mtime component. Every test above calls extract() exactly once +# under its own tmp_path, so each gets a fresh cache key and the staleness never +# shows. `graphify watch` and the MCP server are the opposite shape: one process, +# one config path, many extract() calls — so an edit to compilerOptions was never +# observed again for the life of the process. These two run extract() twice +# against the SAME config path, which is the case that was unguarded. + + +def test_edited_paths_alias_is_observed_by_a_later_extract(tmp_path): + """Editing `paths` mid-session must retarget the alias, not keep the old map.""" + _write(tmp_path / "src" / "target.ts", "export function hit() { return 1; }\n") + _write(tmp_path / "lib" / "target.ts", "export function hit() { return 2; }\n") + importer = _write(tmp_path / "main.ts", "import { hit } from '@app/target';\nhit();\n") + + def _run() -> set[str]: + return _targets(extract([importer], root=tmp_path)) + + _write(tmp_path / "tsconfig.json", + '{\n "compilerOptions": {\n' + ' "baseUrl": ".",\n' + ' "paths": { "@app/*": ["src/*"] }\n' + ' }\n}\n') + assert _cid(tmp_path, tmp_path / "src" / "target.ts") in _run() + + _write(tmp_path / "tsconfig.json", + '{\n "compilerOptions": {\n' + ' "baseUrl": ".",\n' + ' "paths": { "@app/*": ["lib/*"] }\n' + ' }\n}\n') + second = _run() + assert _cid(tmp_path, tmp_path / "lib" / "target.ts") in second, ( + "second extract() resolved @app/target through the alias map cached by the " + "first run, so the edited tsconfig was never read" + ) + assert _cid(tmp_path, tmp_path / "src" / "target.ts") not in second + + +def test_edited_baseurl_is_observed_by_a_later_extract(tmp_path): + """Same contract for the separately cached `baseUrl` root (#2153).""" + _write(tmp_path / "one" / "mods" / "Widget.js", "export default function Widget() {}\n") + _write(tmp_path / "two" / "mods" / "Widget.js", "export default function Widget() {}\n") + importer = _write(tmp_path / "packs" / "dashboard.js", + "import Widget from 'mods/Widget.js';\nexport default Widget;\n") + + def _run() -> set[str]: + return _targets(extract([importer], root=tmp_path)) + + _write(tmp_path / "jsconfig.json", '{\n "compilerOptions": { "baseUrl": "one" }\n}\n') + assert _cid(tmp_path, tmp_path / "one" / "mods" / "Widget.js") in _run() + + _write(tmp_path / "jsconfig.json", '{\n "compilerOptions": { "baseUrl": "two" }\n}\n') + second = _run() + assert _cid(tmp_path, tmp_path / "two" / "mods" / "Widget.js") in second, ( + "second extract() resolved through the baseUrl cached by the first run" + )