Skip to content

test(exports): check every implementation key and cross-sibling condition order - #753

Merged
sroussey merged 1 commit into
claude/wonderful-turing-rjtcnx-ai-typesfrom
claude/exports-guard-order-and-multi-impl
Aug 13, 2026
Merged

test(exports): check every implementation key and cross-sibling condition order#753
sroussey merged 1 commit into
claude/wonderful-turing-rjtcnx-ai-typesfrom
claude/exports-guard-order-and-multi-impl

Conversation

@sroussey

@sroussey sroussey commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Based on #717 (retarget to main once that merges). Both branches rebased onto origin/main (67bed681). Deliberately not combined with the manifest changes already on #717 — those are verified correct and this is purely about the guard that protects them.

Rebase note

#717 was 94 commits behind. It rebased onto current main with zero conflicts (4 replayed commits), confirming the earlier merge-preview prediction — including that main had independently landed the byte-identical ./worker types change, which git resolved silently. This branch was then rebased onto the updated #717. The guard suite was re-run against the post-rebase manifest set, which matters because main moved 94 commits: 20 passed, with the new workspace-wide order check finding 0 violations across today's real manifests.

What

Two coverage gaps in ExportTypesPairing.test.ts. Both are latent: no workspace manifest violates either rule today, which is precisely why neither was noticed.

1. Only the first implementation key per object was examined. IMPLEMENTATION_KEYS.find(...) stopped at the first match, and the recursion continued past string-valued implementation keys. So a flat dual-package object —

{ "types": "./dist/a.d.ts", "import": "./dist/a.js", "require": "./dist/a.cjs" }

— had require checked by nothing at all. A .cjs implementation declared by a .d.ts (which TypeScript does not honour: .cjs wants .d.cts) passes silently. The file's own .cjs fixtures use the nested require: {types, default} form, which is exactly what kept the flat shape from ever being exercised.

2. Condition order across sibling keys was never checked. typesBeforeImplementation compares indices within one object. A map whose branches are each internally well formed but ordered wrongly passes everything:

{ "types": "./dist/node.d.ts",
  "browser": { "types": "./dist/browser.d.ts", "import": "./dist/browser.js" },
  "import": "./dist/node.js" }

Every types here names the right target beside the right implementation, so violations is [] and late is [] — while TypeScript matches the outer types first and never looks at browser. That is the browser-typed-as-node bug this file exists to prevent, expressed through ordering instead of a wrong target. {import, browser: {…}} is the runtime equivalent: resolution stops at import, so the browser build never loads.

Why this fix

MEDIUM-2 landed first within the PR because it changes label(), which MEDIUM-1's fixtures quote.

Collecting every string-valued implementation key means one object can now yield several branches, which would collide in label() — the key for both ALLOWED_MISMATCHES and the staleness check. So implementationKey joins the branch identity and the label reads [condition > key]. The string-shorthand form keeps its bare [condition]: there the condition's value is the implementation, so there is no key to name, and typing it string | undefined says that rather than inventing one. ALLOWED_MISMATCHES ships empty, so no allowlist migration — only failure-message text changes.

orderViolations walks each subpath object recursively and reports any key that is neither types nor a string-valued implementation key when a types or an implementation string precedes it. The string-shorthand form is included (browser: "./dist/x.js" after types): it is just as dead as the object form, and the file's existing fixtures prove this repo writes that shape.

Tests

20 pass (was 15, then 13 on the rebased base). New fixtures:

fixture pre-fix
flat dual-package {types, import, require} → one message naming require, ./dist/a.cjs, expected ./dist/a.d.cts fails (returns [])
{types, import, default} where both agree → [] (extra branches must not become noise) passes
outer-types-before-nested-browser → reported, and findViolations asserted [] explicitly fails
{import, browser:{…}} → reported fails
string shorthand after types → reported (both reasons) fails
correct order {browser:{…}, types, import}[] passes

Plus a new workspace-wide assertion: every manifest must declare each condition before the siblings that would shadow it.

The findViolations(...) === [] assertion inside the order fixture is deliberate. It documents why a second check is needed and is the permanent, inline demonstration that the existing rule is blind to this shape.

Actually executed (this is the part the plan could only reason about):

  • Full suite on the rebased branch: 20 passed, including the workspace-wide order check — so 0 order violations across today's real manifests, confirming the check is safe to add with no allowlist entry. Condition keys in use are browser, bun, react-native.
  • Simulating the old single-key collection (.slice(0, 1)): 1 failed / 19 passed — the flat dual-package fixture.
  • Stubbing orderViolations to return []: 3 failed / 17 passed — exactly the three positive order fixtures.
  • Updated the two existing fixtures whose expected strings carry the new label ([browser > import], [require > default]); the > 50 branch-count floor still holds, since branch count grows only where a second implementation key exists — nowhere today.
  • prettier --check clean.

Risk / blast radius

Test-only. Nothing ships to consumers. No manifest is modified by this PR.

Two things a reviewer must accept:

  1. Failure-message text changes for any branch whose implementation came from an object key — [browser] becomes [browser > import]. Harmless today (ALLOWED_MISMATCHES is empty) but it would invalidate allowlist entries if any existed, so future entries must use the new form.
  2. The guard is now stricter. A manifest added later with a flat dual-package map, or with a condition ordered after types, fails CI where it previously passed. That is the intent.

Unverified

  • The suite was run under Node v22.22.2, not the Node 24 the repo asks for, and in use-source mode. This file only reads package.json files off disk, so neither should matter.
  • Dropped from the plan, deliberately: the LOW finding that the > 50 vacuity floor cannot detect a whole workspace GROUP disappearing (workspaceManifests continues on a missing group dir; workspaceRoot drops any workspaces glob still containing * after the /** strip). It is real, but it is not a rider on either fix here. The one-line follow-up is to assert a non-zero count per group.

@sroussey
sroussey force-pushed the claude/wonderful-turing-rjtcnx-ai-types branch from d86050a to f8a9718 Compare August 13, 2026 03:55
Two coverage gaps in the exports guard. Both are latent — no workspace manifest
violates either rule today (168 branches across 38 manifests, 0 new violations),
which is exactly why they went unnoticed.

Only the first implementation key per object was examined.
`IMPLEMENTATION_KEYS.find(...)` stopped at `import`, and the recursion skipped
string-valued implementation keys, so a flat dual-package object
`{types, import: "./a.js", require: "./a.cjs"}` never had its `require` paired
against anything — a `.cjs` declared by a `.d.ts` sailed through. The file's own
`.cjs` fixtures use the nested `require: {types, default}` form, which is what
hid it. Now every string-valued implementation key yields a branch.

That makes one object produce several branches, which would collide in `label()`
— the key for ALLOWED_MISMATCHES and the staleness check — so the
implementation key joins the branch identity and the label reads
`[condition > key]`. The shorthand form keeps its bare `[condition]`: its value
IS the implementation, so there is no key to name. ALLOWED_MISMATCHES ships
empty, so no allowlist migration is needed; only failure text changes.

Condition ORDER across sibling keys was never checked. `typesBeforeImplementation`
compares indices within one object, so a map whose branches are each internally
well formed but ordered `{types, browser: {…}, import}` passes every existing
check while TypeScript matches the outer `types` and never looks at `browser` —
the browser-typed-as-node bug this file exists to prevent, expressed through
ordering rather than through a wrong target. `{import, browser: {…}}` is the
runtime equivalent. Adds `orderViolations`, covering object and string-shorthand
condition keys alike, and asserts it over every workspace manifest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H797qbH356jjznKgUax63o
@sroussey
sroussey force-pushed the claude/exports-guard-order-and-multi-impl branch from f04ccc5 to 422fe8c Compare August 13, 2026 03:55
@sroussey
sroussey merged commit c32a28f into claude/wonderful-turing-rjtcnx-ai-types Aug 13, 2026
10 of 11 checks passed
@sroussey
sroussey deleted the claude/exports-guard-order-and-multi-impl branch August 13, 2026 05:01
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.

2 participants