Skip to content

fix(imports): pick workspace exports by importer platform, runtime before types (#3487) - #3489

Open
dajiaohuang wants to merge 1 commit into
Graphify-Labs:v8from
dajiaohuang:fix/3487-exports-condition-platform
Open

fix(imports): pick workspace exports by importer platform, runtime before types (#3487)#3489
dajiaohuang wants to merge 1 commit into
Graphify-Labs:v8from
dajiaohuang:fix/3487-exports-condition-platform

Conversation

@dajiaohuang

@dajiaohuang dajiaohuang commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Fixes #3487.

What actually goes wrong

The exports map is consulted — #1308 added that, in e8dabad. The defect is in how a condition object is reduced to one target.

_EXPORT_CONDITION_PRIORITY shipped as:

("source", "import", "module", "svelte", "types", "require", "default")

types is a declaration-only condition and normally points into dist/, which is build output and not in the corpus. So for

"./components/Icon": {
  "types": "./dist/components/Icon.web.d.ts",
  "react-native": "./src/components/Icon.native.tsx",
  "default": "./src/components/Icon.web.tsx"
}

the resolver returned the dist path, found nothing on disk, and stopped. There was no fall-through, because _package_entry_candidates returned [candidate] as soon as any condition matched — so the default source sitting right beside it was never tried, and the import became a dangling ref_* external.

That is not the order the map was added with. The commit that introduced it says "default is consulted LAST … so a matching import/module/svelte condition wins", and #1308 specifies source -> import -> module -> default -> require -> types. The tuple put types ahead of require and default instead.

Two more gaps in the same path: react-native was never consulted, and no platform-suffixed sibling was ever tried, so "./controls/*": {"default": "./src/controls/*.tsx"} could not reach the real BottomNav.web.tsx / BottomNav.native.tsx.

The fix

  • types moves to the end of _EXPORT_CONDITION_PRIORITY, restoring the order TypeScript workspace imports do not resolve package subpath exports #1308 specified. A runtime target always beats a declaration. require/default keep their relative order, so the behaviour pinned by test_workspace_subpath_export_default_consulted_last is unchanged.
  • _resolve_export_targets keeps every condition target in preference order rather than returning the first, so a target that is not on disk falls through to the next condition instead of taking the import down with it.
  • react-native is honoured only for a native importer, so a web importer resolves the same specifier through default rather than being attributed a native file it never imports. This is a custom condition, so it is per-importer by nature.
  • Each target is tried alongside its platform-suffixed siblings (Icon.tsx → Icon.web.tsx / Icon.native.tsx), which is how a bundler reaches a platform split whose exports target names the unsuffixed path.
  • _resolve_export_target keeps its signature and semantics for the package.json imports path; platform expansion applies only to the workspace exports path, so relative imports elsewhere are untouched.

Behaviour notes for review

  • When every exports target escapes the package directory, resolution still falls through to the bare-path fallback exactly as before. A new test pins this — my first revision accidentally swallowed that fallback, and the containment guard made it reachable.
  • The "." branch now applies the same containment guard the subpath branch already applied. This is the only change beyond the reported defect, and it is a consistency fix: such a package previously resolved outside its own directory.
  • Importer platform is inferred from whole path segments (apps/mobile/src → native; packages/web-utils → none, since matching is segment-wide, not substring). It is best-effort by design: when nothing distinguishes the importer every platform keeps the same priority, so resolution stays deterministic rather than dropping the edge.

Verified

Measured through _resolve_js_module_path on the fixture from the issue, before and after:

Case Specifier Importer Before After
A @acme/ui/components/Icon ({types, react-native, default}) web unresolved Icon.web.tsx
B same native unresolved Icon.native.tsx
C @acme/ui2/controls/BottomNav (wildcard, target absent) web unresolved BottomNav.web.tsx
C2 same native unresolved BottomNav.native.tsx
D @acme/lib/util (exact subpath, target exists) web resolved resolved (control)
E @acme/root (bare root, "." exists) web resolved resolved (control)
F @acme/nodot (exports has no ".") web unresolved unresolved (control)

The controls were already correct before the change, which localises the defect to condition selection and fall-through rather than to a missing resolution path.

Seven tests added to tests/test_js_import_resolution.py, one per behaviour above plus locks on the ordering and the containment fall-through. Focused run: tests/test_js_import_resolution.py + tests/test_import_extension_resolution.py → 109 passed (PR #3488's tests still pass unchanged). ruff check clean. tools.skillgen CI-parity checks all OK.

Full suite, same commands CI runs:

before:  29 failed, 5500 passed, 44 skipped in 227.16s
after:   29 failed, 5507 passed, 44 skipped in 238.04s

Byte-identical failure sets — diff of the sorted FAILED lines is empty. The 29 are pre-existing and unrelated to this path (non-regular-file, watch and uninstall-scope tests on Windows). The +7 passing are the tests added here. graphify --help and graphify install both exit 0.

Note on #3487's reporter

@cbartens reports in the issue that they have this working and offered to contribute it. I could not tell from the thread whether maintainers want that, so I am putting this up as a concrete patch — happy to close it in favour of theirs, or to have it serve as a reference implementation. No maintainer has replied on the issue, and nothing here is claimed or assigned.

…fore types

The workspace `exports` resolver reduced a condition object to a single target,
so a package that splits its runtime source from its declarations never
produced a cross-package edge. Reported in Graphify-Labs#3487; the exports map itself is
present and works, the defect is in how one is chosen.

`_EXPORT_CONDITION_PRIORITY` listed `types` ahead of `require` and `default`.
`types` is declaration-only and normally points into `dist/`, which is build
output and not part of the corpus, so the resolver returned a path that is not
on disk and stopped there — `_package_entry_candidates` returned `[candidate]`
as soon as any condition matched, so the `default` source beside it was never
tried and the import became a dangling `ref_*` external.

That is not the order the map was added with. Graphify-Labs#1308 specifies
`source -> import -> module -> default -> require -> types`, and the commit
that implemented it says `default` is consulted LAST so a matching
`import`/`module`/`svelte` condition wins; the tuple shipped with `types` in
the middle instead.

Three changes, all on the workspace `exports` path:

- `types` moves to the end of `_EXPORT_CONDITION_PRIORITY`, restoring the
  specified order. `require`/`default` keep their relative order, so the
  behaviour pinned by `test_workspace_subpath_export_default_consulted_last`
  is unchanged.
- `_resolve_export_targets` keeps every condition target in preference order
  instead of returning the first, so a target that is not on disk falls
  through to the next condition rather than taking the import down with it.
- `react-native` is honoured only for an importer that is itself native, and
  each target is tried alongside its platform-suffixed siblings
  (`Icon.tsx` -> `Icon.web.tsx` / `Icon.native.tsx`), which is how a bundler
  reaches a platform split whose `exports` target names the unsuffixed path.
  Importer platform comes from whole path segments, so `apps/mobile/src` is
  native while `packages/web-utils` is not web.

`_resolve_export_target` keeps its signature and its semantics for the
`package.json` `imports` path, and platform expansion applies only to the
workspace `exports` path, so relative imports elsewhere are untouched.

Behaviour note for review: when every `exports` target escapes the package
directory, resolution still falls through to the bare-path fallback exactly as
before. The first revision of this change swallowed that fallback, which the
containment guard made reachable, and a test now pins it. The `"."` branch
additionally gains the containment guard its subpath sibling already had; that
is the only change here beyond the reported defect, and such a package
previously resolved outside its own directory.

Verified: same 29 pre-existing failures before and after, byte-identical
failure sets (`diff` clean; unrelated to this path — non-regular-file, watch
and uninstall-scope tests on Windows). Passing goes 5500 -> 5507, the seven
tests added here. The fixture from Graphify-Labs#3487 resolves its four previously-dropped
imports and leaves the three control cases unchanged; focused run 109 passed;
`ruff check` clean; all five `tools.skillgen` CI-parity checks OK.

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Graphify reviewed this change.

Looks safe to merge — no coupling regressions and no blocking issues, checked against the code graph (not a self-assessment).

Formal verification. 1 change(s) tested, no difference found (not proven).


Graphify review — findings

Reorders JS exports-map condition priority so types (which points into unbuilt dist/) is tried last, and has _resolve_export_targets keep every matching condition in preference order so an import resolves to whichever target is real source on disk instead of dropping the edge on the first missing one. Adds per-importer platform awareness: _importer_platform infers a platform from the importing directory's path segments, _platform_variants expands exports targets into platform-suffixed siblings (.web.tsx/.native.tsx) with the importer's own platform first, and react-native conditions are honoured only for native importers. Ambiguous importers fall back to a fixed platform order so resolution stays deterministic rather than producing no edge.

No blocking issues surfaced. 6 lower-confidence candidates did not survive cross-model review.

Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 1875 functions depend on the 234 functions this change touches.

Health — this change adds coupling hotspots:

  • new: extract() — 585 callers, 44 callees
  • new: _rebuild_code() — 115 callers, 51 callees
  • new: _extract_generic() — 18 callers, 26 callees
  • new: extract_js() — 85 callers, 4 callees
  • new: extract_xaml() — 19 callers, 17 callees
  • new: _resolve_js_module_path() — 34 callers, 9 callees
  • new: dispatch_command() — 2 callers, 124 callees
  • new: extract_objc() — 27 callers, 9 callees
  • …and 36 more — each is listed as a finding

Verification — 1875 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 876 function(s) in the blast radius were not formally verified this run

Test selection

Test selection

30 of 268 test file(s) selected (11%) via static blast radius.

  • tests/test_astro_extraction.py — impact
  • tests/test_build.py — impact
  • tests/test_cjs_module_extension.py — impact
  • tests/test_cpp_nested_and_cli.py — impact
  • tests/test_dotnet.py — impact
  • tests/test_extract.py — impact
  • tests/test_forwarding_review_findings.py — impact
  • tests/test_import_extension_resolution.py — impact
  • tests/test_indirect_dispatch.py — impact
  • tests/test_indirect_dispatch_assign_return.py — impact
  • tests/test_indirect_dispatch_getattr.py — impact
  • tests/test_js_exported_scalar_bindings.py — impact
  • tests/test_js_import_resolution.py — impact, changed-test
  • tests/test_languages.py — impact
  • tests/test_multilang.py — impact
  • tests/test_package_json_subpath_imports.py — impact
  • tests/test_pascal.py — impact
  • tests/test_pascal_resolution.py — impact
  • tests/test_phantom_external_import.py — impact
  • tests/test_python_import_resolution.py — impact
  • tests/test_python_underscore_resolution.py — impact
  • tests/test_rationale.py — impact
  • tests/test_ruby_resolution.py — impact
  • tests/test_scala_self_type.py — impact
  • tests/test_src_layout_import_resolution.py — impact
  • tests/test_swift_computed_properties.py — impact
  • tests/test_ts_new_expression_calls.py — impact
  • tests/test_typescript_module_extensions.py — impact
  • tests/test_unmapped_at_alias_resolution.py — impact
  • tests/test_vue_extraction.py — impact

Selection is safe under the controlled-regression assumption; always-run tests + a periodic full run are the backstops. Advisory — it never changes the check verdict.

Formal verification

Could not verify: Could not verify \_package\_entry\_candidates.

The verifier did not have enough to check \_package\_entry\_candidates, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `package_dir` is annotated `Path` — outside the synthesizable primitive/collection set

No difference found (not proven): No behavior difference found in \_resolve\_export\_target (not a proof).

The verifier ran both versions of \_resolve\_export\_target on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.

Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.

Note: An input the sampler did not try could still differ.

Could not verify: Could not verify \_resolve\_workspace\_import.

The verifier did not have enough to check \_resolve\_workspace\_import, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `start_dir` is annotated `Path` — outside the synthesizable primitive/collection set

· 44 more finding(s) on lines outside this diff (see the check run).

@dajiaohuang
dajiaohuang force-pushed the fix/3487-exports-condition-platform branch from 9a1978e to c704a5b Compare September 11, 2026 10:30

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Graphify reviewed this change.

Looks safe to merge — no coupling regressions and no blocking issues, checked against the code graph (not a self-assessment).

Formal verification. 1 change(s) tested, no difference found (not proven).


Graphify review — findings

Reorders JavaScript exports-map condition priority so types (which points into unbuilt dist/) loses to real runtime targets like require/default, and makes _resolve_export_targets return every matching target in preference order so an import falls through to the next condition when a target isn't on disk instead of dropping the edge entirely. Adds per-importer platform selection: _importer_platform infers a platform from whole path segments of the importer's directory, _platform_variants expands an exports target to platform-suffixed siblings (importer's platform first, deterministic order otherwise), and native-only conditions like react-native are honoured only for native importers. Preserves the existing _contained_in_package guard against targets escaping the package directory when generating candidates.

No blocking issues surfaced. 7 lower-confidence candidates did not survive cross-model review.

Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 1875 functions depend on the 234 functions this change touches.

Health — this change adds coupling hotspots:

  • new: extract() — 585 callers, 44 callees
  • new: _rebuild_code() — 115 callers, 51 callees
  • new: _extract_generic() — 18 callers, 26 callees
  • new: extract_js() — 85 callers, 4 callees
  • new: extract_xaml() — 19 callers, 17 callees
  • new: _resolve_js_module_path() — 34 callers, 9 callees
  • new: dispatch_command() — 2 callers, 124 callees
  • new: extract_objc() — 27 callers, 9 callees
  • …and 36 more — each is listed as a finding

Verification — 1875 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 876 function(s) in the blast radius were not formally verified this run

Test selection

Test selection

30 of 268 test file(s) selected (11%) via static blast radius.

  • tests/test_astro_extraction.py — impact
  • tests/test_build.py — impact
  • tests/test_cjs_module_extension.py — impact
  • tests/test_cpp_nested_and_cli.py — impact
  • tests/test_dotnet.py — impact
  • tests/test_extract.py — impact
  • tests/test_forwarding_review_findings.py — impact
  • tests/test_import_extension_resolution.py — impact
  • tests/test_indirect_dispatch.py — impact
  • tests/test_indirect_dispatch_assign_return.py — impact
  • tests/test_indirect_dispatch_getattr.py — impact
  • tests/test_js_exported_scalar_bindings.py — impact
  • tests/test_js_import_resolution.py — impact, changed-test
  • tests/test_languages.py — impact
  • tests/test_multilang.py — impact
  • tests/test_package_json_subpath_imports.py — impact
  • tests/test_pascal.py — impact
  • tests/test_pascal_resolution.py — impact
  • tests/test_phantom_external_import.py — impact
  • tests/test_python_import_resolution.py — impact
  • tests/test_python_underscore_resolution.py — impact
  • tests/test_rationale.py — impact
  • tests/test_ruby_resolution.py — impact
  • tests/test_scala_self_type.py — impact
  • tests/test_src_layout_import_resolution.py — impact
  • tests/test_swift_computed_properties.py — impact
  • tests/test_ts_new_expression_calls.py — impact
  • tests/test_typescript_module_extensions.py — impact
  • tests/test_unmapped_at_alias_resolution.py — impact
  • tests/test_vue_extraction.py — impact

Selection is safe under the controlled-regression assumption; always-run tests + a periodic full run are the backstops. Advisory — it never changes the check verdict.

Formal verification

Could not verify: Could not verify \_package\_entry\_candidates.

The verifier did not have enough to check \_package\_entry\_candidates, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `package_dir` is annotated `Path` — outside the synthesizable primitive/collection set

No difference found (not proven): No behavior difference found in \_resolve\_export\_target (not a proof).

The verifier ran both versions of \_resolve\_export\_target on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.

Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.

Note: An input the sampler did not try could still differ.

Could not verify: Could not verify \_resolve\_workspace\_import.

The verifier did not have enough to check \_resolve\_workspace\_import, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `start_dir` is annotated `Path` — outside the synthesizable primitive/collection set

· 44 more finding(s) on lines outside this diff (see the check run).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Monorepo: workspace subpath imports are not resolved through the target package's exports map, dropping cross-package edges

1 participant