From a8a81e5c3523ec92a5ce2bae23c800a552c57c8d Mon Sep 17 00:00:00 2001 From: Pavel Kudinov Date: Sun, 14 Jun 2026 03:50:53 -0700 Subject: [PATCH] fix: resolve package subpath exports --- graphify/extract.py | 76 ++++++++++++++++---- tests/test_js_import_resolution.py | 109 +++++++++++++++++++++++++++-- 2 files changed, 166 insertions(+), 19 deletions(-) diff --git a/graphify/extract.py b/graphify/extract.py index 008c150b0..622e50a27 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -383,6 +383,61 @@ def _load_workspace_packages(start_dir: Path) -> dict[str, Path]: return packages +_PACKAGE_EXPORT_CONDITION_PRIORITY = ("source", "import", "module", "default", "require", "types", "svelte") + + +def _package_export_target_values(value: Any) -> list[str]: + if isinstance(value, str): + return [value] + if isinstance(value, list): + targets: list[str] = [] + for item in value: + targets.extend(_package_export_target_values(item)) + return targets + if not isinstance(value, dict): + return [] + + targets: list[str] = [] + for key in _PACKAGE_EXPORT_CONDITION_PRIORITY: + targets.extend(_package_export_target_values(value.get(key))) + for key, child in value.items(): + if key not in _PACKAGE_EXPORT_CONDITION_PRIORITY: + targets.extend(_package_export_target_values(child)) + return targets + + +def _package_exports_candidates(exports: Any, subpath: str) -> list[Path]: + if isinstance(exports, str): + return [Path(exports)] if not subpath else [] + if not isinstance(exports, dict): + return [] + + requested = "." if not subpath else f"./{subpath}" + candidates: list[Path] = [] + + exact_export = exports.get(requested) + for target in _package_export_target_values(exact_export): + candidates.append(Path(target)) + + if subpath: + for export_key, export_value in exports.items(): + if "*" not in export_key: + continue + prefix, suffix = export_key.split("*", 1) + if not requested.startswith(prefix) or (suffix and not requested.endswith(suffix)): + continue + replacement_end = len(requested) - len(suffix) if suffix else len(requested) + replacement = requested[len(prefix):replacement_end] + for target in _package_export_target_values(export_value): + candidates.append(Path(target.replace("*", replacement))) + + if not subpath and not candidates: + for target in _package_export_target_values(exports): + candidates.append(Path(target)) + + return candidates + + def _package_entry_candidates(package_dir: Path, subpath: str) -> list[Path]: manifest = package_dir / "package.json" manifest_data: dict[str, Any] = {} @@ -391,23 +446,14 @@ def _package_entry_candidates(package_dir: Path, subpath: str) -> list[Path]: except Exception: pass - if subpath: - return [package_dir / subpath] - + candidates: list[Path] = [] exports = manifest_data.get("exports") - if isinstance(exports, str): - return [package_dir / exports] - if isinstance(exports, dict): - dot_export = exports.get(".") - if isinstance(dot_export, str): - return [package_dir / dot_export] - if isinstance(dot_export, dict): - for key in ("types", "import", "default", "svelte"): - value = dot_export.get(key) - if isinstance(value, str): - return [package_dir / value] + candidates.extend(package_dir / candidate for candidate in _package_exports_candidates(exports, subpath)) + + if subpath: + candidates.append(package_dir / subpath) + return candidates - candidates: list[Path] = [] for key in ("svelte", "module", "main", "types"): value = manifest_data.get(key) if isinstance(value, str): diff --git a/tests/test_js_import_resolution.py b/tests/test_js_import_resolution.py index 5aa44e895..44e97a07d 100644 --- a/tests/test_js_import_resolution.py +++ b/tests/test_js_import_resolution.py @@ -506,14 +506,14 @@ def test_yarn_workspace_package_import_resolves_package_entry(tmp_path: Path): def test_pnpm_workspace_takes_precedence_over_package_json_workspaces(tmp_path: Path): - _write( - tmp_path / "pnpm-workspace.yaml", - "packages:\n - 'apps/*'\n - 'packages/*'\n", - ) _write( tmp_path / "package.json", json.dumps({"workspaces": ["other/*"]}), ) + _write( + tmp_path / "pnpm-workspace.yaml", + "packages:\n - 'apps/*'\n - 'packages/*'\n", + ) _write( tmp_path / "packages/types/package.json", json.dumps({"name": "@workspace/types", "exports": "./src/index.ts"}), @@ -532,6 +532,107 @@ def test_pnpm_workspace_takes_precedence_over_package_json_workspaces(tmp_path: assert _has_edge(result, "apps/web/src/page.ts", "packages/types/src/index.ts") +def test_pnpm_workspace_package_subpath_import_resolves_export_source_condition(tmp_path: Path): + _write( + tmp_path / "pnpm-workspace.yaml", + "packages:\n - 'apps/*'\n - 'packages/*'\n", + ) + _write( + tmp_path / "packages/kit/package.json", + json.dumps( + { + "name": "@workspace/kit", + "exports": { + "./browser": { + "source": "./src/browser.ts", + "import": "./dist/browser.js", + "types": "./dist/browser.d.ts", + } + }, + } + ), + ) + target = _write( + tmp_path / "packages/kit/src/browser.ts", + "export interface BrowserDto { id: string }\n", + ) + importer = _write( + tmp_path / "apps/web/src/page.ts", + "import type { BrowserDto } from '@workspace/kit/browser'\nconst dto: BrowserDto = { id: '1' }\n", + ) + + result = _extract_for([target, importer], tmp_path) + + assert _has_edge(result, "apps/web/src/page.ts", "packages/kit/src/browser.ts") + + +def test_pnpm_workspace_package_subpath_import_resolves_export_array_form_targets(tmp_path: Path): + _write( + tmp_path / "pnpm-workspace.yaml", + "packages:\n - 'apps/*'\n - 'packages/*'\n", + ) + _write( + tmp_path / "packages/kit/package.json", + json.dumps( + { + "name": "@workspace/kit", + "exports": { + "./browser": [ + "./src/browser.ts", + "./dist/browser.js", + ] + }, + } + ), + ) + target = _write( + tmp_path / "packages/kit/src/browser.ts", + "export interface BrowserDto { id: string }\n", + ) + importer = _write( + tmp_path / "apps/web/src/page.ts", + "import type { BrowserDto } from '@workspace/kit/browser'\nconst dto: BrowserDto = { id: '1' }\n", + ) + + result = _extract_for([target, importer], tmp_path) + + assert _has_edge(result, "apps/web/src/page.ts", "packages/kit/src/browser.ts") + + +def test_pnpm_workspace_package_subpath_import_resolves_wildcard_export(tmp_path: Path): + _write( + tmp_path / "pnpm-workspace.yaml", + "packages:\n - 'apps/*'\n - 'packages/*'\n", + ) + _write( + tmp_path / "packages/db/package.json", + json.dumps( + { + "name": "@workspace/db", + "exports": { + "./server/queries/*": { + "source": "./server/queries/*.ts", + "import": "./dist/server/queries/*.js", + "types": "./dist/server/queries/*.d.ts", + } + }, + } + ), + ) + target = _write( + tmp_path / "packages/db/server/queries/wallets.ts", + "export interface WalletQuery { id: string }\n", + ) + importer = _write( + tmp_path / "apps/api/src/route.ts", + "import type { WalletQuery } from '@workspace/db/server/queries/wallets'\nconst query: WalletQuery = { id: '1' }\n", + ) + + result = _extract_for([target, importer], tmp_path) + + assert _has_edge(result, "apps/api/src/route.ts", "packages/db/server/queries/wallets.ts") + + def test_js_import_resolution_ignores_stale_importer_cache_when_target_appears(tmp_path: Path): importer = _write( tmp_path / "src/lib/page.ts",