Skip to content

fix(providers): point each export condition's types at its own declaration - #717

Draft
sroussey wants to merge 10 commits into
mainfrom
claude/wonderful-turing-rjtcnx-ai-types
Draft

fix(providers): point each export condition's types at its own declaration#717
sroussey wants to merge 10 commits into
mainfrom
claude/wonderful-turing-rjtcnx-ai-types

Conversation

@sroussey

@sroussey sroussey commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

The real count: 25 branches, 9 manifests

Not ~24 ./ai entries in providers — 25 condition branches across 9 workspace manifests, and they are not all in providers/*:

where branches
providers/* (8 packages × ./ai + ./ai-runtime, minus cactus's already-correct ./ai) 15
packages/workglow (9 provider re-export shims + ./worker) 10

Affected providers: openai, ollama, xai, deepseek, openrouter, llamacpp-server, stable-diffusion-server, cactus.

Shape in providers/*: the "browser" block loads ./dist/ai.browser.js but types it with ./dist/ai.d.ts. packages/workglow's nine shims are the same shape; its ./worker is the inverse — the default branch loads worker-node.js but types it with worker-browser.d.ts.

providers/anthropic, google-gemini, huggingface-inference, huggingface-transformers, mlx, node-llama-cpp have no browser condition at all and are unaffected. tf-mediapipe, chrome-ai, cloudflare have a browser block 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.ts against X.browser.d.ts using the TypeScript checker — the emitted files are one-line export * from … stubs, so a textual diff is meaningless.

Type-surface difference (live bug) — 7 branches:

branch node-only symbols
openai/ai _testOnly, registerOpenAiImageValidator, OpenAI_ModelSearch_Stream
ollama/ai _testOnly
ollama/ai-runtime createOllamaTextGenerationStream, createOllamaStructuredGenerationStream
xai/ai _testOnly, Xai_ModelSearch_Stream
deepseek/ai assertNotTruncatedByReasoning, resolveMaxTokens, DEEPSEEK_DEFAULT_REASONING_ALLOWANCE, DeepSeekToolChoiceNotHonoredError, assertToolChoiceHonored, isForcingToolChoice, _testOnly, DeepSeek_ModelSearch_Stream
openrouter/ai 10 symbols (_testOnly, OPENROUTER_RUN_FN_SPECS, fetchOpenRouterModels, mapOpenRouterModels, OpenRouterRawModel, OPENROUTER_FALLBACK_MODELS, …)
cactus/ai-runtime same names, different signature for getCactusModelCacheInfo

Same surface (wrong pointer, currently harmless) — 18 branches: openai/ai-runtime, xai/ai-runtime, deepseek/ai-runtime, openrouter/ai-runtime, both llamacpp-server entries, both stable-diffusion-server entries (their ai.ts and ai.browser.ts are byte-identical), and all 10 packages/workglow branches (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:

node-only runtime exports: [ "OpenAI_ModelSearch_Stream", "_testOnly", "registerOpenAiImageValidator" ]
browser-only runtime exports: []

Phase 1 — build and consumers

Per-condition declarations are already emitted; no build change is needed. Each package's tsconfig.json uses include: ["src/**/*"] with emitDeclarationOnly, so tsgo produces a .d.ts for every source file — dist/ai.browser.d.ts and dist/worker-node.d.ts all exist after a clean build. The browser .js bundles come from an existing per-package build-browser script (wired into build-js / build-package), which I initially missed by reading only build-code. Verified by listing dist/ after bun run build:packages: all eight providers and packages/workglow emit both .js and .d.ts for 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 (root tsconfig.json, tsconfig.typecheck.json, vitest) resolves under the default conditions, which are unchanged. packages/test imports _testOnly from these providers under the default condition — unaffected.

Phase 2 — decision

Point each condition's types at 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.ts walks every condition branch (recursively, so nested conditions are covered) of every {packages,providers,examples}/*/package.json and asserts each types equals its sibling import/require/default with .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_MISMATCHES is 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 browser condition will start failing to compile. That is the point of the fix: those symbols were already undefined at 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.
  • Negative check on the guard: reverted providers/openai's ./ai browser types back 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.
  • Targeted typecheck proving corrected resolution: a probe importing { OPENAI, _testOnly } from "@workglow/openai/ai", compiled twice with moduleResolution: bundler. After the fix — under customConditions: ["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.
  • Provider tests that consume _testOnly at runtime (OpenAI_ReasoningTemperature, ProviderUsageNormalization, CrossProviderRefusals) — 50/50 passed, confirming runtime resolution is untouched (types is a TypeScript-only condition).
  • npx eslint on the new test — clean. prettier --check on the new test and all changed manifests — clean; docs/technical/19-build-system.md reports a pre-existing prettier warning that is present on main before my edit (unrelated table/JSON formatting), so I left it rather than adding reformatting churn.
  • git status checked after every command; no stray manifest rewrites (bun run use-source was never invoked).

Not verified: I did not run the full repo test suite (only the util section 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

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 59.8% 37385 / 62509
🔵 Statements 59.32% 39228 / 66127
🔵 Functions 60.71% 7245 / 11933
🔵 Branches 48.07% 19038 / 39599
File CoverageNo changed files found.
Generated in workflow #3092 for commit d53f0ba by the Vitest Coverage Report Action

claude added 4 commits August 13, 2026 03:55
…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
sroussey force-pushed the claude/wonderful-turing-rjtcnx-ai-types branch from d86050a to f8a9718 Compare August 13, 2026 03:55
claude and others added 2 commits 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
…and-multi-impl

test(exports): check every implementation key and cross-sibling condition order
sroussey and others added 3 commits August 13, 2026 10:35
…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>
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
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