From 1d8ed5799ce69d1c7a56005eda97f014bfcb9b10 Mon Sep 17 00:00:00 2001 From: Ousama Ben Younes Date: Fri, 21 Aug 2026 12:20:59 +0000 Subject: [PATCH] fix(extract): invalidate tsconfig alias and baseUrl caches on edit (#2917) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_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. --- CHANGELOG.md | 1 + graphify/extract.py | 6 +++ graphify/extractors/resolution.py | 25 ++++++++-- tests/test_jsconfig_baseurl.py | 76 +++++++++++++++++++++++++++++++ 4 files changed, 105 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6930b4e76..0f3c5cd298 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu ## 0.9.48 (2026-08-20) +- Fix: `graphify watch`, the MCP server, and any caller that runs `extract()` or `extract_js()` more than once in a process now see an edited `tsconfig.json` / `jsconfig.json`; the `compilerOptions.paths` and `baseUrl` caches are keyed by config mtime — as the workspace-manifest cache already is — and cleared per run so an edit to an `extends` base config lands too, instead of silently wiring imports to the previous alias target for the life of the process (#2917, thanks @sashankh). - Fix: a control character in a node label or id no longer aborts the whole export; the GraphML and Obsidian exporters scrub only the characters those formats forbid (tab, newline, and non-ASCII letters are preserved), and `graph.json` and its byte-identity round-trip are untouched (#2897, thanks @abhay-codes07). - Fix: `graphify update` / `label` / `cluster-only` no longer leave a large graph without a `graph.html`; the aggregated community view renders instead of raising, a failed render preserves the previous file, and a missing `graph.html` is regenerated on the no-change fast path without reclustering (#2853, thanks @oleksii-tumanov). - Feature: `graphify extract --no-dedup` skips the fuzzy near-duplicate merge on build and incremental merge, for operators who would rather keep distinct symbols that fuzzy-matched; exact-id uniqueness is unaffected and the flag arms the shrink guard so a surprising node drop is refused loudly (#2881, thanks @rajarshidattapy). diff --git a/graphify/extract.py b/graphify/extract.py index ffc6153f82..d2c6de13ce 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, @@ -5522,9 +5523,14 @@ def extract( _check_tree_sitter_version() _raise_recursion_limit() # Workspace package manifests/globs can change during watch or repeated extraction. + # The tsconfig/jsconfig caches are mtime-keyed per config file, which the run + # boundary completes: an alias inherited through an `extends` chain is keyed on + # the leaf config only, so an edit to the BASE config needs this clear (#2917). _WORKSPACE_PACKAGE_CACHE.clear() _XAML_CSHARP_CLASS_CACHE.clear() _MD_LINK_INDEX_CACHE.clear() + _TSCONFIG_ALIAS_CACHE.clear() + _TSCONFIG_BASEURL_CACHE.clear() # Infer a common root for cache keys (use first diverging segment, not sum of all matches) try: diff --git a/graphify/extractors/resolution.py b/graphify/extractors/resolution.py index a75b450f8e..c762cd67d9 100644 --- a/graphify/extractors/resolution.py +++ b/graphify/extractors/resolution.py @@ -22,6 +22,10 @@ # compilerOptions.baseUrl per config path, as an absolute dir (#2153). _TSCONFIG_BASEURL_CACHE: "dict[str, Path | None]" = {} +# stat() sentinel for a config that vanished between the exists() probe and the +# cache-key read — a distinct key, so the miss is recomputed rather than served. +_CONFIG_MTIME_UNAVAILABLE = -1 + _WORKSPACE_MANIFEST_NAMES = ("pnpm-workspace.yaml", "package.json") _JS_RESOLVE_EXTS = (".ts", ".tsx", ".mts", ".cts", ".svelte", ".js", ".jsx", ".mjs", ".cjs") @@ -203,19 +207,34 @@ def _find_js_config(start_dir: Path) -> "tuple[Path, Path] | None": return config, candidate return None +def _js_config_cache_key(config: Path) -> str: + """Cache key that changes when the config file is edited (#2917). + + Keying on the path alone froze an edited `compilerOptions` for the life of + the process, so `graphify watch`, the MCP server, and repeated `extract_js` + calls kept wiring imports to the previous alias target. Mirrors the + manifest-mtime key `_load_workspace_packages` already uses. + """ + try: + mtime = config.stat().st_mtime_ns + except OSError: + mtime = _CONFIG_MTIME_UNAVAILABLE + return str((str(config), mtime)) + + def _load_tsconfig_aliases(start_dir: Path) -> dict[str, list[str]]: """Walk up from start_dir to find tsconfig/jsconfig.json and return compilerOptions.paths aliases. Follows extends chains so SvelteKit/Nuxt/NestJS inherited aliases are included. Returns a dict mapping alias patterns to ordered resolved target patterns; wildcard tokens remain intact for substitution during resolution (#927). - Result is cached by config path string. + Result is cached by config path and mtime. """ found = _find_js_config(start_dir) if found is None: return {} config, candidate = found - key = str(config) + key = _js_config_cache_key(config) if key not in _TSCONFIG_ALIAS_CACHE: _TSCONFIG_ALIAS_CACHE[key] = _read_tsconfig_aliases(config, candidate, seen=set()) return _TSCONFIG_ALIAS_CACHE[key] @@ -233,7 +252,7 @@ def _load_tsconfig_base_url(start_dir: Path) -> "Path | None": if found is None: return None config, candidate = found - key = str(config) + key = _js_config_cache_key(config) if key not in _TSCONFIG_BASEURL_CACHE: base_url = None data = _read_json_config(config) diff --git a/tests/test_jsconfig_baseurl.py b/tests/test_jsconfig_baseurl.py index c99d07950e..99bdbb6757 100644 --- a/tests/test_jsconfig_baseurl.py +++ b/tests/test_jsconfig_baseurl.py @@ -191,3 +191,79 @@ 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 + + +def test_tsconfig_paths_alias_edit_is_seen_by_a_second_extract(tmp_path): + # #2917: watch / MCP call extract() repeatedly in one process. The alias + # cache is keyed on the config path with no invalidation, so retargeting + # `paths` mid-session kept wiring imports to the previous directory — + # silently, since the edges still existed and still looked plausible. + _write(tmp_path / "src" / "target.ts", "export function hit() { return 1; }\n") + _write(tmp_path / "lib" / "target.ts", "export function hit() { return 2; }\n") + f = _write(tmp_path / "main.ts", "import { hit } from '@app/target';\nhit();\n") + + def _retarget(alias_dir: str) -> set[str]: + _write(tmp_path / "tsconfig.json", + '{\n "compilerOptions": {\n "baseUrl": ".",\n' + f' "paths": {{ "@app/*": ["{alias_dir}/*"] }}\n' + ' }\n}\n') + return _targets(extract([f], root=tmp_path)) + + first = _retarget("src") + assert any(t.startswith("src_target") for t in first), first + second = _retarget("lib") + assert any(t.startswith("lib_target") for t in second), second + assert not any(t.startswith("src_target") for t in second), second + + +def test_tsconfig_baseurl_edit_is_seen_by_a_second_extract(tmp_path): + # #2917, the baseUrl half: same missing invalidation on the sibling cache. + _write(tmp_path / "a_root" / "mods" / "W.js", "export default 1;\n") + _write(tmp_path / "b_root" / "mods" / "W.js", "export default 2;\n") + f = _write(tmp_path / "packs" / "d.js", + "import W from 'mods/W.js';\nexport default W;\n") + + def _rebase(base_url: str) -> set[str]: + _write(tmp_path / "tsconfig.json", + f'{{\n "compilerOptions": {{ "baseUrl": "{base_url}" }}\n}}\n') + return _targets(extract([f], cache_root=tmp_path)) + + a_hit = _cid(tmp_path, tmp_path / "a_root" / "mods" / "W.js") + b_hit = _cid(tmp_path, tmp_path / "b_root" / "mods" / "W.js") + assert a_hit in _rebase("a_root") + second = _rebase("b_root") + assert b_hit in second + assert a_hit not in second + + +def test_tsconfig_alias_edit_is_seen_without_a_full_extract(tmp_path): + # #2917: extract_js() reads the alias/baseUrl caches directly, so callers + # that never go through extract() need the entries themselves to expire. + # (`os.utime` stands in for a later edit, so the test cannot depend on the + # filesystem's mtime granularity.) + import os + + from graphify.extractors.resolution import ( + _load_tsconfig_aliases, + _load_tsconfig_base_url, + ) + + config = tmp_path / "tsconfig.json" + src = _write(tmp_path / "src" / "main.ts", "export const x = 1;\n") + + def _retarget(root_dir: str, bump: int) -> None: + _write(config, + '{\n "compilerOptions": {\n' + f' "baseUrl": "{root_dir}",\n' + ' "paths": { "@app/*": ["*"] }\n' + ' }\n}\n') + stamp = config.stat().st_mtime_ns + bump + os.utime(config, ns=(stamp, stamp)) + + _retarget("src", bump=0) + assert _load_tsconfig_aliases(src.parent)["@app/*"] == [f"{tmp_path / 'src'}/*"] + assert _load_tsconfig_base_url(src.parent) == tmp_path / "src" + + _retarget("lib", bump=10**9) + assert _load_tsconfig_aliases(src.parent)["@app/*"] == [f"{tmp_path / 'lib'}/*"] + assert _load_tsconfig_base_url(src.parent) == tmp_path / "lib"