Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
6 changes: 6 additions & 0 deletions graphify/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down
25 changes: 22 additions & 3 deletions graphify/extractors/resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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]]:

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.

"""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]
Expand All @@ -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)
Expand Down
76 changes: 76 additions & 0 deletions tests/test_jsconfig_baseurl.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading