test(exports): flag condition branches that declare no types at all - #722
Merged
sroussey merged 1 commit intoAug 8, 2026
Conversation
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
Coverage Report
File CoverageNo changed files found. |
sroussey
merged commit Aug 8, 2026
c302104
into
claude/wonderful-turing-rjtcnx-ai-types
10 checks passed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
collectBranchesrecorded a branch only when the node was an object that already had atypesstring. So both of these are invisible to it: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/aiand 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
mismatchedarray, so the assertion and theALLOWED_MISMATCHESsemantics are unchanged;Branchbecomes a discriminated union onreason: "mismatch" | "missing"withtypes: string | undefined, and the missing case reads: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.declarationForderived the wrong extension for.cjs/.mjsIt 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/.mjstoday — pre-emptive.The walk skipped nested implementation keys
While pinning the
.cjscase it became clear the walk skippedimport/require/defaultwholesale 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.cjsfixture 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:browserstring shorthandai.browserbrowser: { import: … }, notypesai.browser.cjs/.d.cts+.mjs/.d.mts.cjsdeclared by a.d.ts.d.cts"./package.json": "./package.json"findViolationstakes 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
collectBranchesanddeclarationForto 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:With the fix in place, all 8 pass:
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:
The
branches.length > 50vacuity guard is unchanged.Two smaller cleanups
repoRootusednew URL(import.meta.url).pathname, which yields a wrong root for a percent-encoded path; it is nowfileURLToPath. Note the siblingBunExportConditions.test.tscited as the precedent no longer exists on this base — it was removed by Drop redundant bun entry points and export conditions #711 — butCactus_BrowserBundles.test.tsandCactus_ModelCatalog.test.tsin this package usefileURLToPath, so the convention still holds. The> 50guard would have turned a bad root into a loud failure either way.["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 ownworkspacesglobs ("./packages/*"→packages), which the walk already reads to locate the root. It is not derived fromscripts/lib/util.ts#findWorkspaces: that function resolves./package.jsonrelative toprocess.cwd(), uses Bun'sGlob, and filters topublishConfig.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-lifecyclecheckout 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 eslintandbunx prettier --checkon 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 onorigin/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