From 336652779b61abe8ea922622422bbcd0dfbc6aac Mon Sep 17 00:00:00 2001 From: msotnikov Date: Tue, 26 May 2026 11:17:14 +0300 Subject: [PATCH] Fix: support array form of tsconfig `extends` (TypeScript 5.0) (#1017) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TS 5.0 allows `"extends": ["./a", "./b"]`. _read_tsconfig_aliases called `extends.startswith("@")` directly, so an array raised `AttributeError: 'list' object has no attribute 'startswith'`. _safe_extract caught it and skipped the WHOLE file — and since alias resolution fires on any path-aliased import, every TS file using `@/`-style imports in a repo with array-extends tsconfig was dropped, yielding an empty graph. Normalize `extends` to a list (str -> [str], list kept, else []), process entries in order (later overrides earlier, current config's paths override all parents per TS semantics), and skip scoped npm configs (`@...`) per entry as before. Adds regression test test_tsconfig_array_extends_alias_resolves_existing_ts_file. --- graphify/extract.py | 19 +++++++++++++++++-- tests/test_js_import_resolution.py | 26 ++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/graphify/extract.py b/graphify/extract.py index 44f37988..5fa47c02 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -190,9 +190,24 @@ def _read_tsconfig_aliases(tsconfig: Path, base_dir: Path, seen: set) -> dict[st return {} aliases: dict[str, str] = {} + # `extends` may be a string or, since TypeScript 5.0, an array of paths. + # For an array, parents are processed in order with later entries + # overriding earlier ones; the extending config (paths below) overrides + # all parents. Without the list branch, an array `extends` raised + # `AttributeError: 'list' object has no attribute 'startswith'`, which + # _safe_extract turned into a skip of the whole file. extends = data.get("extends") - if extends and not extends.startswith("@"): - extended_path = (base_dir / extends).resolve() + if isinstance(extends, str): + extends_list = [extends] + elif isinstance(extends, list): + extends_list = [e for e in extends if isinstance(e, str)] + else: + extends_list = [] + for ext in extends_list: + # Skip scoped npm package configs (e.g. @tsconfig/svelte) — not on disk. + if not ext or ext.startswith("@"): + continue + extended_path = (base_dir / ext).resolve() if not extended_path.suffix: extended_path = extended_path.with_suffix(".json") if extended_path.exists(): diff --git a/tests/test_js_import_resolution.py b/tests/test_js_import_resolution.py index 5a9d1d7b..e72cbaac 100644 --- a/tests/test_js_import_resolution.py +++ b/tests/test_js_import_resolution.py @@ -334,6 +334,32 @@ def test_tsconfig_alias_import_resolves_existing_ts_file(tmp_path: Path): assert _has_edge(result, "src/routes/page.ts", "src/lib/types/type-helpers.ts") +def test_tsconfig_array_extends_alias_resolves_existing_ts_file(tmp_path: Path): + # TypeScript 5.0 allows `extends` as an array; later entries override + # earlier ones. The `paths` alias is inherited from the second parent. + # Regression: an array `extends` previously raised + # `AttributeError: 'list' object has no attribute 'startswith'`, which + # _safe_extract turned into a skip of every file using the alias. + _write(tmp_path / "tsconfig.base.json", json.dumps({"compilerOptions": {"strict": True}})) + _write( + tmp_path / "tsconfig.paths.json", + json.dumps({"compilerOptions": {"baseUrl": ".", "paths": {"$lib/*": ["src/lib/*"]}}}), + ) + _write( + tmp_path / "tsconfig.json", + json.dumps({"extends": ["./tsconfig.base.json", "./tsconfig.paths.json"]}), + ) + target = _write(tmp_path / "src/lib/types/type-helpers.ts", "export type Helper = string\n") + importer = _write( + tmp_path / "src/routes/page.ts", + "import type { Helper } from '$lib/types/type-helpers'\nconst value: Helper = 'x'\n", + ) + + result = _extract_for([target, importer], tmp_path) + + assert _has_edge(result, "src/routes/page.ts", "src/lib/types/type-helpers.ts") + + def test_pnpm_workspace_package_import_resolves_package_entry(tmp_path: Path): _write( tmp_path / "pnpm-workspace.yaml",