Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 38 additions & 3 deletions graphify/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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():
Expand All @@ -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"
Expand Down
73 changes: 73 additions & 0 deletions tests/test_js_import_resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading