From 3e31d0b488bc4e7956a1ebeab38115e12ac2cbbf Mon Sep 17 00:00:00 2001 From: Mao Nakamoto <41178744+maonakamoto@users.noreply.github.com> Date: Wed, 26 Aug 2026 23:36:55 +0200 Subject: [PATCH] fix(exports)!: form filling leaves the root, one install stays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first app to adopt the merged package broke on it, which is the fastest possible feedback and worth acting on rather than patching around. `ai-forms` is ESM-only. Re-exporting it from this root meant that importing the CHAIN pulled the forms package in behind it, so AOZ — which wanted `freeChain` and nothing else — had its Jest run die on `Unexpected token 'export'` inside a module it never asked for. Three suites, seven tests. The available remedy was a `transformIgnorePatterns` entry in AOZ. Then the same entry in the next adopter, and the one after: a single class of breakage, paid once per repo, forever. That is precisely the shape this fleet has a rule against, so the fix goes here instead. One install was always the promise, and it is untouched. Same package, same version, nothing new to install — only the import path moves: import { freeChain } from "ai-kit"; // the chain import { defineFields } from "ai-kit/forms"; // form filling import { useAssist } from "ai-kit/react"; // the React hook Which is what the exports map was for. Collapsing it into the root threw away the one thing it buys: a server that wants a provider chain no longer pays for a form library, or for a UI peer behind it. BREAKING for anyone importing form symbols from the root. Measured before changing rather than assumed: across both consumers — FleetCrown (5 files) and AOZ (2) — every root import is chain, limits or fair-share. Zero form symbols. Nothing in the fleet has to change. The absence is now a test, not a convention. `test/exports.test.js` asserts both that `ai-kit/forms` resolves and that the root does NOT carry those symbols, because a convenience re-export added back at the root would read as harmless in review and break the next consumer in exactly this way. verify: lint, typecheck, build, 48/48 tests. Co-Authored-By: Claude Opus 5 --- README.md | 10 +++++++++- package-lock.json | 4 ++-- package.json | 2 +- src/index.ts | 24 ++++++++++++++++++++--- test/exports.test.js | 45 +++++++++++++++++++++++++++++++------------- 5 files changed, 65 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index b0c0aa6..3515fe0 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ is room. ### Filling forms — from prose, then by talking to it ```ts -import { runFormAssist } from 'ai-kit'; +import { runFormAssist } from 'ai-kit/forms'; import { useAiForm } from 'ai-kit/react'; import { createFormAssistHandler } from 'ai-kit/server'; ``` @@ -129,6 +129,14 @@ stays its own package — it works, four apps run it, and it is useful well outs this fleet. Swallowing it would have broken those four for the sake of a filing system. +**Note the subpath.** Form filling is at `ai-kit/forms`, not at the root. For one +release it was both, and the first app to adopt the merged package paid for it: +`ai-forms` is ESM-only, so importing the *chain* from the root dragged the forms +package in behind it and the app's Jest run — which executes CJS — died inside a +module it never asked for. One install is still the whole promise; the exports +map is what keeps it, while letting a server that only wants a provider chain +stop paying for a form library. + React lives on its own subpath and is an **optional** peer, so importing `ai-kit` on a server never pulls in a UI library. diff --git a/package-lock.json b/package-lock.json index aa43049..e7a1cd5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ai-kit", - "version": "0.3.0", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ai-kit", - "version": "0.3.0", + "version": "0.4.0", "license": "MIT", "dependencies": { "ai-forms": "^0.1.2" diff --git a/package.json b/package.json index f88e741..57bb39e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ai-kit", - "version": "0.3.1", + "version": "0.4.0", "description": "One install for the AI layer of an app: which model to call and what to do when the vendor retires it, how to read the three kinds of 429, a fair daily budget across users, and headless AI form filling.", "license": "MIT", "author": "Mao Nakamoto", diff --git a/src/index.ts b/src/index.ts index f1df465..57286f9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -81,6 +81,24 @@ export { utcDayKey, } from "./fair-share.js"; -// Form filling. Re-exported so that adding AI to an app is one install; the -// package itself stays independent and separately useful. -export * from "./forms.js"; +// Form filling lives at `ai-kit/forms`, NOT here. +// +// It was re-exported from this root for one release, so that "adding AI" was a +// single import as well as a single install. The first app to adopt the merged +// package showed what that costs: `ai-forms` is ESM-only, so pulling it in from +// this root made every consumer of the CHAIN load the forms package too — and +// the app's Jest run, which executes CJS, died on `Unexpected token 'export'` +// inside a module it never asked for. The fix would have been a +// `transformIgnorePatterns` entry in that app, and in the next one, and in +// every app thereafter: one class of breakage, paid per repo, forever. +// +// One install is still the promise, and the exports map already keeps it: +// +// import { freeChain } from "ai-kit"; // the chain +// import { defineFields } from "ai-kit/forms"; // form filling +// import { useAssist } from "ai-kit/react"; // the React hook +// +// Same dependency, same version, nothing extra to install — a consumer just +// stops paying for the half it does not use. That is what subpath exports are +// for, and collapsing them into the root threw the benefit away. + diff --git a/test/exports.test.js b/test/exports.test.js index f35ff55..734055c 100644 --- a/test/exports.test.js +++ b/test/exports.test.js @@ -28,26 +28,45 @@ test('the package exports its public surface through the exports map', () => { }); /** - * The merge is the feature, so it needs a test. + * The merge is the feature, so it needs a test — but the feature is ONE INSTALL, + * not one import. * - * `ai-kit` exists so that adding AI to an app is ONE install rather than four - * separate decisions — AOZ made one of those decisions (ai-forms), skipped the - * other two, and was taken down by the one it skipped. If the form-filling - * re-export silently stops resolving, the package quietly becomes the old - * ai-ration again under a friendlier name, and nothing else here would notice. + * `ai-kit` exists so that adding AI to an app is a single decision rather than + * four: AOZ made one of those decisions (ai-forms), skipped the other two, and + * was taken down by one it skipped. Everything below still ships from this one + * package at one version. What changed is WHERE from. */ -test('form filling is reachable from the root, so one install covers it', async () => { - const pkg = await import('ai-kit'); +test('form filling is reachable from the package, so one install covers it', async () => { + const forms = await import('ai-kit/forms'); for (const name of ['runFormAssist', 'defineFields', 'mergeValues', 'sanitizeValues']) { - assert.equal(typeof pkg[name], 'function', `missing re-export: ${name}`); + assert.equal(typeof forms[name], 'function', `missing export: ${name}`); } }); -test('the model layer and the form layer coexist without shadowing', async () => { +/** + * ...and it must NOT be reachable from the root. This is a regression test with + * a scar behind it. + * + * For one release the root re-exported forms, so `import { freeChain } from + * 'ai-kit'` dragged `ai-forms` in behind it. That package is ESM-only, so the + * first adopting app's Jest run — which executes CJS — died on `Unexpected token + * 'export'` inside a module it had never asked for. The remedy would have been a + * `transformIgnorePatterns` entry in that app, then the next, then every app + * after: one class of breakage, paid per repo, forever. + * + * So the absence is the contract. A convenience re-export added back at the root + * would look harmless in review and break the next consumer the same way. + */ +test('the root does NOT drag the form layer in behind the chain', async () => { const pkg = await import('ai-kit'); - // One from each half. A collision would drop one silently at build time. - assert.equal(typeof pkg.freeChain, 'function'); - assert.equal(typeof pkg.runFormAssist, 'function'); + assert.equal(typeof pkg.freeChain, 'function', 'the chain belongs at the root'); + for (const name of ['runFormAssist', 'defineFields']) { + assert.equal( + name in pkg, + false, + `${name} is re-exported from the root again — a chain-only consumer now loads ai-forms`, + ); + } }); test('./forms resolves through the exports map', async () => {