From 601177ad6bf86b5f815781d5e811f1b20d56d803 Mon Sep 17 00:00:00 2001 From: Rishet Mehra Date: Sat, 25 Jul 2026 06:33:07 +0530 Subject: [PATCH 1/2] fix(resolution): honor jsconfig.json and compilerOptions.baseUrl (#2153) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Non-relative imports produced no edge in a Rails/webpacker project, so every module under `baseUrl` was orphaned and `affected` answered nothing. Two defects. First, only `tsconfig.json` was probed, never `jsconfig.json` — the plain-JS spelling of the same file, which json_config.py already indexes, so the config's nodes appeared in the graph while resolution ignored it entirely. Second, `baseUrl` was consumed only as the base that `paths` targets resolve against, so a config declaring `baseUrl` and NO `paths` produced an empty alias map and every bare specifier died. `_find_js_config` now probes both names, tsconfig winning within a directory as tsc and editors do. `baseUrl` is exposed separately and used as a resolution root of LAST RESORT, tried only when no declared alias matches, so `paths` precedence (#1269, #927, #1531) is untouched. It is deliberately not modelled as a synthesized `*` alias: that would score (1, 0) in _match_tsconfig_alias and beat a declared non-wildcard directory-prefix alias at (2, -len), silently shadowing it. The fallback also returns a candidate only when it is a real file, so an external package import is not fabricated into a / edge. Threaded through the three regex-rescue dynamic-import paths (Svelte, Astro, TS/TSX) as well as static resolution, since the issue reports both. --- graphify/extract.py | 14 ++-- graphify/extractors/resolution.py | 103 ++++++++++++++++++++++++++---- 2 files changed, 99 insertions(+), 18 deletions(-) diff --git a/graphify/extract.py b/graphify/extract.py index bbfa301cc..7b84010f4 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -91,6 +91,7 @@ _js_source_path, _js_top_level_function_bodies, _load_tsconfig_aliases, + _load_tsconfig_base_url, _load_workspace_packages, _match_tsconfig_alias, _merge_decl_def_classes, @@ -1285,6 +1286,7 @@ def extract_svelte(path: Path) -> dict: # endpoint is a phantom node and build_from_json drops the edge (#701). file_node_id = _make_id(str(path)) aliases = _load_tsconfig_aliases(path.parent) + base_url = _load_tsconfig_base_url(path.parent) for m in _re.finditer(r"""import\(\s*['"]([^'"]+)['"]\s*\)""", src): raw = m.group(1) if not raw: @@ -1302,7 +1304,7 @@ def extract_svelte(path: Path) -> dict: # Check tsconfig.json path aliases (e.g. "$lib/" -> "src/lib/", "@/" -> "src/") # before treating as external. Mirrors _import_js logic so SvelteKit alias # imports resolve to the same file node IDs the extractor creates (#701). - resolved_alias = _resolve_tsconfig_alias(raw, aliases) + resolved_alias = _resolve_tsconfig_alias(raw, aliases, base_url=base_url) if resolved_alias is not None: resolved_alias = _resolve_js_module_path(resolved_alias) node_id = _make_id(str(resolved_alias)) @@ -1360,7 +1362,7 @@ def extract_svelte(path: Path) -> dict: node_id = _make_id(str(resolved)) stub_source_file = str(resolved) else: - resolved_alias = _resolve_tsconfig_alias(raw, aliases) + resolved_alias = _resolve_tsconfig_alias(raw, aliases, base_url=base_url) if resolved_alias is not None: node_id = _make_id(str(resolved_alias)) stub_source_file = str(resolved_alias) @@ -1413,6 +1415,7 @@ def extract_astro(path: Path) -> dict: existing_ids = {n["id"] for n in result.get("nodes", [])} file_node_id = _make_id(str(path)) aliases = _load_tsconfig_aliases(path.parent) + base_url = _load_tsconfig_base_url(path.parent) # Dynamic imports anywhere in the file: `import('./X.astro')` is legal in # frontmatter setup code and inside expression slots. for m in _re.finditer(r"""import\(\s*['"]([^'"]+)['"]\s*\)""", src): @@ -1425,7 +1428,7 @@ def extract_astro(path: Path) -> dict: node_id = _make_id(str(resolved)) stub_source_file = str(resolved) else: - resolved_alias = _resolve_tsconfig_alias(raw, aliases) + resolved_alias = _resolve_tsconfig_alias(raw, aliases, base_url=base_url) if resolved_alias is not None: resolved_alias = _resolve_js_module_path(resolved_alias) node_id = _make_id(str(resolved_alias)) @@ -1486,7 +1489,7 @@ def extract_astro(path: Path) -> dict: node_id = _make_id(str(resolved)) stub_source_file = str(resolved) else: - resolved_alias = _resolve_tsconfig_alias(raw, aliases) + resolved_alias = _resolve_tsconfig_alias(raw, aliases, base_url=base_url) if resolved_alias is not None: node_id = _make_id(str(resolved_alias)) stub_source_file = str(resolved_alias) @@ -1553,6 +1556,7 @@ def extract_vue(path: Path) -> dict: existing_ids = {n["id"] for n in result.get("nodes", [])} file_node_id = _make_id(str(path)) aliases = _load_tsconfig_aliases(path.parent) + base_url = _load_tsconfig_base_url(path.parent) for m in re.finditer(r"""import\(\s*['"]([^'"]+)['"]\s*\)""", src): raw = m.group(1) if not raw: @@ -1563,7 +1567,7 @@ def extract_vue(path: Path) -> dict: node_id = _make_id(str(resolved)) stub_source_file = str(resolved) else: - resolved_alias = _resolve_tsconfig_alias(raw, aliases) + resolved_alias = _resolve_tsconfig_alias(raw, aliases, base_url=base_url) if resolved_alias is not None: resolved_alias = _resolve_js_module_path(resolved_alias) node_id = _make_id(str(resolved_alias)) diff --git a/graphify/extractors/resolution.py b/graphify/extractors/resolution.py index e88e36372..457c26615 100644 --- a/graphify/extractors/resolution.py +++ b/graphify/extractors/resolution.py @@ -19,6 +19,9 @@ _TSCONFIG_ALIAS_CACHE: dict[str, dict[str, list[str]]] = {} +# compilerOptions.baseUrl per config path, as an absolute dir (#2153). +_TSCONFIG_BASEURL_CACHE: "dict[str, Path | None]" = {} + _WORKSPACE_MANIFEST_NAMES = ("pnpm-workspace.yaml", "package.json") _JS_RESOLVE_EXTS = (".ts", ".tsx", ".mts", ".cts", ".svelte", ".js", ".jsx", ".mjs", ".cjs") @@ -164,23 +167,82 @@ def _read_tsconfig_aliases(tsconfig: Path, base_dir: Path, seen: set) -> dict[st return aliases +def _read_json_config(path: Path) -> "dict | None": + """Parse a tsconfig/jsconfig as JSON, falling back to JSONC (#2153). + + Mirrors the read/parse handling in `_read_tsconfig_aliases`; returns None on + any unreadable or unparseable file so a malformed config degrades to "no + baseUrl" instead of raising. + """ + try: + raw = path.read_text(encoding="utf-8", errors="replace") + except OSError: + return None + for candidate in (raw, _strip_jsonc(raw)): + try: + data = json.loads(candidate) + except Exception: + continue + return data if isinstance(data, dict) else None + return None + +def _find_js_config(start_dir: Path) -> "tuple[Path, Path] | None": + """Nearest tsconfig.json/jsconfig.json walking up from start_dir. + + `jsconfig.json` is the plain-JS spelling of the same file (already indexed by + json_config.py) and was never probed here, so a Rails/webpacker project that + configures resolution in jsconfig.json got no aliases at all (#2153). + tsconfig.json wins when both sit in one directory, matching tsc and editors, + which consult jsconfig.json only when there is no tsconfig.json. + """ + current = start_dir.resolve() + for candidate in [current, *current.parents]: + for name in ("tsconfig.json", "jsconfig.json"): + config = candidate / name + if config.exists(): + return config, candidate + return None + def _load_tsconfig_aliases(start_dir: Path) -> dict[str, list[str]]: - """Walk up from start_dir to find tsconfig.json and return compilerOptions.paths aliases. + """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 tsconfig path string. + Result is cached by config path string. """ - current = start_dir.resolve() - for candidate in [current, *current.parents]: - tsconfig = candidate / "tsconfig.json" - if tsconfig.exists(): - key = str(tsconfig) - if key not in _TSCONFIG_ALIAS_CACHE: - _TSCONFIG_ALIAS_CACHE[key] = _read_tsconfig_aliases(tsconfig, candidate, seen=set()) - return _TSCONFIG_ALIAS_CACHE[key] - return {} + found = _find_js_config(start_dir) + if found is None: + return {} + config, candidate = found + key = str(config) + if key not in _TSCONFIG_ALIAS_CACHE: + _TSCONFIG_ALIAS_CACHE[key] = _read_tsconfig_aliases(config, candidate, seen=set()) + return _TSCONFIG_ALIAS_CACHE[key] + +def _load_tsconfig_base_url(start_dir: Path) -> "Path | None": + """`compilerOptions.baseUrl` of the nearest config, as an absolute directory. + + baseUrl was only ever used as the base that `paths` targets resolve against, + so a config declaring baseUrl and NO paths yielded an empty alias map and + every non-relative import went unresolved (#2153). Exposed separately so it + can act as a resolution root of last resort, after all declared aliases miss. + Returns None when no config declares baseUrl. + """ + found = _find_js_config(start_dir) + if found is None: + return None + config, candidate = found + key = str(config) + if key not in _TSCONFIG_BASEURL_CACHE: + base_url = None + data = _read_json_config(config) + if data is not None: + raw_base = data.get("compilerOptions", {}).get("baseUrl") + if isinstance(raw_base, str) and raw_base: + base_url = Path(os.path.normpath(candidate / raw_base)) + _TSCONFIG_BASEURL_CACHE[key] = base_url + return _TSCONFIG_BASEURL_CACHE[key] def _match_tsconfig_alias(raw: str, pattern: str) -> "tuple[tuple[int, int], str, bool] | None": """Return (specificity, captured text, is_wildcard) when pattern matches raw. @@ -208,12 +270,21 @@ def _match_tsconfig_alias(raw: str, pattern: str) -> "tuple[tuple[int, int], str return (2, -len(prefix)), raw[len(prefix):].lstrip("/"), False return None -def _resolve_tsconfig_alias(raw: str, aliases: dict[str, list[str]]) -> "Path | None": +def _resolve_tsconfig_alias(raw: str, aliases: dict[str, list[str]], + base_url: "Path | None" = None) -> "Path | None": """Resolve `raw` against the most specific matching tsconfig alias pattern. Within that pattern, try targets in declared order and return the first whose candidate resolves to a real file. If none exist, return the first candidate so existing phantom/external-edge behavior stays unchanged. + + `base_url` is a resolution root of last resort, tried only when NO declared + alias matches (#2153). It must not participate in the specificity contest: + a bare `*` alias would score (1, 0) and so beat a declared non-wildcard + directory-prefix alias at (2, -len), silently shadowing it and regressing + #1269. Unlike the alias path this returns a candidate only when it is a real + file on disk, so a genuine external package (`import React from 'react'`) + still resolves to nothing instead of a fabricated /react edge. """ best: "tuple[tuple[int, int], str, bool, list[str]] | None" = None for pattern, targets in aliases.items(): @@ -225,6 +296,11 @@ def _resolve_tsconfig_alias(raw: str, aliases: dict[str, list[str]]) -> "Path | best = specificity, captured, is_wildcard, targets if best is None: + if base_url is not None: + candidate = Path(os.path.normpath(base_url / raw)) + resolved = _resolve_js_import_path(candidate) + if resolved.is_file(): + return resolved return None _, captured, is_wildcard, targets = best @@ -442,7 +518,8 @@ def _resolve_js_module_path(raw: str | Path, start_dir: Path | None = None) -> P return _resolve_js_import_path(start_dir / raw) aliases = _load_tsconfig_aliases(start_dir) - hit = _resolve_tsconfig_alias(raw, aliases) + hit = _resolve_tsconfig_alias(raw, aliases, + base_url=_load_tsconfig_base_url(start_dir)) if hit is not None: return _resolve_js_import_path(hit) From 4c167bb72a972fb9dec28d6bb43645df8a66c705 Mon Sep 17 00:00:00 2001 From: Rishet Mehra Date: Sat, 25 Jul 2026 06:33:08 +0530 Subject: [PATCH 2/2] test(resolution): cover jsconfig/baseUrl module resolution (#2153) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eleven cases: the webpacker repro (jsconfig + baseUrl, no paths) for static, dynamic and extensionless specifiers; the same for tsconfig; tsconfig winning over jsconfig in one directory; and four preservation guards that pass before and after — declared paths and directory-prefix aliases are not shadowed, relative imports are untouched, an absent baseUrl changes nothing, and an external package is not fabricated. --- tests/test_jsconfig_baseurl.py | 185 +++++++++++++++++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 tests/test_jsconfig_baseurl.py diff --git a/tests/test_jsconfig_baseurl.py b/tests/test_jsconfig_baseurl.py new file mode 100644 index 000000000..a3c9185fe --- /dev/null +++ b/tests/test_jsconfig_baseurl.py @@ -0,0 +1,185 @@ +"""Regression tests: jsconfig.json / baseUrl module resolution (#2153). + +`compilerOptions.baseUrl` was only ever used as the base that `paths` aliases +resolve against, so a config declaring `baseUrl` and NO `paths` produced an +empty alias map and every non-relative import died. In a Rails/webpacker +project (`jsconfig.json` with `"baseUrl": "app/javascript"`) that orphaned every +module: `import Widget from 'mods/Widget.js'` emitted no edge, so `affected` +answered nothing. `jsconfig.json` was also never probed at all — only +`tsconfig.json` — even though json_config.py already indexes it. + +baseUrl is now a resolution root of last resort: tried only after every declared +alias fails to match, so existing `paths` precedence (#1269, #927, #1531) is +untouched. +""" +from pathlib import Path + +from graphify.extract import _make_id, extract + + +def _write(path: Path, text: str) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return path + + +def _targets(result: dict) -> set[str]: + return {e["target"] for e in result["edges"]} + + +def _rails_tree(tmp_path: Path, config_name: str, importer_body: str, + base_url: str = "app/javascript", paths: str = "") -> Path: + """A webpacker-shaped project: config at the root, modules under baseUrl.""" + paths_frag = f',\n "paths": {{ {paths} }}' if paths else "" + _write(tmp_path / config_name, + '{\n "compilerOptions": {\n' + f' "baseUrl": "{base_url}"{paths_frag}\n' + ' }\n}\n') + _write(tmp_path / base_url / "mods" / "Widget.js", + "export default function Widget() {}\n") + return _write(tmp_path / base_url / "packs" / "dashboard.js", importer_body) + + +def _widget(tmp_path: Path, base_url: str = "app/javascript") -> str: + return _make_id(str(tmp_path / base_url / "mods" / "Widget.js")) + + +def test_jsconfig_baseurl_static_import_resolves(tmp_path): + # The issue repro: jsconfig.json, baseUrl, NO paths. + f = _rails_tree(tmp_path, "jsconfig.json", + "import Widget from 'mods/Widget.js';\n" + "export default Widget;\n") + r = extract([f], cache_root=tmp_path) + assert _widget(tmp_path) in _targets(r) + + +def test_jsconfig_baseurl_dynamic_import_resolves(tmp_path): + # The issue states static AND dynamic are both affected. + f = _rails_tree(tmp_path, "jsconfig.json", + "export async function load() {\n" + " return import('mods/Widget.js');\n" + "}\n") + r = extract([f], cache_root=tmp_path) + assert _widget(tmp_path) in _targets(r) + + +def test_jsconfig_baseurl_extensionless_specifier_resolves(tmp_path): + f = _rails_tree(tmp_path, "jsconfig.json", + "import Widget from 'mods/Widget';\n" + "export default Widget;\n") + r = extract([f], cache_root=tmp_path) + assert _widget(tmp_path) in _targets(r) + + +def test_tsconfig_baseurl_without_paths_also_resolves(tmp_path): + # Same defect applies to tsconfig.json, not just jsconfig.json. + f = _rails_tree(tmp_path, "tsconfig.json", + "import Widget from 'mods/Widget.js';\n" + "export default Widget;\n") + r = extract([f], cache_root=tmp_path) + assert _widget(tmp_path) in _targets(r) + + +def test_declared_paths_alias_still_wins_over_baseurl(tmp_path): + # Guards #1269: a declared alias must not be shadowed by the baseUrl + # fallback. "@mods/Widget.js" must resolve via paths to real/Widget.js, + # NOT to /@mods/Widget.js. + _write(tmp_path / "jsconfig.json", + '{\n "compilerOptions": {\n' + ' "baseUrl": "app/javascript",\n' + ' "paths": { "@mods/*": ["real/*"] }\n' + ' }\n}\n') + # paths targets are relative to baseUrl (#1269), so "real/*" is + # /real/*. + real = _write(tmp_path / "app/javascript" / "real" / "Widget.js", + "export default 1;\n") + _write(tmp_path / "app/javascript" / "@mods" / "Widget.js", "export default 2;\n") + f = _write(tmp_path / "app/javascript" / "packs" / "d.js", + "import W from '@mods/Widget.js';\nexport default W;\n") + r = extract([f], cache_root=tmp_path) + targets = _targets(r) + assert _make_id(str(real)) in targets + assert _make_id(str(tmp_path / "app/javascript" / "@mods" / "Widget.js")) not in targets + + +def test_declared_directory_prefix_alias_still_wins(tmp_path): + # A non-wildcard alias used as a directory prefix scores WORSE than a + # wildcard in _match_tsconfig_alias, so a naive implicit "*" alias would + # shadow it. The fallback must not. + _write(tmp_path / "jsconfig.json", + '{\n "compilerOptions": {\n' + ' "baseUrl": "app/javascript",\n' + ' "paths": { "@lib": ["src/lib"] }\n' + ' }\n}\n') + real = _write(tmp_path / "app/javascript" / "src" / "lib" / "util.js", + "export default 1;\n") + _write(tmp_path / "app/javascript" / "@lib" / "util.js", "export default 2;\n") + f = _write(tmp_path / "app/javascript" / "packs" / "p.js", + "import u from '@lib/util.js';\nexport default u;\n") + r = extract([f], cache_root=tmp_path) + assert _make_id(str(real)) in _targets(r) + + +def test_tsconfig_paths_alias_unchanged(tmp_path): + # Pre-existing #1269 behavior, green before and after this change: a + # tsconfig paths alias resolves relative to baseUrl. + _write(tmp_path / "tsconfig.json", + '{\n "compilerOptions": {\n' + ' "baseUrl": "./src",\n' + ' "paths": { "@services/*": ["services/*"] }\n' + ' }\n}\n') + svc = _write(tmp_path / "src" / "services" / "api.ts", "export const a = 1;\n") + f = _write(tmp_path / "src" / "app" / "main.ts", + "import { a } from '@services/api';\nexport default a;\n") + r = extract([f], cache_root=tmp_path) + assert _make_id(str(svc)) in _targets(r) + + +def test_external_package_not_fabricated_under_baseurl(tmp_path): + # baseUrl must not turn a real npm import into a phantom /react + # edge; the fallback only returns paths that exist on disk. + _write(tmp_path / "jsconfig.json", + '{\n "compilerOptions": { "baseUrl": "app/javascript" }\n}\n') + f = _write(tmp_path / "app/javascript" / "packs" / "p.js", + "import React from 'react';\nexport default React;\n") + r = extract([f], cache_root=tmp_path) + assert _make_id(str(tmp_path / "app/javascript" / "react")) not in _targets(r) + + +def test_relative_import_unaffected_by_baseurl(tmp_path): + _write(tmp_path / "jsconfig.json", + '{\n "compilerOptions": { "baseUrl": "app/javascript" }\n}\n') + local = _write(tmp_path / "app/javascript" / "packs" / "Local.js", + "export default 1;\n") + f = _write(tmp_path / "app/javascript" / "packs" / "main.js", + "import L from './Local.js';\nexport default L;\n") + r = extract([f], cache_root=tmp_path) + assert _make_id(str(local)) in _targets(r) + + +def test_no_baseurl_declared_changes_nothing(tmp_path): + # Without baseUrl there is no fallback root, so a bare specifier stays + # external and must not resolve to a file under the config dir. + _write(tmp_path / "jsconfig.json", '{\n "compilerOptions": {}\n}\n') + _write(tmp_path / "mods" / "Widget.js", "export default 1;\n") + f = _write(tmp_path / "packs" / "d.js", + "import W from 'mods/Widget.js';\nexport default W;\n") + r = extract([f], cache_root=tmp_path) + assert _make_id(str(tmp_path / "mods" / "Widget.js")) not in _targets(r) + + +def test_tsconfig_wins_when_both_configs_present(tmp_path): + # tsconfig.json is the authoritative config when both exist (tsc/VS Code + # only fall back to jsconfig.json when there is no tsconfig.json). + _write(tmp_path / "tsconfig.json", + '{\n "compilerOptions": { "baseUrl": "ts_root" }\n}\n') + _write(tmp_path / "jsconfig.json", + '{\n "compilerOptions": { "baseUrl": "js_root" }\n}\n') + ts_hit = _write(tmp_path / "ts_root" / "mods" / "W.js", "export default 1;\n") + _write(tmp_path / "js_root" / "mods" / "W.js", "export default 2;\n") + f = _write(tmp_path / "ts_root" / "packs" / "d.js", + "import W from 'mods/W.js';\nexport default W;\n") + r = extract([f], cache_root=tmp_path) + targets = _targets(r) + assert _make_id(str(ts_hit)) in targets + assert _make_id(str(tmp_path / "js_root" / "mods" / "W.js")) not in targets