build(types)!: ship the ./zod subpath as ONE bundled module (#8598) - #8646
Conversation
Build the `./zod` subpath as ONE module, so the objectui#8344 node recursion-point fill survives a bundler that honours `"sideEffects": false`. The config overwrites `dist/zod/index.zod.js` in place, after `tsc` has written the rest of `dist/`; no schema, no `exports` entry and no publish contract field moves. The manifest half — `packages/types` `build` running `vite build` between `tsc` and the completeness count — is deliberately NOT in this commit. It is the card's stop-and-report point and is posted on objectui#8598 for approval, so the config here is inert until it lands. Two further files are withheld with it because they are RED until it lands, measured rather than assumed: the built-artifact pin for the bundled face, and the build-script expectation in `package-exports-manifest.test.ts` that asks, by design, to be edited and justified when a bundler step arrives. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
Contract review (
|
…#8598) Lands the manifest half the card held for the stop-and-report point, ruled **A** by the director seat's decision batch #103. `packages/types` now builds `tsc && vite build && node ../../scripts/check-dist-completeness.mjs`, and declares `vite` explicitly like the other 23 packages that carry a `vite.config.ts`. With this, `dist/zod/index.zod.js` is ONE self-contained module and a consumer importing a single schema from `@object-ui/types/zod` gets the same accept set as one importing the barrel: a nested off-spec node is REFUSED where it was silently ACCEPTED. The lockfile is settled in the same commit, per the ruling's two measurable conditions. The importer block is pinned to the resolution already locked for 28 of the 29 existing vite importers, so the diff is exactly three added lines and `vite` stays at ONE version across all 30 importers. A naive install was measured first and rejected: it lands 8.2.2 beside the rest and drags an unrelated `picomatch` / `rolldown` refresh with it. Also lands the two pins that could not travel without this: - `package-exports-manifest.test.ts` asks, by design, for its build-script expectation to be edited and justified when a bundler step arrives. The justification is written in: lib mode, `formats: ['es']`, ONE format, so the `require`-less exports map below it stays correct. - `zod-subpath-single-module-8598.dist.spec.tsx` is the built-artifact pin — the shipped barrel imports no sibling category module, and `CardSchema` from it REFUSES the nested off-spec node while still accepting the well-formed control. `zod-subpath-bundle-config-8598.test.ts` moves its config import behind a variable specifier. A literal one pulls `vite.config.ts` into this package's type program (`tsconfig.test.json` compiles every test, deliberately), and the config imports the untyped `vitest-invocation-guard.mjs` — TS7016, measured on this tree. The runtime import, and every assertion, is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w
./zod subpath as ONE module (#8598) — manifest half withheld./zod subpath as ONE bundled module (#8598)
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
`Test (shard 2/4)` went red on `8b8cbfd` on a diff that touched no test.
`cli-bin.test.ts` self-builds `@object-ui/types` when the zod bundle is
absent — CI's test job has no build step by design — and it spawned
`pnpm --filter @object-ui/types run build` with the vitest worker's whole
environment. Vitest sets `VITEST=true`; children inherit it; all 24
`packages/*` vite configs open with
`if (process.env.VITEST) { assertCanonicalVitestInvocation(...) }`; and that
guard derives its vitest root from `cwd`, which `pnpm --filter` sets to the
package directory. So the guard read a build as a vitest run launched from the
wrong place and exited 1 before the bundler started.
⭐ The build script, the spawning test and the guard are each correct alone.
This PR created the intersection by making `@object-ui/types` the first
vite-built package that a test builds from inside vitest; the cli's own
self-build survives only because it builds with `tsup`.
Fixed at the SPAWN — the environment is what is false there, and it is the one
place that knows the child is a build rather than a test run. ⛔ Not fixed by
gating the config on vite's `command`: that diverges 1 of 24 byte-identical
guard blocks, and `scripts/__tests__/vitest-invocation-guard.test.ts` refuses
that divergence mechanically — its own case name is "gates that call on VITEST,
so `vite build` is never refused", i.e. the property the leak falsifies from
outside, where no config can see it.
`scripts/__tests__/spawned-build-vitest-env-8598.test.ts` keeps the class shut:
it derives every build spawn in the test tree by AST, resolves an `env:`
identifier to its declaration, and requires `VITEST` to be scrubbed. Both of
today's spawns are covered; a third cannot land unguarded.
Also lands the two owed items from the review: the unit pin's header now says
that importing the config EXECUTES that same guard and why it passes here, and
the changeset gains the deep-link sentence — a raw-path sibling keeps the
pre-#8344 accept set, is not reachable through `exports`
(`ERR_PACKAGE_PATH_NOT_EXPORTED`), and mixing it with the bundled entry yields
two distinct schema instances (measured).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
Fixes #8598.
Manifest half landed under decision batch #103 (option A). The contract review then returned FAIL on
8b8cbfdfor one real defect this PR introduced, plus two owed items; all three are addressed ond7a81dbbe6ca5bc33a4c92251928cbbdc452220f.Test (shard 2/4)was red on8b8cbfdon a diff that touched no test at all.packages/cli/src/__tests__/cli-bin.test.tsself-builds@object-ui/typeswhen the zod bundle is absent — CI's test job has no build step, by design — and it spawnedpnpm --filter @object-ui/types run buildwith the vitest worker's entire environment. Vitest setsVITEST=true, children inherit it, all 24packages/*vite configs open withif (process.env.VITEST) { assertCanonicalVitestInvocation(...) }, and that guard derives its vitest root fromcwd— whichpnpm --filtersets to the package directory. So the guard read a build as a vitest run launched from the wrong place and calledprocess.exit(1)before the bundler started.⭐ The build script, the spawning test and the guard are each correct in isolation. This PR created the intersection: it made
@object-ui/typesthe first vite-built package that a test builds from inside vitest. The cli's own self-build survives only because it builds withtsup. On8e028c2the build script never loaded the config, so nothing could have surfaced it earlier.Fix home chosen: the spawn, not the config
VITESTfrom the spawned build's env (BUILD_ENVincli-bin.test.ts, applied to both build spawns in that file — the only two in the repo)command: 'build'instead of the variableThree reasons, in order of force:
VITESTmeans "vitest is loading this config". In a build the test starts, that is untrue. The spawn is the only place that knows its child is a build rather than a test run.scripts/__tests__/vitest-invocation-guard.test.tsrequires the literalif (process.env.VITEST) {+assertCanonicalVitestInvocation(shape in everypackages/*vite config. Gating oncommandwould red that ratchet and diverge 1 of 24 byte-identical blocks.vite buildis never refused". The leak falsifies it from OUTSIDE, where no config can see it.What the rejected fix would still leave open: it immunises one config and leaves the other 23 exactly as exposed — the next test that spawns a build of
@object-ui/fields,plugin-gridor any of the rest breaks the same way. The chosen fix is caller-side, so it holds no matter which package a future spawn targets.Item 4 — the class, not just the symptom
The review recorded this class LOW last round and it became the HIGH defect this round. Closed two ways:
scripts/__tests__/spawned-build-vitest-env-8598.test.ts(new) derives every build spawn in the test tree by AST, resolves anenv:identifier to its declaration in the same file, and requiresVITESTto be scrubbed. Population floor plus a named member, so an empty walk goes red rather than green.zod-subpath-bundle-config-8598.test.ts's header now says that importing the config executes that same guard, why it passes there (canonical root invocation) and where the harmful face of the class was repaired.env:property text, and the fix is naturally written as a named constant (env: BUILD_ENV), which contains noVITESTtoken. Resolving the identifier to its declaration is what makes it a measurement. A gate that reports a fixed tree as broken is one edit away from a gate that reports a broken tree as fixed.Both legs of the re-measurement
Leg (c) is the review's prescribed shape: from the repo root, with
packages/types/dist/zod/index.zod.jsabsent,pnpm exec vitest run packages/cli/src/__tests__/cli-bin.test.ts.8b8cbfd)d7a81db)Failed to build @object-ui/types for tests, 1 file failedVITEST=true pnpm --filter @object-ui/types run buildvitest root: …/packages/typesvs 仓库根…/objectui-issue-8598)env -u VITEST✓ built in 157ms, 124 files verified⭐ Leg (a) staying red is the point: the guard is untouched and still refuses a
VITEST-marked build. What changed is that no test lies to it any more. And on leg (c) after the fix, the test's ownbeforeAllrebuilt the artifact and it is the bundle — 0 relative specifiers — so the CLI suite now exercises the new published face.Ratchet reverse-verification, hash-proven and trap-restored: fixed file
sha256 9f633e13…with 2 scrubs → mutated to70e38e39…with 1 → the ratchet goes red namingcli-bin.test.ts:103→ restored to9f633e13…, 2 scrubs.Item 5 — the changeset now carries the deep-link sentence
It was byte-unchanged in the previous increment; the statement lived only here. Added to
.changeset/8598-zod-subpath-single-bundled-module.md, and re-measured rather than inherited: a raw-path sibling keeps the pre-#8344 accept set (nested off-spec ACCEPTED), the path is not reachable through the package name (ERR_PACKAGE_PATH_NOT_EXPORTED), and mixing a raw-path import with the bundled entry yields two distinct schema instances (Bundled === RawPath→false).⭐ Item 6 —
check:published-distis measured locally or nowhereThe ruling said CI would run it on the moved head. It cannot.
published-dist-gate.ymlfires only onworkflow_dispatch, cron41 3 * * *, andpush: main, and its own header says why: "the gate has to build all 39 published packages. This repository deliberately has no per-PR full-repo build."⇒ The reading below is the only one this change will ever get. ⛔ It is not deferred to CI:
Build Docs(#8647) andcheck:node-esm-load.What ships
packages/types/package.json"build": "tsc && vite build && node ../../scripts/check-dist-completeness.mjs"+"vite": "^8.2.1"pnpm-lock.yamlviteentry for this importer, nothing elsepackages/types/vite.config.ts./zodas ONE module, overwritingdist/zod/index.zod.jsin placepackages/types/src/__tests__/zod-subpath-bundle-config-8598.test.tsunitpin on the config, chieflyemptyOutDir: falsepackages/types/src/__tests__/zod-subpath-single-module-8598.dist.spec.tsxpackages/types/src/__tests__/package-exports-manifest.test.tspackages/cli/src/__tests__/cli-bin.test.tsscripts/__tests__/spawned-build-vitest-env-8598.test.ts.changeset/8598-zod-subpath-single-bundled-module.mdThe defect this card closes, in one paragraph
@object-ui/typesdeclares"sideEffects": false.src/zod/index.zod.tsfills the #8344 node recursion point as the initializer of itsAnyComponentSchemaconst, andtscemitted that barrel as a module whose only other content is re-exports — so a bundler resolvingimport { CardSchema } from '@object-ui/types/zod'followed the re-export todist/zod/layout.zod.js, needed nothing from the barrel's body, and the flag let it drop that body whole, fill included. Bundling removes the boundary the drop needs.The H-check — three bundlers, each with a control
Taken on
8b8cbfd; the increment since touches nopackages/typessource or build input, so the artifact is unchanged (sha256 e06b8a5b…). Entry in every row: onlyCardSchema;zodand@objectstack/specexternal. Left column is the ablation.tscbarrel (ablated)./zodRollup is measured beside Vite because Vite 8 bundles with rolldown, not rollup. Well-formed control (
size: 16) ACCEPTED in all six cells. Ablation hash-proven (e06b8a5b…/ 0 relative specifiers / 331,934 B →4b46cbda…/ 29 / 15,943 B) and restored byte-identical under anEXIT INT TERMtrap.Build Docsis a no-regression reading, not an accept-set one — it is green on the ablated artifact too, and per #8647 the CI check skips the build entirely on apackages/**-only diff. The Turbopack accept-set reading is the third row, from a standalone Next 16.3.1 app whose only import isCardSchema.Lockfile — the ruling's two conditions, both MET
Mechanism: the importer block was written to the resolution already locked for 28 of the 29 existing
viteimporters, then verified withpnpm install --frozen-lockfile(exit 0, lockfile not rewritten, same store path as the other 28). A naive install lands8.2.2and dragspicomatch;resolution-mode=lowest-directgets the version right but rewrites 17/176 lines. Both measured, both rejected.origin/main29 importers, this head 30, base set{8.2.1}on both.packages/typesis the only addition; no existing resolution moved.The pre-existing peer-suffix split (
esbuild@0.28.2×29,esbuild@0.27.7×1) and the 17 transitivevite@8.2.2peer keys are byte-equal on both trees.Gates on
d7a81db, exit captured before any pipepnpm --filter @object-ui/types type-checktscprojectspnpm --filter @object-ui/cli type-checkvitest run --project unit packages/types/ scripts/__tests__/Test Files 278 passed | 2 skipped (280) · Tests 6568 passedpnpm test:distTest Files 2 passed (2) · Tests 8 passed (8)pnpm exec eslinton the three edited/new filespnpm check:control-bytesscanned 6840 tracked text file(s)Carried from
8b8cbfd, unaffected by an increment that touches only tests and the changeset:pnpm check(All checks passed, CI runs it atlint.yml:481),check:published-dist(see item 6),check:node-esm-load --force-build(0, provenance 37/37 — its plain run exits 1 on a foreign shared-turbo-cache entry naming a package outside this diff, and the gate prints that remedy itself),check:dist-completeness,check:phantom-deps,check:unused-deps,check:side-effects-array,check:readme-exports,check:self-import,check:published-tsconfig-exclude,check:unreferenced-sources,check:esm-specifiers,check-governed-queue-guard --test(NOT GOVERNED).Recorded, not fixed here
@object-ui/types/zodtosrc, so they always evaluate the barrel and are unaffected. The console is a class-(ii) consumer (plugin-map/ObjectMap.tsxonly).sideEffectschange, noci.ymlchange, nothing underpackages/types/src/zod/**. check-side-effects-array cannot see a load-time effect written inside aconstinitializer, and scores @object-ui/types zero #8578 and ChatbotSchema.body accepts more than the declared contract #8572 untouched.scripts/vitest-invocation-guard.mjsships no typings; that is why the config import here is behind a variable specifier rather than a literal one.Generated by Claude Code — session
session_01Jmxdo7bmeqCQHLSfmLVX9w(https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w). Kept in prose because a PR-body edit strips the markdown attribution footer.