From 9a7dbfbb84f5bf8cb954af71e484e3368f2a16a8 Mon Sep 17 00:00:00 2001 From: balloon72 <96562725+balloon72@users.noreply.github.com> Date: Wed, 17 Jun 2026 17:29:47 +0800 Subject: [PATCH] fix js workspace import resolution (#1352) Fixes #1334: detect npm/yarn package.json workspaces (array + yarn object form), pnpm precedence preserved. Reviewed: full suite 2087 passed, no regressions. Thanks @balloon72. --- graphify/extract.py | 41 +++++++++++++++-- tests/test_js_import_resolution.py | 73 ++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 3 deletions(-) diff --git a/graphify/extract.py b/graphify/extract.py index fa9b21ec..49dabdcf 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -99,6 +99,7 @@ def _file_node_id(rel_path: Path) -> str: _TSCONFIG_ALIAS_CACHE: dict[str, dict[str, str]] = {} _WORKSPACE_PACKAGE_CACHE: dict[str, dict[str, Path]] = {} +_WORKSPACE_MANIFEST_NAMES = ("pnpm-workspace.yaml", "package.json") _JS_CACHE_BYPASS_SUFFIXES = {".js", ".jsx", ".mjs", ".ts", ".tsx", ".vue", ".svelte"} _JS_RESOLVE_EXTS = (".ts", ".tsx", ".svelte", ".js", ".jsx", ".mjs") _JS_INDEX_FILES = ("index.ts", "index.tsx", "index.svelte", "index.js", "index.jsx", "index.mjs") @@ -299,10 +300,18 @@ def _find_workspace_root(start_dir: Path) -> Path | None: for candidate in [current, *current.parents]: if (candidate / "pnpm-workspace.yaml").exists(): return candidate + package_json = candidate / "package.json" + if package_json.is_file(): + try: + data = json.loads(package_json.read_text(encoding="utf-8")) + except Exception: + continue + if "workspaces" in data: + return candidate return None -def _workspace_globs(workspace_file: Path) -> list[str]: +def _pnpm_workspace_globs(workspace_file: Path) -> list[str]: globs: list[str] = [] in_packages = False for raw_line in workspace_file.read_text(encoding="utf-8", errors="replace").splitlines(): @@ -322,16 +331,42 @@ def _workspace_globs(workspace_file: Path) -> list[str]: return globs +def _workspace_globs(root: Path) -> list[str]: + pnpm_workspace = root / "pnpm-workspace.yaml" + if pnpm_workspace.exists(): + return _pnpm_workspace_globs(pnpm_workspace) + + package_json = root / "package.json" + try: + data = json.loads(package_json.read_text(encoding="utf-8")) + except Exception: + return [] + + workspaces = data.get("workspaces") + if isinstance(workspaces, list): + return [item for item in workspaces if isinstance(item, str) and not item.startswith("!")] + if isinstance(workspaces, dict): + packages = workspaces.get("packages") + if isinstance(packages, list): + return [item for item in packages if isinstance(item, str) and not item.startswith("!")] + return [] + + def _load_workspace_packages(start_dir: Path) -> dict[str, Path]: root = _find_workspace_root(start_dir) if root is None: return {} - key = str(root) + manifest_mtimes = tuple( + (name, (root / name).stat().st_mtime_ns) + for name in _WORKSPACE_MANIFEST_NAMES + if (root / name).is_file() + ) + key = str((root, manifest_mtimes)) if key in _WORKSPACE_PACKAGE_CACHE: return _WORKSPACE_PACKAGE_CACHE[key] packages: dict[str, Path] = {} - for pattern in _workspace_globs(root / "pnpm-workspace.yaml"): + for pattern in _workspace_globs(root): package_dirs: list[Path] = [root] if pattern in (".", "./") else list(root.glob(pattern)) for package_dir in package_dirs: manifest = package_dir / "package.json" diff --git a/tests/test_js_import_resolution.py b/tests/test_js_import_resolution.py index 9a6d8742..5aa44e89 100644 --- a/tests/test_js_import_resolution.py +++ b/tests/test_js_import_resolution.py @@ -459,6 +459,79 @@ def test_pnpm_workspace_package_import_resolves_package_entry(tmp_path: Path): assert _has_edge(result, "apps/web/src/page.ts", "packages/types/src/index.ts") +def test_npm_workspace_package_import_resolves_package_entry(tmp_path: Path): + _write( + tmp_path / "package.json", + json.dumps({"workspaces": ["apps/*", "packages/*"]}), + ) + _write( + tmp_path / "packages/types/package.json", + json.dumps({"name": "@workspace/types", "exports": "./src/index.ts"}), + ) + target = _write( + tmp_path / "packages/types/src/index.ts", + "export interface SomeDto { id: string }\n", + ) + importer = _write( + tmp_path / "apps/web/src/page.ts", + "import type { SomeDto } from '@workspace/types'\nconst dto: SomeDto = { id: '1' }\n", + ) + + result = _extract_for([target, importer], tmp_path) + + assert _has_edge(result, "apps/web/src/page.ts", "packages/types/src/index.ts") + + +def test_yarn_workspace_package_import_resolves_package_entry(tmp_path: Path): + _write( + tmp_path / "package.json", + json.dumps({"workspaces": {"packages": ["apps/*", "packages/*"]}}), + ) + _write( + tmp_path / "packages/types/package.json", + json.dumps({"name": "@workspace/types", "exports": "./src/index.ts"}), + ) + target = _write( + tmp_path / "packages/types/src/index.ts", + "export interface SomeDto { id: string }\n", + ) + importer = _write( + tmp_path / "apps/web/src/page.ts", + "import type { SomeDto } from '@workspace/types'\nconst dto: SomeDto = { id: '1' }\n", + ) + + result = _extract_for([target, importer], tmp_path) + + assert _has_edge(result, "apps/web/src/page.ts", "packages/types/src/index.ts") + + +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 / "packages/types/package.json", + json.dumps({"name": "@workspace/types", "exports": "./src/index.ts"}), + ) + target = _write( + tmp_path / "packages/types/src/index.ts", + "export interface SomeDto { id: string }\n", + ) + importer = _write( + tmp_path / "apps/web/src/page.ts", + "import type { SomeDto } from '@workspace/types'\nconst dto: SomeDto = { id: '1' }\n", + ) + + result = _extract_for([target, importer], tmp_path) + + assert _has_edge(result, "apps/web/src/page.ts", "packages/types/src/index.ts") + + def test_js_import_resolution_ignores_stale_importer_cache_when_target_appears(tmp_path: Path): importer = _write( tmp_path / "src/lib/page.ts",