diff --git a/.changeset/mini-lynx-native-dist.md b/.changeset/mini-lynx-native-dist.md new file mode 100644 index 0000000..a7fcc7e --- /dev/null +++ b/.changeset/mini-lynx-native-dist.md @@ -0,0 +1,48 @@ +--- +"@amritk/mini-lynx-native": patch +--- + +Republish the bridge with the `dist/` its manifest has always promised, and +remove the `development` export condition that hid the miss. + +`@amritk/mini-lynx-native@0.2.0` went to npm as a src-only tarball. It was +published by hand rather than through the release workflow, so none of the +things that workflow does before `changeset publish` ever ran: no `bun run +build`, so there was no `dist/`; no `strip-development-exports`, so the +`development` condition survived; no `copy-license`, so the tarball carried no +LICENSE. The manifest still declared `./dist/index.js`, `./dist/background/index.js` +and `./dist/testing/index.js`, and `files` still listed `dist` — every one of +those pointed at nothing. + +The surviving condition is why this was survivable rather than fatal, and why it +went unnoticed for a release: it resolved to `./src/*.ts`, and `src` does ship, +so anything honouring it got raw TypeScript and appeared to work. Anything that +did not — plain Node, a bundler on default conditions, `tsc` reading `types` — +got a resolution failure against a package whose exports named files that were +not in the tarball. + +0.2.1 is the same code, published through the release workflow, so it carries +`dist/` and its type declarations. Consumers working around the miss by forcing +the `development` condition — a `customConditions` entry in `tsconfig.json`, a +resolve condition in the bundler config, a `--conditions development` flag on +the test command — can drop all three and resolve normally. + +The four `@amritk/lynx-*` packages pin the bridge at an exact version, so their +0.2.0 releases still point at the broken tarball; they go out alongside this one +re-pinned to 0.2.1. + +**The condition is gone from every package.** It existed so the workspace could +resolve its own packages to source without a build, but it lived in the one +place that ships — the `exports` map — which is what let it reach a tarball at +all. Nothing strips it at publish time now because nothing declares it: the +packages resolve each other through `types`/`import` like any consumer, and the +build simply runs before the type check. Tests are unaffected, having always +used `vitest.config.ts`'s `src` aliases rather than the condition. The +playgrounds and the bundle-size bench keep resolving to source through +repo-local mechanisms that cannot ship. + +Publishing out of band can no longer do this quietly. Every publishable package +runs `scripts/check-publishable.mjs` as `prepublishOnly`, which fails the +publish when an exports map points at a `dist/` file that is not on disk, when a +`development` condition is present at all, or when the package directory has no +LICENSE. diff --git a/.claude/architecture.md b/.claude/architecture.md index 30d2bfa..918bfa6 100644 --- a/.claude/architecture.md +++ b/.claude/architecture.md @@ -201,12 +201,10 @@ reactivity, no platform.** `URLSearchParams`, which is a web global and not an ECMAScript one. - **Depends on:** nothing. `@amritk/runtime-validators` is an optional peer of `/schema` alone. -- **Build:** the same `tsgo` + `tsc-alias` + `strip-comments` pipeline. Both - dependents resolve it through the `development` condition while type-checking - (`customConditions` in their `tsconfig.json`, dropped again in - `tsconfig.build.json`) so CI can type-check before it builds; the emit - resolves it through `types` instead, which is why `bun run --workspaces build` - builds this package first. +- **Build:** the same `tsgo` + `tsc-alias` + `strip-comments` pipeline. Its + dependents resolve it through `types`/`import` like any consumer, in the type + check as well as the emit, which is why `bun run --workspaces build` builds + this package first and why CI builds before it type-checks. ### `@amritk/mini-lynx-native` (`packages/mini-lynx-native`) @@ -408,10 +406,12 @@ checked. Two conventions keep them honest, and both are worth preserving: -- **They resolve the packages through the `development` condition**, pinned in - each app's `vite.config.ts` and `tsconfig.json`, so they build from `src` and - run in a fresh clone with no prior `bun run build`. Packaging is deliberately - not their job — `scripts/consumer-e2e.test.ts` packs and installs real +- **They resolve the packages the way a consumer does**, through `types` and + `import` to `dist`, so the packages have to be built before either app is + type-checked or built. They used to pin a `development` condition and build + straight from `src`; that condition shipped in the published manifests and + pointed at the `src` the tarballs also carry, so it is gone. Packaging is + still not their job — `scripts/consumer-e2e.test.ts` packs and installs real tarballs for that. - **The root `build`, `types:check` and `test` include them**, so a breaking change to a package fails CI in the playground too. `playground-mini-lynx` @@ -475,8 +475,8 @@ reason. `bun run build`. It loads every compiled module under plain Node, drives the built `mini-lynx` runtime through its memory host, and — in `consumer-e2e.test.ts` — packs both packages the way `release:publish` does - (`catalog:`/`workspace:` resolved, the `development` condition stripped), - installs the tarballs into scratch projects, and imports every declared + (`catalog:`/`workspace:` resolved), installs the tarballs into scratch + projects, and imports every declared subpath from them. That catches build-, pack- and manifest-level breakage the src-aliased suite cannot see by construction. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b8a235..a8f7455 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,15 +44,24 @@ jobs: bun run generate-llms git diff --exit-code llms.txt llms-full.txt + # Ahead of the type check, not after it. The packages used to resolve each + # other's SOURCE while type-checking, through a `development` condition in + # their exports maps — which meant the manifests shipped a condition + # pointing at `./src/*.ts` inside a tarball that also ships `src`, so + # anything honouring it handed consumers raw TypeScript. The condition is + # gone; cross-package imports resolve to `dist` like a consumer's do, and + # `dist` has to exist by the time anything reads it. + - name: Build + run: bun run build + - name: Type check run: bun run types:check + # Unaffected by the ordering above: vitest.config.ts aliases every + # workspace package to its `src/`, so the suite has never needed a build. - name: Test run: bun run test - - name: Build - run: bun run build - # Loads every compiled module under plain Node, drives the built # mini-lynx runtime end-to-end through its fake Element PAPI, and # installs packed tarballs like an npm consumer — catches build-, pack- diff --git a/.github/workflows/deploy-playgrounds.yml b/.github/workflows/deploy-playgrounds.yml index f8fd521..527a3f3 100644 --- a/.github/workflows/deploy-playgrounds.yml +++ b/.github/workflows/deploy-playgrounds.yml @@ -38,10 +38,19 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile - # The apps resolve `@amritk/mini` and `@amritk/mini-lynx` through the - # `development` condition, so they build straight from `src` and need no - # prior package build. Type-checking them is still worth the seconds: a - # broken playground should fail here rather than deploy. + # The apps used to resolve `@amritk/mini` and `@amritk/mini-lynx` through + # a `development` condition and build straight from `src`. That condition + # is gone — it shipped in the published manifests and pointed at the `src` + # the tarballs also carry — so the playgrounds now resolve `dist` like any + # consumer, and the packages have to be built before either app is + # type-checked or built. Building the whole workspace rather than the + # app's dependency closure keeps this from needing an edit every time a + # playground picks up another package. + - name: Build packages + run: bun run --filter './packages/*' build + + # Worth the seconds: a broken playground should fail here rather than + # deploy. - name: Type check run: bun run --filter "@amritk/${{ matrix.app }}" types:check diff --git a/apps/playground-mini-lynx/tsconfig.json b/apps/playground-mini-lynx/tsconfig.json index 24180a7..e6e0842 100644 --- a/apps/playground-mini-lynx/tsconfig.json +++ b/apps/playground-mini-lynx/tsconfig.json @@ -9,12 +9,7 @@ "lib": ["ESNext", "DOM", "DOM.Iterable"], "types": ["vite/client"], "jsx": "react-jsx", - "jsxImportSource": "@amritk/mini-lynx", - // Resolve `@amritk/mini-lynx` and every subpath to the workspace's SOURCE - // through the `development` condition the package already declares. The - // same condition is set in `vite.config.ts`, so the playground runs and - // builds in a fresh clone with no prior `bun run build`. - "customConditions": ["development"] + "jsxImportSource": "@amritk/mini-lynx" }, "include": ["src/**/*.ts", "src/**/*.tsx", "vite.config.ts"] } diff --git a/apps/playground-mini-lynx/vite.config.ts b/apps/playground-mini-lynx/vite.config.ts index 82c1dfd..20c84b6 100644 --- a/apps/playground-mini-lynx/vite.config.ts +++ b/apps/playground-mini-lynx/vite.config.ts @@ -2,11 +2,6 @@ import { defineConfig } from 'vite' export default defineConfig({ resolve: { - // Pin the `development` condition in both `serve` and `build`, so - // `@amritk/mini-lynx` and its subpaths resolve to source rather than to - // `dist/`. The playground then always exercises the code in this checkout - // and never depends on the packages having been built first. - conditions: ['development', 'module', 'browser'], extensions: ['.tsx', '.ts', '.jsx', '.js', '.json'], }, build: { diff --git a/apps/playground-mini/README.md b/apps/playground-mini/README.md index f85ba8f..d144241 100644 --- a/apps/playground-mini/README.md +++ b/apps/playground-mini/README.md @@ -55,17 +55,22 @@ that must not scroll the demo out from under you. ## How it resolves the package -`vite.config.ts` and `tsconfig.json` both pin the `development` condition, so -`@amritk/mini` and every subpath resolve to `packages/mini/src` rather than to -`dist/`. That means the playground runs in a fresh clone with no prior build, -always exercises the code in this checkout, and needs no `paths` table kept in -sync with the exports map. - -The one thing that cannot resolve that way is `@amritk/mini/vite` — Vite loads -its own config with plain Node resolution — so `vite.config.ts` imports the two -plugins by relative path. Both are on: `catchCalledSignals` fails the build on -`attr={signal()}`, and `acceptHotUpdates` makes `src/main.tsx` the hot-update -boundary. +`@amritk/mini` and every subpath resolve through the package's `exports` map to +`packages/mini/dist`, exactly as they would for anyone installing from npm. So +the playground needs `bun run build` at the workspace root before it will start. + +It used to pin a `development` condition in both `vite.config.ts` and +`tsconfig.json` and resolve to `src` instead, which meant no build was needed. +That condition lived in the packages' own `exports` maps, which is what made it +reachable from a published tarball — and `@amritk/mini-lynx-native@0.2.0` +shipped one, resolving consumers to raw TypeScript. Needing a build first is the +price of the condition not existing at all. + +`@amritk/mini/vite` is the exception, and it is a bootstrapping one: a config +file has to load before Vite can do anything, including before the first build +exists, so `vite.config.ts` imports the two plugins by relative path instead. +Both are on: `catchCalledSignals` fails the build on `attr={signal()}`, and +`acceptHotUpdates` makes `src/main.tsx` the hot-update boundary. Packaging is deliberately *not* what this app checks. `scripts/consumer-e2e.test.ts` packs and installs real tarballs for that, which is the honest test of it. diff --git a/apps/playground-mini/tsconfig.json b/apps/playground-mini/tsconfig.json index d4ca8aa..1c1ff46 100644 --- a/apps/playground-mini/tsconfig.json +++ b/apps/playground-mini/tsconfig.json @@ -7,13 +7,7 @@ "lib": ["ESNext", "DOM", "DOM.Iterable"], "types": ["vite/client"], "jsx": "react-jsx", - "jsxImportSource": "@amritk/mini", - // Resolve `@amritk/mini` and every subpath to the workspace's SOURCE rather - // than to `dist`, through the `development` condition the package already - // declares. The same condition is set in `vite.config.ts`, and it is what - // lets the playground run (and build) in a fresh clone with no prior - // `bun run build` — no `paths` table to keep in sync with the exports map. - "customConditions": ["development"] + "jsxImportSource": "@amritk/mini" }, "include": ["src/**/*.ts", "src/**/*.tsx", "vite.config.ts"] } diff --git a/apps/playground-mini/vite.config.ts b/apps/playground-mini/vite.config.ts index 0720820..6cd55b9 100644 --- a/apps/playground-mini/vite.config.ts +++ b/apps/playground-mini/vite.config.ts @@ -1,11 +1,11 @@ import { defineConfig } from 'vite' // The plugins are imported from the workspace SOURCE rather than through -// `@amritk/mini/vite`. Vite loads this config with plain Node resolution, which -// does not apply the `development` condition the rest of the app resolves -// through — so the package name here would point at `dist/` and fail before the -// package has ever been built. A relative import keeps `bun run dev` working in -// a fresh clone, and has the playground dogfood the plugin's source. +// `@amritk/mini/vite`, because a config file has to load before anything else +// can: the package name would point at a `dist/` that a fresh clone has not +// built yet, and the failure would be vite refusing to start rather than +// anything about the app. A relative import also has the playground dogfood the +// plugin's source. import { acceptHotUpdates, catchCalledSignals } from '../../packages/mini/src/vite/index.ts' export default defineConfig({ @@ -18,14 +18,6 @@ export default defineConfig({ // required: see `src/main.tsx`. acceptHotUpdates(), ], - resolve: { - // Pin the `development` condition in both `serve` and `build`. Vite's - // default is `development|production`, which would resolve `@amritk/mini` - // to `dist/` for a production build and make the playground depend on - // build ordering; resolving to source instead means this app always - // exercises the code in this checkout, which is the point of a playground. - conditions: ['development', 'module', 'browser'], - }, build: { target: 'es2022', // The whole app is one page of demos — a single chunk keeps the Cloudflare diff --git a/package.json b/package.json index 1b1c3ab..d64b3f5 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "bench:reconciler": "bun run scripts/bench-reconciler.ts", "generate-llms": "bun run scripts/generate-llms.ts", "release:version": "changeset version && bun install --no-frozen-lockfile", - "release:publish": "bun run scripts/resolve-workspace-protocol.ts && bun run scripts/strip-development-exports.ts && bun run scripts/copy-license.ts && changeset publish" + "release:publish": "bun run scripts/resolve-workspace-protocol.ts && bun run scripts/copy-license.ts && changeset publish" }, "devDependencies": { "@biomejs/biome": "2.4.4", diff --git a/packages/lynx-deep-linking/package.json b/packages/lynx-deep-linking/package.json index fd0629c..0a7fbe3 100644 --- a/packages/lynx-deep-linking/package.json +++ b/packages/lynx-deep-linking/package.json @@ -42,6 +42,7 @@ }, "scripts": { "build": "tsgo -p tsconfig.build.json && tsc-alias -p tsconfig.build.json -f && node ../../scripts/strip-comments.mjs", + "prepublishOnly": "node ../../scripts/check-publishable.mjs", "types:check": "tsgo -p . --noEmit", "test": "NODE_ENV=production vitest run --root ../.. packages/lynx-deep-linking/" }, @@ -49,13 +50,11 @@ "./package.json": "./package.json", "./lynx.lib.json": "./lynx.lib.json", ".": { - "development": "./src/index.ts", "types": "./dist/index.d.ts", "import": "./dist/index.js", "default": "./dist/index.js" }, "./testing": { - "development": "./src/testing/index.ts", "types": "./dist/testing/index.d.ts", "import": "./dist/testing/index.js", "default": "./dist/testing/index.js" diff --git a/packages/lynx-deep-linking/tsconfig.build.json b/packages/lynx-deep-linking/tsconfig.build.json index d90aca9..e80781e 100644 --- a/packages/lynx-deep-linking/tsconfig.build.json +++ b/packages/lynx-deep-linking/tsconfig.build.json @@ -6,13 +6,7 @@ // Re-asserted because the shared `../../tsconfig.build.json` (applied last // in `extends`) resets `lib`/`types` back to the Node-only defaults. "lib": ["ESNext"], - "types": [], - // Dropped for the emit pass, exactly as `@amritk/lynx-location` drops it: - // here the `development` condition would put another package's `src` inside - // this one's `rootDir`. The build resolves both dependencies through the - // normal `types` condition instead, which is why the root `build` script - // builds them first. - "customConditions": [] + "types": [] }, "include": ["src/**/*.ts"], "exclude": ["dist", "node_modules", "**/*.test.ts"] diff --git a/packages/lynx-deep-linking/tsconfig.json b/packages/lynx-deep-linking/tsconfig.json index 3bf28cb..4fcfa8f 100644 --- a/packages/lynx-deep-linking/tsconfig.json +++ b/packages/lynx-deep-linking/tsconfig.json @@ -6,13 +6,7 @@ // browser or a Node process. It is also what keeps `parse-url.ts` off the // `URL` global, which is a web platform API a native engine need not have. "lib": ["ESNext"], - "types": [], - // Resolve `@amritk/mini-lynx-native` and `@amritk/mini-helpers` to the - // workspace's SOURCE through the `development` condition they already - // declare, rather than to their `dist`. CI type-checks BEFORE it builds, so - // without this a fresh clone would look for a `.d.ts` that does not exist - // yet. - "customConditions": ["development"] + "types": [] }, "include": ["src/**/*.ts"] } diff --git a/packages/lynx-dialogs/package.json b/packages/lynx-dialogs/package.json index d6443d3..3f714ad 100644 --- a/packages/lynx-dialogs/package.json +++ b/packages/lynx-dialogs/package.json @@ -40,6 +40,7 @@ }, "scripts": { "build": "tsgo -p tsconfig.build.json && tsc-alias -p tsconfig.build.json -f && node ../../scripts/strip-comments.mjs", + "prepublishOnly": "node ../../scripts/check-publishable.mjs", "types:check": "tsgo -p . --noEmit", "test": "NODE_ENV=production vitest run --root ../.. packages/lynx-dialogs/" }, @@ -47,13 +48,11 @@ "./package.json": "./package.json", "./lynx.lib.json": "./lynx.lib.json", ".": { - "development": "./src/index.ts", "types": "./dist/index.d.ts", "import": "./dist/index.js", "default": "./dist/index.js" }, "./testing": { - "development": "./src/testing/index.ts", "types": "./dist/testing/index.d.ts", "import": "./dist/testing/index.js", "default": "./dist/testing/index.js" diff --git a/packages/lynx-dialogs/tsconfig.build.json b/packages/lynx-dialogs/tsconfig.build.json index 65c9fbc..e80781e 100644 --- a/packages/lynx-dialogs/tsconfig.build.json +++ b/packages/lynx-dialogs/tsconfig.build.json @@ -6,12 +6,7 @@ // Re-asserted because the shared `../../tsconfig.build.json` (applied last // in `extends`) resets `lib`/`types` back to the Node-only defaults. "lib": ["ESNext"], - "types": [], - // Dropped for the emit pass, exactly as `@amritk/mini-lynx` drops it: here - // the `development` condition would put another package's `src` inside this - // one's `rootDir`. The build resolves the bridge through the normal `types` - // condition instead, which is why the root `build` script builds it first. - "customConditions": [] + "types": [] }, "include": ["src/**/*.ts"], "exclude": ["dist", "node_modules", "**/*.test.ts"] diff --git a/packages/lynx-dialogs/tsconfig.json b/packages/lynx-dialogs/tsconfig.json index 70545a9..334a2f3 100644 --- a/packages/lynx-dialogs/tsconfig.json +++ b/packages/lynx-dialogs/tsconfig.json @@ -5,12 +5,7 @@ // them: this is Lynx code, and neither of Lynx's two script contexts is a // browser or a Node process. "lib": ["ESNext"], - "types": [], - // Resolve `@amritk/mini-lynx-native` to the workspace's SOURCE through the - // `development` condition it already declares, rather than to its `dist`. - // CI type-checks BEFORE it builds, so without this a fresh clone would look - // for a `.d.ts` that does not exist yet. - "customConditions": ["development"] + "types": [] }, "include": ["src/**/*.ts"] } diff --git a/packages/lynx-location/package.json b/packages/lynx-location/package.json index d54cf5b..131829e 100644 --- a/packages/lynx-location/package.json +++ b/packages/lynx-location/package.json @@ -40,6 +40,7 @@ }, "scripts": { "build": "tsgo -p tsconfig.build.json && tsc-alias -p tsconfig.build.json -f && node ../../scripts/strip-comments.mjs", + "prepublishOnly": "node ../../scripts/check-publishable.mjs", "types:check": "tsgo -p . --noEmit", "test": "NODE_ENV=production vitest run --root ../.. packages/lynx-location/" }, @@ -47,13 +48,11 @@ "./package.json": "./package.json", "./lynx.lib.json": "./lynx.lib.json", ".": { - "development": "./src/index.ts", "types": "./dist/index.d.ts", "import": "./dist/index.js", "default": "./dist/index.js" }, "./testing": { - "development": "./src/testing/index.ts", "types": "./dist/testing/index.d.ts", "import": "./dist/testing/index.js", "default": "./dist/testing/index.js" diff --git a/packages/lynx-location/tsconfig.build.json b/packages/lynx-location/tsconfig.build.json index 65c9fbc..e80781e 100644 --- a/packages/lynx-location/tsconfig.build.json +++ b/packages/lynx-location/tsconfig.build.json @@ -6,12 +6,7 @@ // Re-asserted because the shared `../../tsconfig.build.json` (applied last // in `extends`) resets `lib`/`types` back to the Node-only defaults. "lib": ["ESNext"], - "types": [], - // Dropped for the emit pass, exactly as `@amritk/mini-lynx` drops it: here - // the `development` condition would put another package's `src` inside this - // one's `rootDir`. The build resolves the bridge through the normal `types` - // condition instead, which is why the root `build` script builds it first. - "customConditions": [] + "types": [] }, "include": ["src/**/*.ts"], "exclude": ["dist", "node_modules", "**/*.test.ts"] diff --git a/packages/lynx-location/tsconfig.json b/packages/lynx-location/tsconfig.json index 70545a9..334a2f3 100644 --- a/packages/lynx-location/tsconfig.json +++ b/packages/lynx-location/tsconfig.json @@ -5,12 +5,7 @@ // them: this is Lynx code, and neither of Lynx's two script contexts is a // browser or a Node process. "lib": ["ESNext"], - "types": [], - // Resolve `@amritk/mini-lynx-native` to the workspace's SOURCE through the - // `development` condition it already declares, rather than to its `dist`. - // CI type-checks BEFORE it builds, so without this a fresh clone would look - // for a `.d.ts` that does not exist yet. - "customConditions": ["development"] + "types": [] }, "include": ["src/**/*.ts"] } diff --git a/packages/lynx-notifications/package.json b/packages/lynx-notifications/package.json index 6ff3f8b..331720e 100644 --- a/packages/lynx-notifications/package.json +++ b/packages/lynx-notifications/package.json @@ -41,6 +41,7 @@ }, "scripts": { "build": "tsgo -p tsconfig.build.json && tsc-alias -p tsconfig.build.json -f && node ../../scripts/strip-comments.mjs", + "prepublishOnly": "node ../../scripts/check-publishable.mjs", "types:check": "tsgo -p . --noEmit", "test": "NODE_ENV=production vitest run --root ../.. packages/lynx-notifications/" }, @@ -48,13 +49,11 @@ "./package.json": "./package.json", "./lynx.lib.json": "./lynx.lib.json", ".": { - "development": "./src/index.ts", "types": "./dist/index.d.ts", "import": "./dist/index.js", "default": "./dist/index.js" }, "./testing": { - "development": "./src/testing/index.ts", "types": "./dist/testing/index.d.ts", "import": "./dist/testing/index.js", "default": "./dist/testing/index.js" diff --git a/packages/lynx-notifications/tsconfig.build.json b/packages/lynx-notifications/tsconfig.build.json index 65c9fbc..e80781e 100644 --- a/packages/lynx-notifications/tsconfig.build.json +++ b/packages/lynx-notifications/tsconfig.build.json @@ -6,12 +6,7 @@ // Re-asserted because the shared `../../tsconfig.build.json` (applied last // in `extends`) resets `lib`/`types` back to the Node-only defaults. "lib": ["ESNext"], - "types": [], - // Dropped for the emit pass, exactly as `@amritk/mini-lynx` drops it: here - // the `development` condition would put another package's `src` inside this - // one's `rootDir`. The build resolves the bridge through the normal `types` - // condition instead, which is why the root `build` script builds it first. - "customConditions": [] + "types": [] }, "include": ["src/**/*.ts"], "exclude": ["dist", "node_modules", "**/*.test.ts"] diff --git a/packages/lynx-notifications/tsconfig.json b/packages/lynx-notifications/tsconfig.json index 70545a9..334a2f3 100644 --- a/packages/lynx-notifications/tsconfig.json +++ b/packages/lynx-notifications/tsconfig.json @@ -5,12 +5,7 @@ // them: this is Lynx code, and neither of Lynx's two script contexts is a // browser or a Node process. "lib": ["ESNext"], - "types": [], - // Resolve `@amritk/mini-lynx-native` to the workspace's SOURCE through the - // `development` condition it already declares, rather than to its `dist`. - // CI type-checks BEFORE it builds, so without this a fresh clone would look - // for a `.d.ts` that does not exist yet. - "customConditions": ["development"] + "types": [] }, "include": ["src/**/*.ts"] } diff --git a/packages/mini-helpers/package.json b/packages/mini-helpers/package.json index bd22f08..da7a1f1 100644 --- a/packages/mini-helpers/package.json +++ b/packages/mini-helpers/package.json @@ -33,19 +33,18 @@ }, "scripts": { "build": "tsgo -p tsconfig.build.json && tsc-alias -p tsconfig.build.json -f && node ../../scripts/strip-comments.mjs", + "prepublishOnly": "node ../../scripts/check-publishable.mjs", "types:check": "tsgo -p . --noEmit", "test": "NODE_ENV=production vitest run --root ../.. packages/mini-helpers/" }, "exports": { "./package.json": "./package.json", ".": { - "development": "./src/index.ts", "types": "./dist/index.d.ts", "import": "./dist/index.js", "default": "./dist/index.js" }, "./schema": { - "development": "./src/schema/index.ts", "types": "./dist/schema/index.d.ts", "import": "./dist/schema/index.js", "default": "./dist/schema/index.js" diff --git a/packages/mini-lynx-native/package.json b/packages/mini-lynx-native/package.json index 04db53a..733b4f5 100644 --- a/packages/mini-lynx-native/package.json +++ b/packages/mini-lynx-native/package.json @@ -34,25 +34,23 @@ }, "scripts": { "build": "tsgo -p tsconfig.build.json && tsc-alias -p tsconfig.build.json -f && node ../../scripts/strip-comments.mjs", + "prepublishOnly": "node ../../scripts/check-publishable.mjs", "types:check": "tsgo -p . --noEmit", "test": "NODE_ENV=production vitest run --root ../.. packages/mini-lynx-native/" }, "exports": { "./package.json": "./package.json", ".": { - "development": "./src/index.ts", "types": "./dist/index.d.ts", "import": "./dist/index.js", "default": "./dist/index.js" }, "./background": { - "development": "./src/background/index.ts", "types": "./dist/background/index.d.ts", "import": "./dist/background/index.js", "default": "./dist/background/index.js" }, "./testing": { - "development": "./src/testing/index.ts", "types": "./dist/testing/index.d.ts", "import": "./dist/testing/index.js", "default": "./dist/testing/index.js" diff --git a/packages/mini-lynx/package.json b/packages/mini-lynx/package.json index b9dd960..8318a1f 100644 --- a/packages/mini-lynx/package.json +++ b/packages/mini-lynx/package.json @@ -38,6 +38,7 @@ }, "scripts": { "build": "tsgo -p tsconfig.build.json && tsgo -p tsconfig.dom.build.json && tsc-alias -p tsconfig.build.json -f && node ../../scripts/strip-comments.mjs", + "prepublishOnly": "node ../../scripts/check-publishable.mjs", "types:check": "tsgo -p . --noEmit && tsgo -p tsconfig.dom.json --noEmit", "test": "NODE_ENV=production vitest run --root ../.. packages/mini-lynx/" }, @@ -48,97 +49,81 @@ "exports": { "./package.json": "./package.json", ".": { - "development": "./src/index.ts", "types": "./dist/index.d.ts", "import": "./dist/index.js", "default": "./dist/index.js" }, "./jsx-runtime": { - "development": "./src/jsx-runtime.ts", "types": "./dist/jsx-runtime.d.ts", "import": "./dist/jsx-runtime.js", "default": "./dist/jsx-runtime.js" }, "./jsx-dev-runtime": { - "development": "./src/jsx-dev-runtime.ts", "types": "./dist/jsx-dev-runtime.d.ts", "import": "./dist/jsx-dev-runtime.js", "default": "./dist/jsx-dev-runtime.js" }, "./bridge": { - "development": "./src/bridge/index.ts", "types": "./dist/bridge/index.d.ts", "import": "./dist/bridge/index.js", "default": "./dist/bridge/index.js" }, "./engine": { - "development": "./src/engine/index.ts", "types": "./dist/engine/index.d.ts", "import": "./dist/engine/index.js", "default": "./dist/engine/index.js" }, "./elements": { - "development": "./src/elements/index.ts", "types": "./dist/elements/index.d.ts", "import": "./dist/elements/index.js", "default": "./dist/elements/index.js" }, "./gestures": { - "development": "./src/gestures/index.ts", "types": "./dist/gestures/index.d.ts", "import": "./dist/gestures/index.js", "default": "./dist/gestures/index.js" }, "./keyboard": { - "development": "./src/keyboard/index.ts", "types": "./dist/keyboard/index.d.ts", "import": "./dist/keyboard/index.js", "default": "./dist/keyboard/index.js" }, "./recycle": { - "development": "./src/recycle/index.ts", "types": "./dist/recycle/index.d.ts", "import": "./dist/recycle/index.js", "default": "./dist/recycle/index.js" }, "./flow": { - "development": "./src/flow/index.ts", "types": "./dist/flow/index.d.ts", "import": "./dist/flow/index.js", "default": "./dist/flow/index.js" }, "./composition": { - "development": "./src/composition/index.ts", "types": "./dist/composition/index.d.ts", "import": "./dist/composition/index.js", "default": "./dist/composition/index.js" }, "./router": { - "development": "./src/router/index.ts", "types": "./dist/router/index.d.ts", "import": "./dist/router/index.js", "default": "./dist/router/index.js" }, "./router/browser": { - "development": "./src/router/browser/index.ts", "types": "./dist/router/browser/index.d.ts", "import": "./dist/router/browser/index.js", "default": "./dist/router/browser/index.js" }, "./forms": { - "development": "./src/forms/index.ts", "types": "./dist/forms/index.d.ts", "import": "./dist/forms/index.js", "default": "./dist/forms/index.js" }, "./query": { - "development": "./src/query/index.ts", "types": "./dist/query/index.d.ts", "import": "./dist/query/index.js", "default": "./dist/query/index.js" }, "./testing": { - "development": "./src/testing/index.ts", "types": "./dist/testing/index.d.ts", "import": "./dist/testing/index.js", "default": "./dist/testing/index.js" diff --git a/packages/mini-lynx/tsconfig.build.json b/packages/mini-lynx/tsconfig.build.json index cb25689..6d13713 100644 --- a/packages/mini-lynx/tsconfig.build.json +++ b/packages/mini-lynx/tsconfig.build.json @@ -11,13 +11,7 @@ "lib": ["ESNext"], "types": [], "jsx": "react-jsx", - "jsxImportSource": "@amritk/mini-lynx", - // Dropped for the emit pass. `tsconfig.json` turns it on so a type-check in - // a fresh clone reads `@amritk/mini-helpers` from source; here that would put - // another package's `src` inside this one's `rootDir`. The build resolves it - // through the normal `types` condition instead — which is why the root - // `build` script builds `mini-helpers` before either package that uses it. - "customConditions": [] + "jsxImportSource": "@amritk/mini-lynx" }, "include": ["src/**/*.ts", "src/**/*.tsx"], // `src/router/browser` is built by `tsconfig.dom.build.json` instead, which is diff --git a/packages/mini-lynx/tsconfig.dom.build.json b/packages/mini-lynx/tsconfig.dom.build.json index f966d22..ca9fc3d 100644 --- a/packages/mini-lynx/tsconfig.dom.build.json +++ b/packages/mini-lynx/tsconfig.dom.build.json @@ -10,11 +10,7 @@ // in `extends` and resets both to the Node-only defaults — the same reason // `tsconfig.build.json` re-asserts its own. "lib": ["ESNext", "DOM"], - "types": [], - // Dropped for the emit pass, exactly as `tsconfig.build.json` drops it: a - // source-resolved `@amritk/mini-helpers` would put another package's `src` - // inside this one's `rootDir`. - "customConditions": [] + "types": [] }, "include": ["src/router/browser/**/*.ts"], "exclude": ["dist", "node_modules", "**/*.test.ts"] diff --git a/packages/mini-lynx/tsconfig.json b/packages/mini-lynx/tsconfig.json index 1731dbd..b73313c 100644 --- a/packages/mini-lynx/tsconfig.json +++ b/packages/mini-lynx/tsconfig.json @@ -26,12 +26,7 @@ "@amritk/mini-lynx/jsx-runtime": ["./src/jsx-runtime.ts"], "@amritk/mini-lynx/jsx-dev-runtime": ["./src/jsx-dev-runtime.ts"], "@amritk/mini-lynx": ["./src/index.ts"] - }, - // Resolve `@amritk/mini-helpers` to the workspace's SOURCE through the - // `development` condition it already declares, rather than to its `dist`. - // CI type-checks BEFORE it builds, so without this a fresh clone would look - // for a `.d.ts` that does not exist yet. - "customConditions": ["development"] + } }, "include": ["src/**/*.ts", "src/**/*.tsx", "examples/**/*.ts", "examples/**/*.tsx"], "exclude": ["dist", "node_modules", "src/router/browser", "examples/js-framework-benchmark/bootstrap.ts"] diff --git a/packages/mini/package.json b/packages/mini/package.json index 1abf7c9..a210f22 100644 --- a/packages/mini/package.json +++ b/packages/mini/package.json @@ -37,6 +37,7 @@ }, "scripts": { "build": "tsgo -p tsconfig.build.json && tsc-alias -p tsconfig.build.json -f && node ../../scripts/strip-comments.mjs", + "prepublishOnly": "node ../../scripts/check-publishable.mjs", "types:check": "tsgo -p . --noEmit", "test": "NODE_ENV=production vitest run --root ../.. packages/mini/" }, @@ -47,55 +48,46 @@ "exports": { "./package.json": "./package.json", ".": { - "development": "./src/index.ts", "types": "./dist/index.d.ts", "import": "./dist/index.js", "default": "./dist/index.js" }, "./jsx-runtime": { - "development": "./src/jsx-runtime.ts", "types": "./dist/jsx-runtime.d.ts", "import": "./dist/jsx-runtime.js", "default": "./dist/jsx-runtime.js" }, "./jsx-dev-runtime": { - "development": "./src/jsx-dev-runtime.ts", "types": "./dist/jsx-dev-runtime.d.ts", "import": "./dist/jsx-dev-runtime.js", "default": "./dist/jsx-dev-runtime.js" }, "./router": { - "development": "./src/router/index.ts", "types": "./dist/router/index.d.ts", "import": "./dist/router/index.js", "default": "./dist/router/index.js" }, "./flow": { - "development": "./src/flow/index.ts", "types": "./dist/flow/index.d.ts", "import": "./dist/flow/index.js", "default": "./dist/flow/index.js" }, "./forms": { - "development": "./src/forms/index.ts", "types": "./dist/forms/index.d.ts", "import": "./dist/forms/index.js", "default": "./dist/forms/index.js" }, "./query": { - "development": "./src/query/index.ts", "types": "./dist/query/index.d.ts", "import": "./dist/query/index.js", "default": "./dist/query/index.js" }, "./hot": { - "development": "./src/hot/index.ts", "types": "./dist/hot/index.d.ts", "import": "./dist/hot/index.js", "default": "./dist/hot/index.js" }, "./vite": { - "development": "./src/vite/index.ts", "types": "./dist/vite/index.d.ts", "import": "./dist/vite/index.js", "default": "./dist/vite/index.js" diff --git a/packages/mini/tsconfig.build.json b/packages/mini/tsconfig.build.json index b1dcfed..13ce722 100644 --- a/packages/mini/tsconfig.build.json +++ b/packages/mini/tsconfig.build.json @@ -9,13 +9,7 @@ "lib": ["ESNext", "DOM", "DOM.Iterable"], "types": [], "jsx": "react-jsx", - "jsxImportSource": "@amritk/mini", - // Dropped for the emit pass. `tsconfig.json` turns it on so a type-check in - // a fresh clone reads `@amritk/mini-helpers` from source; here that would put - // another package's `src` inside this one's `rootDir`. The build resolves it - // through the normal `types` condition instead — which is why the root - // `build` script builds `mini-helpers` before either package that uses it. - "customConditions": [] + "jsxImportSource": "@amritk/mini" }, "exclude": ["dist", "node_modules", "**/*.test.ts", "**/*.test.tsx"] } diff --git a/packages/mini/tsconfig.json b/packages/mini/tsconfig.json index 3808663..e4447ec 100644 --- a/packages/mini/tsconfig.json +++ b/packages/mini/tsconfig.json @@ -15,15 +15,7 @@ "@amritk/mini/jsx-runtime": ["./src/jsx-runtime.ts"], "@amritk/mini/jsx-dev-runtime": ["./src/jsx-dev-runtime.ts"], "@amritk/mini": ["./src/index.ts"] - }, - // Resolve `@amritk/mini-helpers` to the workspace's SOURCE through the - // `development` condition it already declares, rather than to its `dist`. - // CI type-checks BEFORE it builds, so without this a fresh clone would look - // for a `.d.ts` that does not exist yet. The playgrounds resolve the - // packages the same way for the same reason — a condition the exports map - // already carries beats a `paths` table that has to be kept in sync with it. - // Undone in `tsconfig.build.json`, where the emit must resolve to `dist`. - "customConditions": ["development"] + } }, "include": ["src/**/*.ts", "src/**/*.tsx"] } diff --git a/scripts/ai-docs.test.ts b/scripts/ai-docs.test.ts index 8c6de99..0bf6e40 100644 --- a/scripts/ai-docs.test.ts +++ b/scripts/ai-docs.test.ts @@ -1,14 +1,21 @@ import { join } from 'node:path' import { describe, expect, it } from 'vitest' -import { type AiDocFinding, auditAiDocs, auditPackage, type PackageManifest, publicValueExports } from './ai-docs' +import { + type AiDocFinding, + auditAiDocs, + auditPackage, + type PackageManifest, + publicValueExports, + sourceForTarget, +} from './ai-docs' const MANIFEST: PackageManifest = { name: '@amritk/thing', files: ['dist', 'AI.md'], exports: { './package.json': './package.json', - '.': { development: './src/index.ts' }, + '.': { types: './dist/index.d.ts', import: './dist/index.js', default: './dist/index.js' }, }, } @@ -57,7 +64,7 @@ describe('ai-docs', () => { it('flags a published subpath the AI.md never names', () => { const manifest = { ...MANIFEST, - exports: { ...MANIFEST.exports, './testing': { development: './src/testing/index.ts' } }, + exports: { ...MANIFEST.exports, './testing': { import: './dist/testing/index.js' } }, } const findings = auditPackage(manifest, reader({ 'AI.md': DOC, './src/index.ts': '' })) expect(problems(findings)).toEqual(['never mentions the published subpath "@amritk/thing/testing"']) @@ -68,7 +75,7 @@ describe('ai-docs', () => { it('does not ask for the JSX transform subpaths to be documented', () => { const manifest = { ...MANIFEST, - exports: { ...MANIFEST.exports, './jsx-runtime': { development: './src/jsx-runtime.ts' } }, + exports: { ...MANIFEST.exports, './jsx-runtime': { import: './dist/jsx-runtime.js' } }, } const findings = auditPackage(manifest, reader({ 'AI.md': DOC, './src/index.ts': '' })) expect(findings).toEqual([]) @@ -91,6 +98,20 @@ describe('ai-docs', () => { expect(publicValueExports(source).sort()).toEqual(['doThing', 'made', 'renamed']) }) + // This mapping replaced the `development` condition, which named the source + // outright. It is the audit's only route from a manifest to a file now, so a + // silent null here would take every export check down with it. + it('maps a built export target back to the source it came from', () => { + expect(sourceForTarget({ import: './dist/index.js' })).toBe('./src/index.ts') + expect(sourceForTarget({ import: './dist/router/browser/index.js' })).toBe('./src/router/browser/index.ts') + expect(sourceForTarget({ default: './dist/jsx-runtime.js' })).toBe('./src/jsx-runtime.ts') + }) + + it('has no source to offer for an entry that names no built module', () => { + expect(sourceForTarget('./package.json')).toBeNull() + expect(sourceForTarget({ import: './lynx.lib.json' })).toBeNull() + }) + // The gate itself: the checked-in packages have to satisfy their own contract. it('finds nothing wrong with the packages in this repo', () => { expect(auditAiDocs(join(import.meta.dirname, '..'))).toEqual([]) diff --git a/scripts/ai-docs.ts b/scripts/ai-docs.ts index 2fac56b..ed3b7d0 100644 --- a/scripts/ai-docs.ts +++ b/scripts/ai-docs.ts @@ -18,10 +18,28 @@ import { join } from 'node:path' /** * One `exports` entry. A bare string for `./package.json`-style passthroughs, - * otherwise the condition map — of which only `development` matters here, - * because it is the one condition pointing at source this can read. + * otherwise the condition map, whose `import`/`default` name the built module. */ -export type ExportTarget = string | { development?: string } +export type ExportTarget = string | { import?: string; default?: string } + +/** + * The source file a `./dist/` export target was built from. + * + * The exports maps used to carry a `development` condition naming the source + * outright, and this read it. That condition is gone — it pointed at `./src/*.ts` + * inside a tarball that ships `src`, so anything honouring it got raw TypeScript + * — and the mapping replaces it. It holds because every package builds with + * `rootDir: "src"` and `outDir: "dist"`, which is what makes `dist/x/index.js` + * and `src/x/index.ts` the same module by construction. + * + * Returns null for an entry with no dist target, so the caller can tell "not a + * module I can audit" from "a module whose source I failed to find". + */ +export const sourceForTarget = (target: ExportTarget): string | null => { + const built = typeof target === 'object' ? (target.import ?? target.default) : undefined + if (built === undefined || !built.startsWith('./dist/') || !built.endsWith('.js')) return null + return `./src/${built.slice('./dist/'.length, -'.js'.length)}.ts` +} export type PackageManifest = { name?: string @@ -117,8 +135,8 @@ const subpathsOf = (manifest: PackageManifest): string[] => Object.entries(manifest.exports ?? {}) .filter(([subpath, target]) => { if (subpath.endsWith('.json') || TRANSFORM_SUBPATHS.includes(subpath)) return false - // Only entries with a `development` condition point at source we can read. - return typeof target === 'object' && typeof target.development === 'string' + // Only entries naming a built module have source behind them to read. + return sourceForTarget(target) !== null }) .map(([subpath]) => subpath) @@ -171,8 +189,8 @@ export const auditPackage = ( continue } const target = manifest.exports?.[subpath] - const entry = typeof target === 'object' ? target.development : undefined - const source = entry ? read(entry) : null + const entry = target === undefined ? null : sourceForTarget(target) + const source = entry === null ? null : read(entry) if (source === null) continue const allowed = new Set(INTERNAL_EXPORTS[specifier] ?? []) diff --git a/scripts/bench-compare.ts b/scripts/bench-compare.ts index 5773ee1..e36f719 100644 --- a/scripts/bench-compare.ts +++ b/scripts/bench-compare.ts @@ -2,7 +2,7 @@ import { execFileSync } from 'node:child_process' import { existsSync, writeFileSync } from 'node:fs' import { join, resolve } from 'node:path' import { gzipSync } from 'node:zlib' -import { build } from 'esbuild' +import { build, type Plugin } from 'esbuild' /** * bench-compare — measures the bundled, gzipped size of every `@amritk/mini` @@ -100,6 +100,31 @@ const gitSha = (tree: string): string => { } } +/** + * Resolves `@amritk/*` imports to the source inside the tree being measured. + * + * The `development` export condition used to do this, and it is gone. The bench + * measures a source tree and never runs a build, so without a source resolver a + * cross-package import — `@amritk/mini`'s router reaching `@amritk/mini-helpers` + * — resolves to a `dist/` that is not there and the entry reports "measure + * failed" instead of a number. Returning null for anything it cannot place + * hands the specifier back to esbuild, so a genuine third-party `@amritk` + * dependency still resolves from node_modules as it should. + */ +const workspaceSource = (tree: string, external: readonly string[]): Plugin => ({ + name: 'workspace-source', + setup(build) { + build.onResolve({ filter: /^@amritk\// }, ({ path }) => { + if (external.includes(path)) return { external: true } + const [, pkg, ...subpath] = path.split('/') + const base = join(tree, 'packages', pkg ?? '', 'src', ...subpath) + // `./router` is a directory with an index; `./jsx-runtime` is a file. + const candidate = [join(base, 'index.ts'), `${base}.ts`].find((file) => existsSync(file)) + return candidate === undefined ? null : { path: candidate } + }) + }, +}) + /** * Gzipped size of a bundled entry in one tree, or `null` when the entry does * not exist there — a subpath the baseline predates renders "n/a (new entry)" @@ -119,13 +144,7 @@ const measureBundle = async (tree: string, entry: string, external: readonly str platform: 'browser', target: 'es2022', external: [...external], - // Resolve workspace packages to their `src/` through the `development` - // condition they declare, the same way the playgrounds and both - // `types:check` passes do. This bench measures a source tree and never - // runs a build, so without it a cross-package import — `@amritk/mini`'s - // router reaching `@amritk/mini-helpers` — resolves to a `dist/` that is - // not there and the entry reports "measure failed" instead of a number. - conditions: ['development'], + plugins: [workspaceSource(tree, external)], }) return { median: gzipSync(result.outputFiles[0]?.contents ?? new Uint8Array()).length } } catch (error) { diff --git a/scripts/check-publishable.mjs b/scripts/check-publishable.mjs new file mode 100644 index 0000000..55015c3 --- /dev/null +++ b/scripts/check-publishable.mjs @@ -0,0 +1,83 @@ +import { existsSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' + +// The last gate before a tarball leaves the machine. `@amritk/mini-lynx-native@0.2.0` +// went to npm as src-only: it was published by hand rather than through the release +// job, so neither `bun run build` nor copy-license ran, and the `development` condition +// the exports maps carried at the time survived into the manifest. The manifest still +// promised `./dist/index.js`, the tarball carried no dist at all, and that condition +// pointed at `./src/*.ts` — which does ship — so anything honouring it resolved raw +// TypeScript and looked healthy. That is how a broken package stayed usable enough +// that nobody noticed for a release. +// +// `npm publish` runs this from the package root as `prepublishOnly`, so publishing out +// of band fails loudly instead of shipping a manifest whose exports point at files that +// are not there. The condition itself is gone from every exports map now, which is why +// the check for it reads as a regression guard: reintroducing one would restore exactly +// the failure above, and the publish is the last place to say so. The equivalent checks +// in scripts/dist-smoke.test.ts and scripts/consumer-e2e.test.ts guard the same ground +// for the repo; this one guards the act of publishing, which a CI step cannot reach. + +/** Every `./dist/...` target declared anywhere in an exports subtree. */ +const distTargets = (node, found = []) => { + if (typeof node === 'string') { + if (node.startsWith('./dist/')) found.push(node) + return found + } + if (node === null || typeof node !== 'object') return found + for (const value of Object.values(node)) distTargets(value, found) + return found +} + +/** True when a `development` condition survives anywhere in an exports subtree. */ +const hasDevelopmentCondition = (node) => { + if (node === null || typeof node !== 'object') return false + if ('development' in node) return true + return Object.values(node).some(hasDevelopmentCondition) +} + +/** + * Returns the reasons `dir` must not be published, empty when it is publishable. + * A private package is publishable by definition — npm will not publish it. + */ +export const checkPublishable = (dir) => { + const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf-8')) + if (pkg.private === true) return [] + + const problems = [] + + const targets = [...new Set(distTargets(pkg.exports ?? {}))] + if (targets.length === 0) { + // Not a stylistic complaint: an exports map with no dist target is how this + // check goes quiet, and a quiet check is worse than no check. + problems.push('exports declares no ./dist/ target — the build output is not reachable from the manifest') + } + const missing = targets.filter((target) => !existsSync(join(dir, target))) + if (missing.length > 0) { + problems.push(`exports points at files that do not exist — run \`bun run build\`:\n ${missing.join('\n ')}`) + } + + if (hasDevelopmentCondition(pkg.exports ?? {})) { + // The condition resolves to `./src/*.ts`, and src ships, so leaving it in + // hands raw TypeScript to any consumer or bundler that honours it. + problems.push('exports carries a `development` condition — it resolves to the src this ships, so it cannot go out') + } + + if (!existsSync(join(dir, 'LICENSE'))) { + problems.push('no LICENSE in the package directory — run `bun run scripts/copy-license.ts`') + } + + return problems +} + +if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) { + const dir = process.argv[2] ?? process.cwd() + const problems = checkPublishable(dir) + if (problems.length > 0) { + const name = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf-8')).name ?? dir + console.error(`${name} is not publishable as it stands:\n\n${problems.map((p) => `- ${p}`).join('\n')}\n`) + console.error('Publish through the release workflow (`bun run release:publish`), which prepares all three.') + process.exit(1) + } +} diff --git a/scripts/check-publishable.test.ts b/scripts/check-publishable.test.ts new file mode 100644 index 0000000..bd67cea --- /dev/null +++ b/scripts/check-publishable.test.ts @@ -0,0 +1,88 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' + +// @ts-expect-error -- plain JS so `prepublishOnly` can run it under bare node. +import { checkPublishable } from './check-publishable.mjs' + +const HEALTHY = { + name: '@amritk/healthy', + exports: { + './package.json': './package.json', + '.': { types: './dist/index.d.ts', import: './dist/index.js', default: './dist/index.js' }, + }, +} + +describe('check-publishable', () => { + let root: string + + /** Writes a package directory, creating whichever of dist/LICENSE it is told to. */ + const write = async ( + name: string, + pkg: Record, + { dist = true, license = true }: { dist?: boolean; license?: boolean } = {}, + ): Promise => { + const dir = join(root, name) + await mkdir(dir, { recursive: true }) + await writeFile(join(dir, 'package.json'), JSON.stringify(pkg), 'utf-8') + if (dist) { + await mkdir(join(dir, 'dist'), { recursive: true }) + await writeFile(join(dir, 'dist/index.js'), 'export const ok = true\n', 'utf-8') + await writeFile(join(dir, 'dist/index.d.ts'), 'export declare const ok: boolean\n', 'utf-8') + } + if (license) await writeFile(join(dir, 'LICENSE'), 'MIT License\n', 'utf-8') + return dir + } + + beforeAll(async () => { + root = await mkdtemp(join(tmpdir(), 'mini-check-publishable-')) + }) + + afterAll(async () => { + await rm(root, { recursive: true, force: true }) + }) + + it('passes a package whose build output, manifest and LICENSE all line up', async () => { + expect(checkPublishable(await write('healthy', HEALTHY))).toEqual([]) + }) + + it('rejects the shape that shipped: a manifest promising dist against a tree with none', async () => { + const problems = checkPublishable(await write('unbuilt', HEALTHY, { dist: false })) + expect(problems).toHaveLength(1) + expect(problems[0]).toContain('./dist/index.js') + expect(problems[0]).toContain('bun run build') + }) + + it('rejects a surviving development condition, which resolves to the src it also ships', async () => { + const pkg = { + ...HEALTHY, + exports: { ...HEALTHY.exports, '.': { development: './src/index.ts', ...HEALTHY.exports['.'] } }, + } + const problems = checkPublishable(await write('undstripped', pkg)) + expect(problems).toHaveLength(1) + expect(problems[0]).toContain('development') + }) + + it('rejects a missing LICENSE, which npm only bundles from inside the package', async () => { + const problems = checkPublishable(await write('unlicensed', HEALTHY, { license: false })) + expect(problems).toHaveLength(1) + expect(problems[0]).toContain('LICENSE') + }) + + it('rejects an exports map with no dist target at all, rather than passing vacuously', async () => { + const pkg = { name: '@amritk/srconly', exports: { '.': { default: './src/index.ts' } } } + const problems = checkPublishable(await write('srconly', pkg)) + expect(problems).toHaveLength(1) + expect(problems[0]).toContain('no ./dist/ target') + }) + + it('reports every problem at once, so one publish attempt names them all', async () => { + expect(checkPublishable(await write('broken', HEALTHY, { dist: false, license: false }))).toHaveLength(2) + }) + + it('has nothing to say about a private package, which npm will not publish', async () => { + const pkg = { name: '@amritk/playground', private: true, exports: { '.': './src/index.ts' } } + expect(checkPublishable(await write('private', pkg, { dist: false, license: false }))).toEqual([]) + }) +}) diff --git a/scripts/consumer-e2e.test.ts b/scripts/consumer-e2e.test.ts index 4565bb3..1f61bb4 100644 --- a/scripts/consumer-e2e.test.ts +++ b/scripts/consumer-e2e.test.ts @@ -5,14 +5,12 @@ import { join } from 'node:path' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { ROOT, runCommand, runNode } from './e2e-helpers' -import { stripDevelopment } from './strip-development-exports' import { type PackageJson, resolveProtocols } from './workspace-protocol' /** * The npm consumer's path, end to end: pack both packages exactly the way - * `release:publish` does — `catalog:`/`workspace:` specifiers resolved, the - * `development` condition stripped — install the tarballs into scratch - * projects, and drive them under plain Node. + * `release:publish` does — `catalog:`/`workspace:` specifiers resolved — + * install the tarballs into scratch projects, and drive them under plain Node. * * Nothing else in the pipeline sees this. The unit tests alias both packages * to their `src/`, and dist-smoke loads `dist/` by absolute path — so a broken @@ -176,7 +174,6 @@ describe('consumer-e2e', () => { const copyDir = join(workDir, 'pack-src', pkg.name.replace('/', '__')) await cp(dir, copyDir, { recursive: true, filter: (source) => !source.includes('node_modules') }) resolveProtocols(pkg, versions, rootPkg) - stripDevelopment(pkg.exports) await writeFile(join(copyDir, 'package.json'), `${JSON.stringify(pkg, null, 2)}\n`, 'utf-8') const { stdout } = await runCommand('npm', ['pack', '--pack-destination', packDir], { cwd: copyDir }) @@ -256,9 +253,12 @@ describe('consumer-e2e', () => { for (const name of PUBLISHED) { const packageDir = join(bareDir, 'node_modules', name) // Every package ships its sources for source maps and go-to-definition, - // which is precisely why the condition must be gone: it points at real - // files in the tarball, so a bundler honouring `development` would hand a - // consumer raw TypeScript instead of the built JS. + // and that is exactly why no exports map may name a `development` + // condition: it would point at real files in the tarball, so a bundler + // honouring it hands a consumer raw TypeScript instead of the built JS. + // The condition is no longer in any manifest and nothing strips it at + // publish time any more, so this is the guard against it coming back — + // `@amritk/mini-lynx-native@0.2.0` shipped with one and resolved to src. expect(existsSync(join(packageDir, 'src/index.ts')), `${name} ships src`).toBe(true) const pkg = await readManifest(join(packageDir, 'package.json')) expect(JSON.stringify(pkg.exports), `${name} exports`).not.toContain('development') diff --git a/scripts/copy-license.ts b/scripts/copy-license.ts index 179c5ba..0ff6a1a 100755 --- a/scripts/copy-license.ts +++ b/scripts/copy-license.ts @@ -8,8 +8,8 @@ import { pathToFileURL } from 'node:url' * npm only bundles a LICENSE file that sits inside the package directory — * the root license never reaches the tarball on its own, so published * packages would carry a `license: MIT` field with no license text. Like - * resolve-workspace-protocol and strip-development-exports, this runs in the - * ephemeral publish job and is never committed. + * resolve-workspace-protocol, this runs in the ephemeral publish job and is + * never committed. */ export const copyLicenses = async (root: string): Promise => { const source = join(root, 'LICENSE') diff --git a/scripts/dist-smoke.test.ts b/scripts/dist-smoke.test.ts index 9af5973..807d6f8 100644 --- a/scripts/dist-smoke.test.ts +++ b/scripts/dist-smoke.test.ts @@ -12,7 +12,7 @@ import { ROOT, runNode } from './e2e-helpers' * there can catch a build step corrupting the output: the sources can be * perfect while `tsgo`, `tsc-alias`, or the comment-stripping pass emits * something Node cannot load. Everything here runs under plain `node` (no Bun, - * no aliases, no `development` export condition) for that reason. + * no aliases) for that reason. * * Requires a prior `bun run build`; run via `bun run test:dist`. */ @@ -62,8 +62,8 @@ const declaredEntries = (pkg: PackageJson, dir: string): string[] => { const targets: string[] = [] const walk = (node: unknown): void => { if (typeof node === 'string') { - // The `development` condition points at src/ and is stripped at publish - // time; package.json itself is not a module to resolve. + // Only the built modules are targets to check; `package.json` and the + // native autolinker's `lynx.lib.json` are data, not modules to resolve. if (node.startsWith('./dist/')) targets.push(join(PACKAGES_DIR, dir, node)) return } diff --git a/scripts/strip-development-exports.test.ts b/scripts/strip-development-exports.test.ts deleted file mode 100644 index 0a21525..0000000 --- a/scripts/strip-development-exports.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterAll, beforeEach, describe, expect, it } from 'vitest' - -import { stripDevelopment, stripDevelopmentExports } from './strip-development-exports' - -describe('strip-development-exports', () => { - let root: string - - const writePackage = async (name: string, pkg: Record): Promise => { - const dir = join(root, 'packages', name) - await mkdir(dir, { recursive: true }) - const path = join(dir, 'package.json') - await writeFile(path, `${JSON.stringify(pkg, null, 2)}\n`, 'utf-8') - return path - } - - const readPackage = async (path: string): Promise<{ name?: string; exports?: Record }> => - JSON.parse(await readFile(path, 'utf-8')) as { name?: string; exports?: Record } - - beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'mini-strip-dev-')) - }) - - afterAll(async () => { - await rm(root, { recursive: true, force: true }) - }) - - it('removes the development condition at every depth of an exports map', () => { - const exports = { - '.': { development: './src/index.ts', types: './dist/index.d.ts', import: './dist/index.js' }, - './hosts/memory': { development: './src/hosts/memory.ts', import: './dist/hosts/memory.js' }, - } - expect(stripDevelopment(exports)).toBe(true) - expect(exports).toEqual({ - '.': { types: './dist/index.d.ts', import: './dist/index.js' }, - './hosts/memory': { import: './dist/hosts/memory.js' }, - }) - }) - - it('reports no change for an exports map that never mentions development', () => { - const exports = { '.': { types: './dist/index.d.ts', import: './dist/index.js' } } - expect(stripDevelopment(exports)).toBe(false) - expect(exports).toEqual({ '.': { types: './dist/index.d.ts', import: './dist/index.js' } }) - }) - - it('leaves a plain string target alone', () => { - // `"./package.json": "./package.json"` is a string, not a conditions - // object — the walk must not choke on it or try to rewrite it. - expect(stripDevelopment('./package.json')).toBe(false) - }) - - it('rewrites every workspace manifest that carries the condition', async () => { - const miniPath = await writePackage('mini', { - name: '@amritk/mini', - exports: { - './package.json': './package.json', - '.': { development: './src/index.ts', types: './dist/index.d.ts', import: './dist/index.js' }, - }, - }) - const nativePath = await writePackage('mini-lynx', { - name: '@amritk/mini-lynx', - exports: { '.': { development: './src/index.ts', import: './dist/index.js' } }, - }) - - expect((await stripDevelopmentExports(root)).sort()).toEqual(['@amritk/mini', '@amritk/mini-lynx']) - - const mini = await readPackage(miniPath) - expect(mini.exports).toEqual({ - './package.json': './package.json', - '.': { types: './dist/index.d.ts', import: './dist/index.js' }, - }) - // Fields other than `exports` survive the rewrite untouched. - expect(mini.name).toBe('@amritk/mini') - expect((await readPackage(nativePath)).exports).toEqual({ '.': { import: './dist/index.js' } }) - }) - - it('is idempotent, so a second publish attempt rewrites nothing', async () => { - await writePackage('mini', { - name: '@amritk/mini', - exports: { '.': { development: './src/index.ts', import: './dist/index.js' } }, - }) - expect(await stripDevelopmentExports(root)).toEqual(['@amritk/mini']) - expect(await stripDevelopmentExports(root)).toEqual([]) - }) - - it('skips a package with no exports map at all', async () => { - await writePackage('tooling', { name: '@amritk/tooling', private: true }) - expect(await stripDevelopmentExports(root)).toEqual([]) - }) -}) diff --git a/scripts/strip-development-exports.ts b/scripts/strip-development-exports.ts deleted file mode 100755 index 182e7ad..0000000 --- a/scripts/strip-development-exports.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { readdir, readFile, writeFile } from 'node:fs/promises' -import { join } from 'node:path' -import { pathToFileURL } from 'node:url' - -type Exports = Record -type PackageJson = { name?: string; exports?: Exports } - -/** - * Recursively deletes every `development` condition within an exports subtree. - * Returns true when something was removed, so a manifest that is already clean - * is left untouched on disk. - */ -export const stripDevelopment = (node: unknown): boolean => { - if (node === null || typeof node !== 'object') return false - let changed = false - const conditions = node as Record - if ('development' in conditions) { - delete conditions.development - changed = true - } - for (const value of Object.values(conditions)) { - if (stripDevelopment(value)) changed = true - } - return changed -} - -/** - * Strips the `development` condition from every workspace package's `exports`. - * - * Locally we resolve packages to their TypeScript source via the `development` - * export condition (see `--conditions development` and the src aliases in - * vitest.config.ts). That condition points at `./src/*.ts`, and both packages - * deliberately ship `src` in their `files` list — so a consumer or bundler - * resolving `development` against a published tarball finds real files and - * pulls in raw, untranspiled TypeScript instead of the built JS in `dist`. - * - * Removing the condition from the published manifests closes that hole. Like - * resolve-workspace-protocol and copy-license, this runs in the ephemeral - * publish job and is never committed. - * - * Returns the names of the packages whose manifests were rewritten. - */ -export const stripDevelopmentExports = async (root: string): Promise => { - const packagesDir = join(root, 'packages') - const stripped: string[] = [] - - for (const entry of await readdir(packagesDir, { withFileTypes: true })) { - if (!entry.isDirectory()) continue - const path = join(packagesDir, entry.name, 'package.json') - const pkg = JSON.parse(await readFile(path, 'utf-8')) as PackageJson - if (!pkg.exports) continue - - if (stripDevelopment(pkg.exports)) { - await writeFile(path, `${JSON.stringify(pkg, null, 2)}\n`, 'utf-8') - stripped.push(pkg.name ?? entry.name) - } - } - - return stripped -} - -if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) { - stripDevelopmentExports(join(import.meta.dir, '..')) - .then((stripped) => { - for (const name of stripped) console.log(`Stripped development exports in ${name}`) - }) - .catch((error: unknown) => { - console.error(error instanceof Error ? error.message : error) - process.exit(1) - }) -}