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.
This commit is contained in:
balloon72
2026-06-17 17:29:47 +08:00
committed by GitHub
parent d885833112
commit 9a7dbfbb84
2 changed files with 111 additions and 3 deletions
+38 -3
View File
@@ -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"
+73
View File
@@ -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",