Skip to content

test(exports): flag condition branches that declare no types at all - #722

Merged
sroussey merged 1 commit into
claude/wonderful-turing-rjtcnx-ai-typesfrom
claude/export-types-guard-p4d8qo
Aug 8, 2026
Merged

test(exports): flag condition branches that declare no types at all#722
sroussey merged 1 commit into
claude/wonderful-turing-rjtcnx-ai-typesfrom
claude/export-types-guard-p4d8qo

Conversation

@sroussey

@sroussey sroussey commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #717 — review that one first; this targets its branch, not main.

#717's manifest fix is correct and complete. All 25 provider/shim export branches are enumerated there, and re-scanning the merged tree finds 0 remaining types/implementation mismatches. Nothing about it needs changing. What this PR fixes is a hole in the guard it added, which lets the same defect back in through two manifest shapes the walk never records.

The hole

collectBranches recorded a branch only when the node was an object that already had a types string. So both of these are invisible to it:

// 1. string shorthand — returns at the `typeof node !== "object"` bail-out
{ ".": { "browser": "./dist/ai.browser.js",
         "types": "./dist/ai.node.d.ts", "import": "./dist/ai.node.js" } }

// 2. object with an implementation but no `types` — walked past
{ ".": { "browser": { "import": "./dist/ai.browser.js" },
         "types": "./dist/ai.node.d.ts", "import": "./dist/ai.node.js" } }

In both, a browser consumer resolves the browser implementation and then falls through to the outer types, so it is type-checked against the node build — exactly the defect #717 fixes for @workglow/openai/ai and friends — while the suite stays green.

The rule is now: a branch that names an implementation must declare the declaration beside it, whether it declares the wrong one or none at all. Both kinds feed the single mismatched array, so the assertion and the ALLOWED_MISMATCHES semantics are unchanged; Branch becomes a discriminated union on reason: "mismatch" | "missing" with types: string | undefined, and the missing case reads:

providers/x/package.json exports["./ai"] [browser]: implementation "./dist/ai.browser.js" declares no types (expected types="./dist/ai.browser.d.ts")

The module-path test (/\.[cm]?js$/) is what keeps "./package.json": "./package.json" out of the missing bucket — asserted by a fixture rather than assumed.

declarationFor derived the wrong extension for .cjs/.mjs

It mapped /\.[cm]?js$/ → ".d.ts", so a correct node16 manifest pairing "require": "./dist/x.cjs" with "types": "./dist/x.d.cts" would have failed the guard and pushed the author toward the allowlist or toward an extension TypeScript will not honor. .cjs.d.cts, .mjs.d.mts, .js.d.ts. No manifest uses .cjs/.mjs today — pre-emptive.

The walk skipped nested implementation keys

While pinning the .cjs case it became clear the walk skipped import/require/default wholesale during recursion, so the standard dual-package form { "import": { "types": …, "default": … } } was never checked at all — the same class of hole as the two above. It is now recursed into when the value is an object (a string there is still this branch's own implementation, already recorded). This is what makes the .cjs fixture a real assertion rather than a vacuous one. No manifest uses that form today, so the repo scan is unaffected.

The guard needed its own guard

Since no manifest violates any of these rules, every new code path would have shipped untested. The walk is extracted into findViolations(manifest, exportsMap), used by both the repo scan and five fixtures:

fixture expected
browser string shorthand 1 violation naming ai.browser
browser: { import: … }, no types 1 violation naming ai.browser
.cjs/.d.cts + .mjs/.d.mts none
.cjs declared by a .d.ts 1 violation, expects .d.cts
"./package.json": "./package.json" none

findViolations takes the manifest path as a first parameter rather than the one-argument form, because the violation message is labelled with it at every call site and defaulting it would make the repo scan's labels depend on an omitted argument.

Demonstrating the hole is real. Reverting collectBranches and declarationFor to their pre-fix bodies while keeping the fixtures, 4 of the 5 fail and the 3 repo-scan tests keep passing — which is the point:

❯ packages/test/src/test/util/ExportTypesPairing.test.ts (8 tests | 4 failed)
AssertionError: expected [] to deeply equal [ Array(1) ]
-   "…[browser]: implementation \"./dist/ai.browser.js\" declares no types (expected types=\"./dist/ai.browser.d.ts\")"
AssertionError: expected [] to deeply equal [ Array(1) ]
-   "…[browser]: implementation \"./dist/ai.browser.js\" declares no types (expected types=\"./dist/ai.browser.d.ts\")"
AssertionError: expected [ Array(1) ] to deeply equal []
+   "…[(default)]: types=\"./dist/ai.d.mts\" but implementation is \"./dist/ai.mjs\" (expected types=\"./dist/ai.d.ts\")"
AssertionError: expected [ Array(1) ] to deeply equal [ Array(1) ]
-   "…[require]: types=\"./dist/ai.d.ts\" but implementation is \"./dist/ai.cjs\" (expected types=\"./dist/ai.d.cts\")"

With the fix in place, all 8 pass:

$ bunx vitest run packages/test/src/test/util/ExportTypesPairing.test.ts --reporter=verbose
 ✓ workspace exports maps > finds condition branches to check
 ✓ workspace exports maps > pairs every `types` target with the implementation beside it
 ✓ workspace exports maps > keeps the allowlist free of entries that no longer mismatch
 ✓ exports map violation detection > catches a string-shorthand branch that declares no types
 ✓ exports map violation detection > catches an object branch with an implementation but no types
 ✓ exports map violation detection > accepts a `.cjs`/`.mjs` implementation declared by its own extension
 ✓ exports map violation detection > rejects a `.cjs` implementation declared by a `.d.ts`
 ✓ exports map violation detection > ignores a non-module string value such as the package manifest itself
 Test Files  1 passed (1)
      Tests  8 passed (8)

The real repo scan still reports 0 — it must, since no manifest violates the new rule. An independent scan of every workspace manifest under the stricter walk agrees:

workspace groups derived from root globs: packages, providers, examples
manifest branches collected: 163
violations: 0

The branches.length > 50 vacuity guard is unchanged.

Two smaller cleanups

  • repoRoot used new URL(import.meta.url).pathname, which yields a wrong root for a percent-encoded path; it is now fileURLToPath. Note the sibling BunExportConditions.test.ts cited as the precedent no longer exists on this base — it was removed by Drop redundant bun entry points and export conditions #711 — but Cactus_BrowserBundles.test.ts and Cactus_ModelCatalog.test.ts in this package use fileURLToPath, so the convention still holds. The > 50 guard would have turned a bad root into a loud failure either way.
  • The workspace group list was hardcoded ["packages", "providers", "examples"], so a fourth group would have been dropped from the scan with no failure. It is now derived from the root manifest's own workspaces globs ("./packages/*"packages), which the walk already reads to locate the root. It is not derived from scripts/lib/util.ts#findWorkspaces: that function resolves ./package.json relative to process.cwd(), uses Bun's Glob, and filters to publishConfig.access === "public" — which would silently drop private manifests (examples/*, packages/test) from a guard whose whole job is to miss nothing.

Verification

  • bunx vitest run packages/test/src/test/util/ExportTypesPairing.test.ts — 8 passed (output above).
  • bun scripts/test.ts util vitest — 1409 passed, 20 skipped, 1 failed: CredentialStore > should provide a default in-memory store, which is pre-existing, unrelated, and was collected out of a stale .claude/worktrees/triggers-lifecycle checkout rather than this branch.
  • bunx tsgo --noEmit -p packages/test/tsconfig.json — 14 errors, all pre-existing: identical count on the base with the change stashed, and none in the edited file.
  • bunx eslint and bunx prettier --check on the edited file — clean.

Rebase observation (no action taken here)

#717's packages/workglow "./worker" hunk — types: "./dist/worker-browser.d.ts""./dist/worker-node.d.ts" — is already on origin/main, fixed independently after this branch's base. On merge it is a redundant no-op hunk. Flagging only; #717 was deliberately not rebased.


🤖 Generated with Claude Code

https://claude.ai/code/session_01K6huUY7hSkRbjun1P9HKsz


Generated by Claude Code

The exports-map guard only recorded a branch when the node was an object that
already had a `types` string, so the two shapes that trigger the very defect it
guards against slipped past it silently:

- `"browser": "./dist/ai.browser.js"` (string shorthand) returned at the
  `typeof node !== "object"` bail-out; and
- `"browser": { "import": "./dist/ai.browser.js" }` (object, no `types`) was
  walked past.

Either one resolves through to the OUTER `types`, type-checking browser
consumers against the node build — the mismatch this guard exists to catch —
with the suite green. A branch that names an implementation must now declare
the declaration beside it, whether it declares a wrong one or none at all; both
kinds feed the single `mismatched` array, so the assertion and the
`ALLOWED_MISMATCHES` semantics are unchanged.

`declarationFor` also derived the wrong extension for `.cjs`/`.mjs`: it mapped
both to `.d.ts`, so a correct node16 manifest pairing `"require":
"./dist/x.cjs"` with `"types": "./dist/x.d.cts"` would have failed and pushed
the author toward the allowlist or toward an extension TypeScript will not
honor. `.cjs` is described by `.d.cts` and `.mjs` by `.d.mts`. No manifest uses
either extension today; this is pre-emptive.

The walk also now descends into an implementation key holding an object — the
`{ "import": { "types": …, "default": … } }` dual-package form — which it
previously skipped wholesale, so that shape was unchecked for the same reason.

Because no manifest violates any of these rules, the new code paths would ship
untested. The walk is extracted into `findViolations(manifest, exportsMap)`,
used by both the repo scan and five fixtures that exercise each path directly:
the two shapes above (one violation each, naming `ai.browser`), a correct
`.cjs`/`.d.cts` and `.mjs`/`.d.mts` pairing (none), a `.cjs` declared by a
`.d.ts` (one), and `"./package.json": "./package.json"`, which the module-path
test keeps out of the missing-declaration bucket (none). The `> 50` vacuity
guard on the real scan is unchanged; the scan still reports 0 violations over
163 collected branches.

Two smaller cleanups: `repoRoot` used `new URL(import.meta.url).pathname`,
which yields a wrong root for a percent-encoded path, and is now
`fileURLToPath` like the other tests in this package; and the workspace group
list was hardcoded `["packages", "providers", "examples"]`, so a fourth group
would have been dropped from the scan with no failure — it is now derived from
the root manifest's own `workspaces` globs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6huUY7hSkRbjun1P9HKsz
@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 64.44% 31813 / 49363
🔵 Statements 64.25% 32915 / 51228
🔵 Functions 65.51% 5986 / 9137
🔵 Branches 53.46% 16416 / 30704
File CoverageNo changed files found.
Generated in workflow #2931 for commit eb310b6 by the Vitest Coverage Report Action

@sroussey
sroussey merged commit c302104 into claude/wonderful-turing-rjtcnx-ai-types Aug 8, 2026
10 checks passed
@sroussey
sroussey deleted the claude/export-types-guard-p4d8qo branch August 8, 2026 17:56
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