Skip to content
Open
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
76 changes: 61 additions & 15 deletions graphify/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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] = {}
Expand All @@ -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):
Expand Down
109 changes: 105 additions & 4 deletions tests/test_js_import_resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}),
Expand All @@ -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",
Expand Down
Loading