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",