From e8dabadeb070c822f1e1794cc09e7573678706c0 Mon Sep 17 00:00:00 2001 From: guy oron Date: Mon, 29 Jun 2026 13:57:16 +0100 Subject: [PATCH] feat(imports): resolve workspace subpath exports via package.json exports map (#1308) Workspace imports with subpath exports (e.g. `import { x } from "@scope/pkg/browser"`) now resolve through the package's `exports` map instead of falling back to a bare path. Supports string values, condition objects, nested conditions, and single-`*` wildcard patterns (`"./*": "./src/*.js"`), falling back to the existing bare-path/index resolution when there is no exports map or no match. Adapted from #1541, taking only the exports-map resolver and not that PR's competing import-node-ID normalization (current v8 already resolves the node-ID mismatch via the #1529 id-remap post-pass, and the PR's _file_stem approach regressed the relative-input alias case). Two hardening changes over the original: - `default` is consulted LAST in the condition priority (it is Node's catch-all), so a matching `import`/`module`/`svelte` condition wins. - Export targets that escape the package directory are rejected (`_contained_in_package`), so a malicious `exports` value like `"./x": "../../../etc/..."` cannot resolve to a file outside the package. Co-Authored-By: Claude Opus 4.8 (1M context) --- graphify/extract.py | 71 +++++++++-- tests/test_js_import_resolution.py | 186 +++++++++++++++++++++++++++++ 2 files changed, 249 insertions(+), 8 deletions(-) diff --git a/graphify/extract.py b/graphify/extract.py index 8ced6e0e..3ee27dd5 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -391,6 +391,42 @@ def _load_workspace_packages(start_dir: Path) -> dict[str, Path]: return packages +# Condition keys consulted when resolving an `exports` target, in priority +# order. `default` is Node's catch-all and must be consulted LAST so a more +# specific condition (source/import/module/etc.) wins when several match. +_EXPORT_CONDITION_PRIORITY = ( + "source", "import", "module", "svelte", "types", "require", "default", +) + + +def _resolve_export_target(value: Any) -> str | None: + """Resolve an `exports` map value (string or condition object) to a + relative target string, honouring _EXPORT_CONDITION_PRIORITY for objects + and recursing into nested condition objects.""" + if isinstance(value, str): + return value + if isinstance(value, dict): + for cond in _EXPORT_CONDITION_PRIORITY: + v = value.get(cond) + if isinstance(v, str): + return v + if isinstance(v, dict): + nested = _resolve_export_target(v) + if nested: + return nested + return None + + +def _contained_in_package(resolved: Path, package_dir: Path) -> bool: + """Guard against `exports` targets that escape the package directory + (e.g. "./evil": "../../../etc/passwd"). Only accept paths that stay + within package_dir after resolution.""" + try: + return resolved.resolve().is_relative_to(package_dir.resolve()) + except ValueError: + return False + + def _package_entry_candidates(package_dir: Path, subpath: str) -> list[Path]: manifest = package_dir / "package.json" manifest_data: dict[str, Any] = {} @@ -400,20 +436,39 @@ def _package_entry_candidates(package_dir: Path, subpath: str) -> list[Path]: pass if subpath: + # Consult the package's `exports` subpath map before the bare-path + # fallback (#1308): "./browser" -> conditions -> file, plus single + # wildcard "./*" patterns. Targets that escape the package dir are + # rejected; resolution then falls through to the bare path. + exports = manifest_data.get("exports") + if isinstance(exports, dict): + subpath_key = "./" + subpath + target = _resolve_export_target(exports.get(subpath_key)) + if target: + candidate = package_dir / target + if _contained_in_package(candidate, package_dir): + return [candidate] + else: + for pattern, pattern_value in exports.items(): + if "*" in pattern and pattern.count("*") == 1: + prefix, suffix = pattern.split("*", 1) + if (subpath_key.startswith(prefix) + and (not suffix or subpath_key.endswith(suffix))): + matched = subpath_key[len(prefix):len(subpath_key) - len(suffix) if suffix else None] + resolved = _resolve_export_target(pattern_value) + if resolved and "*" in resolved: + candidate = package_dir / resolved.replace("*", matched) + if _contained_in_package(candidate, package_dir): + return [candidate] return [package_dir / subpath] 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] + dot_target = _resolve_export_target(exports.get(".")) + if dot_target: + return [package_dir / dot_target] candidates: list[Path] = [] for key in ("svelte", "module", "main", "types"): diff --git a/tests/test_js_import_resolution.py b/tests/test_js_import_resolution.py index 80d4cbf2..231520e2 100644 --- a/tests/test_js_import_resolution.py +++ b/tests/test_js_import_resolution.py @@ -532,6 +532,192 @@ 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_workspace_subpath_export_string_resolves(tmp_path: Path): + _write( + tmp_path / "pnpm-workspace.yaml", + "packages:\n - 'apps/*'\n - 'packages/*'\n", + ) + _write( + tmp_path / "packages/pkg-a/package.json", + json.dumps({ + "name": "@example/pkg-a", + "exports": { + ".": "./src/index.ts", + "./browser": "./src/browser.ts", + }, + }), + ) + target = _write( + tmp_path / "packages/pkg-a/src/browser.ts", + 'export const value = "ok"\n', + ) + importer = _write( + tmp_path / "apps/web/src/consumer.ts", + "import { value } from '@example/pkg-a/browser'\nexport const v = value\n", + ) + + result = _extract_for([target, importer], tmp_path) + + assert _has_edge(result, "apps/web/src/consumer.ts", "packages/pkg-a/src/browser.ts") + + +def test_workspace_subpath_export_condition_object_resolves(tmp_path: Path): + _write( + tmp_path / "pnpm-workspace.yaml", + "packages:\n - 'apps/*'\n - 'packages/*'\n", + ) + _write( + tmp_path / "packages/pkg-a/package.json", + json.dumps({ + "name": "@example/pkg-a", + "exports": { + "./browser": { + "source": "./src/browser.ts", + "import": "./dist/esm/browser.js", + "require": "./dist/cjs/browser.js", + "types": "./dist/types/browser.d.ts", + }, + }, + }), + ) + target = _write( + tmp_path / "packages/pkg-a/src/browser.ts", + 'export const value = "ok"\n', + ) + importer = _write( + tmp_path / "apps/web/src/consumer.ts", + "import { value } from '@example/pkg-a/browser'\nexport const v = value\n", + ) + + result = _extract_for([target, importer], tmp_path) + + assert _has_edge(result, "apps/web/src/consumer.ts", "packages/pkg-a/src/browser.ts") + + +def test_workspace_subpath_export_wildcard_resolves(tmp_path: Path): + _write( + tmp_path / "pnpm-workspace.yaml", + "packages:\n - 'apps/*'\n - 'packages/*'\n", + ) + _write( + tmp_path / "packages/pkg-a/package.json", + json.dumps({ + "name": "@example/pkg-a", + "exports": { + "./*": {"source": "./src/*.ts"}, + }, + }), + ) + target = _write( + tmp_path / "packages/pkg-a/src/utils.ts", + "export function add(a: number, b: number) { return a + b }\n", + ) + importer = _write( + tmp_path / "apps/web/src/consumer.ts", + "import { add } from '@example/pkg-a/utils'\nexport const sum = add(1, 2)\n", + ) + + result = _extract_for([target, importer], tmp_path) + + assert _has_edge(result, "apps/web/src/consumer.ts", "packages/pkg-a/src/utils.ts") + + +def test_workspace_subpath_export_falls_back_to_filesystem(tmp_path: Path): + _write( + tmp_path / "pnpm-workspace.yaml", + "packages:\n - 'apps/*'\n - 'packages/*'\n", + ) + _write( + tmp_path / "packages/pkg-a/package.json", + json.dumps({"name": "@example/pkg-a"}), + ) + target = _write( + tmp_path / "packages/pkg-a/browser.ts", + 'export const value = "ok"\n', + ) + importer = _write( + tmp_path / "apps/web/src/consumer.ts", + "import { value } from '@example/pkg-a/browser'\nexport const v = value\n", + ) + + result = _extract_for([target, importer], tmp_path) + + assert _has_edge(result, "apps/web/src/consumer.ts", "packages/pkg-a/browser.ts") + + +def test_workspace_subpath_export_rejects_path_escape(tmp_path: Path): + # An exports target that escapes the package dir must NOT resolve to the + # outside path (path-containment security guard). Resolution falls through + # to the bare-path fallback, which has no real file here, so no edge lands + # on the escaped target. + _write( + tmp_path / "pnpm-workspace.yaml", + "packages:\n - 'apps/*'\n - 'packages/*'\n", + ) + _write( + tmp_path / "packages/pkg-a/package.json", + json.dumps({ + "name": "@example/pkg-a", + "exports": { + "./evil": "../../../../secret.ts", + }, + }), + ) + # A real file outside the package that the malicious export points at. + outside = _write( + tmp_path / "secret.ts", + 'export const leak = "secret"\n', + ) + importer = _write( + tmp_path / "apps/web/src/consumer.ts", + "import { leak } from '@example/pkg-a/evil'\nexport const v = leak\n", + ) + + result = _extract_for([outside, importer], tmp_path) + + # The import must NOT resolve to the escaped outside file. + assert not _has_edge(result, "apps/web/src/consumer.ts", "secret.ts") + + +def test_workspace_subpath_export_default_consulted_last(tmp_path: Path): + # When both `default` and an earlier condition match, the earlier + # condition (import) must win -- `default` is Node's catch-all. + _write( + tmp_path / "pnpm-workspace.yaml", + "packages:\n - 'apps/*'\n - 'packages/*'\n", + ) + _write( + tmp_path / "packages/pkg-a/package.json", + json.dumps({ + "name": "@example/pkg-a", + "exports": { + "./browser": { + "default": "./src/default-entry.ts", + "import": "./src/import-entry.ts", + }, + }, + }), + ) + import_entry = _write( + tmp_path / "packages/pkg-a/src/import-entry.ts", + 'export const value = "import"\n', + ) + default_entry = _write( + tmp_path / "packages/pkg-a/src/default-entry.ts", + 'export const value = "default"\n', + ) + importer = _write( + tmp_path / "apps/web/src/consumer.ts", + "import { value } from '@example/pkg-a/browser'\nexport const v = value\n", + ) + + result = _extract_for([import_entry, default_entry, importer], tmp_path) + + # `import` wins over `default`. + assert _has_edge(result, "apps/web/src/consumer.ts", "packages/pkg-a/src/import-entry.ts") + assert not _has_edge(result, "apps/web/src/consumer.ts", "packages/pkg-a/src/default-entry.ts") + + def test_js_import_resolution_ignores_stale_importer_cache_when_target_appears(tmp_path: Path): importer = _write( tmp_path / "src/lib/page.ts",