fix(providers): point each export condition's types at its own declaration - #717
Draft
sroussey wants to merge 10 commits into
Draft
fix(providers): point each export condition's types at its own declaration#717sroussey wants to merge 10 commits into
sroussey wants to merge 10 commits into
Conversation
Coverage Report
File CoverageNo changed files found. |
sroussey
marked this pull request as draft
August 7, 2026 21:55
This was referenced Aug 8, 2026
…ation Twenty-five condition branches across nine workspace manifests declared a `types` target that did not belong to the implementation named beside it. Fifteen are in `providers/*` (openai, ollama, xai, deepseek, openrouter, llamacpp-server, stable-diffusion-server, cactus), where a `"browser"` block loaded `dist/ai.browser.js` but typed it with `dist/ai.d.ts`; ten are in `packages/workglow` (the nine provider re-export shims plus `./worker`, whose default branch typed `worker-node.js` with `worker-browser.d.ts`). The mismatch is not cosmetic for all of them. Comparing the resolved export surfaces of the emitted declarations shows seven provider branches where the browser build genuinely exports less than the node build — `_testOnly`, `registerOpenAiImageValidator` and `OpenAI_ModelSearch_Stream` (openai/ai), `_testOnly` (ollama/ai), two stream factories (ollama/ai-runtime), the reasoning and tool-choice helpers (deepseek/ai), ten model-search symbols (openrouter/ai), two (xai/ai), and a changed `getCactusModelCacheInfo` signature (cactus/ai-runtime). Importing `@workglow/openai/ai` under the `browser` condition compiled clean against `_testOnly`; the browser bundle's runtime exports do not include it. The remaining eighteen branches share a surface today and are corrected for consistency, since "identical right now" is exactly how the seven drifted. No build change is needed: `tsgo` already compiles all of `src/**/*`, so every `*.browser.d.ts` is emitted, and the browser bundles come from the existing per-package `build-browser` script. This is a manifest-only fix. `ExportTypesPairing.test.ts` walks every condition branch of every workspace manifest and asserts each `types` pairs with the implementation beside it, with an empty, documented allowlist so the class of drift cannot return silently. Consumer-visible change: browser builds of the seven differing entries now see the browser type surface, so code importing a node-only symbol under the `browser` condition starts failing to compile — correctly, since that symbol was already `undefined` at runtime. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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
Correct `exports` manifests pair each condition's `types` with the implementation beside it, but TypeScript's own condition set is ["import", "types"] under moduleResolution "bundler" and ["node", "import", "types"] under node16/nodenext — "browser" is in neither. A browser app therefore bundles dist/browser.js while tsc type-checks it against the node declarations unless the consumer sets customConditions: ["browser"]. Document that in the Conditional Exports section and in the multi-runtime resolution rules, and pin examples/web's opt-in with an assertion so deleting that one line fails loudly instead of silently reverting the example project to node declarations. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013oVdDSRMJeALBPLDQf3DgH
`ExportTypesPairing.test.ts` only compared a branch's `types` string to the implementation string beside it, so a branch could be internally consistent and still name files nothing builds. Copying another package's `browser` block into a manifest that has no `*.browser.ts` declares a matching `.d.ts`/`.js` pair for a build that does not exist, and every existing assertion passes. Two repo-wide assertions close that: - every `types` and implementation target must derive to a `src/<stem>.ts` (or `.tsx`) that exists; a target whose layout the derivation cannot describe is reported rather than skipped. - `types` must precede the implementation key in the same object, since Node stops at the first matching condition. The `src` correlation is a proxy for "the build emits this", not a check of each package's build-script entry list: a source file that exists but was never added to `build-code` still passes. It catches the copy-paste case. All 163 branches across the workspace pass both today, so no manifest changes. Also corrects three comments: a branch with no `types` is not typed by an outer one (resolution stops at the matched branch and TypeScript looks beside the resolved file), and `ALLOWED_MISMATCHES` silences missing declarations too, not just mismatched ones. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PDvMMv78PuEw4T5atLeJeS
sroussey
force-pushed
the
claude/wonderful-turing-rjtcnx-ai-types
branch
from
August 13, 2026 03:55
d86050a to
f8a9718
Compare
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
…and-multi-impl test(exports): check every implementation key and cross-sibling condition order
…756) Every check in ExportTypesPairing is internally self-consistent: it compares a branch against itself. A `browser` block that duplicates the default branch therefore passes all six while routing browser consumers to the NODE bundle. Adds `browserSplitViolations`, keyed on the one piece of evidence that tells a legitimate duplicate from a bug — whether a `<stem>.browser.ts` source entry exists beside it. The probe is injected so fixtures drive both sides. chrome-ai and tf-mediapipe have no such entry, so their duplicate blocks stay green and are left untouched; the rule fires the moment someone adds the source entry. Also widens `orderViolations`: it counted only string-valued implementation keys as shadowers, so a nested `"import": { default: … }` could hide a later `"browser"` unflagged. An object counts when it resolves unconditionally; one with no unconditional target does not, because Node falls through it. Claude-Session: https://claude.ai/code/session_01RomTUtZSTgUbFCYqFs4pcu Co-authored-by: Claude <noreply@anthropic.com>
…ng-rjtcnx-ai-types
Both are cases where the guard test does not guard what it claims. A `browser` block declaring only `types` was invisible to ALL SIX rules. `collectBranches` records a branch only when a string-valued implementation key is present, so such a block produces no branch at all — not even for the source-entry rule. Bundlers, meanwhile, enter the `browser` condition, match nothing, and fall through to the outer `import`, bundling the node build while tsc (customConditions ["browser"]) types against the browser declarations. That is exactly the browser-typed-as-node inversion this file exists to prevent, reached by omission rather than by a wrong target. `browserSplitViolations` now decides the empty-implementation case BEFORE the browser-source-entry guard. Hoisting past that guard is deliberate: the `packages/*` layout (stem `node`, no `src/node.browser.ts`) never reaches the guarded code, so the bug would otherwise go unreported for every package in the repo. Fixtures cover the openai manifest with its browser `"import"` deleted, the `packages/storage` layout with the `never` probe, and a healthy block that must stay silent — the first two assert `findViolations` and `orderViolations` are `[]` first, the go-red proof. "Source entry exists" proved only the `.d.ts` half. `build-types` runs `tsgo` over the whole `src` tree, so the file's existence is what makes the declaration appear; the `.js` comes from hand-written entry lists in each package's `build*` scripts, which nothing read. Add `providers/foo/src/ai.browser.ts` and a `browser` block, forget to append `./src/ai.browser.ts` to `build-browser`, and every check passed while `dist/ai.browser.js` was never emitted. `buildEntryViolations` collects every `.js`/`.cjs`/`.mjs` implementation target, derives its source entry from the dist stem, and requires a whole-token match in the joined text of the package's `build*` scripts — so duckdb's nested `--outdir` matches and `./src/ai.browser.ts` does not satisfy the stem `ai`. Unrecognized layouts are reported, not skipped. Zero violations across every manifest today, with exactly one exemption: `packages/workglow`, whose build is glob-driven. That exemption is pinned in `GLOB_BUILT_PACKAGES` and guarded by a test asserting the package really does hand its build to a repo-local `*.ts` program, so it dies if the package goes back to naming its entries. This lives in the test file that reviews the manifest rather than in a `publish-workspaces.ts` prepack assertion: a prepack assertion fires after review and after merge, and a guard that does not guard has to be able to fail the PR that introduces it. Also corrects the comment above "declares only targets a source entry file can emit", which claimed the source entry was the cheapest evidence the build produces the target at all — true of the declaration only.
…hxoj5s-exports-guard test(exports): close two holes in the exports-pairing guard
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.
The real count: 25 branches, 9 manifests
Not ~24
./aientries in providers — 25 condition branches across 9 workspace manifests, and they are not all inproviders/*:providers/*(8 packages ×./ai+./ai-runtime, minus cactus's already-correct./ai)packages/workglow(9 provider re-export shims +./worker)Affected providers:
openai,ollama,xai,deepseek,openrouter,llamacpp-server,stable-diffusion-server,cactus.Shape in
providers/*: the"browser"block loads./dist/ai.browser.jsbut types it with./dist/ai.d.ts.packages/workglow's nine shims are the same shape; its./workeris the inverse — the default branch loadsworker-node.jsbut types it withworker-browser.d.ts.providers/anthropic,google-gemini,huggingface-inference,huggingface-transformers,mlx,node-llama-cpphave nobrowsercondition at all and are unaffected.tf-mediapipe,chrome-ai,cloudflarehave abrowserblock that points at the same files as the default — redundant, but internally consistent, so untouched.Phase 1 classification — do the surfaces actually differ?
I compared the resolved export surface (not the text) of each emitted
X.d.tsagainstX.browser.d.tsusing the TypeScript checker — the emitted files are one-lineexport * from …stubs, so a textual diff is meaningless.Type-surface difference (live bug) — 7 branches:
openai/ai_testOnly,registerOpenAiImageValidator,OpenAI_ModelSearch_Streamollama/ai_testOnlyollama/ai-runtimecreateOllamaTextGenerationStream,createOllamaStructuredGenerationStreamxai/ai_testOnly,Xai_ModelSearch_Streamdeepseek/aiassertNotTruncatedByReasoning,resolveMaxTokens,DEEPSEEK_DEFAULT_REASONING_ALLOWANCE,DeepSeekToolChoiceNotHonoredError,assertToolChoiceHonored,isForcingToolChoice,_testOnly,DeepSeek_ModelSearch_Streamopenrouter/ai_testOnly,OPENROUTER_RUN_FN_SPECS,fetchOpenRouterModels,mapOpenRouterModels,OpenRouterRawModel,OPENROUTER_FALLBACK_MODELS, …)cactus/ai-runtimegetCactusModelCacheInfoSame surface (wrong pointer, currently harmless) — 18 branches:
openai/ai-runtime,xai/ai-runtime,deepseek/ai-runtime,openrouter/ai-runtime, bothllamacpp-serverentries, bothstable-diffusion-serverentries (theirai.tsandai.browser.tsare byte-identical), and all 10packages/workglowbranches (its shims are byte-identical pass-throughs — the browser/node split really happens one layer down, in the provider's own exports map). No branch is browser-only or has a browser-superset surface: node ⊇ browser everywhere.The difference is real at runtime, not just in the declarations. Importing both openai bundles and diffing
Object.keys:Phase 1 — build and consumers
Per-condition declarations are already emitted; no build change is needed. Each package's
tsconfig.jsonusesinclude: ["src/**/*"]withemitDeclarationOnly, sotsgoproduces a.d.tsfor every source file —dist/ai.browser.d.tsanddist/worker-node.d.tsall exist after a clean build. The browser.jsbundles come from an existing per-packagebuild-browserscript (wired intobuild-js/build-package), which I initially missed by reading onlybuild-code. Verified by listingdist/afterbun run build:packages: all eight providers andpackages/workglowemit both.jsand.d.tsfor both targets. This is a manifest-only fix.Nothing in-repo depends on the mismatch.
customConditions: ["browser"]appears in exactly one place,examples/web/tsconfig.json, and that app does not reference any affected provider. Everything else (roottsconfig.json,tsconfig.typecheck.json, vitest) resolves under the default conditions, which are unchanged.packages/testimports_testOnlyfrom these providers under the default condition — unaffected.Phase 2 — decision
Point each condition's
typesat its own emitted declaration. The evidence rules out the cheaper options: 7 branches have genuinely different export surfaces, so "browser and node share a type surface" is factually false and documenting it would be wrong; and they should differ (the browser entries deliberately omit node-only test hooks and model-search code), so collapsing them onto one shared surface would be a product regression, not a de-drift. The fix costs nothing to build because the per-target declarations are already emitted, and I applied it uniformly to all 25 branches — including the 18 that agree today — because "identical right now" is exactly the state the seven divergent ones were in before they drifted. No package needed different treatment.Phase 3 — the guard
packages/test/src/test/util/ExportTypesPairing.test.tswalks every condition branch (recursively, so nested conditions are covered) of every{packages,providers,examples}/*/package.jsonand asserts eachtypesequals its siblingimport/require/defaultwith.js→.d.ts. Three assertions: the pairing invariant; a non-vacuity check (>50 branches found, so a broken scan can't pass silently); and a staleness check on the allowlist.ALLOWED_MISMATCHESis empty and documented as such — no legitimate exception exists today.A short paragraph was added to
docs/technical/19-build-system.md's "Conditional Exports" section stating the invariant and naming the guard.Consumer-visible type surface change — yes
For the 7 divergent branches, browser builds now see the browser type surface. Downstream code importing a node-only symbol under the
browsercondition will start failing to compile. That is the point of the fix: those symbols were alreadyundefinedat runtime in a browser bundle, so the old behavior was a silent runtime failure dressed up as a clean compile. The other 18 branches are a no-op for consumers.Verified
Everything below was run in this worktree and observed to pass:
bun install— clean.bun run build:types— 41/41 tasks successful.bun run build:packages— 81/81 tasks successful.npx vitest run packages/test/src/test/util/ExportTypesPairing.test.ts— 3/3 passed.providers/openai's./aibrowsertypesback to./dist/ai.d.ts→ the test failed with the exact branch named (providers/openai/package.json exports["./ai"] [browser]: types="./dist/ai.d.ts" but implementation is "./dist/ai.browser.js"); restored, test passes again.{ OPENAI, _testOnly } from "@workglow/openai/ai", compiled twice withmoduleResolution: bundler. After the fix — undercustomConditions: ["browser"]:TS2305: Module '"@workglow/openai/ai"' has no exported member '_testOnly'; under default conditions: exit 0. Before the fix, the browser-condition compile exited 0 — i.e. it silently accepted a symbol that does not exist in the browser bundle.npx vitest run packages/test/src/test/util/— 45 files, 669 passed / 6 skipped._testOnlyat runtime (OpenAI_ReasoningTemperature,ProviderUsageNormalization,CrossProviderRefusals) — 50/50 passed, confirming runtime resolution is untouched (typesis a TypeScript-only condition).npx eslinton the new test — clean.prettier --checkon the new test and all changed manifests — clean;docs/technical/19-build-system.mdreports a pre-existing prettier warning that is present onmainbefore my edit (unrelated table/JSON formatting), so I left it rather than adding reformatting churn.git statuschecked after every command; no stray manifest rewrites (bun run use-sourcewas never invoked).Not verified: I did not run the full repo test suite (only the
utilsection plus the three provider tests above), and I did not build a downstream browser app against the corrected types — the compile-condition probe is the evidence for that path.🤖 Generated with Claude Code
Generated by Claude Code