From 76ec61688935534dfd368a3f2873c80b05ed099d Mon Sep 17 00:00:00 2001 From: sebi Date: Thu, 20 Aug 2026 19:18:15 -0500 Subject: [PATCH 1/2] fix: four vocabularies declared once, and three conventions become build errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the hygiene half of the open-issue backlog. Every piece here replaces a rule that was documented with one that fails the gate. #255 — the lazy `asyncContext` seam is exported from `@ultimat3/core` and adopted at the six module-scope `new AsyncLocalStorage` sites outside it (`db` x3, `entity`, `ai` x2). Each was unimportable in a browser bundle: `node:async_hooks` stubbed to `{}` throws `TypeError: undefined is not a constructor` at module evaluation, before any code runs. `scripts/async-context-guard.ts` refuses a seventh. Server behaviour is unchanged at all six. #261 — `RenderMode`, `OfflineStrategy` and `HydrateStrategy` were declared twelve times across six packages, because imports go down tiers only and copying was the available move. They now live once, at tier 0, in `packages/core/src/route-vocabulary.ts`, with each union derived from its array. `scripts/render-modes.ts` refuses a thirteenth by literal set, not by name — the copy that did the damage was called `PwaRenderMode`. BREAKING — `PwaRenderMode` and `PwaOfflineStrategy` are deleted from `@ultimat3/pwa`. Members unchanged; `wiki/Upgrading.md` carries the rename. The `Object.freeze` hole, found while proving the above: `const X: Readonly> = Object.freeze({...})` infers T from the literal, so the annotation is only an assignability check and freshness is already gone. Twenty- one closed-key tables accepted an unknown key in silence — reverting four and adding a bogus key compiles clean today, including a `Record` row for a role that does not exist. All twenty-one now pass their type argument explicitly; `scripts/frozen-records.ts` holds it, on a count ratchet because deleting a constraint is invisible to the rule and visible only to the number. #264 — two tests asserted wall-clock behaviour. The cron one now pins which refusal arrived rather than how long it took. The `type-chain` one was not a timing flake at all: eight identical `tsc` runs on an unchanged tree printed 117/119/119/120/120/120/120/117 diagnostics, because TypeScript 7's parallel workers race over which importer is blamed for a `TS6307`. The in-app diagnostics were byte-identical in all eight, so the filter is scope, not tolerance. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KgsU1WBJMAjnLaazvfuJmD --- CHANGELOG.md | 103 ++++++++-- docs/architecture/18-observer-seam.md | 22 ++- examples/dummy/type-chain.test.ts | 21 +- packages/ai/src/budget.ts | 15 +- packages/ai/src/llm-stream.ts | 8 +- packages/core/CLAUDE.md | 19 ++ packages/core/README.md | 4 +- packages/core/src/config.ts | 5 +- packages/core/src/error-reporter-sentry.ts | 2 +- packages/core/src/index.ts | 4 +- packages/core/src/logger.ts | 2 +- packages/core/src/otlp-span-exporter.ts | 4 +- packages/core/src/roles.ts | 2 +- packages/core/src/route-vocabulary.test.ts | 30 +++ packages/core/src/route-vocabulary.ts | 23 +++ packages/core/src/runtime-metrics.ts | 2 +- packages/core/src/type-pins.ts | 29 ++- packages/db/CLAUDE.md | 23 ++- packages/db/README.md | 20 +- packages/db/src/attribution.ts | 14 +- packages/db/src/client.ts | 2 +- packages/db/src/errors.ts | 2 +- packages/db/src/expected-loop.ts | 15 +- packages/db/src/transaction.ts | 15 +- packages/entity/CLAUDE.md | 15 +- packages/entity/src/cross-tenant.ts | 14 +- packages/http/src/index.ts | 2 +- packages/http/src/router.ts | 3 +- packages/jobs/src/worker.ts | 13 +- packages/mail/src/layout.ts | 29 ++- packages/manifest/src/index.ts | 4 +- packages/manifest/src/schema.ts | 9 +- packages/mcp/src/audit.ts | 2 +- packages/pwa/src/capabilities.ts | 35 ++-- packages/pwa/src/index.ts | 8 +- packages/pwa/src/strategies.test.ts | 13 +- packages/pwa/src/strategies.ts | 36 ++-- packages/render/src/hydrate.ts | 2 +- packages/render/src/index.ts | 22 +-- packages/render/src/island-collector.ts | 2 +- packages/render/src/islands.ts | 2 +- packages/render/src/modes.test.ts | 6 +- packages/render/src/modes.ts | 11 +- packages/render/src/registry.ts | 12 +- packages/render/src/route.ts | 9 +- packages/render/src/surfaces.ts | 4 +- packages/schema/src/json-schema.ts | 2 +- packages/seo/src/index.ts | 3 +- packages/seo/src/routes.ts | 3 +- packages/time/src/cron-occurrence.test.ts | 54 ++++-- scripts/async-context-guard.test.ts | 126 ++++++++++++ scripts/async-context-guard.ts | 174 +++++++++++++++++ scripts/frozen-records.test.ts | 139 ++++++++++++++ scripts/frozen-records.ts | 212 +++++++++++++++++++++ scripts/render-modes.test.ts | 145 ++++++++++++++ scripts/render-modes.ts | 200 +++++++++++++++++++ wiki/Error-Codes.md | 7 +- wiki/Upgrading.md | 6 +- 58 files changed, 1500 insertions(+), 210 deletions(-) create mode 100644 packages/core/src/route-vocabulary.test.ts create mode 100644 packages/core/src/route-vocabulary.ts create mode 100644 scripts/async-context-guard.test.ts create mode 100644 scripts/async-context-guard.ts create mode 100644 scripts/frozen-records.test.ts create mode 100644 scripts/frozen-records.ts create mode 100644 scripts/render-modes.test.ts create mode 100644 scripts/render-modes.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 03fb5985..d63a5e37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,93 @@ Semver applies from 1.0.0. A breaking change to a documented API needs a major ## [Unreleased] -Nothing yet. +### Added + +- **`asyncContext(subject)` is public API on `@ultimat3/core`**, with its `AsyncContext` type. + One lazily-constructed `AsyncLocalStorage` for the whole framework, and the answer to "what happens + where there is no async context" in one place: **reads degrade, writes throw.** `get()` answers + `undefined` — in a browser nothing IS in flight, so that is the true answer — and `run()` throws + `X_ASYNC_CONTEXT_UNAVAILABLE` naming the scope that could not be opened, instead of leaving a bare + `TypeError` from a stack mentioning no file the caller wrote. A server pays nothing: `getStore()` + before any `run()` answered `undefined` whether the storage was ever constructed or not (#255) +- `scripts/changelog-check.ts` — `CHANGELOG.md`'s sections and `wiki/Upgrading.md`'s migration counts, + held to each other. Seven rules, one per way the two files can disagree, collected by the gate's + `unit` step through `scripts/changelog-check.test.ts` (#267) + + | Rule | Refuses | + |---|---| + | `duplicate` | two `## ` headings naming one version | + | `empty` | a released section with no body | + | `unreleased-breaking` | a `BREAKING —` entry under `## [Unreleased]` at a tagged commit | + | `count` | a `wiki/Upgrading.md` row whose number is not that major's own section's `BREAKING —` count | + | `total` | the aggregate row disagreeing with the sum of the per-major rows | + | `missing-row` | a released major from 2.0.0 on with no row sending the reader to its section | + | `unscanned` | no row sends the reader to any single version section, so the rule read nothing | + + The count it replaces was derived from the **whole file**, which cannot see a migration filed under + the wrong heading: a misplaced entry only makes the number smaller. Codes: + `X_DOC_CHANGELOG_SECTION_INVALID`, `X_DOC_CHANGELOG_UNRELEASED_BREAKING`, + `X_DOC_MIGRATION_COUNT_STALE`, `X_DOC_MIGRATION_UNSCANNED`. + +### Fixed + +- **Six more module-scope `AsyncLocalStorage` constructions, all outside core, all with the same + browser defect** (#255, the follow-up #244 left open). A bundler stubs `node:async_hooks` to `{}` — + Bun's `target: 'browser'` emits `var { AsyncLocalStorage } = (() => ({}))` — so the `new` threw + `TypeError: undefined is not a constructor` at module **evaluation**, before a line of app code ran, + and took every importer of that file with it. Each now opens through core's seam. + + | Site | Scope | + |---|---| + | `packages/db/src/transaction.ts` | the open transaction (`currentTx`, `inLiveTx`) | + | `packages/db/src/attribution.ts` | the entity/op pair on `StatementEvent.attribution` | + | `packages/db/src/expected-loop.ts` | the written reason a loop of statements is deliberate | + | `packages/entity/src/cross-tenant.ts` | the written reason a read spans tenants | + | `packages/ai/src/budget.ts` | the ambient budget ledger | + | `packages/ai/src/llm-stream.ts` | the streamed-invocation sink | + + **And it is a build error now, not a convention** (axiom 3): `scripts/async-context-guard.ts` + refuses a `new AsyncLocalStorage` **and** the import that binds the class — aliased, or reached + through a namespace import — anywhere but `packages/core/src/async-context.ts`. It reads + `packages/*/src`, `packages/*/e2e` and `scripts/`, comment-stripped, and runs in the gate's `unit` + step through `scripts/async-context-guard.test.ts`. A floor rather than a proof: a runtime + `await import('node:async_hooks')` and a constructor stashed in a variable are both outside what it + can see, and its header says so. +- **`scripts/release.ts` promotes `[Unreleased]` instead of appending a section generated from commit + subjects** (#267). `[Unreleased]` **is** the release notes — written as each change lands, migration + and all — so a release renames that heading to the version and opens a fresh empty one above it. + Appending is what put two `## 5.0.1` headings and two `## 5.0.0` headings in the file — a dateless + generated section above the hand-written one, each time — and both pairs sat there from `release: 5.0.1` + until `release: 6.0.0` merged them by hand. `v6.0.0` still carries the other half of the same defect: + its section holds **two** `### Fixed` blocks, the hand-written one and a generated one restating `#243` + as `#253` and `#244` as `#256` in merge-subject words. And the commit before that release had all of + 6.0.0's migration under `## [Unreleased]` while `wiki/Upgrading.md` already sent the reader to a + `6.0.0` section the file did not have — promoted by hand, in the release commit, which is the manual + step this replaces. Promotion cannot produce any of those shapes: one heading, renamed, never + duplicated, and commit subjects land as a `### Commits` block **inside** the promoted section under a + heading no hand-written section uses. + + Two refusals, both raised before a single manifest is rewritten and under `--dry-run` too, because + finding out after 47 files have moved is the expensive order to find it out in: + `X_RELEASE_UNRELEASED_MISSING` when there is no `## [Unreleased]` heading to promote, and + `X_DOC_CHANGELOG_SECTION_INVALID` when it is empty and no commit landed since the previous tag. The + report also states whether the previous tag was found, since a clone without it lists no commits and + a silent empty list reads exactly like a quiet release. +- **Two tests that could red the gate for reasons belonging to no change** (#264), both made + algorithmic rather than given a longer timeout. + + `packages/time/src/cron-occurrence.test.ts` asserted `performance.now()` under 20 ms to prove an + unmatchable day/month is refused at parse time without walking. Measured on this repo the same call + is 3.2 ms at worst idle and 21.3 ms at worst under eight `bun test` workers, so the bound separated a + loaded box from an unloaded one and never the walk from the parse. It now asserts **which** refusal + arrives, and the budget backstop is exercised on a `CronExpression` assembled past the parser. + + `examples/dummy/type-chain.test.ts` diffed the whole diagnostic set before and after a rename. Eight + identical `tsc --noEmit` runs over an unchanged tree answered 117, 119 and 120 diagnostics — the same + 115 in-app ones byte for byte, plus 1 to 4 TS6307 lines about framework files reached through the + `node_modules` symlink, whose blamed importer is a race between TypeScript's parallel workers. The + diff is now scoped to diagnostics inside the app, where the rename is the only thing that can move + them. ## 6.0.0 @@ -132,16 +218,11 @@ Nothing yet. JSX became `React.createElement` against a `React` that is never imported and every island containing JSX threw `ReferenceError` on first interaction, while the build reported success and `x verify` stayed green. Islands are now compiled with `babel-preset-solid` (#243) -- a browser bundle can load `@ultimat3/core`: three module-scope `AsyncLocalStorage` constructions moved - onto one lazy seam, so `@ultimat3/ui` no longer throws `TypeError: undefined is not a constructor` at - module evaluation (#244) - -### Fixed - -- island JSX compiles to real Solid reactivity, not to an undefined React (#253) -- one lazy AsyncLocalStorage, so a browser bundle can load @ultimat3/core (#256) -- One timezone rule everywhere, and CI runs the Bun this repo runs (#265) -- 6.0.0: Solid reactivity that works, Bun.Image, and four defects that shipped green (#263) +- a browser bundle can load `@ultimat3/core`: **core's** three module-scope `AsyncLocalStorage` + constructions — the request context, the active span, the impersonation reason — moved onto one lazy + seam, so `@ultimat3/ui` no longer throws `TypeError: undefined is not a constructor` at module + evaluation (#244). The seam was private to core here, and six more constructions outside it were + untouched and unwatched; both are `[Unreleased]` (#255) ## 5.0.1 - 2026-08-20 diff --git a/docs/architecture/18-observer-seam.md b/docs/architecture/18-observer-seam.md index 3a8ca846..20a9b3d6 100644 --- a/docs/architecture/18-observer-seam.md +++ b/docs/architecture/18-observer-seam.md @@ -85,7 +85,7 @@ impossible. A reporting-only observer (the dev ledger) must not throw. would make "which diagnostic saw this statement" order-dependent, and the one consumer that needs several — the dev server — composes them itself, in its own order, where that order is reviewable. -## Attribution: two scopes, both `AsyncLocalStorage` +## Attribution: two scopes, both on core's one async-context seam The funnel knows the SQL. It does not know that the SQL came from `findById` on `members` — by the time a statement exists, it has left `postgresRepo` by several stack frames and at least one @@ -104,8 +104,22 @@ that context down across the `await`s a module-scope variable could not survive: to anything that only measures. Applied in `packages/admin/src/search.ts` (one indexed lookup per search field, argued optimal in the call) and twice in `packages/db/src/migrate.ts`. -Both scopes cost nothing when no observer is installed — `withStatementAttribution` checks -`statementObserver() === undefined` and calls `fn()` directly, entering no `AsyncLocalStorage` scope +Neither constructs an `AsyncLocalStorage`. Both open through `asyncContext(subject)` +(`packages/core/src/async-context.ts`), the framework's one lazily-constructed store, `As of +2026-08`. What that buys is a browser bundle: a bundler stubs `node:async_hooks` to `{}`, so a +module-scope `new` threw `TypeError: undefined is not a constructor` at module EVALUATION and every +importer of `@ultimat3/db` died before a line of app code ran. Now the module evaluates, `get()` +answers `undefined` — nothing is in flight in a browser, which is TRUE — and `run()` throws +`X_ASYNC_CONTEXT_UNAVAILABLE`, naming the scope that could not be opened. The server pays nothing: +`getStore()` before any `run()` answers `undefined` whether the storage was ever constructed or not. + +**A build error, not a convention.** `scripts/async-context-guard.ts` refuses a +`new AsyncLocalStorage` — and the import that binds the class, aliased or namespaced — anywhere but +that one file, and `scripts/async-context-guard.test.ts` runs it over the real tree as part of the +gate's `unit` step. + +Both scopes cost nothing when no observer is installed — `withStatementAttribution` reads +`statementObserver() === undefined` and calls `fn()` directly, entering no async-context scope at all. That is why the pair travels as two plain strings rather than a pre-built `StatementAttribution` object: allocating one before the branch could decline it would tax every production statement in the process for a diagnostic that is off. @@ -158,7 +172,7 @@ Nothing above this seam changes shape when it is off: - `runOn`/`statement` call `sendOn`/`send` directly on the `undefined` branch — the exact call the funnel made before the seam existed. -- No `performance.now()` read, no span, no `StatementEvent` allocated, no `AsyncLocalStorage` scope +- No `performance.now()` read, no span, no `StatementEvent` allocated, no async-context scope entered by either `withStatementAttribution` or `expectedQueryLoop`. - `packages/db/src/observe.test.ts` pins this directly: with no observer installed, `runOn`'s behavior is byte-identical to the pre-seam funnel. diff --git a/examples/dummy/type-chain.test.ts b/examples/dummy/type-chain.test.ts index 8eefe682..c1c18da2 100644 --- a/examples/dummy/type-chain.test.ts +++ b/examples/dummy/type-chain.test.ts @@ -69,6 +69,20 @@ const DIAGNOSTICS_REPORTED = 1; /** File + line + code + message: the same diagnostic reported twice is one diagnostic, not two. */ const key = (d: Diagnostic): string => `${d.file}:${d.line} ${d.code} ${d.message}`; +/** + * Inside this app — `apps/…`, `packages/…`, never `../../packages/cli/…`. A rename in this app's + * schema cannot reach a framework source (nothing in `packages/` imports Postly), so a diagnostic + * out there is never this test's finding; and the compiler does not report the same set of them + * twice. Eight identical `tsc --noEmit -p tsconfig.json` runs over an unchanged tree, at idle, + * answered 117, 119 and 120 diagnostics — always the same 115 in-app ones, byte for byte, and 1 to + * 4 TS6307 "not listed within the file list of project" lines about framework files reached + * through the `node_modules` symlink. TypeScript 7 checks in parallel, and which importer it + * blames for an unlisted file is a race between its workers, so a diff over the raw set reads that + * race as a hop the rename broke. Contention only shifts the odds — this is not a timing flake and + * a longer timeout would not have touched it. + */ +const inApp = (d: Diagnostic): boolean => !d.file.startsWith('..'); + /** * Both shapes, because a config error (TS5083 / TS6046 / TS18003) carries no `file(line,col):` * prefix. Parsing only the prefixed form would let a compiler that never opened a single source @@ -211,7 +225,8 @@ describe('type chain · the rename proof (docs/architecture/05-type-chain.md)', // Before any diff: a compiler that did not run produces an empty baseline, and every // assertion below would then pass or fail for a reason that has nothing to do with the chain. expect(harnessFault(before)).toBe(''); - const beforeKeys = new Set(before.diagnostics.map(key)); + const beforeInApp = before.diagnostics.filter(inApp); + const beforeKeys = new Set(beforeInApp.map(key)); // Sanity: every file the rename is about to hit compiles clean today. Otherwise a // diagnostic appearing "after" the rename could just be pre-existing noise wearing a new line @@ -244,7 +259,7 @@ describe('type chain · the rename proof (docs/architecture/05-type-chain.md)', * Deriving the set from the baseline rather than listing it keeps this honest — a file that * gets repaired drops out on its own, and its next real regression is caught. */ - const alreadyFailing = new Set(before.diagnostics.map((d) => d.file)); + const alreadyFailing = new Set(beforeInApp.map((d) => d.file)); let after: TypecheckRun; const release = guardRestore(SCHEMA_FILE, pristine); @@ -259,7 +274,7 @@ describe('type chain · the rename proof (docs/architecture/05-type-chain.md)', expect(harnessFault(after)).toBe(''); const introduced = after.diagnostics.filter( - (d) => !beforeKeys.has(key(d)) && !alreadyFailing.has(d.file), + (d) => inApp(d) && !beforeKeys.has(key(d)) && !alreadyFailing.has(d.file), ); // The rename must surface as new, attributable failures — not vanish into whatever the app's diff --git a/packages/ai/src/budget.ts b/packages/ai/src/budget.ts index c624c522..983fd1e6 100644 --- a/packages/ai/src/budget.ts +++ b/packages/ai/src/budget.ts @@ -4,12 +4,13 @@ // wrong answer that looks like a real one, and the caller has no signal anything happened. // A thrown X_AI_BUDGET_EXCEEDED with the remaining count is strictly more useful. // -// The carrier is an AsyncLocalStorage so nested calls (a RAG retrieval, a tool call that -// generates, an eval judge) all debit the same ledger without threading it through every -// signature. `node:async_hooks` is used directly because Bun implements it natively and the -// framework's ALS context is established at the HTTP boundary, above this package. +// The carrier is an async context so nested calls (a RAG retrieval, a tool call that generates, +// an eval judge) all debit the same ledger without threading it through every signature. It opens +// through `@ultimat3/core`'s one lazy seam rather than constructing an `AsyncLocalStorage` here: a +// module-scope `new` threw at EVALUATION in a browser bundle, where the bundler stubs +// `node:async_hooks` to `{}`, and took every importer of `@ultimat3/ai` with it. -import { AsyncLocalStorage } from 'node:async_hooks'; +import { asyncContext } from '@ultimat3/core'; import type { Money } from '@ultimat3/money'; import { assertSameCurrency } from '@ultimat3/money'; import { AiBudgetExceededError } from './errors'; @@ -307,7 +308,7 @@ function tighterMoney(a: Money | undefined, b: Money | undefined): Money | undef return a.minor <= b.minor ? a : b; } -const storage = new AsyncLocalStorage(); +const storage = asyncContext('an AI budget'); /** Run `fn` with `ledger` as the ambient budget for everything it awaits. */ export function withBudget(ledger: BudgetLedger, fn: () => Promise): Promise { @@ -316,5 +317,5 @@ export function withBudget(ledger: BudgetLedger, fn: () => Promise): Promi /** The ambient ledger, or `undefined` outside a budget scope (spend is then unmetered). */ export function currentBudget(): BudgetLedger | undefined { - return storage.getStore(); + return storage.get(); } diff --git a/packages/ai/src/llm-stream.ts b/packages/ai/src/llm-stream.ts index b6ee9e1e..a7e1a0f2 100644 --- a/packages/ai/src/llm-stream.ts +++ b/packages/ai/src/llm-stream.ts @@ -21,7 +21,7 @@ * async chain — abandoning the iterator stops delivery, never the accounting. */ -import { AsyncLocalStorage } from 'node:async_hooks'; +import { asyncContext } from '@ultimat3/core'; import { AiTransportError } from './errors'; import type { Gateway } from './gateway'; import type { GenerateRequest, GenerateResult } from './provider'; @@ -102,7 +102,9 @@ export class LlmSink { } } -const sinks = new AsyncLocalStorage(); +// Core's one lazy seam, never a construction here: a module-scope `new` threw at EVALUATION in a +// browser bundle, where the bundler stubs `node:async_hooks` to `{}`. +const sinks = asyncContext('an LLM stream sink'); /** Mark everything `fn` awaits as a streamed invocation. */ export function withLlmSink(sink: LlmSink, fn: () => Promise): Promise { @@ -111,7 +113,7 @@ export function withLlmSink(sink: LlmSink, fn: () => Promise): Promise /** The sink of the streamed invocation this call belongs to, or `undefined` for a plain one. */ export function currentLlmSink(): LlmSink | undefined { - return sinks.getStore(); + return sinks.get(); } /** diff --git a/packages/core/CLAUDE.md b/packages/core/CLAUDE.md index 6b0598de..4b6fd0d6 100644 --- a/packages/core/CLAUDE.md +++ b/packages/core/CLAUDE.md @@ -14,6 +14,7 @@ is a change to every package. | New code | add to `CORE_CODE_TITLES` in `error-codes.ts`, else the title is auto-humanised | | Time | take a `Clock`; `Date.now()` / `new Date()` only inside `clock.ts` | | Context | never thread `ctx` as a parameter — `useContext()` | +| A value ambient across an `await` | `asyncContext(subject)` from `async-context.ts`, in **every** package — never `new AsyncLocalStorage` | | Exports | add to `src/index.ts` explicitly; no `export *`. Three subjects that each span a dozen modules arrive through `src/exports/` — every name is still written out in `index.ts`, so the public surface is one file to read | | Files | < 200 LOC, 500 hard ceiling, one responsibility, `kebab-case.ts`, test beside source | | Type claims | `type-pins.ts`, never a `.test.ts` — `tsconfig.json` excludes tests, so `tsc` never reads one | @@ -22,6 +23,23 @@ Deliberate cycles (safe — nothing is referenced at module-evaluation time): `errors.ts ⇄ error-codes.ts`. Keep it that way: no top-level `UltimateError` use in `error-codes.ts`. +**`async-context.ts` is the framework's ONE `AsyncLocalStorage`, and that is a framework rule +rather than a core one, `As of 2026-08-20`.** `asyncContext` is exported from `src/index.ts` and +six modules outside this package opened their own before they adopted it — `@ultimat3/db`'s +transaction, statement attribution and expected-loop scopes, `@ultimat3/entity`'s `crossTenant`, +`@ultimat3/ai`'s budget ledger and LLM stream sink. Each was a module-scope `new` a browser bundler +turns into `TypeError: undefined is not a constructor` at module EVALUATION, so importing any of +those packages from a client bundle failed before a line of app code ran. Reads degrade to +`undefined`, writes throw `X_ASYNC_CONTEXT_UNAVAILABLE`; the server pays nothing, because +`getStore()` before any `run()` answers `undefined` whether the storage exists or not. + +The mechanical half is `scripts/async-context-guard.ts`, collected by `x verify`'s `unit` step +through `scripts/async-context-guard.test.ts` — it refuses a `new AsyncLocalStorage` **and** the +import that binds the class, aliased or namespaced, anywhere but this one file. The browser-barrel +test in `async-context.test.ts` covers the same defect for core alone and cannot see another +package; the guard cannot see a runtime `await import('node:async_hooks')`. Neither is the other's +duplicate. + `error-render.ts` imports nothing, including from this package — an error factory that dies formatting its own message is the failure it exists to prevent, so it cannot depend on anything that could itself throw. The same defect shipped three times (`entity`, `flags`, `cli`) before @@ -85,6 +103,7 @@ shape against a locally declared sample interface for exactly that reason. |---|---|---| | which deploy this is | `environment.ts` (`ULTIMATE_ENV`) | the twin of `ROLE`; never declare a second env var for it | | what this process does | `roles.ts` (`ROLE`) | | +| how a route renders, caches offline and hydrates | `route-vocabulary.ts` (`RENDER_MODES`, `OFFLINE_STRATEGIES`, `HYDRATE_STRATEGIES`) | tier 0 because SIX packages name them and imports only go down — `render`, `http`, `seo`, `manifest` and `pwa` each kept a hand-copy until 2026-08, and `'spa'` was deleted from one while five went on admitting it under a green typecheck. Every union is `(typeof ARRAY)[number]`, pinned in `type-pins.ts`; `scripts/render-modes.test.ts` refuses a second declaration anywhere in `packages/*/src`. Re-export it, never restate it | | which build of the APP this is | `app-version.ts` (`APP_VERSION`) | one reader, `dev` by default: `db` writes it into `x_migrations` and `jobs` into `x_backfills`, and `jobs` cannot reach `db` for the answer | | the values | `env.ts` | `checkEnv().values` holds REAL secrets — anything that prints goes through `maskedEnvValues()` | | `.env.example` | `env-example.ts` | a projection of the schema, never hand-maintained | diff --git a/packages/core/README.md b/packages/core/README.md index b82eaf93..e8d50720 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -9,7 +9,8 @@ Zero dependencies, zero `@ultimat3/*` imports. | rendering an app's value into a `cause` / `fix` without throwing | `error-render.ts` | | code → `{ title, docs }` registry, `registerErrorCodes()` | `error-codes.ts` | | `Result` for boundaries where throwing is wrong | `result.ts` | -| request context on `AsyncLocalStorage` | `context.ts` | +| the one lazy `AsyncLocalStorage`, every ambient scope in the framework | `async-context.ts` | +| request context on that seam | `context.ts` | | `Actor` (`user \| service \| agent \| anonymous`) | `actor.ts` | | acting as another actor, with an origin and a reason | `impersonate.ts` | | is an error worth retrying? one classification per code | `error-retry.ts` | @@ -20,6 +21,7 @@ Zero dependencies, zero `@ultimat3/*` imports. | the committed encrypted secrets envelope, AES-256-GCM | `secrets.ts` | | the two secrets files, and decrypted values → `defineEnv` | `secrets-store.ts` | | `defineConfig()` for `app.config.ts` | `config.ts` | +| the closed route vocabulary every renderer names | `route-vocabulary.ts` | | runtime roles + `ROLE` resolution | `roles.ts` | | `Clock` — the only source of "now" | `clock.ts` | | UUIDv7, nanoid, branded ids | `ids.ts` | diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index fb464870..481200bc 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -4,10 +4,13 @@ import { ConfigInvalidError } from './errors'; import { ROLES, type Role } from './roles'; +// `app.config.ts` CONSUMES the route vocabulary; it does not own it. Declaring `OfflineStrategy` +// here is what made it copyable — `render`, `manifest` and `pwa` each wrote their own rather than +// import a name that reads like a config key. +import type { OfflineStrategy } from './route-vocabulary'; import { isIanaZoneName } from './time-zone-name'; export type ThemeMode = 'light' | 'dark' | 'system'; -export type OfflineStrategy = 'precache' | 'runtime' | 'network-only'; export type CacheTier = 'memo' | 'lru' | 'shared' | 'isr' | 'cdn'; export type RealtimeTier = 'channels' | 'live-queries' | 'local-first'; export type RealtimeTransport = 'memory' | 'nats' | 'redis'; diff --git a/packages/core/src/error-reporter-sentry.ts b/packages/core/src/error-reporter-sentry.ts index fa93d907..56ab8175 100644 --- a/packages/core/src/error-reporter-sentry.ts +++ b/packages/core/src/error-reporter-sentry.ts @@ -64,7 +64,7 @@ export function parseSentryDsn(dsn: string): SentryDsn { } /** The protocol's own level names. `warning`/`error`/`fatal` happen to be the same three words. */ -const LEVELS: Readonly> = Object.freeze({ +const LEVELS = Object.freeze>({ warning: 'warning', error: 'error', fatal: 'fatal', diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index af890566..e63bced3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -31,6 +31,7 @@ export { } from './actor'; export { APP_VERSION_KEY, appVersion, DEFAULT_APP_VERSION } from './app-version'; export { assert, assertNever, type InvariantOptions, invariant } from './assert'; +export { type AsyncContext, asyncContext } from './async-context'; export { canonicalJson, fingerprint } from './canonical-json'; export { type Clock, type FrozenClock, frozenClock, systemClock } from './clock'; export type { @@ -45,7 +46,6 @@ export type { DatabaseConfig, JobsConfig, McpConfig, - OfflineStrategy, PwaConfig, RealtimeConfig, RealtimeTier, @@ -479,6 +479,8 @@ export type { Err, Ok, Result } from './result'; export { err, isErr, isOk, map, mapErr, ok, tryCatch, unwrap, unwrapOr } from './result'; export type { ResolveRoleOptions, Role, RoleInfo, ScalingSignal } from './roles'; export { DEFAULT_ROLE, isRole, ROLE_INFO, ROLES, resolveRole } from './roles'; +export type { HydrateStrategy, OfflineStrategy, RenderMode } from './route-vocabulary'; +export { HYDRATE_STRATEGIES, OFFLINE_STRATEGIES, RENDER_MODES } from './route-vocabulary'; export { safeUrl, URL_ATTRIBUTES } from './safe-url'; export { defineService, resetServices, type ServiceFactory } from './service'; export { timingSafeEqual } from './timing-safe-equal'; diff --git a/packages/core/src/logger.ts b/packages/core/src/logger.ts index ba22ca67..428bda94 100644 --- a/packages/core/src/logger.ts +++ b/packages/core/src/logger.ts @@ -17,7 +17,7 @@ export const LOG_LEVELS = ['trace', 'debug', 'info', 'warn', 'error', 'fatal', ' export type LogLevel = (typeof LOG_LEVELS)[number]; -const LEVEL_WEIGHT: Readonly> = Object.freeze({ +const LEVEL_WEIGHT = Object.freeze>({ trace: 10, debug: 20, info: 30, diff --git a/packages/core/src/otlp-span-exporter.ts b/packages/core/src/otlp-span-exporter.ts index 352ee031..e99a8d3d 100644 --- a/packages/core/src/otlp-span-exporter.ts +++ b/packages/core/src/otlp-span-exporter.ts @@ -23,7 +23,7 @@ import type { } from './telemetry'; /** OTLP's `SpanKind` enum; `UNSPECIFIED` is 0 and Ultimate never emits it. */ -const SPAN_KIND: Readonly> = Object.freeze({ +const SPAN_KIND = Object.freeze>({ internal: 1, server: 2, client: 3, @@ -31,7 +31,7 @@ const SPAN_KIND: Readonly> = Object.freeze({ consumer: 5, }); -const STATUS_CODE: Readonly> = Object.freeze({ +const STATUS_CODE = Object.freeze>({ unset: 0, ok: 1, error: 2, diff --git a/packages/core/src/roles.ts b/packages/core/src/roles.ts index e6ecad1e..52f46e7f 100644 --- a/packages/core/src/roles.ts +++ b/packages/core/src/roles.ts @@ -26,7 +26,7 @@ export interface RoleInfo { readonly stateful: boolean; } -export const ROLE_INFO: Readonly> = Object.freeze({ +export const ROLE_INFO = Object.freeze>({ web: { role: 'web', scalesOn: 'rps', maxReplicas: null, stateful: false }, sync: { role: 'sync', scalesOn: 'ws-connections', maxReplicas: null, stateful: false }, worker: { role: 'worker', scalesOn: 'queue-depth', maxReplicas: null, stateful: false }, diff --git a/packages/core/src/route-vocabulary.test.ts b/packages/core/src/route-vocabulary.test.ts new file mode 100644 index 00000000..14605cd5 --- /dev/null +++ b/packages/core/src/route-vocabulary.test.ts @@ -0,0 +1,30 @@ +// The three vocabularies are CLOSED sets, so their members are pinned here the way +// `registrar.test.ts` pins the eight primitives: adding or removing one is a failing test that +// makes the author say so, not a silent widening five packages inherit. + +import { describe, expect, test } from 'bun:test'; +import { HYDRATE_STRATEGIES, OFFLINE_STRATEGIES, RENDER_MODES } from './route-vocabulary'; + +// The TYPE half — that each union is still `(typeof ARRAY)[number]` — is pinned in `type-pins.ts`, +// never here: `tsconfig.json` excludes `src/**/*.test.ts`, so `tsc -b` never reads this file and a +// type-level assertion written in it cannot fail. Runtime members are what this file can prove. + +describe('the route vocabulary', () => { + test('render modes are exactly the four, in the order every fix line prints them', () => { + expect([...RENDER_MODES]).toEqual(['static', 'isr', 'ssr', 'stream']); + }); + + test('offline strategies are exactly the three', () => { + expect([...OFFLINE_STRATEGIES]).toEqual(['precache', 'runtime', 'network-only']); + }); + + test('hydrate strategies are exactly the four', () => { + expect([...HYDRATE_STRATEGIES]).toEqual(['idle', 'visible', 'interaction', 'never']); + }); + + test('no member is repeated, so a Record over one has a row per member', () => { + for (const set of [RENDER_MODES, OFFLINE_STRATEGIES, HYDRATE_STRATEGIES]) { + expect(new Set(set).size).toBe(set.length); + } + }); +}); diff --git a/packages/core/src/route-vocabulary.ts b/packages/core/src/route-vocabulary.ts new file mode 100644 index 00000000..c685e184 --- /dev/null +++ b/packages/core/src/route-vocabulary.ts @@ -0,0 +1,23 @@ +// Single responsibility: the three closed vocabularies a route is declared in — how it renders, +// how it survives offline, when it hydrates. Tier 0 so every package that names one imports it. +// Deliberately not `config.ts`: `app.config.ts` CONSUMES `OfflineStrategy`, it does not own it. + +/** + * Each union is DERIVED from its array rather than written twice, so the pair cannot disagree: + * the array is the one place a member is added or removed and the type follows. + * + * This module exists because the alternative was measured. Twelve declarations of these three sets + * lived across six packages — `render` alone spelled `RenderMode` and `RENDER_MODES` separately — + * and `'spa'` was deleted from one of them while five others went on admitting it under a green + * project-wide typecheck. `@ultimat3/pwa`'s copy mapped `spa` to `cache-first`, the one strategy + * that gives an `app/` route a SHARED cache entry: one member's authed HTML served to the next. + * A copy is not a style question. `scripts/render-modes.test.ts` refuses a second declaration. + */ +export const RENDER_MODES = ['static', 'isr', 'ssr', 'stream'] as const; +export type RenderMode = (typeof RENDER_MODES)[number]; + +export const OFFLINE_STRATEGIES = ['precache', 'runtime', 'network-only'] as const; +export type OfflineStrategy = (typeof OFFLINE_STRATEGIES)[number]; + +export const HYDRATE_STRATEGIES = ['idle', 'visible', 'interaction', 'never'] as const; +export type HydrateStrategy = (typeof HYDRATE_STRATEGIES)[number]; diff --git a/packages/core/src/runtime-metrics.ts b/packages/core/src/runtime-metrics.ts index 63609df8..ea5ef6ce 100644 --- a/packages/core/src/runtime-metrics.ts +++ b/packages/core/src/runtime-metrics.ts @@ -14,7 +14,7 @@ import type { ScalingSignal } from './roles'; * own comment ("via the ingress metric adapter") already assumes. The other two are instantaneous * values a scrape can read directly, so their series names are the chart's words verbatim. */ -export const SCALING_METRICS: Readonly> = Object.freeze({ +export const SCALING_METRICS = Object.freeze>({ rps: 'http_requests_total', 'ws-connections': 'connections', 'queue-depth': 'queue_depth', diff --git a/packages/core/src/type-pins.ts b/packages/core/src/type-pins.ts index 343ce573..0d9e0800 100644 --- a/packages/core/src/type-pins.ts +++ b/packages/core/src/type-pins.ts @@ -1,4 +1,5 @@ -// Compile-time pins for the actor-facts seam and the config surface. Source, not a `.test.ts`, +// Compile-time pins for the actor-facts seam, the config surface and the route vocabulary. Source, +// not a `.test.ts`, // on purpose: // `tsconfig.json` excludes `src/**/*.test.ts`, so `tsc -b` never reads a test file and a // type-level assertion written there can never fail. This module emits nothing and exports @@ -6,6 +7,7 @@ import type { Actor, ActorFactMap, FactKeysOf, FactMapOf } from './actor'; import type { AppConfigInput, DatabaseConfig } from './config'; +import type { HydrateStrategy, OfflineStrategy, RenderMode } from './route-vocabulary'; /** Fails to compile when `T` is anything but `true`. The whole mechanism. */ type Assert = T; @@ -98,3 +100,28 @@ type _DatabaseInputCarriesNoDeadField = Assert< ? true : false >; + +/** + * Mutual assignability, not one-way. The tuples are load-bearing: a bare `A extends B` distributes + * over a union and answers `true` for every member separately, so it cannot see a widening — which + * is the only thing these three pins are looking for. + */ +type Exact = [A] extends [B] ? ([B] extends [A] ? true : false) : false; + +/** + * Each route vocabulary's union must stay DERIVED from its array. `(typeof ARRAY)[number]` is what + * makes the pair unable to disagree, and it is one careless edit from being a hand-written union + * again — which is the shape six packages shipped until `route-vocabulary.ts` existed. Restating + * the members here is a pin, not a copy: nothing imports these, and a member added to the array + * without a word in the changelog is a build error rather than a silent widening five packages + * inherit through a re-export. + */ +type _RenderModeIsItsArray = Assert>; + +type _OfflineStrategyIsItsArray = Assert< + Exact +>; + +type _HydrateStrategyIsItsArray = Assert< + Exact +>; diff --git a/packages/db/CLAUDE.md b/packages/db/CLAUDE.md index bc8e88b0..a94f51c1 100644 --- a/packages/db/CLAUDE.md +++ b/packages/db/CLAUDE.md @@ -14,6 +14,7 @@ reaches down to this package for it. **Never** import `entity`, `jobs`, `http` o | Reading a caught value | `renderThrowable()` from core; never `error instanceof Error ? error.message : String(error)` — both halves RUN app code (a `Proxy` trap, `Symbol.toPrimitive`) and `checkDb` backs `/readyz`, where a render that throws is an exception in place of the report the kubelet asked for | | Errors | subclass `DbError`; never `throw new Error` **in source**. A test simulating a *database* failure throws `dbUnavailable()`; a test simulating the *caller's body* failing throws a bare `Error` on purpose — an arbitrary throw is exactly what rollback and disposal must survive, and a `DbError` there would prove the narrower thing | | New code | add to `DB_ERROR_CODES` **and** `DB_ERROR_TITLES` in `errors.ts` | +| A value ambient across an `await` | `asyncContext(subject)` from `@ultimat3/core` — never `new AsyncLocalStorage`. Three scopes here use it: `transaction.ts`, `attribution.ts`, `expected-loop.ts` | | Exports | explicit in `src/index.ts`; no `export *` | | Files | < 200 LOC, one responsibility, `kebab-case.ts`, test beside source | @@ -28,6 +29,20 @@ Deliberate cycle (safe — nothing is referenced at module-evaluation time): consults `currentTx()`; `withTransaction` uses `baseClient()`, never `db()`, or it would re-enter itself. Keep both sides `function` declarations so hoisting covers the TDZ. +**The three ambient scopes open through core's one lazy seam, and that is a build error rather than +a convention, `As of 2026-08`.** `transaction.ts` (`TxState`), `attribution.ts` (the entity/op +pair) and `expected-loop.ts` (the reason) each constructed a module-scope `AsyncLocalStorage` until +#255. A bundler stubs `node:async_hooks` to `{}` — Bun's `target: 'browser'` emits +`var { AsyncLocalStorage } = (() => ({}))` — so the `new` threw +`TypeError: undefined is not a constructor` at module **evaluation**, before any app code ran, and +took every importer of that file down with it. Through `asyncContext(subject)` the module +evaluates, `get()` answers `undefined` (in a browser nothing IS in flight, so that is the true +answer) and `run()` throws `X_ASYNC_CONTEXT_UNAVAILABLE` naming the scope. The server pays nothing: +`getStore()` before any `run()` answered `undefined` whether the storage existed or not. +`scripts/async-context-guard.ts` refuses a `new AsyncLocalStorage` — and the import that binds the +class, aliased or namespaced — anywhere but `packages/core/src/async-context.ts`, and +`scripts/async-context-guard.test.ts` runs it over the tree in the gate's `unit` step. + `pglite.ts` is a pool of exactly one: PGlite is a single session, so `reserve()` (backed by `pglite-turns.ts`) is what stops two concurrent `BEGIN`s becoming one transaction. Three rules hold it together and none is optional — the plain path takes a turn; a statement issued while a @@ -45,7 +60,7 @@ seam `observe.ts` already draws. **The second rule fences on `inLiveTx()`, never on `currentTx() !== undefined`** — the two are different questions and reading the second as the first was a cross-transaction write. The -`AsyncLocalStorage` store rides into every promise chain started inside `withTransaction`, so a +async-context store rides into every promise chain started inside `withTransaction`, so a statement the app forgot to `await` still found a store after COMMIT, skipped the turn queue, and landed inside whichever unit of work held the single session next: measured `BEGIN`, `select 'inside tx'`, `COMMIT`, `BEGIN`, `select 'straggler'`, `select 'inside tx 2'`, `COMMIT` — committed by a @@ -213,7 +228,7 @@ report and the return value cannot disagree about one statement. `attribution.ts` is `StatementEvent.attribution`'s producer: `withStatementAttribution(entity, op, fn)` runs `fn` with every statement it issues — at any depth, across every `await` — attributed to -that pair, on an `AsyncLocalStorage` the same shape `expected-loop.ts` already uses. Four rules, +that pair, on an async context the same shape `expected-loop.ts` already uses. Four rules, none optional. **Guard first** — it reads `statementObserver()` before touching the scope at all and, with nothing installed, hands straight to `fn`: one property read, one branch, no object allocated, on the path every statement in the process takes (axiom 6) — which is also why the pair @@ -263,14 +278,14 @@ the process. The OTel `kind` is `client`; the database is the remote peer. `expected-loop.ts` is the **only** suppression mechanism, and the reason it is a scope rather than a pragma or a list is the same reason `observe.ts` is one observer: a second path is the tax -(axiom 1). `expectedQueryLoop(reason, fn)` rides an `AsyncLocalStorage`, so it survives every +(axiom 1). `expectedQueryLoop(reason, fn)` rides an async context, so it survives every `await` at any depth and two loops running concurrently never read each other; nesting keeps the innermost reason, because the closest scope is the one describing this loop. A blank reason is `X_INVARIANT` through core's `assert` — no new code for it, and an exemption with no argument is a pragma with extra steps. Three rules. **The funnel stamps, the consumer reads** — `runOn` and `statement()` call `expectedQueryLoopReason()` inside the branch that already found an observer and put the answer on the event as `expected`; a detector that judges a whole request runs long after -every scope in it closed, so reading the ALS later would find nothing. **It suppresses a verdict, +every scope in it closed, so reading the scope later would find nothing. **It suppresses a verdict, not a statement** — the SQL is still sent, still observed, and the span still opens, so anything that measures still sees the loop and only the thing that warns is told the author already answered. **It costs nothing uninstalled** — the read lives inside the observer branch, so the diff --git a/packages/db/README.md b/packages/db/README.md index 699bcfff..410eca20 100644 --- a/packages/db/README.md +++ b/packages/db/README.md @@ -261,7 +261,7 @@ reader cannot tell a considered loop from a silenced one. | | | |---|---| -| Scope | an `AsyncLocalStorage`: it survives every `await` at any depth, and two loops running at once never read each other. Nesting keeps the innermost reason | +| Scope | core's `asyncContext('the expected-loop reason')`, never a `new AsyncLocalStorage` here: it survives every `await` at any depth, and two loops running at once never read each other. Nesting keeps the innermost reason | | What it carries | `StatementEvent.expected`, stamped by both funnels at settle time — a diagnostic judging a whole request runs after every scope in it closed | | What it suppresses | a **verdict**, never a statement. The SQL is still sent, still observed, still a span: only the thing that warns is told the author already answered | | What it costs | nothing without a diagnostic — the reason is read inside the branch that already checks for an installed observer | @@ -270,6 +270,22 @@ The framework's own deliberate loops declare themselves at source: `migrate()` a (one transaction per migration, so a failure leaves an exact ledger) and `@ultimat3/admin`'s cross-entity search (one indexed lookup per text field). +**Every ambient scope in this package opens through `asyncContext(subject)` from +`@ultimat3/core`** — the transaction store, the attribution pair and this reason — and none of the +three constructs an `AsyncLocalStorage`, `As of 2026-08`. What changed is what a browser bundle +does with these three modules: a bundler stubs `node:async_hooks` to `{}`, so the module-scope `new` +threw `TypeError: undefined is not a constructor` at module **evaluation** — before a line of app +code ran, and taking every importer of the file with it. Now the module evaluates, a read answers +`undefined` (nothing is in flight in a browser, so that is the true answer), and a write throws +`X_ASYNC_CONTEXT_UNAVAILABLE` naming the scope it could not open. A server pays nothing — +`getStore()` before any `run()` answered `undefined` either way. Not a claim that the whole package +bundles: `pglite-branch.ts` imports `node:fs/promises`, which is a separate question. + +The rule is a **build error**, not a convention: `scripts/async-context-guard.ts` refuses a +`new AsyncLocalStorage` — and the import that binds the class, aliased or namespaced — anywhere but +`packages/core/src/async-context.ts`, and `scripts/async-context-guard.test.ts` runs it over the +tree in the gate's `unit` step. + ## A statement knows who compiled it `As of 2026-08`: `StatementEvent.attribution` is no longer always `undefined`. @@ -282,7 +298,7 @@ return withStatementAttribution('members', 'findById', () => | | | |---|---| -| Scope | an `AsyncLocalStorage`, `expectedQueryLoop()`'s own shape: it survives every `await` at any depth, and nesting keeps the innermost pair | +| Scope | core's `asyncContext()`, `expectedQueryLoop()`'s own shape: it survives every `await` at any depth, and nesting keeps the innermost pair | | What it carries | `StatementEvent.attribution`, stamped by both funnels at settle time, next to `expected` | | Producer | `@ultimat3/entity`'s `postgresRepo` — the last caller that still knows the entity and the operation once the SQL exists | | What it costs | nothing uninstalled — `statementObserver()` is read first, and with nothing installed `fn` runs directly; no scope entered, no object allocated | diff --git a/packages/db/src/attribution.ts b/packages/db/src/attribution.ts index d627b35d..813f585f 100644 --- a/packages/db/src/attribution.ts +++ b/packages/db/src/attribution.ts @@ -3,13 +3,15 @@ // instead of fifty copies of one `select`. A scope, not a parameter: the statement leaves several // frames and at least one microtask below the repository call that caused it. -// `node:` for the same reason `expected-loop.ts` needs it — Bun exposes no native async-context -// primitive, and the pair has to survive every `await` between the repository call and the -// statement it causes. A module-scope variable would be shared by two concurrent requests. -import { AsyncLocalStorage } from 'node:async_hooks'; +// The pair has to survive every `await` between the repository call and the statement it causes, +// and a module-scope variable would be shared by two concurrent requests — so it needs an async +// context. It opens through core's seam for the same reason `expected-loop.ts` does: constructing +// an `AsyncLocalStorage` here threw at module EVALUATION in a browser bundle, where +// `node:async_hooks` is stubbed to `{}`, taking every importer of `@ultimat3/db` down with it. +import { asyncContext } from '@ultimat3/core'; import { type StatementAttribution, statementObserver } from './observe'; -const storage = new AsyncLocalStorage(); +const storage = asyncContext('the statement attribution'); /** * Run `fn` with every statement it issues — at any depth, across every `await` — attributed to @@ -41,5 +43,5 @@ export function withStatementAttribution(entity: string, op: string, fn: () = * same answer captured at the moment the statement settled. */ export function statementAttribution(): StatementAttribution | undefined { - return storage.getStore(); + return storage.get(); } diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts index c94726f6..c9daaa25 100644 --- a/packages/db/src/client.ts +++ b/packages/db/src/client.ts @@ -61,7 +61,7 @@ export interface PoolProfile { } /** Sized per role because the failure modes differ: RPS bursts vs. queue depth vs. run-once. */ -export const POOL_PROFILES: Readonly> = Object.freeze({ +export const POOL_PROFILES = Object.freeze>({ web: { max: 20, statementTimeoutMs: 10_000, diff --git a/packages/db/src/errors.ts b/packages/db/src/errors.ts index b63061c0..fd1739d9 100644 --- a/packages/db/src/errors.ts +++ b/packages/db/src/errors.ts @@ -121,7 +121,7 @@ export const dbUnavailable = (detail: string, sourceError?: unknown): DbError => * named, so the fix points at the one index or key that refused the row rather than at the idea * of one; `driverError` substitutes the placeholder when the driver reported none. */ -const SQLSTATE_FIXES: Readonly> = Object.freeze({ +const SQLSTATE_FIXES = Object.freeze>({ X_DB_UNIQUE_VIOLATION: 'upsertAll(rows, { onConflict: [...] }) over the columns {constraint} covers — ' + 'or catch X_DB_UNIQUE_VIOLATION and answer 409, which is what a raced signup is', diff --git a/packages/db/src/expected-loop.ts b/packages/db/src/expected-loop.ts index 46126fd1..fd42fa14 100644 --- a/packages/db/src/expected-loop.ts +++ b/packages/db/src/expected-loop.ts @@ -3,13 +3,14 @@ // A scope with a written reason — never a comment pragma and never a config list of exempt call // sites (axiom 1), because both put the argument somewhere other than the loop it defends. -// `node:` because Bun exposes no native async-context primitive: the reason has to outlive every -// `await` inside the scope, and `AsyncLocalStorage` is the only thing that carries a value across -// them. A module-scope variable would be shared by two concurrent loops. -import { AsyncLocalStorage } from 'node:async_hooks'; -import { assert } from '@ultimat3/core'; +// The reason has to outlive every `await` inside the scope and a module-scope variable would be +// shared by two concurrent loops, so it needs an async context — opened through core's one lazy +// seam rather than a `node:async_hooks` construction here, which threw at module EVALUATION in a +// browser bundle (the bundler stubs the module to `{}`) and took every importer of `@ultimat3/db` +// with it. +import { assert, asyncContext } from '@ultimat3/core'; -const storage = new AsyncLocalStorage(); +const storage = asyncContext('the expected-loop reason'); /** * Run `fn` with every statement it issues — at any depth, across every `await` — marked expected @@ -49,5 +50,5 @@ export function expectedQueryLoop(reason: string, fn: () => T): T { * answer captured at the moment the statement settled. */ export function expectedQueryLoopReason(): string | undefined { - return storage.getStore(); + return storage.get(); } diff --git a/packages/db/src/transaction.ts b/packages/db/src/transaction.ts index 823f4048..212b610e 100644 --- a/packages/db/src/transaction.ts +++ b/packages/db/src/transaction.ts @@ -3,8 +3,7 @@ // the transactional outbox is only atomic because `currentTx()` finds this store. Nesting maps // to SAVEPOINTs, so an inner failure never silently aborts the outer unit of work. -import { AsyncLocalStorage } from 'node:async_hooks'; -import { assert, nanoid } from '@ultimat3/core'; +import { assert, asyncContext, nanoid } from '@ultimat3/core'; import { baseClient, type DbClient, type DbConnection, isReservable } from './client'; import { serializationExhausted } from './errors'; import { raw, type SqlFragment } from './sql'; @@ -79,11 +78,15 @@ interface TxState { readonly live: { value: boolean }; } -const storage = new AsyncLocalStorage(); +// Core's one lazy seam, never a construction here: a module-scope `new` threw at EVALUATION in a +// browser bundle, where the bundler stubs `node:async_hooks` to `{}`, and took every importer of +// `@ultimat3/db` with it. `get()` still answers `undefined` outside a scope, so the server pays +// nothing for the deferral. +const storage = asyncContext('a database transaction'); /** The open transaction, or `undefined` outside one. `@ultimat3/jobs` calls this per enqueue. */ export function currentTx(): DbTx | undefined { - return storage.getStore()?.tx; + return storage.get()?.tx; } /** @@ -96,7 +99,7 @@ export function currentTx(): DbTx | undefined { * reservation, whose own `held` fence already re-queues them. */ export function inLiveTx(): boolean { - return storage.getStore()?.live.value === true; + return storage.get()?.live.value === true; } export function beginStatement(options: TransactionOptions): string { @@ -217,7 +220,7 @@ export async function withTransaction( `withTransaction({ retry }) needs a whole number of extra attempts, 0 or more; a budget that is not one opens nothing and runs fn zero times`, "pass an integer — withTransaction(fn, { retry: 3, isolation: 'serializable' }) — and parse it before you pass it: Number(process.env.DB_RETRY) is NaN when the variable is unset", ); - const outer = storage.getStore(); + const outer = storage.get(); if (outer !== undefined) { // A nested scope is a SAVEPOINT, and a savepoint cannot survive the thing `retry` exists for: // measured against Postgres 17, a `40001` aborts the **whole** transaction, so the diff --git a/packages/entity/CLAUDE.md b/packages/entity/CLAUDE.md index 95a6d4ac..329978bb 100644 --- a/packages/entity/CLAUDE.md +++ b/packages/entity/CLAUDE.md @@ -568,9 +568,22 @@ Columns + invariants; the row type is derived from the columns. Tier 2. the tenant is a request-time value, so the seam is the enforcement. - **`crossTenant(reason, fn)` (`cross-tenant.ts`) is the ONE way to read across tenants**, for the three cases that have no single one: an admin surface over every org, background reconciliation, - support tooling. An `AsyncLocalStorage` scope with a written reason, the same shape + support tooling. An async-context scope with a written reason, the same shape `@ultimat3/db`'s `expectedQueryLoop` has, never a boolean argument on a repository call — which reads exactly like forgetting the tenant — and never a config list of exempt entities (axiom 1). + The scope opens through `asyncContext('the cross-tenant reason')` from `@ultimat3/core`, + **never a `new AsyncLocalStorage` here, and that is a build error rather than a convention `As of + 2026-08`** — `scripts/async-context-guard.ts` refuses the construction *and* the import that + binds the class, anywhere but `packages/core/src/async-context.ts`, and + `scripts/async-context-guard.test.ts` runs it over the tree in the gate's `unit` step. The + module-scope `new` this replaced threw `TypeError: undefined is not a constructor` at module + **evaluation** in a browser bundle, where the bundler stubs `node:async_hooks` to `{}`, taking + every importer of `cross-tenant.ts` with it. Now the module evaluates and `crossTenantReason()` + answers `undefined` there — in a browser nothing IS in flight, so that is the true answer. A + write is the case that names itself: `storage.run` throws `X_ASYNC_CONTEXT_UNAVAILABLE` instead + of a bare `TypeError`, though `crossTenant()` reaches it only past `assertCrossTenant`, which + wants a request context a browser does not have. A server pays nothing either way — + `getStore()` before any `run()` answered `undefined` whether the storage existed or not. **The capability is proven twice**: `CROSS_TENANT_SCOPE` (`tenancy:cross`) on the actor, at the call and again at every plan built inside it, because `withChildContext({ actor })` swaps the actor without closing the scope and an impersonated caller must not inherit it — diff --git a/packages/entity/src/cross-tenant.ts b/packages/entity/src/cross-tenant.ts index 521b5b7e..43ea517a 100644 --- a/packages/entity/src/cross-tenant.ts +++ b/packages/entity/src/cross-tenant.ts @@ -3,11 +3,11 @@ // reads exactly like forgetting the tenant, and never a config list of exempt entities (axiom 1): // both put the argument somewhere other than the read it defends. -// `node:` because Bun exposes no native async-context primitive: the scope has to outlive every -// `await` inside it, and `AsyncLocalStorage` is the only thing that carries a value across them. -// A module-scope flag would be shared by two concurrent requests — one of them ordinary. -import { AsyncLocalStorage } from 'node:async_hooks'; -import { actorLabel, assert, hasScope, tryUseContext } from '@ultimat3/core'; +// The scope has to outlive every `await` inside it and a module-scope flag would be shared by two +// concurrent requests — one of them ordinary — so it needs an async context. Opened through core's +// one lazy seam rather than a `node:async_hooks` construction here, which threw at module +// EVALUATION in a browser bundle (the bundler stubs the module to `{}`). +import { actorLabel, assert, asyncContext, hasScope, tryUseContext } from '@ultimat3/core'; import { crossTenantDenied } from './errors'; /** @@ -18,7 +18,7 @@ import { crossTenantDenied } from './errors'; */ export const CROSS_TENANT_SCOPE = 'tenancy:cross'; -const storage = new AsyncLocalStorage(); +const storage = asyncContext('the cross-tenant reason'); /** * Run `fn` with the tenant guard lifted — every read and write it issues, at any depth and across @@ -54,7 +54,7 @@ export function crossTenant(reason: string, fn: () => T): T { * The innermost enclosing reason, or `undefined` outside every scope — which is every query in an * app that never calls `crossTenant`. Read by the tenant guard, and by nothing else. */ -export const crossTenantReason = (): string | undefined => storage.getStore(); +export const crossTenantReason = (): string | undefined => storage.get(); /** * The capability check itself, run at `crossTenant()` and again for every plan built inside it. diff --git a/packages/http/src/index.ts b/packages/http/src/index.ts index 53ae25e7..0efaf79d 100644 --- a/packages/http/src/index.ts +++ b/packages/http/src/index.ts @@ -1,6 +1,7 @@ // The public surface of @ultimat3/http. Explicit, never `export *`: what is not // listed here is an implementation detail and may change without a major bump. +export type { RenderMode } from '@ultimat3/core'; export { NEXT_PARAM, nextAfterSignIn, signInRedirect } from './auth-redirect'; export type { HttpConfig, HttpConfigInput } from './config'; export { defineHttpConfig, stripBasePath } from './config'; @@ -129,7 +130,6 @@ export { export type { HttpMethod, MatchResult, - RenderMode, Route, RouteDescription, RouteHandler, diff --git a/packages/http/src/router.ts b/packages/http/src/router.ts index 3f6bcb9a..0cff6234 100644 --- a/packages/http/src/router.ts +++ b/packages/http/src/router.ts @@ -10,6 +10,7 @@ // param branch would also match, and a dead end in the static branch still falls // back to the param branch. Two routes that would tie are a build error // (`X_ROUTE_CONFLICT`) rather than a coin flip. +import type { RenderMode } from '@ultimat3/core'; import type { RequestContext } from './context'; import { routeConflict } from './errors'; import type { Bucket } from './rate-limit'; @@ -29,8 +30,6 @@ export const HTTP_METHODS: readonly HttpMethod[] = [ 'OPTIONS', ]; -export type RenderMode = 'static' | 'isr' | 'ssr' | 'stream'; - export type RouteParams = Readonly>; export interface RouteMeta { diff --git a/packages/jobs/src/worker.ts b/packages/jobs/src/worker.ts index 186b6443..d5574fe3 100644 --- a/packages/jobs/src/worker.ts +++ b/packages/jobs/src/worker.ts @@ -31,13 +31,12 @@ const QUEUE_DEPTH_INTERVAL_MS = 15_000; * is deliberately unmapped: parking a run is control flow, so counting it would make every * `step.sleep` read as a finished job and make the failure ratio meaningless. */ -const JOB_OUTCOME_LABELS: Readonly> = - Object.freeze({ - completed: 'ok', - suspended: null, - retried: 'failed', - 'dead-lettered': 'dead', - }); +const JOB_OUTCOME_LABELS = Object.freeze>({ + completed: 'ok', + suspended: null, + retried: 'failed', + 'dead-lettered': 'dead', +}); export interface WorkerOptions { readonly driver: JobDriver; diff --git a/packages/mail/src/layout.ts b/packages/mail/src/layout.ts index 092a1c24..6e13d13f 100644 --- a/packages/mail/src/layout.ts +++ b/packages/mail/src/layout.ts @@ -21,21 +21,20 @@ export type MailToken = | 'calloutDangerBg' | 'calloutDangerText'; -export const MAIL_TOKENS: Readonly>>> = - Object.freeze({ - pageBg: { light: '#f4f5f7', dark: '#0b0d10' }, - surfaceBg: { light: '#ffffff', dark: '#14181d' }, - textPrimary: { light: '#16191d', dark: '#e7eaee' }, - textMuted: { light: '#5c6470', dark: '#9aa4b1' }, - borderSubtle: { light: '#e2e6ea', dark: '#262c33' }, - accentBg: { light: '#2f6df6', dark: '#4f86ff' }, - accentText: { light: '#ffffff', dark: '#0b0d10' }, - linkText: { light: '#2f6df6', dark: '#7aa7ff' }, - calloutInfoBg: { light: '#eef3ff', dark: '#141c2c' }, - calloutInfoText: { light: '#1f3f8f', dark: '#b9cdfb' }, - calloutDangerBg: { light: '#fdeceb', dark: '#2a1416' }, - calloutDangerText: { light: '#8c2118', dark: '#f5b3ad' }, - }); +export const MAIL_TOKENS = Object.freeze>>>({ + pageBg: { light: '#f4f5f7', dark: '#0b0d10' }, + surfaceBg: { light: '#ffffff', dark: '#14181d' }, + textPrimary: { light: '#16191d', dark: '#e7eaee' }, + textMuted: { light: '#5c6470', dark: '#9aa4b1' }, + borderSubtle: { light: '#e2e6ea', dark: '#262c33' }, + accentBg: { light: '#2f6df6', dark: '#4f86ff' }, + accentText: { light: '#ffffff', dark: '#0b0d10' }, + linkText: { light: '#2f6df6', dark: '#7aa7ff' }, + calloutInfoBg: { light: '#eef3ff', dark: '#141c2c' }, + calloutInfoText: { light: '#1f3f8f', dark: '#b9cdfb' }, + calloutDangerBg: { light: '#fdeceb', dark: '#2a1416' }, + calloutDangerText: { light: '#8c2118', dark: '#f5b3ad' }, +}); /** Resolve a token to a hex value. The only function in the package that returns a colour. */ export function token(name: MailToken, scheme: ColorScheme = 'light'): string { diff --git a/packages/manifest/src/index.ts b/packages/manifest/src/index.ts index fcd1a044..a93e2355 100644 --- a/packages/manifest/src/index.ts +++ b/packages/manifest/src/index.ts @@ -1,6 +1,7 @@ // Public API of @ultimat3/manifest. Explicit — `x verify`, `x manifest`, and the MCP // `manifest.read` resource are all built from exactly these exports. +export type { HydrateStrategy, OfflineStrategy, RenderMode } from '@ultimat3/core'; export type { AgentsMdCheck, CheckAgentsMdInput } from './agents-md'; export { AGENTS_MD_FILENAME, @@ -46,15 +47,12 @@ export type { ColumnFact, EntityFact, ErrorCodeFact, - HydrateStrategy, JobFact, JsonValue, Manifest, - OfflineStrategy, PolicyFact, QueryFact, RateLimitFact, - RenderMode, RouteFact, TaskFact, } from './schema'; diff --git a/packages/manifest/src/schema.ts b/packages/manifest/src/schema.ts index fe4a5030..df474ff2 100644 --- a/packages/manifest/src/schema.ts +++ b/packages/manifest/src/schema.ts @@ -5,6 +5,11 @@ // Every collection is `readonly` and every field is a plain JSON value: the manifest must // round-trip through `JSON.stringify` without loss, because that is how it is stored. +// The route vocabulary is `@ultimat3/core`'s, at tier 0. It is IMPORTED rather than restated even +// though every other field here is a plain literal: the manifest's `render` field means the same +// thing as the route's, and two spellings of one closed set is what `'spa'` escaped through. +import type { HydrateStrategy, OfflineStrategy, RenderMode } from '@ultimat3/core'; + /** * Bumped when a reader built for the previous version would be WRONG, not merely incomplete: * a field removed, retyped, or given a new meaning. @@ -25,10 +30,6 @@ export type JsonValue = | readonly JsonValue[] | { readonly [key: string]: JsonValue }; -export type RenderMode = 'static' | 'isr' | 'ssr' | 'stream'; -export type OfflineStrategy = 'precache' | 'runtime' | 'network-only'; -export type HydrateStrategy = 'idle' | 'visible' | 'interaction' | 'never'; - export interface RouteFact { readonly url: string; readonly render: RenderMode; diff --git a/packages/mcp/src/audit.ts b/packages/mcp/src/audit.ts index 27f34a94..ba725069 100644 --- a/packages/mcp/src/audit.ts +++ b/packages/mcp/src/audit.ts @@ -58,7 +58,7 @@ export interface McpAuditEntry { * the whole enumeration surface; `invalid-args` is a well-behaved client misreading a schema, * and an unexpected throw is the only `error` because it is the only one that is a bug. */ -const LEVEL: Readonly> = Object.freeze({ +const LEVEL = Object.freeze>({ ok: 'info', hidden: 'warn', 'scope-denied': 'warn', diff --git a/packages/pwa/src/capabilities.ts b/packages/pwa/src/capabilities.ts index 5a3f5500..8ccb7082 100644 --- a/packages/pwa/src/capabilities.ts +++ b/packages/pwa/src/capabilities.ts @@ -43,15 +43,14 @@ export function enabledCapabilities(capabilities: ResolvedCapabilities): readonl } /** Manifest members a capability owns. Absent capability → absent member. */ -export const CAPABILITY_MANIFEST_KEYS: Readonly> = - Object.freeze({ - push: [], - backgroundSync: [], - badging: [], - shareTarget: ['share_target'], - fileHandlers: ['file_handlers'], - protocolHandlers: ['protocol_handlers'], - }); +export const CAPABILITY_MANIFEST_KEYS = Object.freeze>({ + push: [], + backgroundSync: [], + badging: [], + shareTarget: ['share_target'], + fileHandlers: ['file_handlers'], + protocolHandlers: ['protocol_handlers'], +}); /** * The service-worker code each capability emits — its listener, and anything that listener alone @@ -64,13 +63,11 @@ export const CAPABILITY_MANIFEST_KEYS: Readonly> = Object.freeze( - { - push: ["addEventListener('push'", "addEventListener('notificationclick'"], - backgroundSync: ["addEventListener('sync'", 'class PwaSyncError'], - badging: ['navigator.setAppBadge'], - shareTarget: [], - fileHandlers: [], - protocolHandlers: [], - }, -); +export const CAPABILITY_SW_MARKERS = Object.freeze>({ + push: ["addEventListener('push'", "addEventListener('notificationclick'"], + backgroundSync: ["addEventListener('sync'", 'class PwaSyncError'], + badging: ['navigator.setAppBadge'], + shareTarget: [], + fileHandlers: [], + protocolHandlers: [], +}); diff --git a/packages/pwa/src/index.ts b/packages/pwa/src/index.ts index 6f6574ca..4666f0fb 100644 --- a/packages/pwa/src/index.ts +++ b/packages/pwa/src/index.ts @@ -1,5 +1,11 @@ /** Public API of `@ultimat3/pwa`. You never open `sw.js`; you call these. */ +/** + * `PwaRenderMode` and `PwaOfflineStrategy` were this package's own NAMES for tier 0's vocabulary — + * the alias was the copy. `PwaRoute` takes both in its signature, so the canonical names are + * re-exported here; a consumer still needs one import, and now it names the real type. + */ +export type { OfflineStrategy, RenderMode } from '@ultimat3/core'; export type { BackgroundSyncOptions, RetryPolicy } from './background-sync'; export { backgroundSyncSource, @@ -111,8 +117,6 @@ export { export type { RouteRule, ServiceWorkerConfig, ServiceWorkerOutput } from './service-worker'; export { assertScope, generateServiceWorker, routeRules } from './service-worker'; export type { - PwaOfflineStrategy, - PwaRenderMode, PwaRoute, StrategyCache, StrategyEnv, diff --git a/packages/pwa/src/strategies.test.ts b/packages/pwa/src/strategies.test.ts index a9d41e9b..0ce02d53 100644 --- a/packages/pwa/src/strategies.test.ts +++ b/packages/pwa/src/strategies.test.ts @@ -1,12 +1,7 @@ import { describe, expect, test } from 'bun:test'; +import type { RenderMode } from '@ultimat3/core'; import { PwaStrategyExhaustedError } from './errors'; -import type { - PwaRenderMode, - PwaRoute, - StrategyCache, - StrategyEnv, - StrategyName, -} from './strategies'; +import type { PwaRoute, StrategyCache, StrategyEnv, StrategyName } from './strategies'; import { cacheFirst, MODE_STRATEGY, @@ -20,7 +15,7 @@ import { strategyFor, } from './strategies'; -function route(partial: Partial & { mode: PwaRenderMode }): PwaRoute { +function route(partial: Partial & { mode: RenderMode }): PwaRoute { return { path: '/x', surface: 'app', @@ -40,7 +35,7 @@ function fakeEnv(seed: Map, network: () => Promise): } describe('render mode → strategy', () => { - test.each<[PwaRenderMode, StrategyName]>([ + test.each<[RenderMode, StrategyName]>([ ['static', 'cache-first'], ['isr', 'stale-while-revalidate'], ['ssr', 'network-first'], diff --git a/packages/pwa/src/strategies.ts b/packages/pwa/src/strategies.ts index 8c68fac9..09124309 100644 --- a/packages/pwa/src/strategies.ts +++ b/packages/pwa/src/strategies.ts @@ -4,6 +4,7 @@ * its bytes have to be, so the mapping is derived and the override is the exception. */ +import type { OfflineStrategy, RenderMode } from '@ultimat3/core'; import { PwaStrategyExhaustedError } from './errors'; export type StrategyName = @@ -19,9 +20,6 @@ export const STRATEGY_NAMES: readonly StrategyName[] = [ 'network-only', ]; -export type PwaRenderMode = 'static' | 'isr' | 'ssr' | 'stream'; -export type PwaOfflineStrategy = 'precache' | 'runtime' | 'network-only'; - /** * Structural view of `@ultimat3/render`'s `RouteDescriptor`. Tier-4 packages must not * import each other, so route data arrives as data and this is the shape it must have. @@ -29,8 +27,8 @@ export type PwaOfflineStrategy = 'precache' | 'runtime' | 'network-only'; export interface PwaRoute { readonly path: string; readonly surface: 'site' | 'app' | 'api'; - readonly mode: PwaRenderMode; - readonly offline: PwaOfflineStrategy; + readonly mode: RenderMode; + readonly offline: OfflineStrategy; readonly dynamic?: boolean; /** Explicit per-route override; wins over the derived strategy. */ readonly strategy?: StrategyName; @@ -41,8 +39,24 @@ export interface PwaRoute { readonly dataUrl?: string; } -/** Render mode → runtime strategy. The whole reason `sw.js` is generated, not written. */ -export const MODE_STRATEGY: Readonly> = Object.freeze({ +/** + * Render mode → runtime strategy. The whole reason `sw.js` is generated, not written. + * + * `Record` over the tier-0 union is the exhaustiveness check: a mode with no row is + * a compile error, and a row for a mode that does not exist is a compile error too. That second + * half is the one that mattered — `spa` kept mapping to `cache-first` here after it was deleted + * from the vocabulary, the one strategy that gives an `app/` route a SHARED cache entry, i.e. one + * member's authed HTML served to the next. It compiled because this Record was keyed on a copy. + */ +/** + * `Object.freeze({…})` with an EXPLICIT type argument, never `const X: T = Object.freeze({…})`. + * The second form loses the object literal's freshness — the literal is inferred first and the + * annotation only checks assignability afterwards — so an EXTRA key compiles silently. That is not + * a hypothetical: `spa: 'cache-first'` sat in `@ultimat3/pwa`'s copy of this table after `spa` was + * deleted from the vocabulary, and `tsc` had nothing to say. Naming the type argument makes the + * literal contextually typed, so a missing key AND an extra key are both build errors. + */ +export const MODE_STRATEGY = Object.freeze>({ static: 'cache-first', isr: 'stale-while-revalidate', ssr: 'network-first', @@ -138,12 +152,12 @@ export async function networkOnly( } } -export const STRATEGY_FNS: Readonly< +export const STRATEGY_FNS = Object.freeze< Record< StrategyName, (request: Request, env: StrategyEnv, options: StrategyOptions) => Promise > -> = Object.freeze({ +>({ 'cache-first': cacheFirst, 'network-first': networkFirst, 'stale-while-revalidate': staleWhileRevalidate, @@ -176,7 +190,7 @@ async function fallbackOrThrow(options: StrategyOptions): Promise { * service worker is a generated artifact with no bundler in the loop — the shapes are * identical on purpose and `strategies.test.ts` asserts both halves stay in step. */ -export const STRATEGY_SOURCE: Readonly> = Object.freeze({ +export const STRATEGY_SOURCE = Object.freeze>({ 'cache-first': `async function cacheFirst(req,cn,fb){ const c=await caches.open(cn);const hit=await c.match(req);if(hit)return hit; try{const r=await fetch(req);if(r.ok)await c.put(req,r.clone());return r}catch(e){if(fb)return fb();throw e} @@ -198,7 +212,7 @@ export const STRATEGY_SOURCE: Readonly> = Object.fr }`, }); -export const STRATEGY_FN_NAMES: Readonly> = Object.freeze({ +export const STRATEGY_FN_NAMES = Object.freeze>({ 'cache-first': 'cacheFirst', 'network-first': 'networkFirst', 'stale-while-revalidate': 'staleWhileRevalidate', diff --git a/packages/render/src/hydrate.ts b/packages/render/src/hydrate.ts index 180d8c7b..af8a5a4f 100644 --- a/packages/render/src/hydrate.ts +++ b/packages/render/src/hydrate.ts @@ -5,8 +5,8 @@ * answered instead of swallowed. */ +import type { HydrateStrategy } from '@ultimat3/core'; import { escapeAttribute, escapeJsonContent } from './html'; -import type { HydrateStrategy } from './route'; export interface IslandDirective { /** Unique per INSTANCE: two of the same island on a page need two prop bags to find. */ diff --git a/packages/render/src/index.ts b/packages/render/src/index.ts index 1bbf134f..2aaf024c 100644 --- a/packages/render/src/index.ts +++ b/packages/render/src/index.ts @@ -9,6 +9,15 @@ import { installRenderLoader } from './module-loader'; // places one fact can be wrong instead of none. installRenderLoader(); +/** + * The route vocabulary is declared once, at tier 0 (`@ultimat3/core`), and re-exported here + * because `defineRoute`, `MODE_SPECS`, `surfaceAllows` and `RouteDescriptor` all take these types + * in their signatures: a consumer calling this package's API should not need a second import to + * name its arguments. A re-export is not a declaration — `scripts/render-modes.test.ts` refuses a + * second declaration, which is what makes re-exporting safe where copying was not. + */ +export type { HydrateStrategy, OfflineStrategy, RenderMode } from '@ultimat3/core'; +export { HYDRATE_STRATEGIES, OFFLINE_STRATEGIES, RENDER_MODES } from '@ultimat3/core'; export type { CompiledStylesheet } from './css-modules'; export { compileStylesheet, isCssModule, isGlobalStylesheet, scopeClasses } from './css-modules'; export { parseTtlMs } from './duration'; @@ -89,7 +98,6 @@ export { defaultHydrate, defaultIslandBudget, MODE_SPECS, - RENDER_MODES, } from './modes'; export type { Stylesheet } from './module-loader'; export { @@ -162,11 +170,8 @@ export { streamResult, } from './render-stream'; export type { - HydrateStrategy, LoadRequirement, - OfflineStrategy, PrerenderFn, - RenderMode, RenderResult, RevalidateConfig, RouteBudget, @@ -182,14 +187,7 @@ export type { RouteMetaFn, RouteParams, } from './route'; -export { - DEFAULT_ISLAND_HYDRATE, - defineRoute, - HYDRATE_STRATEGIES, - isRouteConfig, - OFFLINE_STRATEGIES, - tagKeys, -} from './route'; +export { DEFAULT_ISLAND_HYDRATE, defineRoute, isRouteConfig, tagKeys } from './route'; export type { RouteComponent } from './route-component'; export { pageComponentOf } from './route-component'; export { metaContextFor, routeDataFor } from './route-data'; diff --git a/packages/render/src/island-collector.ts b/packages/render/src/island-collector.ts index e40ac31a..0a65a87c 100644 --- a/packages/render/src/island-collector.ts +++ b/packages/render/src/island-collector.ts @@ -4,6 +4,7 @@ * one place a route says it ships JavaScript, and the directives stay a per-render fact. */ +import type { HydrateStrategy } from '@ultimat3/core'; import { IslandInvalidError, IslandNotHydratedError } from './errors'; import type { IslandDirective } from './hydrate'; import { DEFAULT_REPLAY_EVENTS } from './hydrate'; @@ -12,7 +13,6 @@ import { isEmittableSpecifier, islandNeverDrained } from './island'; import type { IslandProps } from './island-props'; import { checkIslandProps } from './island-props'; import type { JsxProps } from './jsx'; -import type { HydrateStrategy } from './route'; /** The distinct client entries a rendered page pulled in — one per module, however many instances. */ export function islandModuleIds(directives: readonly IslandDirective[]): readonly string[] { diff --git a/packages/render/src/islands.ts b/packages/render/src/islands.ts index 8e1022ba..211ad725 100644 --- a/packages/render/src/islands.ts +++ b/packages/render/src/islands.ts @@ -5,11 +5,11 @@ * opt-in, budgeted island. */ +import type { HydrateStrategy } from '@ultimat3/core'; import { BudgetExceededError } from './errors'; import type { IslandDirective } from './hydrate'; import { hydrateRuntimeBytes } from './hydrate'; import type { RouteEntry } from './registry'; -import type { HydrateStrategy } from './route'; import type { Surface } from './surfaces'; import { SURFACE_SPECS } from './surfaces'; diff --git a/packages/render/src/modes.test.ts b/packages/render/src/modes.test.ts index 0041e658..776f9a3b 100644 --- a/packages/render/src/modes.test.ts +++ b/packages/render/src/modes.test.ts @@ -1,9 +1,11 @@ import { describe, expect, test } from 'bun:test'; import { tag } from '@ultimat3/cache'; +import type { RenderMode } from '@ultimat3/core'; +import { RENDER_MODES } from '@ultimat3/core'; import { RouteModeInvalidError } from './errors'; -import { assertModeInvariants, defaultHydrate, MODE_SPECS, RENDER_MODES } from './modes'; +import { assertModeInvariants, defaultHydrate, MODE_SPECS } from './modes'; import { clearRoutes, registerRoute } from './registry'; -import type { RenderMode, RouteConfig, RouteGuard, RouteMetaFn } from './route'; +import type { RouteConfig, RouteGuard, RouteMetaFn } from './route'; import { defineRoute } from './route'; import { SURFACE_SPECS } from './surfaces'; diff --git a/packages/render/src/modes.ts b/packages/render/src/modes.ts index bd280420..269d5678 100644 --- a/packages/render/src/modes.ts +++ b/packages/render/src/modes.ts @@ -5,15 +5,14 @@ * invariant is only documented is a mode that silently degrades in production. */ +import type { HydrateStrategy, RenderMode } from '@ultimat3/core'; +import { HYDRATE_STRATEGIES, RENDER_MODES } from '@ultimat3/core'; import { parseTtlMs } from './duration'; import { RouteModeInvalidError } from './errors'; -import type { HydrateStrategy, RenderMode, RouteConfig } from './route'; -import { HYDRATE_STRATEGIES } from './route'; +import type { RouteConfig } from './route'; import type { Surface } from './surfaces'; import { SURFACE_SPECS, surfaceAllows } from './surfaces'; -export const RENDER_MODES = ['static', 'isr', 'ssr', 'stream'] as const; - /** * Everything about a route except the two keys carrying its data generic. Mode invariants never * read metadata and never load anything, and omitting both keeps these checks free of `TData`. @@ -38,7 +37,9 @@ export interface ModeSpec { readonly description: string; } -export const MODE_SPECS: Readonly> = Object.freeze({ +/** Keyed with an explicit type argument for the reason `MODE_STRATEGY` in `@ultimat3/pwa` + * gives: a `const X: Record<…> = Object.freeze({…})` accepts an extra key in silence. */ +export const MODE_SPECS = Object.freeze>({ static: { mode: 'static', perRequestState: false, diff --git a/packages/render/src/registry.ts b/packages/render/src/registry.ts index ef9f567e..37630475 100644 --- a/packages/render/src/registry.ts +++ b/packages/render/src/registry.ts @@ -5,6 +5,7 @@ * from. Nothing downstream may keep its own list of routes. */ +import type { HydrateStrategy, OfflineStrategy, RenderMode } from '@ultimat3/core'; import { RouteDuplicateError, RouteFileInvalidError, @@ -12,14 +13,7 @@ import { SurfaceBoundaryError, } from './errors'; import { assertModeInvariants, defaultIslandBudget } from './modes'; -import type { - HydrateStrategy, - OfflineStrategy, - RenderMode, - RouteConfig, - RouteData, - RouteParams, -} from './route'; +import type { RouteConfig, RouteData, RouteParams } from './route'; import { isRouteConfig, tagKeys } from './route'; import type { RouteComponent } from './route-component'; import type { Surface } from './surfaces'; @@ -29,7 +23,7 @@ import { locateSurface } from './surfaces'; * The one filename a route may carry, per surface. `shared/` is absent on purpose: it is a leaf * of helpers with no URL, so a route file there has nowhere to resolve to. */ -export const ROUTE_FILENAME: Readonly>> = Object.freeze({ +export const ROUTE_FILENAME = Object.freeze>>({ site: 'page.tsx', app: 'page.tsx', api: 'route.ts', diff --git a/packages/render/src/route.ts b/packages/render/src/route.ts index 23fc7073..fe063256 100644 --- a/packages/render/src/route.ts +++ b/packages/render/src/route.ts @@ -14,6 +14,8 @@ import type { CacheTag } from '@ultimat3/cache'; import { serializeTags } from '@ultimat3/cache'; +import type { HydrateStrategy, OfflineStrategy, RenderMode } from '@ultimat3/core'; +import { OFFLINE_STRATEGIES } from '@ultimat3/core'; import type { Translator } from '@ultimat3/i18n'; import type { RouteMeta } from '@ultimat3/seo'; import { RouteLoadInvalidError, RouteMetaMissingError, RouteOfflineMissingError } from './errors'; @@ -21,13 +23,6 @@ import type { IslandSpec } from './island'; import { drainDeclaredIslands } from './island'; import { assertModeShape } from './modes'; -export type RenderMode = 'static' | 'isr' | 'ssr' | 'stream'; -export type OfflineStrategy = 'precache' | 'runtime' | 'network-only'; -export type HydrateStrategy = 'idle' | 'visible' | 'interaction' | 'never'; - -export const OFFLINE_STRATEGIES = ['precache', 'runtime', 'network-only'] as const; -export const HYDRATE_STRATEGIES = ['idle', 'visible', 'interaction', 'never'] as const; - /** * What a page that declares an island hydrates as when it says nothing. The most conservative of * the three that ship JavaScript: nothing runs until the visitor acts, and `interaction` is the diff --git a/packages/render/src/surfaces.ts b/packages/render/src/surfaces.ts index 74e40db3..cd14b95f 100644 --- a/packages/render/src/surfaces.ts +++ b/packages/render/src/surfaces.ts @@ -5,8 +5,8 @@ * aspirational. Violations are build errors, resolved through the whole chain. */ +import type { RenderMode } from '@ultimat3/core'; import { SurfaceBoundaryError } from './errors'; -import type { RenderMode } from './route'; export type Surface = 'site' | 'app' | 'api' | 'shared'; @@ -24,7 +24,7 @@ export interface SurfaceSpec { readonly mayImportTypes: readonly Surface[]; } -export const SURFACE_SPECS: Readonly> = Object.freeze({ +export const SURFACE_SPECS = Object.freeze>({ site: { surface: 'site', defaultMode: 'static', diff --git a/packages/schema/src/json-schema.ts b/packages/schema/src/json-schema.ts index 755deb93..4b32b140 100644 --- a/packages/schema/src/json-schema.ts +++ b/packages/schema/src/json-schema.ts @@ -51,7 +51,7 @@ export interface JsonSchema { export type JsonSchemaDialect = '2020-12' | 'draft-07'; -const DIALECTS: Readonly> = Object.freeze({ +const DIALECTS = Object.freeze>({ '2020-12': 'https://json-schema.org/draft/2020-12/schema', 'draft-07': 'http://json-schema.org/draft-07/schema#', }); diff --git a/packages/seo/src/index.ts b/packages/seo/src/index.ts index 31832fed..62c1632e 100644 --- a/packages/seo/src/index.ts +++ b/packages/seo/src/index.ts @@ -1,5 +1,6 @@ // The public surface of @ultimat3/seo. Explicit named exports only. +export type { RenderMode } from '@ultimat3/core'; export type { SeoErrorCode, SeoErrorInit } from './errors'; export { canonicalMismatch, @@ -90,7 +91,7 @@ export { } from './meta'; export type { RobotsConfig, RobotsGroup } from './robots'; export { buildRobots, isIndexable } from './robots'; -export type { ChangeFreq, RenderMode, RouteRecord, Surface } from './routes'; +export type { ChangeFreq, RouteRecord, Surface } from './routes'; export { expandRoute, indexableRoutes, isDynamic } from './routes'; export type { BuildFeedOptions, Feed, FeedAuthor, FeedChannel, FeedItem } from './rss'; export { buildFeed } from './rss'; diff --git a/packages/seo/src/routes.ts b/packages/seo/src/routes.ts index 93e3e6c0..c49bdcd7 100644 --- a/packages/seo/src/routes.ts +++ b/packages/seo/src/routes.ts @@ -2,10 +2,9 @@ // `x.manifest.json`; every checker here reports against `file`, so an agent can // open the exact source rather than guess which route a URL came from. +import type { RenderMode } from '@ultimat3/core'; import type { RouteMeta } from './meta'; -export type RenderMode = 'static' | 'isr' | 'ssr' | 'stream'; - /** `site/` is the only surface SEO applies to; `app/` is behind auth. */ export type Surface = 'site' | 'app' | 'api'; diff --git a/packages/time/src/cron-occurrence.test.ts b/packages/time/src/cron-occurrence.test.ts index aadb05af..54137d2c 100644 --- a/packages/time/src/cron-occurrence.test.ts +++ b/packages/time/src/cron-occurrence.test.ts @@ -6,6 +6,7 @@ import { nextCronOccurrenceMs, nextCronOccurrences, } from './cron-occurrence'; +import { type CronExpression, parseCron } from './cron-parse'; import { fromIso, toIso } from './instant'; import { toZoned } from './zoned'; @@ -54,32 +55,55 @@ describe('nextCronOccurrence', () => { // T9. This used to be answered by exhausting the 200,000-step budget: ~150 ms of blocking CPU // per call, and `firedSince` — the scheduler leader loop's entry point — pays it every tick. // The day/month combination is decidable from the parsed fields alone. - test('an unmatchable date is refused at PARSE time, in constant time', () => { - const started = performance.now(); - const error = errorOf(() => + test('an unmatchable date is refused at PARSE time, having walked no steps at all', () => { + const refused = errorOf(() => nextCronOccurrence('0 0 30 2 *', UTC, fromIso('2026-03-14T00:00:00Z')), ); - const spent = performance.now() - started; - const cause = String(error.cause); - expect(error.code).toBe('X_CRON_INVALID'); + expect(refused.code).toBe('X_CRON_INVALID'); // The cause names the combination rather than the budget: a search that ran out of steps says // nothing about WHY, and "30" and "february" are the two words a reader needs. - expect(cause).toContain('february'); - expect(cause).not.toContain('search steps'); - // Generous by two orders of magnitude — the point is that no walk happened at all. - expect(spent).toBeLessThan(20); + expect(String(refused.cause)).toContain('february'); + expect(String(refused.cause)).not.toContain('search steps'); + + // WHICH refusal arrived is the step count, and that is why nothing here is timed. This used to + // assert `performance.now()` under 20 ms: measured on this repo, the same call is 3.2 ms at + // worst idle and 21.3 ms at worst under eight `bun test` workers, so the bound separated a + // loaded box from an unloaded one and never the walk from the parse. `UNMATCHABLE_PAST_THE_PARSER` + // is the identical field set handed in already parsed, so `parseCronOnce` has nothing left to + // check — it walks, and the test below pins that it dies on the budget. Landing on the parse + // refusal here instead of that one is what says no step was taken. + expect(String(refused.cause)).toContain('never occurs in'); }); test('the search budget still guards the walk it was written for', () => { - // Untouched: the constant-time check answers the day/month case, and MAX_STEPS stays the - // backstop for anything it cannot see. - const error = errorOf(() => - nextCronOccurrence('0 0 * * *', UTC, fromIso('2026-03-14T00:00:00Z')), + // The backstop, exercised on the one input that can still reach it: fields the parser would + // have refused, assembled behind it. Untouched by the parse-time check above — MAX_STEPS stays + // the answer for anything that check cannot see. The count is pinned on purpose: a budget + // quietly lowered is a far-future schedule that stops resolving, which is the assertion under + // this one. + const exhausted = errorOf(() => + nextCronOccurrence(UNMATCHABLE_PAST_THE_PARSER, UTC, fromIso('2026-03-14T00:00:00Z')), + ); + expect(exhausted.code).toBe('X_CRON_INVALID'); + expect(String(exhausted.cause)).toContain('200000 search steps'); + // And a schedule that CAN fire is untouched by either refusal. + expect(toIso(nextCronOccurrence('0 0 29 2 *', UTC, fromIso('2026-03-14T00:00:00Z')))).toBe( + '2028-02-29T00:00:00.000Z', ); - expect(error.code).toBe('no-throw'); }); }); +/** + * The 30th of February, past the one place that can refuse it. Every field comes from the real + * parser — only `months` is moved, from a January the day exists in to a February it never does — + * so this is the same expression the test above hands in as a string, minus the parse. + */ +const UNMATCHABLE_PAST_THE_PARSER: CronExpression = { + ...parseCron('0 0 30 1 *'), + source: '0 0 30 2 *', + months: [2], +}; + describe('nextCronOccurrence across a DST boundary', () => { // Europe/Berlin springs forward 2026-03-29 at 02:00 local (01:00Z). test('0 3 * * * stays at 03:00 local on both sides of the change', () => { diff --git a/scripts/async-context-guard.test.ts b/scripts/async-context-guard.test.ts new file mode 100644 index 00000000..3a7c4cb5 --- /dev/null +++ b/scripts/async-context-guard.test.ts @@ -0,0 +1,126 @@ +// The guard's own proof, in two halves: fixtures that MUST be reported (so the check can fail at +// all) and the real tree, which must be clean. `bun-pin.test.ts` is the precedent for a repo-wide +// rule living beside its script — the gate's `unit` step already collects `scripts/**/*.test.ts`, +// so this needs no step of its own. + +import { describe, expect, test } from 'bun:test'; +import { + ASYNC_CONTEXT_SEAM, + type AsyncStorageSite, + asyncStorageFinding, + checkAsyncStorage, +} from './async-context-guard'; +import { collectSourceFiles } from './boundaries'; +import { repoRoot } from './lib/run'; + +const one = (source: string, path = 'packages/thing/src/scope.ts'): readonly AsyncStorageSite[] => + checkAsyncStorage([{ path, source }]); + +const kinds = (sites: readonly AsyncStorageSite[]): readonly string[] => + sites.map((site) => `${site.kind}:${site.name}`); + +describe('the AsyncLocalStorage guard, on source it must refuse', () => { + test('reports a module-scope construction and the import that made it expressible', () => { + const sites = one(`import { AsyncLocalStorage } from 'node:async_hooks'; + +const storage = new AsyncLocalStorage(); +export const read = (): string | undefined => storage.getStore(); +`); + expect(kinds(sites)).toEqual(['binding:AsyncLocalStorage', 'construction:AsyncLocalStorage']); + expect(sites[1]?.line).toBe(3); + }); + + test('follows an alias, so renaming the class on the way in hides nothing', () => { + const sites = one(`import { AsyncLocalStorage as Ambient } from 'node:async_hooks'; +const store = new Ambient(); +`); + expect(kinds(sites)).toEqual(['binding:Ambient', 'construction:Ambient']); + }); + + test('follows a namespace import, where the class is reached as a property', () => { + const sites = one(`import * as hooks from 'async_hooks'; +const store = new hooks.AsyncLocalStorage(); +`); + expect(kinds(sites)).toEqual([ + 'binding:hooks.AsyncLocalStorage', + 'construction:hooks.AsyncLocalStorage', + ]); + }); + + test('reports a binding nothing has constructed yet, because the next edit will', () => { + const sites = one(`import { AsyncLocalStorage } from 'node:async_hooks'; +export type Store = AsyncLocalStorage; +`); + expect(kinds(sites)).toEqual(['binding:AsyncLocalStorage']); + }); + + test('reports a bare construction with no import in the file — a global is still a new', () => { + expect(kinds(one('const store = new AsyncLocalStorage();'))).toEqual([ + 'construction:AsyncLocalStorage', + ]); + }); + + test('its finding names the file, the line and a fix that is a call to paste', () => { + const site = one('const store = new AsyncLocalStorage();')[0]; + const finding = asyncStorageFinding(site ?? expect.unreachable('no site was reported')); + expect(finding.code).toBe('X_ASYNC_CONTEXT_UNAVAILABLE'); + expect(finding.at).toBe('packages/thing/src/scope.ts:1'); + expect(finding.fix).toContain("asyncContext('what the scope carries')"); + }); +}); + +describe('the AsyncLocalStorage guard, on source it must leave alone', () => { + test('reads no defect out of a comment — `telemetry.ts` explains the bug by writing it', () => { + const sites = one(`// \`new AsyncLocalStorage()\` throws at EVALUATION in a browser bundle. +/** import { AsyncLocalStorage } from 'node:async_hooks'; is what this file must not do. */ +export const nothing = 1; +`); + expect(sites).toEqual([]); + }); + + test('an import of another member of node:async_hooks is not this rule', () => { + expect(one(`import { AsyncResource } from 'node:async_hooks';`)).toEqual([]); + }); + + test('an unrelated `new` is not a finding', () => { + expect(one('const seen = new Set();\nconst at = new Date();')).toEqual([]); + }); + + test('the seam itself is the one module allowed to construct one', () => { + const source = `import { AsyncLocalStorage } from 'node:async_hooks'; +const storage = new AsyncLocalStorage(); +`; + expect(one(source, ASYNC_CONTEXT_SEAM)).toEqual([]); + // …and only that path: the exemption is the file, never the shape of what is in it. + expect(one(source, 'packages/core/src/other.ts')).toHaveLength(2); + }); + + test('a test file is skipped — a test is in nobody`s bundle', () => { + const source = 'const storage = new AsyncLocalStorage();'; + expect(one(source, 'packages/db/src/transaction.test.ts')).toEqual([]); + }); +}); + +describe('the framework tree', () => { + test('holds exactly one AsyncLocalStorage, and it is the seam', async () => { + const files = await collectSourceFiles(repoRoot()); + // Non-vacuity: the collector must actually be reading the packages this rule is about. + expect(files.length).toBeGreaterThan(1000); + expect(files.some((file) => file.path === 'packages/db/src/transaction.ts')).toBe(true); + expect(checkAsyncStorage(files).map(asyncStorageFinding)).toEqual([]); + }, 60_000); + + /** + * The mutation, performed rather than described: the seam's REAL source, read off disk and + * relabelled, is what a seventh module-scope construction would look like. A green run above + * means the tree is clean only if this one is red. + */ + test('would report the seam`s own construction from any other path', async () => { + const files = await collectSourceFiles(repoRoot()); + const seam = + files.find((file) => file.path === ASYNC_CONTEXT_SEAM) ?? + expect.unreachable(`${ASYNC_CONTEXT_SEAM} is not in the collected source set`); + const sites = checkAsyncStorage([{ path: 'packages/core/src/moved.ts', source: seam.source }]); + expect(kinds(sites)).toEqual(['binding:AsyncLocalStorage', 'construction:AsyncLocalStorage']); + }, 60_000); +}); diff --git a/scripts/async-context-guard.ts b/scripts/async-context-guard.ts new file mode 100644 index 00000000..f11b978a --- /dev/null +++ b/scripts/async-context-guard.ts @@ -0,0 +1,174 @@ +#!/usr/bin/env bun +// Enforce, as a build error, that `packages/core/src/async-context.ts` holds the framework's ONE +// `AsyncLocalStorage`. A browser bundler stubs `node:async_hooks` to `{}` — Bun's `target: +// 'browser'` emits `var { AsyncLocalStorage } = (() => ({}))` — so a module-scope construction +// throws `TypeError: undefined is not a constructor` at module EVALUATION and takes every importer +// of that package with it. Core fixed its own three sites behind a lazy seam and nothing watched +// the other six (#244, #255), which is the definition of a convention rather than a rule. +// +// WHAT IT SEES, over comment-stripped source: +// - `new AsyncLocalStorage`, `new ALS` where `ALS` is an alias bound by an import of +// `node:async_hooks`, and `new hooks.AsyncLocalStorage` through a namespace import; +// - the IMPORT itself — any binding of the class, aliased or namespaced, outside the seam. +// That second rule is what makes the first hard to walk around: a construction needs a +// binding, and the binding is one line an alias cannot hide. +// +// WHAT IT CANNOT SEE, honestly: `await import('node:async_hooks')` and any other runtime +// resolution; the constructor stored in a variable or returned by a factory and `new`ed off that +// (`const C = hooks.AsyncLocalStorage; new C()`); a construction inside a `.test.ts`, which is +// skipped because a test is not in anybody's bundle and this file's own fixture is one; and +// anything outside `collectSourceFiles`'s reach — `packages/*/src`, `packages/*/e2e` and +// `scripts/`, so neither tracked app is read. A floor, not a proof. +// +// bun run scripts/async-context-guard.ts [--json] + +import { stripComments } from '@ultimat3/cli'; +import { collectSourceFiles, type SourceFile } from './boundaries'; +import { parseScriptArgs } from './lib/args'; +import type { Finding } from './lib/log'; +import { report } from './lib/log'; +import { repoRoot } from './lib/run'; + +/** The one module allowed to name the class, because deferring the `new` is its whole job. */ +export const ASYNC_CONTEXT_SEAM = 'packages/core/src/async-context.ts'; + +const CLASS = 'AsyncLocalStorage'; + +/** `node:async_hooks` and the bare spelling Bun also resolves. */ +const HOOKS_MODULE = /^(?:node:)?async_hooks$/; + +/** + * Two ways one file can own an `AsyncLocalStorage`, and both are reported: `construction` is the + * defect itself, `binding` is the line that makes a construction expressible under any name. + */ +export type AsyncStorageKind = 'construction' | 'binding'; + +export interface AsyncStorageSite { + readonly file: string; + readonly line: number; + readonly kind: AsyncStorageKind; + /** The local name — `AsyncLocalStorage`, an alias, or `.AsyncLocalStorage`. */ + readonly name: string; +} + +interface Bindings { + /** Local names that ARE the class: `AsyncLocalStorage` itself and every alias of it. */ + readonly direct: ReadonlySet; + /** Local names of a whole-module import, which carries the class as a property. */ + readonly namespaces: ReadonlySet; + /** One `binding` finding per import that bound either — the line an alias cannot hide. */ + readonly sites: readonly AsyncStorageSite[]; +} + +/** `import from ''`, with the clause and the module captured separately. */ +const IMPORT = /\bimport\s+([^;]*?)\s*from\s*(['"])([^'"]+)\2/g; +const NAMESPACE = /\*\s+as\s+([A-Za-z_$][\w$]*)/; +const NAMED = /\{([^}]*)\}/; +/** `new Foo`, `new ns.Foo` — the generic argument and the argument list are irrelevant here. */ +const CONSTRUCTION = /\bnew\s+([A-Za-z_$][\w$]*)(?:\s*\.\s*([A-Za-z_$][\w$]*))?/g; + +const lineAt = (source: string, index: number): number => source.slice(0, index).split('\n').length; + +/** + * Every local name an import of `node:async_hooks` binds to the class. `CLASS` seeds `direct` + * unconditionally: a bare `new AsyncLocalStorage` with no import in the file is either an ambient + * global or an import this scan misread, and both deserve the finding. + */ +export function asyncStorageBindings(code: string, file: string): Bindings { + const direct = new Set([CLASS]); + const namespaces = new Set(); + const sites: AsyncStorageSite[] = []; + for (const found of code.matchAll(IMPORT)) { + if (!HOOKS_MODULE.test(found[3] ?? '')) continue; + const clause = found[1] ?? ''; + const bound: string[] = []; + const namespace = NAMESPACE.exec(clause); + if (namespace?.[1] !== undefined) { + namespaces.add(namespace[1]); + bound.push(`${namespace[1]}.${CLASS}`); + } + for (const member of (NAMED.exec(clause)?.[1] ?? '').split(',')) { + const parts = member.trim().split(/\s+as\s+/); + if (parts[0]?.replace(/^type\s+/, '') !== CLASS) continue; + direct.add(parts[1] ?? CLASS); + bound.push(parts[1] ?? CLASS); + } + const line = lineAt(code, found.index); + for (const name of bound) sites.push({ file, line, kind: 'binding', name }); + } + return { direct, namespaces, sites }; +} + +/** + * Pure, so the negative case is a fixture string rather than a defect planted in the tree. Takes + * the files whole: the same `SourceFile[]` `bun run boundaries` already collects. + */ +export function checkAsyncStorage(files: readonly SourceFile[]): readonly AsyncStorageSite[] { + const sites: AsyncStorageSite[] = []; + for (const file of files) { + if (file.path === ASYNC_CONTEXT_SEAM || file.path.includes('.test.')) continue; + // Comments, or the seam's own prose about the defect reports itself — and `telemetry.ts` and + // `context.ts` both explain the fix by writing the construction out. + const code = stripComments(file.source); + const bindings = asyncStorageBindings(code, file.path); + sites.push(...bindings.sites); + for (const found of code.matchAll(CONSTRUCTION)) { + const [head, tail] = [found[1] ?? '', found[2]]; + const named = tail === undefined ? head : `${head}.${tail}`; + const hit = + tail === undefined + ? bindings.direct.has(head) + : bindings.namespaces.has(head) && tail === CLASS; + if (!hit) continue; + sites.push({ + file: file.path, + line: lineAt(code, found.index), + kind: 'construction', + name: named, + }); + } + } + return sites; +} + +const FIX = `open the scope through the seam instead — import { asyncContext } from '@ultimat3/core', then const scope = asyncContext('what the scope carries'), scope.get() and scope.run(value, fn)`; + +export function asyncStorageFinding(site: AsyncStorageSite): Finding { + const cause = + site.kind === 'construction' + ? `${site.file}:${site.line} constructs its own ${site.name}; a browser bundler stubs node:async_hooks to {}, so the new throws TypeError at module evaluation and every importer of that package dies with it` + : `${site.file}:${site.line} binds ${site.name} from node:async_hooks, and ${ASYNC_CONTEXT_SEAM} is the one module in the framework that may`; + return { + // Core's code for the condition this rule exists to keep unreachable: a runtime with no + // async_hooks. Reused rather than minted so the guard and the throw name one fact. + code: 'X_ASYNC_CONTEXT_UNAVAILABLE', + cause, + fix: FIX, + at: `${site.file}:${site.line}`, + }; +} + +/** The rule over the real tree — what the test and the command both ask. */ +export async function asyncStorageSites(root: string): Promise { + return checkAsyncStorage(await collectSourceFiles(root)); +} + +if (import.meta.main) { + const args = parseScriptArgs(Bun.argv.slice(2)); + const root = repoRoot(); + const files = await collectSourceFiles(root); + const sites = checkAsyncStorage(files); + report( + { + ok: sites.length === 0, + script: 'async-context-guard', + summary: + sites.length === 0 + ? `${files.length} files, one AsyncLocalStorage — ${ASYNC_CONTEXT_SEAM}` + : `${sites.length} AsyncLocalStorage site(s) outside ${ASYNC_CONTEXT_SEAM}`, + findings: sites.map(asyncStorageFinding), + data: { files: files.length, sites }, + }, + args.json, + ); +} diff --git a/scripts/frozen-records.test.ts b/scripts/frozen-records.test.ts new file mode 100644 index 00000000..5b4458a2 --- /dev/null +++ b/scripts/frozen-records.test.ts @@ -0,0 +1,139 @@ +// The enforcement half of `scripts/frozen-records.ts`: this file IS the build error. The gate's +// `unit` step runs every `scripts/**/*.test.ts`, so a `const X: Readonly> = +// Object.freeze({…})` re-entering the tree fails `bun run verify` with no extra wiring. +// +// The failure cases come first, and the real repo is asserted NON-VACUOUSLY: a scan that matched +// nothing answers exactly what a clean tree answers, which is the trap both of this repo's other +// source-scanning guards had to be built against. + +import { describe, expect, test } from 'bun:test'; +import type { SourceFile } from './frozen-records'; +import { checkFrozenRecords, isOpenKey, readSources, recordKeyType } from './frozen-records'; +import { repoRoot } from './lib/run'; + +const ROOT = repoRoot(); + +/** A known-good site, so the vacuity guard ("this scan recognises the correct form") is satisfied. */ +const good: SourceFile = { + at: 'packages/core/src/roles.ts', + text: 'export const ROLE_INFO = Object.freeze>({\n web: 1,\n});\n', +}; + +const file = (at: string, text: string): SourceFile => ({ at, text }); + +describe('a freeze that claims a closed set and does not enforce it', () => { + test('is reported, and the finding names the key type it failed to close', () => { + const { findings } = checkFrozenRecords([ + good, + file( + 'packages/pwa/src/strategies.ts', + 'export const MODE_STRATEGY: Readonly> = Object.freeze({\n static: 1,\n});\n', + ), + ]); + expect(findings).toHaveLength(1); + expect(findings[0]?.at).toBe('packages/pwa/src/strategies.ts:1'); + expect(findings[0]?.cause).toContain('RenderMode'); + expect(findings[0]?.fix).toContain('Object.freeze<'); + }); + + test('is reported when the annotation wraps onto its own line', () => { + const wrapped = + "const JOB_OUTCOME_LABELS: Readonly> =\n Object.freeze({\n completed: 'ok',\n });\n"; + expect( + checkFrozenRecords([good, file('packages/jobs/src/worker.ts', wrapped)]).findings, + ).toHaveLength(1); + }); + + test('is reported through Partial<> — missing keys are legal there, extra ones never were', () => { + const partial = + "export const ROUTE_FILENAME: Readonly>> = Object.freeze({\n site: 'page.tsx',\n});\n"; + const { findings } = checkFrozenRecords([ + good, + file('packages/render/src/registry.ts', partial), + ]); + expect(findings).toHaveLength(1); + expect(findings[0]?.cause).toContain('Surface'); + }); + + test('is NOT reported once the type argument is spelled out', () => { + const fixed = + 'export const MODE_STRATEGY = Object.freeze>({\n static: 1,\n});\n'; + expect(checkFrozenRecords([good, file('packages/pwa/src/a.ts', fixed)]).findings).toEqual([]); + }); +}); + +describe('a genuinely open table is left alone', () => { + test('Record is not a finding — every key is already known', () => { + const open = + 'export const DB_SQLSTATE_CODES: Readonly> = Object.freeze({\n 23505: 1,\n});\n'; + const report = checkFrozenRecords([good, file('packages/db/src/sqlstate.ts', open)]); + expect(report.findings).toEqual([]); + expect(report.counts['annotated-open']).toBe(1); + }); + + test('a key union with `string` anywhere in it is open', () => { + expect(isOpenKey('string')).toBe(true); + expect(isOpenKey("string | 'a'")).toBe(true); + expect(isOpenKey('Role')).toBe(false); + }); + + test('an argument that is not an object literal has no freshness to lose', () => { + const computed = + 'export const CORE_ERROR_CODES: Readonly> = Object.freeze(\n Object.fromEntries(entries),\n);\n'; + expect(checkFrozenRecords([good, file('packages/core/src/a.ts', computed)]).findings).toEqual( + [], + ); + }); +}); + +describe('the outermost Record decides the key', () => { + test('a nested Record value does not supply the key type', () => { + expect( + recordKeyType('Readonly>>>'), + ).toBe('MailToken'); + }); + + test('a multi-line generic still resolves', () => { + expect(recordKeyType('Readonly<\n Record<\n StrategyName,\n (r: R) => P\n >\n>')).toBe( + 'StrategyName', + ); + }); + + test('an annotation with no Record supplies none', () => { + expect(recordKeyType('Clock')).toBeUndefined(); + }); +}); + +describe('the scan cannot pass by reading nothing', () => { + test('no files at all is a finding, not a clean tree', () => { + const { findings } = checkFrozenRecords([]); + expect(findings).toHaveLength(1); + expect(findings[0]?.cause).toContain('no Object.freeze at all'); + }); + + test('recognising no CORRECT form is a finding — the scanner may be broken, not the tree', () => { + const { findings } = checkFrozenRecords([ + file('packages/a/src/a.ts', 'const x = Object.freeze(y);\n'), + ]); + expect(findings).toHaveLength(1); + expect(findings[0]?.cause).toContain('no Object.freeze'); + }); +}); + +describe('this repository', () => { + test('has no Object.freeze that admits an extra key in silence', async () => { + const files = await readSources(ROOT); + expect(checkFrozenRecords(files).findings).toEqual([]); + }); + + test('and the scan really read the tree, skipping tests', async () => { + const files = await readSources(ROOT); + const { counts } = checkFrozenRecords(files); + expect(files.filter((one) => one.at.includes('.test.'))).toEqual([]); + // Every closed-key table in the repo, spelled correctly. A number, not `> 0`: this dropping + // is the same silence as a broken scan, and it should have to be looked at. + expect(counts.explicit).toBeGreaterThanOrEqual(21); + expect(counts['annotated-open']).toBeGreaterThanOrEqual(4); + expect(counts.unconstrained).toBeGreaterThan(20); + }); +}); diff --git a/scripts/frozen-records.ts b/scripts/frozen-records.ts new file mode 100644 index 00000000..03428ab3 --- /dev/null +++ b/scripts/frozen-records.ts @@ -0,0 +1,212 @@ +#!/usr/bin/env bun +// One rule: `Object.freeze` over an object literal whose type is a CLOSED-KEY `Record` must pass +// that type as an explicit type argument — `Object.freeze>({…})`, never +// `const X: Readonly> = Object.freeze({…})`. +// +// The second form looks like it constrains a closed set and does not. `Object.freeze(o: T)` +// INFERS `T` from the literal, so the literal is no longer fresh by the time the annotation is +// checked, and excess-property checking never runs: a missing key is an error, an extra key +// compiles in silence. Measured on 21 sites — every one of them silent before, every one a +// `TS2353` after. +// +// It is not a style rule. `spa: 'cache-first'` sat in `@ultimat3/pwa`'s render-mode table after +// `spa` was deleted from the vocabulary, mapping a mode that did not exist onto the one strategy +// that gives an `app/` route a SHARED cache entry — one member's authed HTML served to the next — +// and `tsc` had nothing to say about it. +// +// bun run scripts/frozen-records.ts [--json] + +import { parseScriptArgs } from './lib/args'; +import { report } from './lib/log'; +import { repoRoot } from './lib/run'; + +const SCRIPT = 'frozen-records'; + +/** + * Key types that are legitimately OPEN: every key is already known, so no excess-property check + * is possible or wanted. `Record` over SQLSTATE codes or error-code titles is a real + * dictionary, and demanding a closed key there would be a worse change than the bug. + */ +export const OPEN_KEY_TYPES: readonly string[] = ['string', 'number', 'symbol', 'PropertyKey']; + +export type FreezeShape = + /** `Object.freeze({…})` — the literal is contextually typed. Correct. */ + | 'explicit' + /** `const X: …Record… = Object.freeze({…})` — the defect. */ + | 'annotated-closed' + /** The same shape over an open key type. Nothing to enforce; left alone on purpose. */ + | 'annotated-open' + /** No `Record` in the annotation, or no annotation, or the argument is not a literal. */ + | 'unconstrained'; + +export interface FreezeSite { + readonly at: string; + readonly line: number; + readonly name: string; + readonly shape: FreezeShape; + readonly keyType?: string; +} + +export interface SourceFile { + readonly at: string; + readonly text: string; +} + +export interface Finding { + readonly at: string; + readonly cause: string; + readonly fix: string; +} + +/** `const NAME: = Object.freeze(` and `const NAME = Object.freeze<`, at column 0. */ +const DECLARED_FREEZE = + /^(?:export )?const ([A-Za-z_$][\w$]*)(?::([\s\S]{0,400}?))?\s*=\s*Object\.freeze(<)?\s*\(?/gm; + +const lineOf = (text: string, index: number): number => text.slice(0, index).split('\n').length; + +/** + * The first type argument of the OUTERMOST `Record<…>` in an annotation, by angle-bracket depth. + * Depth-counted rather than split on the first comma: `Record>>` has three commas' worth of nesting and a naive split reads the inner key. + */ +export function recordKeyType(annotation: string): string | undefined { + const start = annotation.indexOf('Record<'); + if (start === -1) return undefined; + let depth = 0; + for (let i = start + 'Record'.length; i < annotation.length; i += 1) { + const ch = annotation[i]; + if (ch === '<') depth += 1; + else if (ch === '>') { + depth -= 1; + if (depth === 0) return annotation.slice(start + 'Record<'.length, i).trim(); + } else if (ch === ',' && depth === 1) { + return annotation.slice(start + 'Record<'.length, i).trim(); + } + } + return undefined; +} + +/** A key type is open when ANY member of it is. `string | 'a'` accepts every string. */ +export const isOpenKey = (keyType: string): boolean => + keyType.split('|').some((part) => OPEN_KEY_TYPES.includes(part.trim())); + +/** + * Every `Object.freeze` a file declares a `const` from, classified. Read as TEXT, deliberately not + * with `tsc`: this runs in the `unit` step, where a type-checker is not available and would be a + * second build of the whole graph. + * + * What it CANNOT see, and therefore what still needs review rather than a green check: + * - a `freeze` that is not the initialiser of a top-level `const` — a `return Object.freeze({…})` + * inside a factory, a nested `Object.freeze` in a property value, an indented declaration; + * - a type laundered through an alias: `const X: FrozenModes = Object.freeze({…})` where + * `FrozenModes = Readonly>` resolves to a closed key this scan cannot follow; + * - `Object.freeze({…}) as Record` — a cast, which loses freshness the same way; + * - an interface annotation (`const c: Clock = Object.freeze({…})`), which also admits an extra + * property. Deliberately out of scope: an extra property on a config object is dead weight, + * where an extra ROW in a closed-key table is a lookup nothing can reach. + * Each of those is silence, not a pass. The vacuity guard below is what keeps the silence from + * becoming the whole answer. + */ +export function scanFreezeSites(text: string, at: string): readonly FreezeSite[] { + const sites: FreezeSite[] = []; + for (const match of text.matchAll(DECLARED_FREEZE)) { + const name = match[1] as string; + const annotation = match[2]; + const line = lineOf(text, match.index); + if (match[3] === '<') { + sites.push({ at, line, name, shape: 'explicit' }); + continue; + } + // Only an object LITERAL is fresh; `Object.freeze(fromEntries(…))` has nothing to check. + const literal = text + .slice(match.index + match[0].length) + .trimStart() + .startsWith('{'); + const keyType = annotation === undefined ? undefined : recordKeyType(annotation); + if (annotation === undefined || keyType === undefined || !literal) { + sites.push({ at, line, name, shape: 'unconstrained' }); + continue; + } + const shape: FreezeShape = isOpenKey(keyType) ? 'annotated-open' : 'annotated-closed'; + sites.push({ at, line, name, shape, keyType }); + } + return sites; +} + +const finding = (site: FreezeSite): Finding => ({ + at: `${site.at}:${site.line}`, + cause: `${site.name} in ${site.at} annotates a Record keyed on ${site.keyType ?? ''} but lets Object.freeze infer it, so an extra key compiles silently`, + fix: `write it as Object.freeze<...>({ ... }) with the type argument spelled out, and drop the annotation — run \`bun run scripts/frozen-records.ts --json\` to re-read the sites`, +}); + +const vacuous = (cause: string): Finding => ({ + at: 'scripts/frozen-records.ts', + cause, + fix: 'fix the scan in scripts/frozen-records.ts — a rule that reads nothing reports the same "ok" as a clean tree', +}); + +export interface FrozenReport { + readonly findings: readonly Finding[]; + readonly counts: Readonly>; +} + +/** + * The rule, plus the counts that make its `ok` mean something. A scan that matched nothing would + * otherwise answer exactly what a clean tree answers — the failure both of this repo's other + * source-scanning guards had to be built against. + */ +export function checkFrozenRecords(files: readonly SourceFile[]): FrozenReport { + const counts = { explicit: 0, 'annotated-closed': 0, 'annotated-open': 0, unconstrained: 0 }; + const findings: Finding[] = []; + for (const file of files) { + for (const site of scanFreezeSites(file.text, file.at)) { + counts[site.shape] += 1; + if (site.shape === 'annotated-closed') findings.push(finding(site)); + } + } + const total = Object.values(counts).reduce((sum, one) => sum + one, 0); + if (total === 0) return { findings: [vacuous('the scan found no Object.freeze at all')], counts }; + if (counts.explicit === 0) { + return { + findings: [ + vacuous('the scan found no Object.freeze({…}) site, so it recognises no correct form'), + ], + counts, + }; + } + return { findings, counts }; +} + +export const SOURCE_GLOB = 'packages/*/src/**/*.{ts,tsx}'; + +const isTest = (path: string): boolean => /\.(test|spec)\.tsx?$/.test(path); + +export async function readSources(root: string): Promise { + const files: SourceFile[] = []; + for await (const path of new Bun.Glob(SOURCE_GLOB).scan({ cwd: root })) { + if (isTest(path) || path.includes('/dist/')) continue; + files.push({ at: path, text: await Bun.file(`${root}/${path}`).text() }); + } + return files.sort((a, b) => a.at.localeCompare(b.at)); +} + +export const frozenRecordReport = async (root: string): Promise => + checkFrozenRecords(await readSources(root)); + +if (import.meta.main) { + const args = parseScriptArgs(Bun.argv.slice(2)); + const { findings, counts } = await frozenRecordReport(repoRoot()); + report( + { + ok: findings.length === 0, + script: SCRIPT, + summary: + findings.length === 0 + ? `${counts.explicit} closed-key freeze(s) spell their type argument, ${counts['annotated-open']} open-key left alone` + : `${findings.length} Object.freeze site(s) that admit an extra key in silence`, + lines: findings.map((one) => ` ${one.at}\n cause: ${one.cause}\n fix: ${one.fix}`), + data: { counts, findings }, + }, + args.json, + ); +} diff --git a/scripts/render-modes.test.ts b/scripts/render-modes.test.ts new file mode 100644 index 00000000..12ab9907 --- /dev/null +++ b/scripts/render-modes.test.ts @@ -0,0 +1,145 @@ +// The enforcement half of `scripts/render-modes.ts`: this file IS the build error. The gate's +// `unit` step runs every `scripts/**/*.test.ts`, so a second declaration of the route vocabulary +// fails `bun run verify` with no extra wiring. +// +// The real repo is asserted NON-VACUOUSLY — a scanner that read nothing would otherwise report +// "no copies", which is the same answer a clean repo gives and the failure this check exists for. + +import { describe, expect, test } from 'bun:test'; +import { HYDRATE_STRATEGIES, OFFLINE_STRATEGIES, RENDER_MODES } from '@ultimat3/core'; +import { repoRoot } from './lib/run'; +import type { SourceFile } from './render-modes'; +import { + COPY_THRESHOLD, + checkVocabulary, + readSources, + scanLiteralSets, + VOCABULARIES, + VOCABULARY_MODULE, + vocabularyFindings, +} from './render-modes'; + +const ROOT = repoRoot(); + +/** A stand-in for the real tier-0 module, so a unit test never depends on the repo's own text. */ +const sanctioned: SourceFile = { + at: VOCABULARY_MODULE, + text: [ + `export const RENDER_MODES = [${RENDER_MODES.map((m) => `'${m}'`).join(', ')}] as const;`, + 'export type RenderMode = (typeof RENDER_MODES)[number];', + `export const OFFLINE_STRATEGIES = [${OFFLINE_STRATEGIES.map((m) => `'${m}'`).join(', ')}] as const;`, + `export const HYDRATE_STRATEGIES = [${HYDRATE_STRATEGIES.map((m) => `'${m}'`).join(', ')}] as const;`, + '', + ].join('\n'), +}; + +const file = (at: string, text: string): SourceFile => ({ at, text }); + +describe('a second declaration of the vocabulary', () => { + test('is reported even under a different NAME — that is how PwaRenderMode survived', () => { + const findings = checkVocabulary([ + sanctioned, + file('packages/pwa/src/strategies.ts', "export type PwaRenderMode = 'static' | 'isr';\n"), + ]); + expect(findings).toHaveLength(1); + expect(findings[0]?.cause).toContain('PwaRenderMode'); + expect(findings[0]?.cause).toContain('RENDER_MODES'); + expect(findings[0]?.at).toBe('packages/pwa/src/strategies.ts:1'); + expect(findings[0]?.fix).toContain('@ultimat3/core'); + }); + + test('is reported when it is a PARTIAL copy — the drift shape, not just the whole set', () => { + const findings = checkVocabulary([ + sanctioned, + file('packages/seo/src/routes.ts', "\nexport type RenderMode = 'static' | 'isr' | 'ssr';\n"), + ]); + expect(findings).toHaveLength(1); + expect(findings[0]?.at).toBe('packages/seo/src/routes.ts:2'); + }); + + test('is reported when it is an as-const ARRAY rather than a union', () => { + const findings = checkVocabulary([ + sanctioned, + file('packages/http/src/router.ts', "const MODES = ['precache', 'runtime'] as const;\n"), + ]); + expect(findings).toHaveLength(1); + expect(findings[0]?.cause).toContain('OFFLINE_STRATEGIES'); + }); + + test('is reported when it gains a member the vocabulary does not have', () => { + const drifted = "export type RenderMode = 'static' | 'isr' | 'ssr' | 'stream' | 'spa';\n"; + expect(checkVocabulary([sanctioned, file('packages/x/src/a.ts', drifted)])).toHaveLength(1); + }); + + test('is NOT reported for a set that merely shares one member', () => { + const cacheTier = "export type CacheTier = 'memo' | 'lru' | 'shared' | 'isr' | 'cdn';\n"; + const strategy = "export type StrategyName = 'cache-first' | 'network-only';\n"; + expect( + checkVocabulary([ + sanctioned, + file('packages/core/src/config.ts', cacheTier), + file('packages/pwa/src/strategies.ts', strategy), + ]), + ).toEqual([]); + }); + + test('is not reported against the one module allowed to declare it', () => { + expect(checkVocabulary([sanctioned])).toEqual([]); + }); +}); + +describe('the scan cannot pass by reading nothing', () => { + test('no files at all is a finding, not agreement', () => { + const findings = checkVocabulary([]); + expect(findings).toHaveLength(1); + expect(findings[0]?.cause).toContain('no files'); + }); + + test('the vocabulary module missing from the walk is a finding', () => { + const findings = checkVocabulary([file('packages/pwa/src/a.ts', "type A = 'static' | 'isr';")]); + expect(findings).toHaveLength(1); + expect(findings[0]?.cause).toContain(VOCABULARY_MODULE); + }); + + test('a vocabulary module the scan cannot read is a finding, never zero copies', () => { + const opaque = file(VOCABULARY_MODULE, 'export const RENDER_MODES = modesFromSomewhere();\n'); + const findings = checkVocabulary([ + opaque, + file('packages/pwa/src/a.ts', "export type P = 'static' | 'isr' | 'ssr';\n"), + ]); + expect(findings).toHaveLength(1); + expect(findings[0]?.cause).toContain('does not declare'); + }); +}); + +describe('what the scanner reads', () => { + test('a set below the copy threshold is not a set it reports at all', () => { + expect(scanLiteralSets("export type One = 'static';\n")).toEqual([]); + expect(scanLiteralSets("export type Two = 'static' | 'isr';\n")).toHaveLength(1); + expect(COPY_THRESHOLD).toBe(2); + }); + + test('a computed union is read as no set — silence, which the vacuity guard covers', () => { + expect(scanLiteralSets('export type X = keyof typeof MODE_SPECS;\n')).toEqual([]); + }); +}); + +describe('this repository', () => { + test('declares the route vocabulary exactly once', async () => { + expect(await vocabularyFindings(ROOT)).toEqual([]); + }); + + test('and the scan really walked shipped source, skipping tests', async () => { + const files = await readSources(ROOT); + expect(files.length).toBeGreaterThan(100); + expect(files.some((one) => one.at === VOCABULARY_MODULE)).toBe(true); + expect(files.filter((one) => one.at.includes('.test.'))).toEqual([]); + }); + + test('and the scan found all three vocabularies in the module that owns them', async () => { + const text = await Bun.file(`${ROOT}/${VOCABULARY_MODULE}`).text(); + const names = scanLiteralSets(text).map((one) => one.name); + for (const vocabulary of VOCABULARIES) expect(names).toContain(vocabulary.name); + expect(VOCABULARIES).toHaveLength(3); + }); +}); diff --git a/scripts/render-modes.ts b/scripts/render-modes.ts new file mode 100644 index 00000000..2ca8ee87 --- /dev/null +++ b/scripts/render-modes.ts @@ -0,0 +1,200 @@ +#!/usr/bin/env bun +// One rule: NOTHING outside `packages/core/src/route-vocabulary.ts` may declare the route +// vocabulary. Twelve declarations of three closed sets lived across six packages until they were +// consolidated into tier 0, and the copies were not a style problem — `'spa'` was deleted from +// `RENDER_MODES` and the repo typechecked green project-wide with five copies still admitting it, +// `@ultimat3/pwa` mapping it to `cache-first`, the one strategy that gives an `app/` route a +// SHARED cache entry. This is what stops copy #13. +// +// It compares LITERAL SETS, not names, because the copy that did the damage was called +// `PwaRenderMode`: a rule keyed on the word `RenderMode` would have read straight past it. +// +// bun run scripts/render-modes.ts [--json] + +import { HYDRATE_STRATEGIES, OFFLINE_STRATEGIES, RENDER_MODES } from '@ultimat3/core'; +import { parseScriptArgs } from './lib/args'; +import { report } from './lib/log'; +import { repoRoot } from './lib/run'; + +const SCRIPT = 'render-modes'; + +/** The one file allowed to declare any of them, and the fix line every finding points at. */ +export const VOCABULARY_MODULE = 'packages/core/src/route-vocabulary.ts'; + +export interface Vocabulary { + readonly name: string; + readonly members: readonly string[]; +} + +/** + * Imported, never re-typed here: a check that restated the members would be the thirteenth copy. + * The unions are derived from these arrays in the module itself, so there is no separate type to + * compare — that half of the old failure mode is gone by construction. + */ +export const VOCABULARIES: readonly Vocabulary[] = [ + { name: 'RENDER_MODES', members: [...RENDER_MODES] }, + { name: 'OFFLINE_STRATEGIES', members: [...OFFLINE_STRATEGIES] }, + { name: 'HYDRATE_STRATEGIES', members: [...HYDRATE_STRATEGIES] }, +]; + +/** + * Shared members before a literal set counts as a copy. ONE is a coincidence between vocabularies + * that genuinely differ — `CacheTier` includes `'isr'`, `StrategyName` includes `'network-only'` — + * and reporting either would be a rule its readers learn to silence. TWO has no innocent example + * in this repo, and a partial copy (`'static' | 'isr' | 'ssr'`, the drift shape) still trips it. + */ +export const COPY_THRESHOLD = 2; + +export interface LiteralSet { + readonly name: string; + readonly line: number; + readonly members: readonly string[]; +} + +export interface SourceFile { + readonly at: string; + readonly text: string; +} + +export interface Finding { + readonly at: string; + readonly cause: string; + readonly fix: string; +} + +const LITERAL = /(['"])([^'"]*)\1/g; +const UNION = /^(?:export )?type ([A-Za-z_$][\w$]*) =([^;]*);/gm; +const AS_CONST = /^(?:export )?const ([A-Za-z_$][\w$]*) = \[([^\]]*)\] as const;/gm; + +const lineOf = (text: string, index: number): number => text.slice(0, index).split('\n').length; + +const membersOf = (body: string): readonly string[] => + [...body.matchAll(LITERAL)].map((match) => match[2] as string); + +/** Whether the body is string literals and separators and nothing else. */ +const closedSet = (body: string, separators: RegExp): boolean => + body.replace(LITERAL, '').replace(separators, '').trim() === ''; + +/** + * Every closed set of string literals a file declares at column 0, read as TEXT. + * + * What it understands: `type NAME = 'a' | 'b';` and `const NAME = ['a', 'b'] as const;`, each with + * an optional `export` and starting at column 0. + * + * What it does NOT understand — and therefore what a determined copier could still hide a set in: + * an INDENTED or nested declaration, a set built from another type (`keyof typeof X`, `Exclude<…>`), + * an array without `as const`, backtick literals, an object literal's keys, and a set spelled + * inside a template literal (`@ultimat3/cli`'s route templates emit source as strings). Those are + * silence, not findings, because a scan over one file cannot tell a declaration from a quotation — + * the vacuity guard below is what keeps that silence from becoming the whole answer. + */ +export function scanLiteralSets(source: string): readonly LiteralSet[] { + const found: LiteralSet[] = []; + for (const [pattern, separators] of [ + [UNION, /[|\s]/g], + [AS_CONST, /[,\s]/g], + ] as const) { + for (const match of source.matchAll(pattern)) { + const body = match[2] as string; + if (!closedSet(body, separators)) continue; + const members = membersOf(body); + if (members.length < COPY_THRESHOLD) continue; + found.push({ name: match[1] as string, line: lineOf(source, match.index), members }); + } + } + return found; +} + +const overlap = (set: LiteralSet, vocabulary: Vocabulary): readonly string[] => + set.members.filter((member) => vocabulary.members.includes(member)); + +const copyFinding = (file: SourceFile, set: LiteralSet, vocabulary: Vocabulary): Finding => ({ + at: `${file.at}:${set.line}`, + cause: `${set.name} in ${file.at} redeclares ${vocabulary.name}, which is declared at tier 0`, + fix: `delete ${set.name} from ${file.at} and import it from '@ultimat3/core' — the set is declared once, in packages/core/src/route-vocabulary.ts`, +}); + +const vacuous = (cause: string): Finding => ({ + at: 'scripts/render-modes.ts', + cause, + fix: 'fix the scan in scripts/render-modes.ts, or point VOCABULARY_MODULE at the file that declares the vocabulary', +}); + +/** + * The whole rule. The sanctioned module is checked FIRST and in the opposite direction: it must + * declare every vocabulary, by name. A scanner that silently read nothing would otherwise report + * a repo with no copies — which is the same answer as a repo with twelve, and the reason the old + * version of this check needed a non-vacuity guard too. + */ +export function checkVocabulary(files: readonly SourceFile[]): readonly Finding[] { + if (files.length === 0) return [vacuous('the scan walked no files, so no copy could be found')]; + const sanctioned = files.find((file) => file.at === VOCABULARY_MODULE); + if (sanctioned === undefined) { + return [vacuous(`${VOCABULARY_MODULE} was not among the files scanned`)]; + } + const declared = scanLiteralSets(sanctioned.text); + const missing = VOCABULARIES.filter( + (vocabulary) => !declared.some((set) => set.name === vocabulary.name), + ); + if (missing.length > 0) { + return [ + vacuous( + `${VOCABULARY_MODULE} does not declare ${missing.map((one) => one.name).join(', ')} in a shape this scan can read`, + ), + ]; + } + + const findings: Finding[] = []; + for (const file of files) { + if (file.at === VOCABULARY_MODULE) continue; + for (const set of scanLiteralSets(file.text)) { + for (const vocabulary of VOCABULARIES) { + if (overlap(set, vocabulary).length >= COPY_THRESHOLD) { + findings.push(copyFinding(file, set, vocabulary)); + } + } + } + } + return findings; +} + +/** + * Shipped source only. A test fixture spelling a vocabulary out is INPUT to the code under test, + * never a declaration anything imports — the same rule `scripts/test-bare-error.ts` applies to a + * `new Error` a test hands to its subject. An app's own source is likewise not the framework's. + */ +export const SOURCE_GLOB = 'packages/*/src/**/*.{ts,tsx}'; + +const isTest = (path: string): boolean => /\.(test|spec)\.tsx?$/.test(path); + +export async function readSources(root: string): Promise { + const files: SourceFile[] = []; + for await (const path of new Bun.Glob(SOURCE_GLOB).scan({ cwd: root })) { + if (isTest(path) || path.includes('/dist/')) continue; + files.push({ at: path, text: await Bun.file(`${root}/${path}`).text() }); + } + return files.sort((a, b) => a.at.localeCompare(b.at)); +} + +export const vocabularyFindings = async (root: string): Promise => + checkVocabulary(await readSources(root)); + +if (import.meta.main) { + const args = parseScriptArgs(Bun.argv.slice(2)); + const root = repoRoot(); + const files = await readSources(root); + const findings = checkVocabulary(files); + report( + { + ok: findings.length === 0, + script: SCRIPT, + summary: + findings.length === 0 + ? `${files.length} files, one declaration each of ${VOCABULARIES.map((one) => one.name).join(', ')}` + : `${findings.length} second declaration(s) of the route vocabulary`, + lines: findings.map((one) => ` ${one.at}\n cause: ${one.cause}\n fix: ${one.fix}`), + data: { scanned: files.length, findings }, + }, + args.json, + ); +} diff --git a/wiki/Error-Codes.md b/wiki/Error-Codes.md index 09ebb7ab..4cd551dd 100644 --- a/wiki/Error-Codes.md +++ b/wiki/Error-Codes.md @@ -22,7 +22,7 @@ X_DB_DRIFT: schema differs from migrations | Registration | a code exists when its owning package calls `registerErrorCodes()`. That one call is what makes it explainable, unique and documented-or-fail — a code emitted as a `Finding` rather than thrown is registered the same way | | Enforcement | the `errors` step of `x verify` fails on an empty or advice-only `fix` (`X_ERROR_FIX_INVALID`), on a declared code with no row on this page (`X_ERROR_CODE_UNDOCUMENTED`), and on a row this page presents as live that no package registers (`X_ERROR_CODE_UNREGISTERED`) | -`As of 2026-08` every code above [Reserved codes](#reserved-codes) resolves through `x errors explain`, with one exception the gate knows about: this repository's own gate scripts (`X_BOUNDARY_VIOLATION`, `X_ROADMAP_*`, `X_REFERENCE_APP_*`, `X_TRUST_*`, `X_SCAFFOLD_OVERRIDES_EMPTY`, `X_SCAFFOLD_GATE_RED`, `X_SCAFFOLD_FIRST_RUN_FAILED`, `X_SETUP_INSTALL_FAILED`, `X_ADMIN_FLATTENER_VIOLATION`, `X_ERROR_RENDER_UNSAFE`, `X_ERROR_STATUS_MISSING`, `X_ERROR_STATUS_BACKLOG_STALE`, `X_ERROR_STATUS_UNKNOWN_CODE`, `X_CATALOG_KEY_UNREACHABLE`, `X_PUBLISH_LIST_INCOMPLETE`, `X_PUBLISH_LIST_UNKNOWN`, `X_BENCH_CLAIM_STALE`, `X_WIKI_TABLE_MALFORMED`, `X_FRAME_DOCS_STALE`, `X_CHART_VERSION_STALE`, `X_IMAGE_LIBC_MISMATCH`, `X_IMAGE_GUARD_MISSING`, `X_DOC_FIX_UNRUNNABLE`, `X_DOC_FIX_UNSCANNED`, `X_DOC_COMMAND_UNKNOWN`, `X_DOC_COMMAND_ALLOWANCE_STALE`, `X_DOC_COMMAND_UNSCANNED`, `X_DOC_COMMAND_PIN_STALE`, `X_DOC_GATE_STEPS_STALE`, `X_DOC_GATE_STEPS_UNSCANNED`, `X_README_EXAMPLE_UNCOMPILED`, `X_README_EXAMPLE_PIN_STALE`, `X_README_EXAMPLE_UNSCANNED`, `X_VERSION_STAMP_STALE`, `X_VERSION_STAMP_DUPLICATE`, `X_VERSION_LOCKSTEP_BROKEN`, `X_VERSION_STAMP_UNSCANNED`, `X_COVERAGE_BELOW`, `X_COVERAGE_PIN_STALE`, `X_COVERAGE_UNMEASURED`, `X_TEST_TYPECHECK_REGRESSED`, `X_TEST_TYPECHECK_PIN_STALE`, `X_TEST_TYPECHECK_UNSCANNED`, `X_DOC_FILE_COUNT_STALE`, `X_DOC_FILE_COUNT_UNSCANNED`, `X_DOC_RELEASE_FACT_STALE`, `X_DOC_RELEASE_FACT_UNSCANNED`, `X_TEST_FIX_UNRUNNABLE`, `X_TEST_FIX_PIN_STALE`, `X_TEST_FIX_UNSCANNED`, `X_TEST_THROW_NOT_THROWN`, `X_TEST_BARE_ERROR`, `X_TEST_BARE_ERROR_PIN_STALE`, `X_TEST_BARE_ERROR_UNSCANNED`, `X_RELEASE_VERSION_UNSTATED`, `X_RELEASE_FLAG_UNKNOWN`, `X_LOCKFILE_STALE`) never ship, so no package may own them. See [Troubleshooting](Troubleshooting) for symptom-first triage and [CLI reference](CLI-Reference) for the commands named in the fixes. +`As of 2026-08` every code above [Reserved codes](#reserved-codes) resolves through `x errors explain`, with one exception the gate knows about: this repository's own gate scripts (`X_BOUNDARY_VIOLATION`, `X_ROADMAP_*`, `X_REFERENCE_APP_*`, `X_TRUST_*`, `X_SCAFFOLD_OVERRIDES_EMPTY`, `X_SCAFFOLD_GATE_RED`, `X_SCAFFOLD_FIRST_RUN_FAILED`, `X_SETUP_INSTALL_FAILED`, `X_ADMIN_FLATTENER_VIOLATION`, `X_ERROR_RENDER_UNSAFE`, `X_ERROR_STATUS_MISSING`, `X_ERROR_STATUS_BACKLOG_STALE`, `X_ERROR_STATUS_UNKNOWN_CODE`, `X_CATALOG_KEY_UNREACHABLE`, `X_PUBLISH_LIST_INCOMPLETE`, `X_PUBLISH_LIST_UNKNOWN`, `X_BENCH_CLAIM_STALE`, `X_WIKI_TABLE_MALFORMED`, `X_FRAME_DOCS_STALE`, `X_CHART_VERSION_STALE`, `X_IMAGE_LIBC_MISMATCH`, `X_IMAGE_GUARD_MISSING`, `X_DOC_FIX_UNRUNNABLE`, `X_DOC_FIX_UNSCANNED`, `X_DOC_COMMAND_UNKNOWN`, `X_DOC_COMMAND_ALLOWANCE_STALE`, `X_DOC_COMMAND_UNSCANNED`, `X_DOC_COMMAND_PIN_STALE`, `X_DOC_GATE_STEPS_STALE`, `X_DOC_GATE_STEPS_UNSCANNED`, `X_README_EXAMPLE_UNCOMPILED`, `X_README_EXAMPLE_PIN_STALE`, `X_README_EXAMPLE_UNSCANNED`, `X_VERSION_STAMP_STALE`, `X_VERSION_STAMP_DUPLICATE`, `X_VERSION_LOCKSTEP_BROKEN`, `X_VERSION_STAMP_UNSCANNED`, `X_COVERAGE_BELOW`, `X_COVERAGE_PIN_STALE`, `X_COVERAGE_UNMEASURED`, `X_TEST_TYPECHECK_REGRESSED`, `X_TEST_TYPECHECK_PIN_STALE`, `X_TEST_TYPECHECK_UNSCANNED`, `X_DOC_FILE_COUNT_STALE`, `X_DOC_FILE_COUNT_UNSCANNED`, `X_DOC_RELEASE_FACT_STALE`, `X_DOC_RELEASE_FACT_UNSCANNED`, `X_TEST_FIX_UNRUNNABLE`, `X_TEST_FIX_PIN_STALE`, `X_TEST_FIX_UNSCANNED`, `X_TEST_THROW_NOT_THROWN`, `X_TEST_BARE_ERROR`, `X_TEST_BARE_ERROR_PIN_STALE`, `X_TEST_BARE_ERROR_UNSCANNED`, `X_RELEASE_VERSION_UNSTATED`, `X_RELEASE_FLAG_UNKNOWN`, `X_DOC_CHANGELOG_SECTION_INVALID`, `X_DOC_CHANGELOG_UNRELEASED_BREAKING`, `X_DOC_MIGRATION_COUNT_STALE`, `X_DOC_MIGRATION_UNSCANNED`, `X_RELEASE_UNRELEASED_MISSING`, `X_LOCKFILE_STALE`) never ship, so no package may own them. See [Troubleshooting](Troubleshooting) for symptom-first triage and [CLI reference](CLI-Reference) for the commands named in the fixes. ## Core and runtime @@ -666,6 +666,11 @@ Two sets override the table, in `failures.ts`: | `X_DOC_COMMAND_PIN_STALE` | a package page holds a different number of unresolved `x` citations than its pin allows | the ratchet in `DOC_COMMAND_PINS`. Widening the globs to `packages/*/*.md` found 23 citations that no rule had ever read, so they are pinned per file and may only shrink — a pin above the real count is slack a new broken line would spend | `bun run scripts/doc-commands.ts --json` names the file and both numbers; set the pin to the first one, or delete the entry at 0 | | `X_DOC_GATE_STEPS_STALE` | a page states how many steps `x verify` runs, or enumerates them, and describes a gate this build does not run | a step was added and the count was not — `seo` shipped as the 18th and 20 files went on saying 17 through a whole major release. The list form fails the same way with a right count: an inserted step missing from an enumeration | `bun run scripts/gate-steps.ts --json` — each finding carries the number or the ordered list to write | | `X_DOC_GATE_STEPS_UNSCANNED` | no page states a step count or enumerates the gate, so the rule read nothing | the globs match nothing, or every page that stated the count was deleted. Reported rather than skipped: a rule with no input is a false green, not a pass | `bun run scripts/gate-steps.ts --json` from the repo root | +| `X_DOC_CHANGELOG_SECTION_INVALID` | a `CHANGELOG.md` section is not one section | two `## ` headings name the same version, or a released section has no body. `scripts/release.ts` raises the same code for the third shape, refusing before a manifest is written: an `[Unreleased]` with no body and no commit since the previous tag would ship a version section that says nothing. The release script used to **append** a section generated from commit subjects instead of promoting `[Unreleased]`, and that is what put two `## 5.0.1` headings and two `## 5.0.0` headings in the file — a dateless generated one above the hand-written one, each time. Both pairs sat there from the `release: 5.0.1` commit until `release: 6.0.0` merged them by hand | merge the two sections into one, or delete the generated one, then `bun run scripts/changelog-check.ts --json` — it names the line. For the empty case, write the notes under `## [Unreleased]` in `CHANGELOG.md` and run the release again | +| `X_DOC_CHANGELOG_UNRELEASED_BREAKING` | `BREAKING —` entries sit under `## [Unreleased]` on a tagged commit | the release did not promote `[Unreleased]`, so the tag's own section does not hold the migration `wiki/Upgrading.md` sends the reader to. The commit before `release: 6.0.0` was exactly that: the migration as it stood then — five `BREAKING —` entries — under `## [Unreleased]`, `wiki/Upgrading.md` already sending the reader to a `6.0.0` section the file did not yet have, and a human promoting it by hand in the release commit. Only fires where `git tag --points-at HEAD` names a `vX.Y.Z` — between releases `[Unreleased]` is *where* a breaking entry belongs | `bun run scripts/release.ts --bump major --dry-run --json` validates the promotion and writes nothing; the same command without `--dry-run` performs it. Promotion is now the only path — the script cannot append | +| `X_DOC_MIGRATION_COUNT_STALE` | a `wiki/Upgrading.md` count is not the count of the section it names | three shapes, one code: a row claiming a number that major's own `CHANGELOG.md` section does not hold, the aggregate row disagreeing with the sum of the per-major rows, and a released major from 2.0.0 on with no row at all. The count used to be derived from the WHOLE file, which cannot see a migration filed under the wrong heading — a misplaced entry only makes the number smaller | `bun run scripts/changelog-check.ts --json` — each finding carries the number that section actually holds; write that number into the cell, or add the missing row | +| `X_DOC_MIGRATION_UNSCANNED` | no row in `wiki/Upgrading.md` sends the reader to a single version section, so the rule read nothing | the summary table was restructured or removed. Reported rather than skipped: a rule with no input is a false green, not a pass | restore the summary table in `wiki/Upgrading.md` — each row reads ``the `X.Y.Z` section, in order`` | +| `X_RELEASE_UNRELEASED_MISSING` | `CHANGELOG.md` has no `## [Unreleased]` heading, so a release has nothing to promote | the heading was renamed or deleted. Refused before any manifest is written, and under `--dry-run` too — guessing where the release notes live is how a release ships the wrong section, and finding out after 47 files have moved is the expensive order to find it out in | add `## [Unreleased]` under the preamble of `CHANGELOG.md`, above the newest version, then `bun run scripts/release.ts --bump patch --dry-run --json` | | `X_README_EXAMPLE_UNCOMPILED` | a package `README.md` gained a `ts`/`tsx` fence that does not compile | an example written against an API that changed, or one that never compiled. A fence is a promise an agent will copy; the count per package may only fall | `bun run scripts/readme-fences.ts --json` names the package, the fence and the diagnostic | | `X_README_EXAMPLE_PIN_STALE` | a package's pinned failing-fence count is higher than the number that actually fail | examples were fixed and the ratchet was not lowered, so the budget would let a new broken fence in for free | `bun run scripts/readme-fences.ts --pin` — it only lowers, never raises | | `X_README_EXAMPLE_UNSCANNED` | `tsc` did not run over the extracted fences | the fixture could not be written, or the compiler refused to start. `tsc` reports no semantic diagnostics at all once a program holds a syntax error, so a check that read this as "nothing wrong" would go green over every package at once | `bun run scripts/readme-fences.ts --json` — the finding carries the compiler's own output | diff --git a/wiki/Upgrading.md b/wiki/Upgrading.md index c07dca37..32c85bd7 100644 --- a/wiki/Upgrading.md +++ b/wiki/Upgrading.md @@ -17,7 +17,7 @@ An entry is a line `CHANGELOG.md` marks `BREAKING —`. The count is derived, ne ```sh grep -cE '^(- \*\*|### )BREAKING —' CHANGELOG.md -# 71 As of 2026-08 — 70 shipped, 1 under [Unreleased] +# 77 As of 2026-08 — every one inside the section of the major that shipped it ``` Each entry changes a surface the table below covers. @@ -35,7 +35,7 @@ Each entry changes a surface the table below covers. **Nothing here is installable until `npm view @ultimat3/core version` answers `6.0.0`.** Run that first; `As of 2026-08` it does not. This section is written as each change lands rather than at the tag, so entries are **appended** — re-read it when `latest` moves. -One breaking entry so far, and it is a **runtime** refusal with no compile error in front of it. +Seven breaking entries, and the first is a **runtime** refusal with no compile error in front of it. ### Start here — the one edit @@ -85,7 +85,7 @@ Run it from the app root. Every hit is a single-label zone; `'UTC'` is the only | Fix | What changes for you | |---|---| | island JSX compiles through `babel-preset-solid` | client-side Solid reactivity inside an island works at all. An island containing JSX compiled to `React.createElement` and threw `ReferenceError: React is not defined` on first interaction, with the gate green. Two build-time dependencies join `@ultimat3/cli`; zero bytes reach your client bundle ([#243](https://github.com/developerz-ai/ultimate/issues/243)) | -| `@ultimat3/core` loads in a browser bundle | three module-scope `AsyncLocalStorage` constructions moved onto one lazy seam, so `@ultimat3/ui` no longer throws `TypeError: undefined is not a constructor` at module evaluation ([#244](https://github.com/developerz-ai/ultimate/issues/244)) | +| `@ultimat3/core` loads in a browser bundle | **core's** three module-scope `AsyncLocalStorage` constructions — the request context, the active span, the impersonation reason — moved onto one lazy seam, so `@ultimat3/ui` no longer throws `TypeError: undefined is not a constructor` at module evaluation ([#244](https://github.com/developerz-ai/ultimate/issues/244)). Six more constructions **outside** core were untouched at 6.0.0 and carry the same defect — `@ultimat3/db`, `@ultimat3/entity`, `@ultimat3/ai`; they are `[Unreleased]`, along with the guard that makes the rule a build error ([#255](https://github.com/developerz-ai/ultimate/issues/255)) | Rebuild to pick either up. From c8007ed53421283ba168390e9781280d0f24c95d Mon Sep 17 00:00:00 2001 From: sebi Date: Thu, 20 Aug 2026 19:40:57 -0500 Subject: [PATCH 2/2] fix: the release script promotes, and five more conventions become build errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #267 — `scripts/release.ts` generated a `## ` section from commit subjects and APPENDED it, leaving the hand-written `## [Unreleased]` untouched above. Read the tag and none of this is visible: `v6.0.0` points at 93443aeb, a human repairing it by hand. Read 8fe7c56d — the release script's own output — and it is the whole diff: seven `BREAKING —` entries stranded under `[Unreleased]`, a `## 6.0.0` holding six merge subjects, and both duplicate heading pairs carried over from the two runs before it. It now promotes, opens a fresh `[Unreleased]`, and refuses before any of the 47 manifests are written. `scripts/changelog-check.ts` holds it: duplicate headings, an empty released section, `BREAKING —` stranded at a tagged commit, and each major's `wiki/Upgrading.md` count against THAT SECTION's own entries. The last one is the point — the count that was supposed to catch 6.0.0 *was* derived, and a misplaced entry is invisible to a derived count because it only makes the number smaller. `scripts/gate-codes.ts` — `wiki/Error-Codes.md`'s never-ships list is a hand-copy of a derived set that nothing read: `checkErrorCodeDocs` counts any `X_*` in backticks anywhere on the page, so being named inside the parenthesis counted as being documented. 26 violations, all closed here: 20 codes had no table row at all, and four `X_REGISTRY_*` rows promised `x errors explain` would resolve them when it answers `X_ERROR_CODE_UNKNOWN`. `scripts/browser-barrel.test.ts` — the async-context guard is a text scan, so it cannot see `await import('node:async_hooks')`. This builds each barrel for `target: 'browser'` and both evaluates it and reads the chunk, because neither assertion alone covers both holes. `ROUTE_FILENAME` keys are mandatory: `Partial>` let any of the three go missing, caught only by three registration tests, and a dropped `api` row would have told every `api/` route author their file is a leaf of helpers. Docs corrected against the code rather than against each other: five wiki pages still taught `render: 'spa'`, deleted in 6.0.0 — one tutorial then instructed a `mv` on a file `x new` stopped writing. `docs/architecture/00-conventions.md` carried a THIRD tier table, unenforced and wrong in five rows; deleted, not corrected. `01-package-map.md` claimed a `schema → core` edge that does not exist and omitted `cli → testing`, which does. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KgsU1WBJMAjnLaazvfuJmD --- CHANGELOG.md | 91 ++++++++ CLAUDE.md | 5 + PUBLISHING.md | 12 +- docs/architecture/00-conventions.md | 28 ++- docs/architecture/01-package-map.md | 11 +- framework.manifest.json | 37 ++- package.json | 5 + packages/core/src/type-pins.ts | 10 +- packages/pwa/src/strategies.ts | 22 +- packages/render/src/registry.ts | 15 +- scripts/browser-barrel.test.ts | 196 ++++++++++++++++ scripts/changelog-check.test.ts | 257 +++++++++++++++++++++ scripts/changelog-check.ts | 338 ++++++++++++++++++++++++++++ scripts/gate-codes-backlog.ts | 23 ++ scripts/gate-codes.test.ts | 129 +++++++++++ scripts/gate-codes.ts | 162 +++++++++++++ scripts/release.test.ts | 126 ++++++++--- scripts/release.ts | 177 +++++++++++---- scripts/render-modes.ts | 2 +- wiki/Admin-Dashboard.md | 2 +- wiki/Error-Codes.md | 26 ++- wiki/Getting-Started.md | 2 +- wiki/Known-Gaps.md | 12 +- wiki/PWA-And-Offline.md | 25 +- wiki/Project-Layout.md | 4 +- wiki/Routes-And-Render-Modes.md | 25 +- wiki/The-Eight-Primitives.md | 4 +- wiki/Tutorial-01-First-App.md | 2 +- wiki/Tutorial-03-Auth-And-Admin.md | 19 +- wiki/Upgrading.md | 24 +- 30 files changed, 1641 insertions(+), 150 deletions(-) create mode 100644 scripts/browser-barrel.test.ts create mode 100644 scripts/changelog-check.test.ts create mode 100644 scripts/changelog-check.ts create mode 100644 scripts/gate-codes-backlog.ts create mode 100644 scripts/gate-codes.test.ts create mode 100644 scripts/gate-codes.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d63a5e37..fb4d0973 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,30 @@ Semver applies from 1.0.0. A breaking change to a documented API needs a major `X_ASYNC_CONTEXT_UNAVAILABLE` naming the scope that could not be opened, instead of leaving a bare `TypeError` from a stack mentioning no file the caller wrote. A server pays nothing: `getStore()` before any `run()` answered `undefined` whether the storage was ever constructed or not (#255) +- `scripts/gate-codes.ts` — `wiki/Error-Codes.md`'s "never ships to an app" parenthesis, held complete + in **both** directions: every code it names has a table row, and every `X_*` code `scripts/` declares + is named in it. Codes: `X_GATE_CODE_UNDOCUMENTED`, `X_GATE_CODE_BACKLOG_STALE`. + + Nothing read that parenthesis. `checkErrorCodeDocs` is satisfied by any `` `X_*` `` in backticks + **anywhere** on the page — `documentedCodes` is one whole-file regex — so being named *inside* the + list counted as being documented; from the other side `checkErrorCodeRegistry` exempts gate codes by + scanning `scripts/`, never by reading the list. A hand-copy of a derived set with no check on it, + which is the shape `gate-steps.ts` and `release-facts.ts` already exist for. + + **26 codes were wrong on day one**, so it shipped on a ratchet, and this change drains it to zero: + + | Was | Count | Fixed by | + |---|---|---| + | named in the list, no table row — documented by parenthesis only | 20 | a row written from the script that declares the code | + | declared under `scripts/`, absent from the list | 6 | added to the parenthesis | + + The 20 rows come from `coverage-gate.ts`, `generator-counts.ts`, `release-facts.ts`, `lockfile-pins.ts`, + `release.ts`, `test-bare-error.ts`, `test-fix-citations.ts`, `to-throw-returns.ts` and + `test-typecheck-gate.ts` — cause and fix read off each declaration site, never invented from the + code's name. The six are the four `X_REGISTRY_*`, whose rows sat **above** `## Reserved codes` where + the page promises `x errors explain` answers for them (it answers `X_ERROR_CODE_UNKNOWN`), plus this + rule's own two. `scripts/gate-codes-backlog.ts` is now empty, so the next gap reds the gate rather + than joining a list. - `scripts/changelog-check.ts` — `CHANGELOG.md`'s sections and `wiki/Upgrading.md`'s migration counts, held to each other. Seven rules, one per way the two files can disagree, collected by the gate's `unit` step through `scripts/changelog-check.test.ts` (#267) @@ -36,6 +60,43 @@ Semver applies from 1.0.0. A breaking change to a documented API needs a major `X_DOC_CHANGELOG_SECTION_INVALID`, `X_DOC_CHANGELOG_UNRELEASED_BREAKING`, `X_DOC_MIGRATION_COUNT_STALE`, `X_DOC_MIGRATION_UNSCANNED`. +### Changed + +- **The route vocabulary is declared once, at tier 0.** `RENDER_MODES`/`RenderMode`, + `OFFLINE_STRATEGIES`/`OfflineStrategy` and `HYDRATE_STRATEGIES`/`HydrateStrategy` now live in + `packages/core/src/route-vocabulary.ts`, each union **derived** from its array — `(typeof + ARRAY)[number]` — so the pair cannot disagree. `@ultimat3/render`, `http`, `seo`, `manifest` and + `pwa` re-export what their own signatures take; `@ultimat3/core`'s `config.ts` imports + `OfflineStrategy` instead of declaring it. **Re-export, never restate** (#261). + + **14 declarations across six packages became one**, measured `As of 2026-08` by running this + change's own scanner over the commit before it. Imports go down tiers only, so copying was the + available move and every package took it — and `'spa'` was deleted from one copy in 6.0.0 while + five others went on admitting it under a green project-wide typecheck. `@ultimat3/pwa`'s copy + mapped `spa` to `cache-first`, the one strategy that gives an `app/` route a **shared** cache + entry: one member's authed HTML served to the next. + + `scripts/render-modes.ts` refuses copy #13 by **literal set, not by name** — the copy that did the + damage was called `PwaRenderMode`, and a rule keyed on the word `RenderMode` reads straight past + it. Two shared members is a copy; one is a coincidence and stays silent (`CacheTier` holds `isr`, + `StrategyName` holds `network-only`, `ChangeFreq` holds `never`). The margin is exactly one: + the highest innocent overlap in the tree is **1**, across seven sets. The rule checks the + sanctioned module first and in the opposite direction — it must declare all three, by name — so a + scan that read nothing cannot answer what a clean tree answers. `packages/core/src/type-pins.ts` + pins each union to its members with a mutual-assignability `Exact`, tuple-wrapped so a + distributed `extends` cannot hide a widening. + +- **BREAKING — `PwaRenderMode` is deleted from `@ultimat3/pwa`.** It was this package's own NAME for + `RenderMode`, hand-copied because tier 4 may not import tier 4, and the copy is why `spa` kept + mapping to `cache-first` after `spa` was deleted from the vocabulary. **Migration:** + `import type { RenderMode } from '@ultimat3/core'` — or from `@ultimat3/pwa`, which re-exports it + under that name — and rename every use. Members unchanged: `'static' | 'isr' | 'ssr' | 'stream'` + (#261) +- **BREAKING — `PwaOfflineStrategy` is deleted from `@ultimat3/pwa`**, same reason, same members: + `'precache' | 'runtime' | 'network-only'`. **Migration:** + `import type { OfflineStrategy } from '@ultimat3/core'` — or from `@ultimat3/pwa` — and rename + every use (#261) + ### Fixed - **Six more module-scope `AsyncLocalStorage` constructions, all outside core, all with the same @@ -80,6 +141,36 @@ Semver applies from 1.0.0. A breaking change to a documented API needs a major `X_DOC_CHANGELOG_SECTION_INVALID` when it is empty and no commit landed since the previous tag. The report also states whether the previous tag was found, since a clone without it lists no commits and a silent empty list reads exactly like a quiet release. +- **21 closed-key `Object.freeze` tables accepted an unknown key in silence.** + `const X: Readonly> = Object.freeze({…})` passes the literal to + `Object.freeze(o: T)`, which **infers** `T` from it — so the literal is no longer fresh by the + time the annotation is checked, excess-property checking never runs, and an extra key compiles. + A *missing* key still errored, which is why the form looked like it was working. + + Not a hypothesis. Four sites were reverted to the old form with a bogus key added and **all four + compiled clean**: `ROLE_INFO`, `SURFACE_SPECS`, `POOL_PROFILES` and `CAPABILITY_MANIFEST_KEYS`. + `POOL_PROFILES` is `Record` — a pool configuration for a role that does not + exist — and `SURFACE_SPECS` a spec for a surface `locateSurface` can never return. Same shape as + `spa: 'cache-first'`, in four more places. + + All 21 now spell the type argument — `Object.freeze>({…})` — and all 21 fail + `TS2353` on an extra key. Four tables stay **deliberately open**, `Record` registries + keyed by codes another package or an external spec owns: + + | Open table | Keyed by | + |---|---| + | `SCHEMA_ERROR_CODE_TITLES` (`packages/core/src/schema-error-codes.ts`) | `@ultimat3/schema`'s codes, which core may not import | + | `SCHEMA_ERROR_CODES` (`packages/schema/src/errors.ts`) | the same codes, at their source | + | `DB_SQLSTATE_CODES` (`packages/db/src/sqlstate.ts`) | Postgres SQLSTATE | + | `FORMAT_MAP` (`packages/schema/src/json-schema.ts`) | JSON Schema `format` names | + + `scripts/frozen-records.ts` holds the rule, read as text rather than through `tsc` — it runs in + the gate's `unit` step, where a type-checker would be a second build of the whole graph. Its + header names what a text scan cannot see: a `freeze` that is not a top-level `const`'s + initialiser, a key type laundered through an alias, a `as Record` cast, an interface + annotation. `scripts/frozen-records.test.ts` carries the floor that keeps that silence honest — + `explicit >= 21`, `annotated-open >= 4` — because deleting a constraint leaves no annotation for + the rule to contradict and shows up only as the number falling. - **Two tests that could red the gate for reasons belonging to no change** (#264), both made algorithmic rather than given a longer timeout. diff --git a/CLAUDE.md b/CLAUDE.md index 9dd40afc..e88b232a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -177,6 +177,11 @@ Milestone detail: [`docs/idea/14-roadmap.md`](docs/idea/14-roadmap.md). | import boundaries | `bun run boundaries` | | bare Errors in tests | `bun run scripts/test-bare-error.ts` — a step of the gate's `errors` check, standalone. A test may not report its own verdict by throwing a bare `Error`; `expect.unreachable` is the idiom. A ratchet, because 422 sites were already there — `--unpin ` lowers a count and refuses to raise one. A `new Error` **not thrown** is the subject's input and is never reported | | unsafe error rendering | `bun run error-render` — a step of the gate's `errors` check, standalone. Refuses an `unknown` reaching a `cause:`/`fix:` through `${x}`, `JSON.stringify(x)` or `String(x)`; all three throw on real app values, and the bug shipped three times before it was mechanised | +| route vocabulary copies | `bun run render-modes` — a step of the gate's `unit` check, standalone. Refuses a second declaration of `RENDER_MODES` / `OFFLINE_STRATEGIES` / `HYDRATE_STRATEGIES` anywhere in `packages/*/src`, matched on the **literal set** rather than the name: the copy that did the damage was called `PwaRenderMode`. Two shared members is a copy, one is a coincidence — the highest innocent overlap in the tree is 1 | +| open closed-key tables | `bun run frozen-records` — a step of the gate's `unit` check, standalone. Refuses `const X: Readonly> = Object.freeze({…})`, which infers `T` from the literal and so accepts an EXTRA key in silence. `Object.freeze>({…})` is the one form. 21 sites, 4 left deliberately open on `Record` | +| a second `AsyncLocalStorage` | `bun run async-context-guard` — a step of the gate's `unit` check, standalone. `packages/core/src/async-context.ts` is the one module that may construct one **or import the class**; every other scope opens through `asyncContext(subject)`. A module-scope `new` throws `TypeError` at module evaluation in a browser bundle | +| undocumented gate codes | `bun run gate-codes` — a step of the gate's `unit` check, standalone. `wiki/Error-Codes.md`'s never-ships list is a hand-copy of a derived set; nothing read it, because `checkErrorCodeDocs` counts any `X_*` in backticks **anywhere on the page** as documentation. A ratchet: 26 violations on day one | +| changelog and migration drift | `bun run changelog-check` — a step of the gate's `unit` check, standalone. Two `## ` headings sharing a version, an empty released section, `BREAKING —` still under `[Unreleased]` at a tagged commit, and each major's `wiki/Upgrading.md` count against **that section's own** entries — a count derived from the whole file cannot see a misplaced entry, because it only makes the number smaller | | regenerate manifest | `bun run manifest` | | list workspaces | `bun run workspaces:list` | | new framework package | `bun run scripts/new-package.ts --tier ` | diff --git a/PUBLISHING.md b/PUBLISHING.md index abe76bc2..d6c24372 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -289,13 +289,23 @@ whether or not anyone updates this table. ## Ongoing releases (automated) +**`## [Unreleased]` in `CHANGELOG.md` IS the release notes.** Write them there as each change +lands; step 1 renames that heading to the version and opens a fresh empty `[Unreleased]` above it. +A release with nothing under `[Unreleased]` and no commit since the previous tag is **refused** +(`X_DOC_CHANGELOG_SECTION_INVALID`), never published as an empty section. + 0. **On a major only:** `wiki/Upgrading.md` already carries a `## .x → ` section, written when the first breaking change landed — releasing it is deleting `unreleased` from its heading and updating the summary table's counts. If the section does not exist, stop and write it before tagging; the procedure is [`docs/architecture/19-cutting-a-major.md`](docs/architecture/19-cutting-a-major.md). + The counts are checked against that major's own `CHANGELOG.md` section — + `bun run scripts/changelog-check.ts --json`. 1. `bun run scripts/release.ts --bump patch|minor|major` — bumps every package in lockstep and - appends the changelog entry. + **promotes** `## [Unreleased]` to `## X.Y.Z - `, opening a fresh empty `[Unreleased]` above + it. Commit subjects since the previous tag are appended **inside** that section under + `### Commits`. Dry it first: `--bump patch --dry-run --json` computes the promotion, refuses on + the same findings, and writes nothing. 2. Commit, tag `vX.Y.Z`, push. 3. Publish a GitHub Release for that tag (or **Actions → release → Run workflow** with the tag selected and the version typed in). **A branch will not do**: the workflow's first step refuses diff --git a/docs/architecture/00-conventions.md b/docs/architecture/00-conventions.md index 40294c14..8ceee11f 100644 --- a/docs/architecture/00-conventions.md +++ b/docs/architecture/00-conventions.md @@ -18,21 +18,27 @@ Rationale lives in [`../idea/00-thesis.md`](../idea/00-thesis.md). This file is ## Package tiers -Imports go **down only**. Never sideways within a tier, never upward. +Imports go **down only**. Never sideways within a tier, never upward. Enforced by `bun run boundaries`; a violation names the importing file, the imported module and the allowed tiers. -| Tier | Packages | May import | -|---|---|---| -| 0 | `core`, `schema` | nothing (`@ultimat3/*`) | -| 1 | `i18n`, `money`, `time`, `cache`, `seo` | tier 0 | -| 2 | `entity`, `policy`, `http` | tier 0–1 | -| 3 | `action`, `query`, `jobs`, `realtime` | tier 0–2 | -| 4 | `render`, `pwa`, `mcp`, `ai`, `manifest` | tier 0–3 | -| 5 | `ui`, `admin`, `testing`, `cli` | tier 0–4 | - -Enforced by `bun run boundaries`. A violation reports the importing file, the imported module, and the allowed tiers. +**The table is not repeated here, on purpose.** [`scripts/lib/tiers.ts`](../../scripts/lib/tiers.ts) is the executable copy and exactly two prose copies are permitted — the root [`CLAUDE.md`](../../CLAUDE.md) and [`01-package-map.md`](01-package-map.md) — because `scripts/tier-table-drift.test.ts` reads those two and nothing else. A third copy on this page went stale in five rows before it was deleted: it still placed `ui` at 5, and had never heard of `db`, `storage`, `flags`, `auth`, `mail` or `scraping`. **Adding a package:** pick the tier first. If it doesn't fit one, the design is wrong — fix the design, don't widen the table. `bun run scripts/new-package.ts --tier ` scaffolds it correctly. +### One declaration, at the lowest tier that can hold it + +A closed vocabulary two packages both name goes in the **lowest tier either can import** — not in whichever package "owns" the concept. Re-export it upward from any package whose own signatures take it; a re-export is not a declaration. + +Imports only go down, so a sideways need becomes a copy, and a copy drifts **silently**: the route vocabulary (`RenderMode`, `OfflineStrategy`, `HydrateStrategy`) reached **14 declarations across six packages** before `'spa'` was deleted from one of them and five went on admitting it under a green project-wide typecheck — `@ultimat3/pwa`'s copy mapping it to `cache-first`, the one strategy that gives an `app/` route a shared cache entry. It now lives once, at tier 0, in `packages/core/src/route-vocabulary.ts`, with each union derived from its array so the pair cannot disagree. + +| Rule | | +|---|---| +| Where | the lowest tier that can hold it — tier 0 for anything the whole graph names | +| Shape | `export const X = [...] as const` and `export type X = (typeof X)[number]`, never a hand-written union beside its array | +| Upward | re-export from each package whose API takes it, so a consumer needs one import | +| Never | restate the members. `bun run scripts/render-modes.ts --json` matches on the **literal set**, not the name — the copy that did the damage was called `PwaRenderMode` | + +Two shared members is a copy; one is a coincidence and stays silent. The margin is measured, not guessed: the highest innocent overlap in this repo is **1** (`CacheTier` holds `isr`, `StrategyName` holds `network-only`, `ChangeFreq` holds `never`), `As of 2026-08`. + Details: [`02-boundaries.md`](02-boundaries.md). ## Package layout diff --git a/docs/architecture/01-package-map.md b/docs/architecture/01-package-map.md index dcfd89d8..cd9b4ca6 100644 --- a/docs/architecture/01-package-map.md +++ b/docs/architecture/01-package-map.md @@ -19,14 +19,17 @@ tier 5 admin, testing, cli, scraping (may import tier 0-4) [`scripts/lib/tiers.ts`](../../scripts/lib/tiers.ts) is the executable copy of this block; `bun run boundaries` reads that one. Prose and code must agree. +`SIDEWAYS_ALLOW` in [`scripts/lib/tiers.ts`](../../scripts/lib/tiers.ts) is the executable copy of this table, `As of 2026-08`. Four edges, each earning its line: + | Sideways exception | Why | |---|---| -| `schema` → `core` | needs `UltimateError` for parse failures. `core` imports nothing. | -| `admin` → `ui` | the admin dashboard *is* the ui kit, composed. Inverting it ships every widget through props. | | `realtime` → `query` | tier 3 is one feature: a live query is a query plus a subscription. Splitting duplicates the SQL shape. | | `cli` → `admin` | `x dev` **mounts** `/_x`; it does not reimplement it. The panels are a tier-5 product, and the alternative is a second dev dashboard inside the CLI. | -| `create-ultimate` → `cli` | a published shim whose whole job is `x new`. The alternative is a second copy of the templates. | -| everything else | none. Siblings share **types only**, declared in the lowest tier that needs them. | +| `cli` → `testing` | `@ultimat3/testing` **is** the framework's harness, and `serve.live.test.ts` spawns the scaffolded `server.ts` as a child, so one real port has to pass the seal. It was already live as a relative specifier the checker could not see, and was declared once `bun run boundaries` learned to follow those. | +| `create-ultimate` → `cli` | a published shim whose whole job is `x new`. The alternative is a second copy of the templates. `create-ultimate` sits above the table at tier 6, and this is its **only** permitted import. | +| everything else | none. Siblings share **types only**, declared in the lowest tier that needs them — see [`00-conventions.md`](00-conventions.md#one-declaration-at-the-lowest-tier-that-can-hold-it). | + +**Two rows left this table and neither was a rule change.** `schema` → `core` never existed: `packages/schema/src/errors.ts:2` says `SchemaError` reproduces `UltimateError`'s shape **structurally** rather than importing it, so tier 0 imports nothing and needs no exception. `admin` → `ui` is now an ordinary **downward** import: `ui` imports `core`, `i18n`, `money` and `time`, so tier 5 was two tiers above its floor, and moving it to 4 made the edge legal on the plain rule. `ui` sits at 4 rather than at its floor so `render` → `ui` stays forbidden — the static bundle graph may not reach the design system, which is axiom 6. An exception line in an enforcement table is a rule with a hole in it, and deleting the hole beats arguing for it. ### Why `db` is tier 1 diff --git a/framework.manifest.json b/framework.manifest.json index 86e2b8d4..2cdd9289 100644 --- a/framework.manifest.json +++ b/framework.manifest.json @@ -1,6 +1,6 @@ { "version": 1, - "buildId": "ed73e93edf511ece8c99f6ae90dec24ab47960cfddcbfd5e03344851fd0ed254", + "buildId": "5ce2746255a0c1b6a5d972a17fd3bd34bfb542255424cc395c591ae3a48fdf1e", "tiers": { "0": [ "core", @@ -787,6 +787,16 @@ "owner": "admin", "at": "packages/admin/src/errors.ts" }, + { + "code": "X_DOC_CHANGELOG_SECTION_INVALID", + "owner": "scripts", + "at": "scripts/changelog-check.ts" + }, + { + "code": "X_DOC_CHANGELOG_UNRELEASED_BREAKING", + "owner": "scripts", + "at": "scripts/changelog-check.ts" + }, { "code": "X_DOC_COMMAND_ALLOWANCE_STALE", "owner": "scripts", @@ -837,6 +847,16 @@ "owner": "scripts", "at": "scripts/gate-steps.ts" }, + { + "code": "X_DOC_MIGRATION_COUNT_STALE", + "owner": "scripts", + "at": "scripts/changelog-check.ts" + }, + { + "code": "X_DOC_MIGRATION_UNSCANNED", + "owner": "scripts", + "at": "scripts/changelog-check.ts" + }, { "code": "X_DOC_RELEASE_FACT_STALE", "owner": "scripts", @@ -1032,6 +1052,16 @@ "owner": "realtime", "at": "packages/realtime/src/errors.ts" }, + { + "code": "X_GATE_CODE_BACKLOG_STALE", + "owner": "scripts", + "at": "scripts/gate-codes.ts" + }, + { + "code": "X_GATE_CODE_UNDOCUMENTED", + "owner": "scripts", + "at": "scripts/gate-codes.ts" + }, { "code": "X_GENERATE_CONFLICT", "owner": "cli", @@ -1832,6 +1862,11 @@ "owner": "scripts", "at": "scripts/release.ts" }, + { + "code": "X_RELEASE_UNRELEASED_MISSING", + "owner": "scripts", + "at": "scripts/release.ts" + }, { "code": "X_RELEASE_VERSION_SKEW", "owner": "cli", diff --git a/package.json b/package.json index ee741343..dc488a43 100644 --- a/package.json +++ b/package.json @@ -12,17 +12,22 @@ "dummy/*/packages/*" ], "scripts": { + "async-context-guard": "bun run scripts/async-context-guard.ts", "boundaries": "bun run scripts/boundaries.ts", + "changelog-check": "bun run scripts/changelog-check.ts", "coverage": "bun run scripts/coverage-gate.ts --all", "coverage:package": "bun run scripts/coverage-gate.ts --package", "dev": "bun run --filter @ultimat3/cli dev", "error-render": "bun run scripts/error-render.ts", "format": "biome format --write .", + "frozen-records": "bun run scripts/frozen-records.ts", + "gate-codes": "bun run scripts/gate-codes.ts", "lint": "biome check .", "lint:fix": "biome check --write .", "lockfile": "bun run scripts/lockfile-pins.ts", "lockfile:fix": "bun run scripts/lockfile-pins.ts --write", "manifest": "bun run scripts/manifest.ts", + "render-modes": "bun run scripts/render-modes.ts", "setup": "bun run scripts/setup.ts", "test": "bun test --isolate --path-ignore-patterns='**/dist/**' --path-ignore-patterns='**/examples/**' --path-ignore-patterns='**/dummy/**'", "test:watch": "bun test --watch --isolate --path-ignore-patterns='**/dist/**' --path-ignore-patterns='**/examples/**' --path-ignore-patterns='**/dummy/**'", diff --git a/packages/core/src/type-pins.ts b/packages/core/src/type-pins.ts index 0d9e0800..6b1a48c9 100644 --- a/packages/core/src/type-pins.ts +++ b/packages/core/src/type-pins.ts @@ -1,9 +1,7 @@ -// Compile-time pins for the actor-facts seam, the config surface and the route vocabulary. Source, -// not a `.test.ts`, -// on purpose: -// `tsconfig.json` excludes `src/**/*.test.ts`, so `tsc -b` never reads a test file and a -// type-level assertion written there can never fail. This module emits nothing and exports -// nothing anybody imports — a regression is a build error, the only enforcement that counts. +// Compile-time pins for the actor-facts seam, the config surface and the route vocabulary. +// Source, not a `.test.ts`, on purpose: `tsconfig.json` excludes `src/**/*.test.ts`, so `tsc -b` +// never reads a test file and a type-level assertion written there can never fail. This module +// emits nothing and exports nothing anybody imports — a regression is a build error. import type { Actor, ActorFactMap, FactKeysOf, FactMapOf } from './actor'; import type { AppConfigInput, DatabaseConfig } from './config'; diff --git a/packages/pwa/src/strategies.ts b/packages/pwa/src/strategies.ts index 09124309..a6845a45 100644 --- a/packages/pwa/src/strategies.ts +++ b/packages/pwa/src/strategies.ts @@ -42,19 +42,15 @@ export interface PwaRoute { /** * Render mode → runtime strategy. The whole reason `sw.js` is generated, not written. * - * `Record` over the tier-0 union is the exhaustiveness check: a mode with no row is - * a compile error, and a row for a mode that does not exist is a compile error too. That second - * half is the one that mattered — `spa` kept mapping to `cache-first` here after it was deleted - * from the vocabulary, the one strategy that gives an `app/` route a SHARED cache entry, i.e. one - * member's authed HTML served to the next. It compiled because this Record was keyed on a copy. - */ -/** - * `Object.freeze({…})` with an EXPLICIT type argument, never `const X: T = Object.freeze({…})`. - * The second form loses the object literal's freshness — the literal is inferred first and the - * annotation only checks assignability afterwards — so an EXTRA key compiles silently. That is not - * a hypothetical: `spa: 'cache-first'` sat in `@ultimat3/pwa`'s copy of this table after `spa` was - * deleted from the vocabulary, and `tsc` had nothing to say. Naming the type argument makes the - * literal contextually typed, so a missing key AND an extra key are both build errors. + * Two separate things make the closed set hold, and the table needed both. `Record` + * over the TIER-0 union is the exhaustiveness check — this was keyed on a hand-copy, which is how + * `spa` went on mapping to `cache-first` after `spa` was deleted from the vocabulary: the one + * strategy that gives an `app/` route a SHARED cache entry, i.e. one member's authed HTML served + * to the next. And `Object.freeze({…})` with an EXPLICIT type argument, never + * `const X: T = Object.freeze({…})` — the second form infers `T` from the literal and the + * annotation only checks assignability afterwards, so the literal's freshness is already gone and + * an EXTRA key compiles in silence. Named, the literal is contextually typed and a missing key + * AND an extra key are both build errors. */ export const MODE_STRATEGY = Object.freeze>({ static: 'cache-first', diff --git a/packages/render/src/registry.ts b/packages/render/src/registry.ts index 37630475..5c946c2a 100644 --- a/packages/render/src/registry.ts +++ b/packages/render/src/registry.ts @@ -20,10 +20,15 @@ import type { Surface } from './surfaces'; import { locateSurface } from './surfaces'; /** - * The one filename a route may carry, per surface. `shared/` is absent on purpose: it is a leaf - * of helpers with no URL, so a route file there has nowhere to resolve to. + * The one filename a route may carry, per surface. `shared/` is `Exclude`d rather than merely + * absent: it is a leaf of helpers with no URL, so a route file there has nowhere to resolve to — + * and stating that in the key type makes the other three MANDATORY. `Partial>` + * said the same thing about `shared` and let any of the three go missing, which only three + * registration tests would have caught. A dropped `api` row is not a crash: `assertRouteFilename` + * reads `undefined` as "this file is under shared/", so every `api/` route author would have been + * told their file is a leaf of helpers. */ -export const ROUTE_FILENAME = Object.freeze>>({ +export const ROUTE_FILENAME = Object.freeze, string>>({ site: 'page.tsx', app: 'page.tsx', api: 'route.ts', @@ -131,7 +136,9 @@ const shellQuote = (value: string): string => `'${value.replaceAll("'", "'\\''") * author already meant, plus the one filename that surface accepts. */ function assertRouteFilename(file: string, surface: Surface, basename: string | undefined): void { - const expected = ROUTE_FILENAME[surface]; + // `shared` is the one surface with no filename, and the key type now says so — which is why + // this is a comparison rather than an `undefined` check on the lookup. + const expected = surface === 'shared' ? undefined : ROUTE_FILENAME[surface]; if (expected === undefined) { throw new RouteFileInvalidError( `${file} is under shared/, which is a leaf of helpers with no URL — a route cannot live there`, diff --git a/scripts/browser-barrel.test.ts b/scripts/browser-barrel.test.ts new file mode 100644 index 00000000..35de6daf --- /dev/null +++ b/scripts/browser-barrel.test.ts @@ -0,0 +1,196 @@ +// Every package that touches the AsyncLocalStorage seam, bundled for the browser and evaluated — +// the end property, not the mechanism, one place for the whole repo. +// +// The guard in `scripts/async-context-guard.ts` reads source for `new AsyncLocalStorage()` at +// module scope and documents two blind spots it cannot see: `await import('node:async_hooks')`, +// and `const C = hooks.AsyncLocalStorage; new C()`. Both are closed here, and by two DIFFERENT +// assertions, because neither one closes both — measured, not assumed: +// +// | Reintroduced as | evaluates the chunk | no `node:async_hooks` in the chunk | +// |---|---|---| +// | `new AsyncLocalStorage()` at module scope | throws — caught | caught | +// | `const C = hooks.AsyncLocalStorage; new C()` | throws — caught | caught | +// | `await import('node:async_hooks')` at module scope | evaluates FINE — missed | caught | +// +// The dynamic form is missed by evaluation because these chunks are evaluated by Bun, where a +// dynamic specifier resolves to the real module at runtime; `target: 'browser'` leaves it in the +// output rather than stubbing it, which is why the text assertion is the one that sees it. +// +// WHAT THIS DOES NOT CLAIM: that these barrels are usable in a browser. `packages/db` statically +// imports `node:fs/promises` in `pglite-branch.ts`; the browser target elides the specifier, so the +// module EVALUATES and the call would throw. The claim is only that nothing constructs an +// AsyncLocalStorage while the module is being evaluated — which is the defect that shipped. + +import { describe, expect, test } from 'bun:test'; +import { mkdtemp } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { repoRoot, run } from './lib/run'; + +/** Every package whose source names the seam. Re-derive with `grep -rl AsyncLocalStorage` under + * each package's `src`, never by memory: a fifth adoption site that is not here is not guarded. */ +const BARRELS = ['ai', 'core', 'db', 'entity'] as const; + +/** A browser build is ~1.3MB for `ai` and `entity`; the build IS the test, so the budget moves. */ +const BUILD_TIMEOUT_MS = 120_000; + +/** + * The SPECIFIER, never the bare string. `@ultimat3/core`'s own `X_ASYNC_CONTEXT_UNAVAILABLE` cause + * says "node:async_hooks is stubbed to {} in this runtime" — a `toContain` on the words reports the + * error message that PROVES the stub is working, which is the opposite of the finding. + */ +const HOOKS_SPECIFIER = /(?:from|import|require)\s*\(?\s*["']node:async_hooks["']/; + +/** + * BOTH halves run in a SUBPROCESS, and neither started that way. + * + * `Bun.build` cannot resolve a `@ultimat3/*` specifier inside `bun test`: there is no + * `node_modules/@ultimat3`, the workspace map belongs to the runtime, and the bundler in this + * process does not consult it — `packages/db/src/client.ts` dies on + * `Could not resolve "@ultimat3/core"`. The same build under `bun run` is fine. Resolving each name + * by hand through a plugin fixed the build and broke something worse: the NEXT test file `bun test` + * loaded then died on `Cannot find module '@ultimat3/http'`. Measured — + * `bun test browser-barrel.test.ts` alone is green, and + * `bun test browser-barrel.test.ts release.test.ts` loses all 26 of release's tests to a resolution + * error in a file this one never touches. Building anywhere but here is the fix that has no + * second-order effect, and a fresh process also makes "throws at module scope" mean exactly that. + */ +interface Chunk { + readonly text: string; + readonly file: string; +} + +/** Built where the workspace map works: cwd is the repo root, and nothing is resolved by hand. */ +const buildScript = (entry: string, out: string): string => + [ + `const built = await Bun.build({ entrypoints: [${JSON.stringify(entry)}], target: 'browser' });`, + 'if (!built.success) { console.error(built.logs.map(String).join(" | ")); process.exit(1); }', + 'const chunk = built.outputs[0];', + 'if (chunk === undefined) { console.error("no chunk"); process.exit(1); }', + `await Bun.write(${JSON.stringify(out)}, await chunk.text());`, + ].join('\n'); + +async function browserChunk(entry: string): Promise { + // A fresh path per build: a reused one would serve a chunk built before a fix. + const file = join(await mkdtemp(join(tmpdir(), 'ultimate-barrel-')), 'barrel.mjs'); + const built = await run(['bun', '-e', buildScript(entry, file)], { cwd: repoRoot() }); + if (!built.ok) { + expect.unreachable(`${entry} did not bundle for the browser: ${built.output}`); + } + return { text: await Bun.file(file).text(), file }; +} + +/** Whether evaluating the chunk throws, and with what — the shape a module-scope `new` produces. */ +async function evaluationError(chunk: Chunk): Promise { + const result = await run(['bun', 'run', chunk.file], { cwd: repoRoot() }); + return result.ok ? undefined : result.output; +} + +const fixture = (name: string, source: string): Promise => + mkdtemp(join(tmpdir(), `ultimate-fixture-${name}-`)) + .then(async (dir) => { + const entry = join(dir, 'entry.ts'); + await Bun.write(entry, source); + return entry; + }) + .then(browserChunk); + +// Negative controls first: without these the two assertions below could both be vacuously true, +// which is exactly what a browser test evaluated under Bun is at risk of being. +describe('the harness can fail', () => { + test( + 'a module-scope construction through an ALIAS throws when the chunk is evaluated', + async () => { + const chunk = await fixture( + 'alias', + [ + "import * as hooks from 'node:async_hooks';", + 'const Ctor = hooks.AsyncLocalStorage;', + 'export const store = new Ctor();', + '', + ].join('\n'), + ); + expect(await evaluationError(chunk)).toContain('undefined is not a constructor'); + }, + BUILD_TIMEOUT_MS, + ); + + test( + 'a module-scope `await import` survives evaluation and is caught by the specifier instead', + async () => { + const chunk = await fixture( + 'dynamic', + [ + "const hooks = await import('node:async_hooks');", + 'export const store = new hooks.AsyncLocalStorage();', + '', + ].join('\n'), + ); + // Evaluates cleanly under Bun — this is the blind spot the second assertion exists for. + expect(await evaluationError(chunk)).toBeUndefined(); + expect(HOOKS_SPECIFIER.test(chunk.text)).toBe(true); + }, + BUILD_TIMEOUT_MS, + ); + + /** + * The mutation, applied to a REAL barrel graph rather than to a two-line fixture: `@ultimat3/db` + * plus one aliased module-scope construction. Editing `packages/db` to prove this would be the + * honest experiment and cannot be run here — a second agent is writing that tree — so the + * reintroduction is grafted onto the real barrel at the entry point instead. Same graph, same + * bundler, same evaluation. + */ + test( + 'a real barrel that GAINS an aliased construction reds the same assertion', + async () => { + const barrel = join(repoRoot(), 'packages/db/src/index.ts'); + const chunk = await fixture( + 'db-alias', + [ + `export * from ${JSON.stringify(barrel)};`, + "import * as hooks from 'node:async_hooks';", + 'const Ctor = hooks.AsyncLocalStorage;', + 'export const grafted = new Ctor();', + '', + ].join('\n'), + ); + expect(await evaluationError(chunk)).toContain('undefined is not a constructor'); + // And the barrel as it stands does not, so the graft is what moved the answer. + expect(await evaluationError(await browserChunk(barrel))).toBeUndefined(); + }, + BUILD_TIMEOUT_MS, + ); + + test( + 'a module that touches neither is clean on both', + async () => { + const chunk = await fixture('clean', 'export const ok = (): boolean => true;\n'); + expect(await evaluationError(chunk)).toBeUndefined(); + expect(HOOKS_SPECIFIER.test(chunk.text)).toBe(false); + }, + BUILD_TIMEOUT_MS, + ); +}); + +// `[...BARRELS]`, not `BARRELS`: `describe.each(table: T[])` wants a MUTABLE array, and a +// `readonly` tuple is 20 x TS2769 under `bun run typecheck`. scripts/tsconfig.json includes +// `**/*.ts` with no test exclusion, so a green `bun test` is not evidence for a file in scripts/. +describe.each([...BARRELS])('a browser bundle of @ultimat3/%s', (name) => { + const entry = (): string => join(repoRoot(), 'packages', name, 'src/index.ts'); + + test( + 'evaluates instead of throwing at module scope', + async () => { + expect(await evaluationError(await browserChunk(entry()))).toBeUndefined(); + }, + BUILD_TIMEOUT_MS, + ); + + test( + 'carries no node:async_hooks specifier into the chunk', + async () => { + expect(HOOKS_SPECIFIER.test((await browserChunk(entry())).text)).toBe(false); + }, + BUILD_TIMEOUT_MS, + ); +}); diff --git a/scripts/changelog-check.test.ts b/scripts/changelog-check.test.ts new file mode 100644 index 00000000..e1b2cd77 --- /dev/null +++ b/scripts/changelog-check.test.ts @@ -0,0 +1,257 @@ +// Every rule in changelog-check.ts, each proved against a fixture that violates exactly it — and +// then the whole rule set against the two files this repo really ships, which is the assertion that +// makes it a gate rather than a demo. + +import { describe, expect, test } from 'bun:test'; +import type { ChangelogGapKind } from './changelog-check'; +import { + BREAKING_ENTRY, + changelogFinding, + checkChangelog, + parseChangelog, + parseDerivedTotal, + parseMigrationTable, + taggedVersion, +} from './changelog-check'; +import { repoRoot } from './lib/run'; + +/** A file that passes every rule. Each test breaks one thing in it and nothing else. */ +const GOOD_CHANGELOG = `# Changelog + +Preamble. + +## [Unreleased] + +Nothing yet. + +## 2.0.0 - 2026-08-17 + +### Changed + +- **BREAKING — one thing moved.** Do the edit. +- **BREAKING — another thing moved.** Do the other edit. + +## 1.0.0 - 2026-08-10 + +First release. +`; + +const GOOD_UPGRADING = `# Upgrading + +| From → to | Breaking entries | Read | +|---|---|---| +| 1.x → 2.0.0 | **2** | the \`2.0.0\` section, in order | +| 1.x → 2.0.0 | **2** | all one sections, oldest first | + +\`\`\`sh +grep -cE '^(- \\*\\*|### )BREAKING —' CHANGELOG.md +# 2 As of 2026-08 +\`\`\` +`; + +const kinds = (changelog: string, upgrading: string, taggedVersion?: string): readonly string[] => + checkChangelog({ changelog, upgrading, taggedVersion }).map((gap) => gap.kind); + +describe('parsing', () => { + test('a section owns the lines under its heading, and knows its own breaking count', () => { + const sections = parseChangelog(GOOD_CHANGELOG); + expect(sections.map((section) => section.version)).toEqual(['unreleased', '2.0.0', '1.0.0']); + expect(sections.map((section) => section.breaking)).toEqual([0, 2, 0]); + expect(sections[1]?.line).toBe(9); + }); + + // The anchor is the rule: three sub-bullets under one entry are ONE entry, which is what makes + // 6.0.0's ten `BREAKING —` lines the seven entries wiki/Upgrading.md counts. + test('an indented BREAKING sub-bullet belongs to the entry above it', () => { + expect(BREAKING_ENTRY.test('- **BREAKING — a thing.** edit')).toBe(true); + expect(BREAKING_ENTRY.test('### BREAKING — a thing')).toBe(true); + expect(BREAKING_ENTRY.test(' - **BREAKING — a detail.** edit')).toBe(false); + }); + + test('a row is read by where it sends the reader, not by its position', () => { + const rows = parseMigrationTable(GOOD_UPGRADING); + expect(rows.map((row) => row.target)).toEqual(['2.0.0', undefined]); + expect(rows.map((row) => row.aggregate)).toEqual([false, true]); + expect(rows[0]?.claimed).toBe(2); + }); + + test('the derived total is read out of the fenced grep the page prints', () => { + expect(parseDerivedTotal(GOOD_UPGRADING)?.claimed).toBe(2); + expect(parseDerivedTotal('# Upgrading\n\nno fence here\n')).toBeUndefined(); + }); +}); + +describe('the rules, each proved against the fixture that breaks it', () => { + test('the good fixture is silent, so every finding below is caused by the mutation', () => { + expect(kinds(GOOD_CHANGELOG, GOOD_UPGRADING, '2.0.0')).toEqual([]); + }); + + // The failure that shipped twice unnoticed: an auto-generated section above a hand-written one, + // both `## 5.0.1`, with the migration in the lower half. + test('two sections naming one version', () => { + const doubled = GOOD_CHANGELOG.replace( + '## 1.0.0 - 2026-08-10', + '## 2.0.0\n\n- generated subject\n\n## 1.0.0 - 2026-08-10', + ); + expect(kinds(doubled, GOOD_UPGRADING)).toContain('duplicate'); + }); + + test('a released section with no body', () => { + const hollow = GOOD_CHANGELOG.replace( + '### Changed\n\n- **BREAKING — one thing moved.** Do the edit.\n- **BREAKING — another thing moved.** Do the other edit.\n', + '', + ); + expect(kinds(hollow, GOOD_UPGRADING)).toContain('empty'); + }); + + test('[Unreleased] is empty on purpose and is never reported as hollow', () => { + expect(kinds(GOOD_CHANGELOG.replace('Nothing yet.\n', ''), GOOD_UPGRADING)).not.toContain( + 'empty', + ); + }); + + // 6.0.0 exactly: the migration under [Unreleased] at the moment the tag was pushed. + test('a BREAKING entry left under [Unreleased] at a tagged commit', () => { + const stranded = GOOD_CHANGELOG.replace( + 'Nothing yet.', + '- **BREAKING — the migration.** Do the edit.', + ); + expect(kinds(stranded, GOOD_UPGRADING, '2.0.0')).toContain('unreleased-breaking'); + // Between releases the same file is correct: that is where a breaking entry is supposed to sit. + expect(kinds(stranded, GOOD_UPGRADING)).not.toContain('unreleased-breaking'); + }); + + // The rule the issue calls out. A whole-file count cannot see this: moving an entry into the + // wrong section leaves the total at 2 and only the PER-SECTION number moves. + test('a count read from the whole file instead of from the major own section', () => { + const misfiled = GOOD_CHANGELOG.replace( + '- **BREAKING — another thing moved.** Do the other edit.\n', + '', + ).replace('First release.', '- **BREAKING — another thing moved.** Do the other edit.'); + expect(kinds(misfiled, GOOD_UPGRADING)).toEqual(['count', 'count']); + // And the whole-file total is STILL 2, which is exactly why a derived total waved this through: + // a misplaced entry does not change the number, it only changes which section holds it. + expect(parseDerivedTotal(GOOD_UPGRADING)?.claimed).toBe(2); + expect(kinds(misfiled, GOOD_UPGRADING)).not.toContain('total'); + }); + + test('a stale per-major count', () => { + expect(kinds(GOOD_CHANGELOG, GOOD_UPGRADING.replace('**2** | the', '**3** | the'))).toContain( + 'count', + ); + }); + + test('a stale aggregate row', () => { + expect(kinds(GOOD_CHANGELOG, GOOD_UPGRADING.replace('**2** | all', '**9** | all'))).toContain( + 'count', + ); + }); + + test('a released major with no row sending the reader anywhere', () => { + const third = GOOD_CHANGELOG.replace( + '## 2.0.0 - 2026-08-17', + '## 3.0.0 - 2026-08-19\n\nA major.\n\n## 2.0.0 - 2026-08-17', + ); + expect(kinds(third, GOOD_UPGRADING)).toContain('missing-row'); + }); + + test('1.0.0 has nothing to migrate from, so it needs no row', () => { + expect(kinds(GOOD_CHANGELOG, GOOD_UPGRADING)).not.toContain('missing-row'); + }); + + test('the fenced grep promises a number the grep does not print', () => { + expect(kinds(GOOD_CHANGELOG, GOOD_UPGRADING.replace('# 2 As of', '# 5 As of'))).toContain( + 'total', + ); + }); + + // A rule with no input is a false green, not a pass — the same argument gate-steps.ts makes. + test('a table that sends the reader to no section at all reports itself', () => { + expect(kinds(GOOD_CHANGELOG, '# Upgrading\n\nnothing here\n')).toEqual(['unscanned']); + }); +}); + +describe('findings', () => { + test('every kind maps to a code, and every fix is runnable as written', () => { + const gaps = checkChangelog({ + changelog: GOOD_CHANGELOG.replace( + '## 1.0.0 - 2026-08-10', + '## 2.0.0\n\n- generated\n\n## 1.0.0 - 2026-08-10', + ).replace('Nothing yet.', '- **BREAKING — stranded.** edit'), + upgrading: GOOD_UPGRADING.replace('**2** | the', '**4** | the'), + taggedVersion: '2.0.0', + }); + const findings = gaps.map(changelogFinding); + expect(findings.map((finding) => finding.code)).toContain('X_DOC_CHANGELOG_SECTION_INVALID'); + expect(findings.map((finding) => finding.code)).toContain( + 'X_DOC_CHANGELOG_UNRELEASED_BREAKING', + ); + expect(findings.map((finding) => finding.code)).toContain('X_DOC_MIGRATION_COUNT_STALE'); + for (const finding of findings) { + expect(finding.fix.length).toBeGreaterThan(0); + expect(finding.fix).not.toContain('<'); + expect(finding.at).toBeDefined(); + } + }); + + // `--dry-run` writes nothing, so a fix: whose whole remedy is a --dry-run command hands the + // reader something that checks the problem and does not solve it. Axiom 4 at the point it is read. + test('a fix: citing --dry-run also says --dry-run only validates', () => { + const kinds: readonly ChangelogGapKind[] = [ + 'duplicate', + 'empty', + 'unreleased-breaking', + 'count', + 'missing-row', + 'total', + 'unscanned', + ]; + for (const kind of kinds) { + const { fix } = changelogFinding({ kind, at: 'CHANGELOG.md:9', detail: 'a detail' }); + if (!fix.includes('--dry-run')) continue; + expect(fix).toContain('validates'); + } + expect( + changelogFinding({ kind: 'unreleased-breaking', at: 'CHANGELOG.md:9', detail: 'd' }).fix, + ).toContain('CHANGELOG.md'); + }); + + test('the unscanned kind names the file to edit, not the file it read', () => { + const gap = checkChangelog({ changelog: GOOD_CHANGELOG, upgrading: '# Upgrading\n' })[0]; + expect(gap).toBeDefined(); + expect(changelogFinding(gap ?? { kind: 'unscanned', at: '', detail: '' }).code).toBe( + 'X_DOC_MIGRATION_UNSCANNED', + ); + }); +}); + +// The point of the whole file: these two are what ships, and they have to be clean under the same +// rules the fixtures above are graded by. +describe('the committed CHANGELOG.md and wiki/Upgrading.md', () => { + test('pass every rule, at whatever commit this is', async () => { + const root = repoRoot(); + const changelog = await Bun.file(`${root}/CHANGELOG.md`).text(); + const upgrading = await Bun.file(`${root}/wiki/Upgrading.md`).text(); + // The real tag, never a literal. Hardcoding one asserts the tree must be RELEASABLE at every + // commit, which forbids the ordinary state of development: a `BREAKING —` entry accumulating + // under [Unreleased] between releases. That is the entry's correct home until a release + // promotes it, and a rule that refuses it would push every breaking change out the day it + // landed — the opposite of what promotion is for. + expect( + checkChangelog({ changelog, upgrading, taggedVersion: await taggedVersion(root) }), + ).toEqual([]); + }); + + test('the same files WOULD be refused if this commit were tagged with breaking entries stranded', async () => { + const root = repoRoot(); + const changelog = await Bun.file(`${root}/CHANGELOG.md`).text(); + const upgrading = await Bun.file(`${root}/wiki/Upgrading.md`).text(); + const stranded = parseChangelog(changelog).find((section) => !section.released)?.breaking ?? 0; + const gaps = checkChangelog({ changelog, upgrading, taggedVersion: '99.0.0' }); + // Non-vacuous in both directions: when [Unreleased] holds a breaking entry the tagged reading + // must refuse it, and when it holds none the tagged reading must be as clean as the untagged + // one. Either way this test reads the real file, so it cannot pass by describing a fixture. + if (stranded > 0) expect(gaps.map((gap) => gap.kind)).toContain('unreleased-breaking'); + else expect(gaps).toEqual([]); + }); +}); diff --git a/scripts/changelog-check.ts b/scripts/changelog-check.ts new file mode 100644 index 00000000..a74bd276 --- /dev/null +++ b/scripts/changelog-check.ts @@ -0,0 +1,338 @@ +#!/usr/bin/env bun +// Enforce, as a gate rule, that CHANGELOG.md's sections are well-formed and that every migration +// count in wiki/Upgrading.md is read out of that major's OWN section. +// +// The gap this closes is at commit 8fe7c56d — `git show 8fe7c56d:CHANGELOG.md`. That is +// `release: 6.0.0`, the release script's OWN output: seven `BREAKING —` entries still under +// `## [Unreleased]`, a `## 6.0.0` holding six merge subjects and nothing else, and two `## 5.0.1` +// plus two `## 5.0.0` headings carried over from the two runs before it — while wiki/Upgrading.md +// already told the reader to read the `6.0.0` section. +// +// Read the TAG and none of that is visible: `git show v6.0.0:CHANGELOG.md` has the migration inside +// `## 6.0.0` and no duplicate `## ` heading, because v6.0.0 points at 93443aeb — a human repairing +// 8fe7c56d by hand. The tag is evidence of the repair, never of the defect, and the repair is what +// this file replaces. Two `### Fixed` blocks inside 6.0.0 (`v6.0.0:CHANGELOG.md:129` and `:139`) +// are what the hand pass missed. +// +// The count that should have caught it WAS derived — from the whole file — and a migration filed +// under the wrong heading is invisible to a whole-file count, because a misplaced entry only makes +// the number smaller. Per-section is the entire point of this file. +// +// bun run scripts/changelog-check.ts [--json] + +import { parseScriptArgs } from './lib/args'; +import type { Finding } from './lib/log'; +import { report } from './lib/log'; +import { repoRoot, run } from './lib/run'; + +const SCRIPT = 'changelog-check'; +export const CHANGELOG_PATH = 'CHANGELOG.md'; +export const UPGRADING_PATH = 'wiki/Upgrading.md'; + +/** + * One line, one breaking entry — the identical regex wiki/Upgrading.md hands the reader in a fenced + * `grep -cE`. Anchored at column 0 deliberately: an INDENTED `- **BREAKING —` is a sub-bullet of + * the entry above it and not an entry of its own, which is how the `Bun.Image` entry carries three. + */ +export const BREAKING_ENTRY = /^(?:- \*\*|### )BREAKING —/; + +/** `## [Unreleased]` holds work that has no version yet, so the released-section rules skip it. */ +const UNRELEASED = 'unreleased'; + +/** Semver applies from 1.0.0, and 1.0.0 itself has nothing to migrate FROM — the table starts at 2. */ +const FIRST_MIGRATABLE_MAJOR = 2; + +export interface ChangelogSection { + /** Heading text after `## `, verbatim. */ + readonly heading: string; + /** `unreleased`, a bare semver, or the lowercased heading when it is neither. */ + readonly version: string; + readonly released: boolean; + /** 1-based, so `CHANGELOG.md:13` opens it in an editor. */ + readonly line: number; + readonly breaking: number; + /** Whether any non-blank line sits under the heading. */ + readonly filled: boolean; +} + +const versionOf = (heading: string): string => { + if (/^\[unreleased\]/i.test(heading)) return UNRELEASED; + return /^\[?(\d+\.\d+\.\d+)\]?/.exec(heading)?.[1] ?? heading.toLowerCase(); +}; + +export function parseChangelog(text: string): readonly ChangelogSection[] { + const sections: ChangelogSection[] = []; + const lines = text.split('\n'); + let current: { heading: string; line: number; breaking: number; filled: boolean } | undefined; + const flush = (): void => { + if (current === undefined) return; + sections.push({ + heading: current.heading, + version: versionOf(current.heading), + released: versionOf(current.heading) !== UNRELEASED, + line: current.line, + breaking: current.breaking, + filled: current.filled, + }); + }; + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index] ?? ''; + if (line.startsWith('## ')) { + flush(); + current = { heading: line.slice(3).trim(), line: index + 1, breaking: 0, filled: false }; + continue; + } + if (current === undefined) continue; + if (line.trim().length > 0) current.filled = true; + if (BREAKING_ENTRY.test(line)) current.breaking += 1; + } + flush(); + return sections; +} + +export interface MigrationRow { + readonly line: number; + readonly claimed: number; + /** The single section this row sends the reader to, or `undefined` on the aggregate row. */ + readonly target: string | undefined; + readonly aggregate: boolean; + readonly quote: string; +} + +/** + * The summary table's rows, told apart by their READ cell rather than by position: a row saying + * "the `4.0.0` section" is about one major, and the row saying "all five sections" is the total. + * Reading position instead would break the day a major is inserted, which is every major. + */ +export function parseMigrationTable(text: string): readonly MigrationRow[] { + const rows: MigrationRow[] = []; + const lines = text.split('\n'); + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index] ?? ''; + const cells = line.split('|'); + if (cells.length !== 5 || !line.trimStart().startsWith('|')) continue; + const claimed = /\*\*(\d+)\*\*/.exec(cells[2] ?? '')?.[1]; + if (claimed === undefined) continue; + const read = cells[3] ?? ''; + const target = /the `(\d+\.\d+\.\d+)` section/.exec(read)?.[1]; + const aggregate = /all\s+\w+\s+sections/.test(read); + if (target === undefined && !aggregate) continue; + rows.push({ + line: index + 1, + claimed: Number.parseInt(claimed, 10), + target, + aggregate, + quote: line.trim(), + }); + } + return rows; +} + +/** The `# ` the fenced `grep -cE` in wiki/Upgrading.md promises the reader they will see. */ +export function parseDerivedTotal(text: string): { line: number; claimed: number } | undefined { + const lines = text.split('\n'); + const at = lines.findIndex((line) => /grep -cE.*BREAKING.*CHANGELOG\.md/.test(line)); + if (at === -1) return undefined; + for (let index = at + 1; index < lines.length && index <= at + 4; index += 1) { + const claimed = /^#\s*(\d+)\b/.exec(lines[index] ?? '')?.[1]; + if (claimed !== undefined) return { line: index + 1, claimed: Number.parseInt(claimed, 10) }; + } + return undefined; +} + +export type ChangelogGapKind = + | 'duplicate' + | 'empty' + | 'unreleased-breaking' + | 'count' + | 'missing-row' + | 'total' + | 'unscanned'; + +export interface ChangelogGap { + readonly kind: ChangelogGapKind; + readonly at: string; + readonly detail: string; +} + +export interface ChangelogInput { + readonly changelog: string; + readonly upgrading: string; + /** The version this commit is tagged as, when it is tagged — `undefined` between releases. */ + readonly taggedVersion?: string | undefined; +} + +export function checkChangelog(input: ChangelogInput): readonly ChangelogGap[] { + const gaps: ChangelogGap[] = []; + const sections = parseChangelog(input.changelog); + const byVersion = new Map(); + + for (const section of sections) { + const first = byVersion.get(section.version); + if (first !== undefined) { + gaps.push({ + kind: 'duplicate', + at: `${CHANGELOG_PATH}:${section.line}`, + detail: `a second \`## ${section.heading}\` — ${section.version} is already the section at line ${first.line}`, + }); + continue; + } + byVersion.set(section.version, section); + if (section.released && !section.filled) { + gaps.push({ + kind: 'empty', + at: `${CHANGELOG_PATH}:${section.line}`, + detail: `\`## ${section.heading}\` has no body, so the release it names says nothing`, + }); + } + } + + const unreleased = byVersion.get(UNRELEASED); + const stranded = unreleased?.breaking ?? 0; + if (input.taggedVersion !== undefined && stranded > 0) { + gaps.push({ + kind: 'unreleased-breaking', + at: `${CHANGELOG_PATH}:${unreleased?.line ?? 1}`, + detail: `${stranded} \`BREAKING —\` ${stranded === 1 ? 'entry sits' : 'entries sit'} under [Unreleased] at tag ${input.taggedVersion}, so the migration is not in ${input.taggedVersion}'s own section`, + }); + } + + const rows = parseMigrationTable(input.upgrading); + const single = rows.filter((row) => row.target !== undefined); + if (single.length === 0) { + gaps.push({ + kind: 'unscanned', + at: UPGRADING_PATH, + detail: 'no row sends the reader to a single version section, so this rule read nothing', + }); + return gaps; + } + + for (const row of single) { + const section = byVersion.get(row.target ?? ''); + const actual = section?.breaking ?? 0; + if (row.claimed === actual && section !== undefined) continue; + gaps.push({ + kind: 'count', + at: `${UPGRADING_PATH}:${row.line}`, + detail: + section === undefined + ? `names a \`${row.target}\` section that ${CHANGELOG_PATH} does not have` + : `claims ${row.claimed} breaking entries; the \`${row.target}\` section of ${CHANGELOG_PATH} holds ${actual}`, + }); + } + + const expectedTotal = single.reduce( + (sum, row) => sum + (byVersion.get(row.target ?? '')?.breaking ?? 0), + 0, + ); + for (const row of rows.filter((candidate) => candidate.aggregate)) { + if (row.claimed === expectedTotal) continue; + gaps.push({ + kind: 'count', + at: `${UPGRADING_PATH}:${row.line}`, + detail: `claims ${row.claimed} breaking entries in all; the per-major sections hold ${expectedTotal}`, + }); + } + + // A major with no row is the 6.0.0 failure one step earlier: released, and no upgrade guide. + for (const section of sections) { + const major = /^(\d+)\.0\.0$/.exec(section.version)?.[1]; + if (major === undefined || Number.parseInt(major, 10) < FIRST_MIGRATABLE_MAJOR) continue; + if (single.some((row) => row.target === section.version)) continue; + gaps.push({ + kind: 'missing-row', + at: `${UPGRADING_PATH}:1`, + detail: `${section.version} is a released major and no row sends the reader to its section`, + }); + } + + const derived = parseDerivedTotal(input.upgrading); + const wholeFile = sections.reduce((sum, section) => sum + section.breaking, 0); + if (derived !== undefined && derived.claimed !== wholeFile) { + gaps.push({ + kind: 'total', + at: `${UPGRADING_PATH}:${derived.line}`, + detail: `promises the grep prints ${derived.claimed}; it prints ${wholeFile}`, + }); + } + return gaps; +} + +const RERUN = 'bun run scripts/changelog-check.ts --json'; + +export function changelogFinding(gap: ChangelogGap): Finding { + if (gap.kind === 'duplicate' || gap.kind === 'empty') { + return { + code: 'X_DOC_CHANGELOG_SECTION_INVALID', + cause: `${gap.at} ${gap.detail}`, + fix: `merge the two sections into one, or delete the generated one, then rerun: ${RERUN}`, + at: gap.at, + }; + } + if (gap.kind === 'unreleased-breaking') { + return { + code: 'X_DOC_CHANGELOG_UNRELEASED_BREAKING', + cause: `${gap.at} ${gap.detail}`, + // `--dry-run` writes NOTHING (release.ts, `if (!dryRun)`), so naming it here handed the + // reader a command that validates the promotion and does not perform it. Axiom 4 at the one + // point it is read: state the edit, and say which command performs it and which only checks. + fix: 'move those entries under the released version heading in CHANGELOG.md — a release performs this promotion (bun run scripts/release.ts --bump major), and --dry-run only validates it', + at: gap.at, + }; + } + if (gap.kind === 'unscanned') { + return { + code: 'X_DOC_MIGRATION_UNSCANNED', + cause: `${gap.at} ${gap.detail}`, + // A literal path, never an interpolation: the fix-line rule reads these statically. + fix: 'restore the summary table in wiki/Upgrading.md — each row reads "the `X.Y.Z` section, in order"', + at: UPGRADING_PATH, + }; + } + return { + code: 'X_DOC_MIGRATION_COUNT_STALE', + cause: `${gap.at} ${gap.detail}`, + fix: `set that count from the section it names, never from the whole file: ${RERUN}`, + at: gap.at, + }; +} + +/** The tag on THIS commit, when there is one — the only moment [Unreleased] must be migration-free. */ +export async function taggedVersion(root: string): Promise { + const tags = await run(['git', 'tag', '--points-at', 'HEAD'], { cwd: root }); + if (!tags.ok) return undefined; + return tags.output + .split('\n') + .map((line) => /^v(\d+\.\d+\.\d+)$/.exec(line.trim())?.[1]) + .find((version) => version !== undefined); +} + +export async function changelogGaps(root: string): Promise { + return checkChangelog({ + changelog: await Bun.file(`${root}/${CHANGELOG_PATH}`).text(), + upgrading: await Bun.file(`${root}/${UPGRADING_PATH}`).text(), + taggedVersion: await taggedVersion(root), + }); +} + +/** Every finding this rule contributes, for a caller that folds it into a gate step. */ +export const changelogFindings = async (root: string): Promise => + (await changelogGaps(root)).map(changelogFinding); + +if (import.meta.main) { + const args = parseScriptArgs(Bun.argv.slice(2)); + const findings = await changelogFindings(repoRoot()); + report( + { + ok: findings.length === 0, + script: SCRIPT, + summary: + findings.length === 0 + ? `${CHANGELOG_PATH} sections are well-formed and every ${UPGRADING_PATH} count is read from its own section` + : `${findings.length} finding(s): the changelog and the upgrade guide disagree`, + findings, + }, + args.json, + ); +} diff --git a/scripts/gate-codes-backlog.ts b/scripts/gate-codes-backlog.ts new file mode 100644 index 00000000..8d71cc86 --- /dev/null +++ b/scripts/gate-codes-backlog.ts @@ -0,0 +1,23 @@ +// The ratchet under `scripts/gate-codes.ts`: every code the "never ships to an app" parenthesis in +// `wiki/Error-Codes.md` is wrong about TODAY. The list may shrink and may never grow. +// +// Two ways to be wrong, and both were already true when the rule shipped, which is why this is a +// ratchet rather than a red gate. NO_ROW is a code the parenthesis names and the page gives no +// table row — documented by parenthesis only, so an agent handed it finds a sentence naming it and +// nothing saying what to do. UNLISTED is a code `scripts/` declares that the parenthesis omits, +// which makes the sentence around it false: it promises every code above `## Reserved codes` +// resolves through `x errors explain` EXCEPT the ones it names, and these resolve through nothing. +// +// Why pinned and not derived: `wiki/Error-Codes.md` belongs to the docs surface and a rule that +// reds 26 rows the day it lands is a rule somebody turns off. Deleting an entry here and writing +// the row is always the better edit — `bun run scripts/gate-codes.ts --json` prints what to write. +// +// **Both lists are empty `As of 2026-08`.** All 26 were drained the way the file asks: 20 rows +// written from the declaring script, and 6 codes added to the parenthesis. An empty ratchet is the +// rule enforcing outright — the next gap reds the gate rather than joining a list. + +/** Named in the parenthesis, no table row on the page. Drain by writing the row. */ +export const GATE_CODE_NO_ROW: readonly string[] = []; + +/** Declared under `scripts/`, absent from the parenthesis. Drain by adding the code to the list. */ +export const GATE_CODE_UNLISTED: readonly string[] = []; diff --git a/scripts/gate-codes.test.ts b/scripts/gate-codes.test.ts new file mode 100644 index 00000000..5290aa05 --- /dev/null +++ b/scripts/gate-codes.test.ts @@ -0,0 +1,129 @@ +// Both directions of the never-ships parenthesis, each proved against a fixture that breaks exactly +// it — then the real `wiki/Error-Codes.md`, which is the assertion that makes this a gate. + +import { describe, expect, test } from 'bun:test'; +import { + checkGateCodes, + gateCodeFinding, + neverShipsList, + scriptDeclaredCodes, + tableRows, +} from './gate-codes'; +import { GATE_CODE_NO_ROW, GATE_CODE_UNLISTED } from './gate-codes-backlog'; +import { REPO_SCAN_TIMEOUT_MS, repoRoot } from './lib/run'; + +/** A page that satisfies both directions: one listed code, one wildcard family, both with rows. */ +const PAGE = [ + '# Error codes', + '', + "…with one exception the gate knows about: this repository's own gate scripts (`X_ROADMAP_*`,", + '`X_GATE_ONE`) never ship, so no package may own them. See Troubleshooting.', + '', + '| Code | Meaning | Cause | Fix |', + '|---|---|---|---|', + '| `X_GATE_ONE` | a thing | a cause | a fix |', + '| `X_ROADMAP_STALE` | a thing | a cause naming `X_GATE_TWO` | a fix |', + '', +].join('\n'); + +const check = ( + page: string, + declared: readonly string[], + pins: Partial<{ noRow: string[]; unlisted: string[] }> = {}, +) => + checkGateCodes({ + declared, + page, + noRowPins: pins.noRow ?? [], + unlistedPins: pins.unlisted ?? [], + }); + +describe('parsing', () => { + test('the list stops at the closing bracket, not at the end of the page', () => { + expect([...neverShipsList(PAGE)].sort()).toEqual(['X_GATE_ONE', 'X_ROADMAP_*']); + }); + + test('no lead sentence means no list, never the whole page', () => { + expect(neverShipsList('# Error codes\n\n| `X_GATE_ONE` | a | b | c |\n').size).toBe(0); + }); + + // The hole `documentedCodes` leaves open: a code named inside ANOTHER row's cause counts as + // documented today, which is how a code can be "documented" with no row of its own. + test('a row is the FIRST cell, never a mention inside someone else cause', () => { + expect([...tableRows(PAGE)].sort()).toEqual(['X_GATE_ONE', 'X_ROADMAP_STALE']); + expect(tableRows(PAGE).has('X_GATE_TWO')).toBe(false); + }); +}); + +describe('the two directions', () => { + test('the good fixture is silent, so every finding below is the mutation', () => { + expect(check(PAGE, ['X_GATE_ONE', 'X_ROADMAP_STALE'])).toEqual([]); + }); + + test('a listed code with no table row', () => { + const page = PAGE.replace('| `X_GATE_ONE` | a thing | a cause | a fix |\n', ''); + expect(check(page, ['X_GATE_ONE'])).toEqual([{ kind: 'no-row', code: 'X_GATE_ONE' }]); + }); + + test('a scripts-declared code the list omits', () => { + expect(check(PAGE, ['X_GATE_ONE', 'X_REGISTRY_UNATTESTED'])).toEqual([ + { kind: 'unlisted', code: 'X_REGISTRY_UNATTESTED' }, + ]); + }); + + test('a wildcard entry covers its whole family', () => { + expect(check(PAGE, ['X_ROADMAP_STALE', 'X_ROADMAP_ANYTHING_AT_ALL'])).toEqual([]); + }); + + test('a pin silences a finding, and only the one it names', () => { + const page = PAGE.replace('| `X_GATE_ONE` | a thing | a cause | a fix |\n', ''); + expect(check(page, [], { noRow: ['X_GATE_ONE'] })).toEqual([]); + expect(check(PAGE, ['X_NEW'], { unlisted: ['X_OTHER'] }).map((gap) => gap.kind)).toEqual([ + 'unlisted', + 'pinned', + ]); + }); + + test('a pin the page no longer needs is itself a finding', () => { + expect(check(PAGE, ['X_GATE_ONE'], { noRow: ['X_GATE_ONE'] })).toEqual([ + { kind: 'pinned', code: 'X_GATE_ONE' }, + ]); + }); +}); + +describe('findings', () => { + test('each kind carries a code and a fix that names the edit', () => { + expect(gateCodeFinding({ kind: 'no-row', code: 'X_A' }).code).toBe('X_GATE_CODE_UNDOCUMENTED'); + expect(gateCodeFinding({ kind: 'unlisted', code: 'X_A' }).code).toBe( + 'X_GATE_CODE_UNDOCUMENTED', + ); + const stale = gateCodeFinding({ kind: 'pinned', code: 'X_A' }); + expect(stale.code).toBe('X_GATE_CODE_BACKLOG_STALE'); + expect(stale.fix).toContain('scripts/gate-codes-backlog.ts'); + }); +}); + +describe('the committed wiki/Error-Codes.md', () => { + test( + 'is wrong about exactly the codes the backlog pins, and no others', + async () => { + const root = repoRoot(); + const page = await Bun.file(`${root}/wiki/Error-Codes.md`).text(); + const declared = await scriptDeclaredCodes(root); + expect(declared.length).toBeGreaterThan(50); + expect( + checkGateCodes({ + declared, + page, + noRowPins: GATE_CODE_NO_ROW, + unlistedPins: GATE_CODE_UNLISTED, + }), + ).toEqual([]); + // Load-bearing pins: drop them all and the real page reds, so the ratchet is measuring + // something rather than pinning an empty set. + const unpinned = checkGateCodes({ declared, page, noRowPins: [], unlistedPins: [] }); + expect(unpinned.length).toBe(GATE_CODE_NO_ROW.length + GATE_CODE_UNLISTED.length); + }, + REPO_SCAN_TIMEOUT_MS, + ); +}); diff --git a/scripts/gate-codes.ts b/scripts/gate-codes.ts new file mode 100644 index 00000000..cea78e97 --- /dev/null +++ b/scripts/gate-codes.ts @@ -0,0 +1,162 @@ +#!/usr/bin/env bun +// Enforce, as a gate rule, that `wiki/Error-Codes.md`'s "never ships to an app" parenthesis is +// complete in both directions: every code it names has a table row, and every code `scripts/` +// declares is named in it. +// +// The gap this closes: nothing read that parenthesis. `checkErrorCodeDocs` is satisfied by ANY +// `X_FOO` in a backtick anywhere on the page — `documentedCodes` is one regex over the whole file — +// so being named INSIDE the parenthesis counts as being documented, and `X_LOCKFILE_STALE`, +// `X_RELEASE_VERSION_UNSTATED` and eighteen others have no row at all. From the other side, +// `checkErrorCodeRegistry` exempts gate codes by SCANNING `scripts/` (`hostOwnedCodes` in +// scripts/verify.ts), never by reading the list — so the list is a hand-copy of a derived set with +// no check on it, which is the same shape `gate-steps.ts` and `release-facts.ts` exist for. Four +// `X_REGISTRY_*` codes are missing from it today, and the sentence around it says every code above +// `## Reserved codes` resolves through `x errors explain` except the ones it names. +// +// bun run scripts/gate-codes.ts [--json] + +import { collectDeclaredCodes } from '@ultimat3/cli'; +import { GATE_CODE_NO_ROW, GATE_CODE_UNLISTED } from './gate-codes-backlog'; +import { parseScriptArgs } from './lib/args'; +import type { Finding } from './lib/log'; +import { report } from './lib/log'; +import { repoRoot } from './lib/run'; + +const SCRIPT = 'gate-codes'; +export const ERROR_REFERENCE = 'wiki/Error-Codes.md'; +export const BACKLOG_FILE = 'scripts/gate-codes-backlog.ts'; + +/** + * Where the parenthesis starts. Matched on the prose that introduces it rather than on a line + * number, because the page is edited far more often than this rule is. + */ +export const NEVER_SHIPS_LEAD = "this repository's own gate scripts ("; + +/** + * The codes the parenthesis names, `X_ROADMAP_*`-style wildcards included. Read from the lead to + * the first `)` after it: the sentence continues past that bracket ("never ship, so no package may + * own them"), and swallowing the rest of the page would make every code on it "listed". + */ +export function neverShipsList(markdown: string): ReadonlySet { + const start = markdown.indexOf(NEVER_SHIPS_LEAD); + if (start === -1) return new Set(); + const from = start + NEVER_SHIPS_LEAD.length; + const close = markdown.indexOf(')', from); + const span = markdown.slice(from, close === -1 ? from : close); + return new Set([...span.matchAll(/`(X_[A-Z0-9_]*\*?)`/g)].map((match) => match[1] as string)); +} + +/** + * A code with a real row. Anchored on `| \`X_…\` |` as the FIRST cell, so a code merely mentioned + * inside another row's cause or fix does not count as having one of its own — which is exactly the + * hole `documentedCodes`' whole-file regex leaves open. + */ +export const tableRows = (markdown: string): ReadonlySet => + new Set( + [...markdown.matchAll(/^\|\s*`(X_[A-Z0-9_]+)`\s*\|/gm)].map((match) => match[1] as string), + ); + +export type GateCodeGapKind = 'no-row' | 'unlisted' | 'pinned'; + +export interface GateCodeGap { + readonly kind: GateCodeGapKind; + readonly code: string; +} + +export interface GateCodeInput { + /** Every `X_*` code declared under `scripts/`, from `collectDeclaredCodes`. */ + readonly declared: readonly string[]; + readonly page: string; + readonly noRowPins: readonly string[]; + readonly unlistedPins: readonly string[]; +} + +/** A wildcard entry covers a family: `X_ROADMAP_*` stands for every code it prefixes. */ +const covers = (listed: ReadonlySet, code: string): boolean => + listed.has(code) || + [...listed].some((entry) => entry.endsWith('*') && code.startsWith(entry.slice(0, -1))); + +export function checkGateCodes(input: GateCodeInput): readonly GateCodeGap[] { + const listed = neverShipsList(input.page); + const rows = tableRows(input.page); + const gaps: GateCodeGap[] = []; + + const noRow = [...listed].filter((code) => !code.endsWith('*') && !rows.has(code)); + for (const code of noRow) { + if (input.noRowPins.includes(code)) continue; + gaps.push({ kind: 'no-row', code }); + } + const unlisted = input.declared.filter((code) => !covers(listed, code)); + for (const code of unlisted) { + if (input.unlistedPins.includes(code)) continue; + gaps.push({ kind: 'unlisted', code }); + } + // A pin nobody removes is a pin nobody reads — the ratchet only ratchets if it shrinks on its own. + for (const code of input.noRowPins) { + if (!noRow.includes(code)) gaps.push({ kind: 'pinned', code }); + } + for (const code of input.unlistedPins) { + if (!unlisted.includes(code)) gaps.push({ kind: 'pinned', code }); + } + return gaps.sort((a, b) => a.code.localeCompare(b.code)); +} + +const FINDINGS: Readonly Finding>> = { + 'no-row': (code) => ({ + code: 'X_GATE_CODE_UNDOCUMENTED', + cause: `${ERROR_REFERENCE} names ${code} in the never-ships list and gives it no table row, so an agent handed ${code} finds the name and no cause and no fix`, + fix: `add a \`| \`${code}\` | … |\` row to ${ERROR_REFERENCE}, or drop ${code} from the never-ships list`, + at: ERROR_REFERENCE, + }), + unlisted: (code) => ({ + code: 'X_GATE_CODE_UNDOCUMENTED', + cause: `${code} is declared under scripts/ and ${ERROR_REFERENCE}'s never-ships list omits it, so that page promises "x errors explain ${code}" answers and it does not`, + fix: `add \`${code}\` to the never-ships parenthesis in ${ERROR_REFERENCE}`, + at: ERROR_REFERENCE, + }), + pinned: (code) => ({ + code: 'X_GATE_CODE_BACKLOG_STALE', + cause: `${code} is pinned in ${BACKLOG_FILE} and ${ERROR_REFERENCE} is no longer wrong about it`, + fix: `delete '${code}' from ${BACKLOG_FILE}`, + at: BACKLOG_FILE, + }), +}; + +export const gateCodeFinding = (gap: GateCodeGap): Finding => FINDINGS[gap.kind](gap.code); + +/** Every `X_*` code this repo's own gate scripts declare — the set the parenthesis restates. */ +export const scriptDeclaredCodes = async (root: string): Promise => + (await collectDeclaredCodes(root)) + .filter((site) => site.at.startsWith('scripts/')) + .map((site) => site.code); + +export const gateCodeGaps = async (root: string): Promise => + checkGateCodes({ + declared: await scriptDeclaredCodes(root), + page: await Bun.file(`${root}/${ERROR_REFERENCE}`).text(), + noRowPins: GATE_CODE_NO_ROW, + unlistedPins: GATE_CODE_UNLISTED, + }); + +/** Every finding this rule contributes, for a caller that folds it into a gate step. */ +export const gateCodeFindings = async (root: string): Promise => + (await gateCodeGaps(root)).map(gateCodeFinding); + +if (import.meta.main) { + const args = parseScriptArgs(Bun.argv.slice(2)); + const pins = GATE_CODE_NO_ROW.length + GATE_CODE_UNLISTED.length; + const findings = await gateCodeFindings(repoRoot()); + report( + { + ok: findings.length === 0, + script: SCRIPT, + summary: + findings.length === 0 + ? `${ERROR_REFERENCE}'s never-ships list is complete in both directions, ${pins} code(s) pinned` + : `${findings.length} finding(s): ${ERROR_REFERENCE}'s never-ships list is incomplete`, + findings, + data: { pinned: pins }, + }, + args.json, + ); +} diff --git a/scripts/release.test.ts b/scripts/release.test.ts index aa3d4123..59dafc69 100644 --- a/scripts/release.test.ts +++ b/scripts/release.test.ts @@ -3,13 +3,15 @@ // installable, and where a new section lands in a newest-first changelog. import { describe, expect, test } from 'bun:test'; +import { checkChangelog } from './changelog-check'; import { BUMPS, - changelogEntry, - insertRelease, + commitBlock, nextVersion, + promoteUnreleased, RELEASE_FLAGS, readReleaseVersion, + releaseDate, repinFrameworkDeps, setOwnVersion, unknownReleaseFlags, @@ -90,41 +92,109 @@ describe('repinFrameworkDeps', () => { }); }); -describe('insertRelease', () => { - const changelog = ['# Changelog', '', '## [Unreleased]', '', '### Added', '', '- a thing', '']; +describe('promoteUnreleased', () => { + const changelog = [ + '# Changelog', + '', + 'Preamble.', + '', + '## [Unreleased]', + '', + '### Changed', + '', + '- **BREAKING — a thing moved.** Do the edit.', + '', + '## 1.0.0 - 2026-08-10', + '', + '- first', + '', + ].join('\n'); - test('lands under [Unreleased] and above every previous version', () => { - const once = insertRelease( - `${changelog.join('\n')}\n## 1.0.0\n\n- first\n`, - '## 1.1.0\n\n- next\n', - ); - const headings = once.split('\n').filter((line) => line.startsWith('## ')); - expect(headings).toEqual(['## [Unreleased]', '## 1.1.0', '## 1.0.0']); + const promote = (text: string, version = '2.0.0', subjects: readonly string[] = []): string => { + const result = promoteUnreleased({ changelog: text, version, date: '2026-08-20', subjects }); + return 'changelog' in result ? result.changelog : ''; + }; + + // The whole issue, in one assertion: the migration ends up IN the version's own section, and + // there is exactly one heading for that version — not a generated one above a hand-written one. + test('the [Unreleased] body becomes the version section, migration and all', () => { + const out = promote(changelog); + const headings = out.split('\n').filter((line) => line.startsWith('## ')); + expect(headings).toEqual(['## [Unreleased]', '## 2.0.0 - 2026-08-20', '## 1.0.0 - 2026-08-10']); + const section = out.slice(out.indexOf('## 2.0.0'), out.indexOf('## 1.0.0')); + expect(section).toContain('- **BREAKING — a thing moved.** Do the edit.'); }); - // Appending was the old behaviour: correct for the first release, and wrong for every one after, - // because the file then read oldest-first from its third entry on. - test('a second release does not sort below the first', () => { - const first = insertRelease(`${changelog.join('\n')}\n`, '## 1.0.0\n\n- first\n'); - const second = insertRelease(first, '## 1.0.1\n\n- fix\n'); - expect(second.indexOf('## 1.0.1')).toBeLessThan(second.indexOf('## 1.0.0')); + test('a fresh, empty [Unreleased] is opened above it', () => { + const out = promote(changelog); + const head = out.slice(out.indexOf('## [Unreleased]'), out.indexOf('## 2.0.0')); + expect(head).toBe('## [Unreleased]\n\nNothing yet.\n\n'); + expect(head).not.toContain('BREAKING'); }); - test('appends when the file has no version heading yet', () => { - expect(insertRelease('# Changelog\n', '## 1.0.0\n')).toBe('# Changelog\n\n## 1.0.0\n'); + // A second release is a second promotion, and the placeholder must not accumulate down the file. + test('the placeholder is never carried into a release section', () => { + const next = promote(changelog).replace('Nothing yet.', '- **BREAKING — the next one.** Edit.'); + const twice = promote(next, '2.0.1'); + expect(twice.split('Nothing yet.').length - 1).toBe(1); + expect(twice.indexOf('Nothing yet.')).toBeLessThan(twice.indexOf('## 2.0.1')); + }); + + test('commit subjects land INSIDE the section, under a heading nothing hand-written uses', () => { + const out = promote(changelog, '2.0.0', ['fix(cli): a thing', 'docs: another']); + const section = out.slice(out.indexOf('## 2.0.0'), out.indexOf('## 1.0.0')); + expect(section).toContain('### Commits\n\n- fix(cli): a thing\n- docs: another\n'); + // Verbatim: a subject is provenance, and rewriting it is how it stops matching `git log`. + expect(section).not.toContain('### Fixed'); + }); + + test('the promoted file passes the changelog gate rules, tagged', () => { + const upgrading = [ + '| From → to | Breaking entries | Read |', + '|---|---|---|', + '| 1.x → 2.0.0 | **1** | the `2.0.0` section, in order |', + ].join('\n'); + expect( + checkChangelog({ changelog: promote(changelog), upgrading, taggedVersion: '2.0.0' }), + ).toEqual([]); + // And the file it was promoted FROM does not, which is what the promotion is for. + expect( + checkChangelog({ changelog, upgrading, taggedVersion: '2.0.0' }).map((gap) => gap.kind), + ).toContain('unreleased-breaking'); }); -}); -describe('changelogEntry', () => { - test('groups conventional subjects under Keep a Changelog headings', () => { - const entry = changelogEntry('1.0.0', ['feat(cli): x verify', 'fix(http): 401 on anonymous']); - expect(entry).toContain('## 1.0.0'); - expect(entry).toContain('### Added\n\n- x verify'); - expect(entry).toContain('### Fixed\n\n- 401 on anonymous'); + test('no [Unreleased] heading is a refusal, never a guess', () => { + const result = promoteUnreleased({ + changelog: '# Changelog\n\n## 1.0.0\n\n- first\n', + version: '2.0.0', + date: '2026-08-20', + subjects: [], + }); + expect('findings' in result && result.findings[0]?.code).toBe('X_RELEASE_UNRELEASED_MISSING'); }); - test('an unconventional subject still lands somewhere, verbatim', () => { - expect(changelogEntry('1.0.0', ['tidy up'])).toContain('### Changed\n\n- tidy up'); + test('nothing to say is a refusal too, so no release ships an empty section', () => { + const result = promoteUnreleased({ + changelog: '# Changelog\n\n## [Unreleased]\n\nNothing yet.\n\n## 1.0.0\n\n- first\n', + version: '1.0.1', + date: '2026-08-20', + subjects: [], + }); + expect('findings' in result && result.findings[0]?.code).toBe( + 'X_DOC_CHANGELOG_SECTION_INVALID', + ); + }); + + test('an empty subject list adds no heading at all', () => { + expect(commitBlock([])).toEqual([]); + }); +}); + +describe('releaseDate', () => { + // No date without an explicit IANA zone, this script included: a release cut at 23:00 local must + // not be dated a day away from the tag it is committed with. + test('is ISO-8601 in UTC', () => { + expect(releaseDate(new Date('2026-08-20T23:30:00-05:00'))).toBe('2026-08-21'); }); }); diff --git a/scripts/release.ts b/scripts/release.ts index acf00a09..2c3831b0 100644 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -113,47 +113,114 @@ export const repinFrameworkDeps = (raw: string, version: string): string => EXACT_PIN.test(range) ? `"${name}": "${version}"` : match, ); +export const UNRELEASED_HEADING = '## [Unreleased]'; +/** What a fresh `[Unreleased]` says once the release has taken its body. */ +export const UNRELEASED_PLACEHOLDER = 'Nothing yet.'; + /** - * Keep a Changelog is newest-first. Appending put the second release below the first and every - * release below that, so the file read oldest-first from its third entry on. The new section goes - * directly under the `## [Unreleased]` block — above every previous version, below the preamble. + * The commit subjects go INSIDE the promoted section, under a heading no hand-written section uses. + * Generating `### Added` / `### Fixed` / `### Changed` was how a release ended up with two + * `### Fixed` blocks in one section — the generated one below the hand-written one, saying the same + * thing in worse words. */ -export function insertRelease(changelog: string, entry: string): string { - const lines = changelog.split('\n'); - const at = lines.findIndex((line) => /^## /.test(line) && !line.includes('[Unreleased]')); - if (at === -1) return `${changelog.trimEnd()}\n\n${entry}`; - return [...lines.slice(0, at), ...`${entry}\n`.split('\n'), ...lines.slice(at)].join('\n'); -} +export const commitBlock = (subjects: readonly string[]): readonly string[] => + subjects.length === 0 + ? [] + : ['### Commits', '', ...subjects.map((subject) => `- ${subject}`), '']; -/** Conventional-commit subjects since the last tag, grouped. Bodies are left to the git log. */ -export function changelogEntry(version: string, subjects: readonly string[]): string { - const groups: Readonly> = { - feat: 'Added', - fix: 'Fixed', - perf: 'Fixed', - refactor: 'Changed', - docs: 'Changed', - }; - const buckets = new Map(); - for (const subject of subjects) { - const match = /^(\w+)(?:\([^)]*\))?!?:\s*(.+)$/.exec(subject); - const heading = groups[match?.[1] ?? ''] ?? 'Changed'; - const text = match?.[2] ?? subject; - const bucket = buckets.get(heading) ?? []; - bucket.push(text); - buckets.set(heading, bucket); +/** + * PROMOTE, never append. `[Unreleased]` IS the release notes — hand-written as each change lands, + * migration and all — so a release renames that heading to the version and opens a fresh empty + * `[Unreleased]` above it. + * + * What appending produced is commit 8fe7c56d — `git show 8fe7c56d:CHANGELOG.md`, this script's own + * output for `release: 6.0.0`: seven `BREAKING —` entries still under `## [Unreleased]`, a + * `## 6.0.0` holding six merge subjects and nothing else, and two `## 5.0.1` plus two `## 5.0.0` + * headings left by the two runs before it — an auto section above a hand-written one, same version. + * `wiki/Upgrading.md` pointed at the `6.0.0` section throughout. + * + * `git show v6.0.0:CHANGELOG.md` does NOT show this: the tag points at 93443aeb, a human repairing + * 8fe7c56d by hand. Read the tag and the bug is invisible; read 8fe7c56d and it is the whole diff. + * + * Promotion cannot produce either shape: one section per version, because there is one heading and + * it is renamed rather than duplicated. + * + * Keep a Changelog stays newest-first for free — `[Unreleased]` is the top section, so the version + * it becomes lands above every previous one. + */ +export function promoteUnreleased(input: { + readonly changelog: string; + readonly version: string; + readonly date: string; + readonly subjects: readonly string[]; +}): { readonly changelog: string } | { readonly findings: readonly Finding[] } { + const lines = input.changelog.split('\n'); + const at = lines.findIndex((line) => /^## \[Unreleased\]/i.test(line)); + if (at === -1) { + return { + findings: [ + { + code: 'X_RELEASE_UNRELEASED_MISSING', + cause: 'CHANGELOG.md has no `## [Unreleased]` heading, so there is nothing to promote', + fix: 'add `## [Unreleased]` under the preamble of CHANGELOG.md, above the newest version', + at: 'CHANGELOG.md', + }, + ], + }; } - const lines = [`## ${version}`, '']; - for (const heading of ['Added', 'Fixed', 'Changed']) { - const items = buckets.get(heading); - if (items === undefined || items.length === 0) continue; - lines.push(`### ${heading}`, ''); - for (const item of items) lines.push(`- ${item}`); - lines.push(''); + let end = lines.length; + for (let index = at + 1; index < lines.length; index += 1) { + if ((lines[index] ?? '').startsWith('## ')) { + end = index; + break; + } } - return lines.join('\n'); + const body = lines + .slice(at + 1, end) + .filter((line) => line.trim() !== UNRELEASED_PLACEHOLDER) + .join('\n') + .trim(); + const commits = commitBlock(input.subjects); + if (body.length === 0 && commits.length === 0) { + return { + findings: [ + { + code: 'X_DOC_CHANGELOG_SECTION_INVALID', + cause: `[Unreleased] is empty and no commit landed since the previous tag, so ${input.version} would ship a section that says nothing`, + fix: 'write the release notes under `## [Unreleased]` in CHANGELOG.md, then run this again', + at: 'CHANGELOG.md', + }, + ], + }; + } + const section = [`## ${input.version} - ${input.date}`, '']; + if (body.length > 0) section.push(...body.split('\n'), ''); + section.push(...commits); + return { + changelog: [ + ...lines.slice(0, at), + UNRELEASED_HEADING, + '', + UNRELEASED_PLACEHOLDER, + '', + ...section, + ...lines.slice(end), + ].join('\n'), + }; } +/** + * `en-CA` is ISO-8601 by locale, and the zone is stated because nothing here may format a date + * without one. UTC, so a release cut at 23:00 in one timezone is not dated a day apart from the tag. + */ +export const releaseDate = (at: Date): string => + new Intl.DateTimeFormat('en-CA', { + timeZone: 'UTC', + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).format(at); + if (import.meta.main) { const args = parseScriptArgs(Bun.argv.slice(2)); const root = repoRoot(); @@ -233,6 +300,9 @@ if (import.meta.main) { fix: `git diff packages/${workspace.dir}/package.json — this release realigns it to ${version}`, at: `packages/${workspace.dir}/package.json`, })); + // Bounded by the PREVIOUS tag, so a subject that shipped in an earlier release cannot appear + // under this one. A clone without that tag answers nothing rather than everything — the report + // says so on its own line, because a silent empty list reads exactly like a quiet release. const log = await run(['git', 'log', '--pretty=format:%s', `v${current}..HEAD`], { cwd: root }); const subjects = log.ok ? log.output.split('\n').filter((line) => line.trim().length > 0) : []; @@ -241,6 +311,32 @@ if (import.meta.main) { const published = new Set(publishable.map((workspace) => join(workspace.path, 'package.json'))); const manifests = await workspaceManifests(root); + // Computed before a single manifest is rewritten, and under `--dry-run` too: a changelog that + // cannot be promoted is a release that must not start, and finding that out after 47 files have + // moved is the expensive order to find it out in. + const changelogPath = join(root, 'CHANGELOG.md'); + const date = releaseDate(new Date()); + const promoted = promoteUnreleased({ + changelog: await Bun.file(changelogPath) + .text() + .catch(() => ''), + version, + date, + subjects, + }); + if ('findings' in promoted) { + report( + { + ok: false, + script: 'release', + summary: 'refusing to release: CHANGELOG.md cannot be promoted', + findings: promoted.findings, + data: { version, current, dryRun }, + }, + args.json, + ); + } + if (!dryRun) { for (const path of manifests) { const raw = await Bun.file(path).text(); @@ -255,11 +351,7 @@ if (import.meta.main) { if (await chart.exists()) { await Bun.write(chartPath, setChartVersions(await chart.text(), version)); } - const changelogPath = join(root, 'CHANGELOG.md'); - const existing = await Bun.file(changelogPath) - .text() - .catch(() => '# Changelog\n\n'); - await Bun.write(changelogPath, insertRelease(existing, changelogEntry(version, subjects))); + await Bun.write(changelogPath, promoted.changelog); } report( @@ -278,7 +370,10 @@ if (import.meta.main) { ` packages ${publishable.map((workspace) => workspace.name).join(', ')}`, ` manifests ${manifests.length} rewritten (${published.size} published, the rest repinned)`, ` chart ${CHART_FILE} version + appVersion -> ${version}`, - ` commits ${subjects.length}`, + ` changelog [Unreleased] promoted to "## ${version} - ${date}", a fresh [Unreleased] above it`, + log.ok + ? ` commits ${subjects.length} since v${current}, appended under ### Commits` + : ` commits none listed — this clone has no v${current} tag to bound the range`, ` next bun install, commit, tag v${version}, then publish a GitHub Release`, ], data: { @@ -287,6 +382,8 @@ if (import.meta.main) { packages: publishable.map((workspace) => workspace.name), manifests: manifests.length, commits: subjects.length, + previousTagFound: log.ok, + changelogDate: date, dryRun, }, }, diff --git a/scripts/render-modes.ts b/scripts/render-modes.ts index 2ca8ee87..1b889b10 100644 --- a/scripts/render-modes.ts +++ b/scripts/render-modes.ts @@ -1,6 +1,6 @@ #!/usr/bin/env bun // One rule: NOTHING outside `packages/core/src/route-vocabulary.ts` may declare the route -// vocabulary. Twelve declarations of three closed sets lived across six packages until they were +// vocabulary. Fourteen declarations of three closed sets lived across six packages until they were // consolidated into tier 0, and the copies were not a style problem — `'spa'` was deleted from // `RENDER_MODES` and the repo typechecked green project-wide with five copies still admitting it, // `@ultimat3/pwa` mapping it to `cache-first`, the one strategy that gives an `app/` route a diff --git a/wiki/Admin-Dashboard.md b/wiki/Admin-Dashboard.md index f26bab5c..f643999d 100644 --- a/wiki/Admin-Dashboard.md +++ b/wiki/Admin-Dashboard.md @@ -86,7 +86,7 @@ The `/_x` **dev** dashboard is a standalone page with no stylesheet pipeline, so |---|---|---| | Login / error pages | `ssr` | no shell to precache, must be correct on first byte | | List and detail screens | `stream` | shell instantly, table streams when the query resolves | -| Job step timelines, live inspector | `spa` + live query | behind auth, entirely interactive, no SEO value | +| Job step timelines, live inspector | `ssr` + `hydrate: 'never'` + live query | behind auth and no SEO value, but a generated view is a pure function of its props — the interactive part arrives as an `island({ src })`, budgeted in real bytes. Every generated route is `ssr`; `packages/admin/src/routes.ts` declares one mode for all of them, never an author's choice | | Offline | `network-only` | an operator acting on stale operational data is worse than an error | See [Routes and render modes](Routes-And-Render-Modes). diff --git a/wiki/Error-Codes.md b/wiki/Error-Codes.md index 4cd551dd..915e2885 100644 --- a/wiki/Error-Codes.md +++ b/wiki/Error-Codes.md @@ -22,7 +22,7 @@ X_DB_DRIFT: schema differs from migrations | Registration | a code exists when its owning package calls `registerErrorCodes()`. That one call is what makes it explainable, unique and documented-or-fail — a code emitted as a `Finding` rather than thrown is registered the same way | | Enforcement | the `errors` step of `x verify` fails on an empty or advice-only `fix` (`X_ERROR_FIX_INVALID`), on a declared code with no row on this page (`X_ERROR_CODE_UNDOCUMENTED`), and on a row this page presents as live that no package registers (`X_ERROR_CODE_UNREGISTERED`) | -`As of 2026-08` every code above [Reserved codes](#reserved-codes) resolves through `x errors explain`, with one exception the gate knows about: this repository's own gate scripts (`X_BOUNDARY_VIOLATION`, `X_ROADMAP_*`, `X_REFERENCE_APP_*`, `X_TRUST_*`, `X_SCAFFOLD_OVERRIDES_EMPTY`, `X_SCAFFOLD_GATE_RED`, `X_SCAFFOLD_FIRST_RUN_FAILED`, `X_SETUP_INSTALL_FAILED`, `X_ADMIN_FLATTENER_VIOLATION`, `X_ERROR_RENDER_UNSAFE`, `X_ERROR_STATUS_MISSING`, `X_ERROR_STATUS_BACKLOG_STALE`, `X_ERROR_STATUS_UNKNOWN_CODE`, `X_CATALOG_KEY_UNREACHABLE`, `X_PUBLISH_LIST_INCOMPLETE`, `X_PUBLISH_LIST_UNKNOWN`, `X_BENCH_CLAIM_STALE`, `X_WIKI_TABLE_MALFORMED`, `X_FRAME_DOCS_STALE`, `X_CHART_VERSION_STALE`, `X_IMAGE_LIBC_MISMATCH`, `X_IMAGE_GUARD_MISSING`, `X_DOC_FIX_UNRUNNABLE`, `X_DOC_FIX_UNSCANNED`, `X_DOC_COMMAND_UNKNOWN`, `X_DOC_COMMAND_ALLOWANCE_STALE`, `X_DOC_COMMAND_UNSCANNED`, `X_DOC_COMMAND_PIN_STALE`, `X_DOC_GATE_STEPS_STALE`, `X_DOC_GATE_STEPS_UNSCANNED`, `X_README_EXAMPLE_UNCOMPILED`, `X_README_EXAMPLE_PIN_STALE`, `X_README_EXAMPLE_UNSCANNED`, `X_VERSION_STAMP_STALE`, `X_VERSION_STAMP_DUPLICATE`, `X_VERSION_LOCKSTEP_BROKEN`, `X_VERSION_STAMP_UNSCANNED`, `X_COVERAGE_BELOW`, `X_COVERAGE_PIN_STALE`, `X_COVERAGE_UNMEASURED`, `X_TEST_TYPECHECK_REGRESSED`, `X_TEST_TYPECHECK_PIN_STALE`, `X_TEST_TYPECHECK_UNSCANNED`, `X_DOC_FILE_COUNT_STALE`, `X_DOC_FILE_COUNT_UNSCANNED`, `X_DOC_RELEASE_FACT_STALE`, `X_DOC_RELEASE_FACT_UNSCANNED`, `X_TEST_FIX_UNRUNNABLE`, `X_TEST_FIX_PIN_STALE`, `X_TEST_FIX_UNSCANNED`, `X_TEST_THROW_NOT_THROWN`, `X_TEST_BARE_ERROR`, `X_TEST_BARE_ERROR_PIN_STALE`, `X_TEST_BARE_ERROR_UNSCANNED`, `X_RELEASE_VERSION_UNSTATED`, `X_RELEASE_FLAG_UNKNOWN`, `X_DOC_CHANGELOG_SECTION_INVALID`, `X_DOC_CHANGELOG_UNRELEASED_BREAKING`, `X_DOC_MIGRATION_COUNT_STALE`, `X_DOC_MIGRATION_UNSCANNED`, `X_RELEASE_UNRELEASED_MISSING`, `X_LOCKFILE_STALE`) never ship, so no package may own them. See [Troubleshooting](Troubleshooting) for symptom-first triage and [CLI reference](CLI-Reference) for the commands named in the fixes. +`As of 2026-08` every code above [Reserved codes](#reserved-codes) resolves through `x errors explain`, with one exception the gate knows about: this repository's own gate scripts (`X_BOUNDARY_VIOLATION`, `X_ROADMAP_*`, `X_REFERENCE_APP_*`, `X_TRUST_*`, `X_SCAFFOLD_OVERRIDES_EMPTY`, `X_SCAFFOLD_GATE_RED`, `X_SCAFFOLD_FIRST_RUN_FAILED`, `X_SETUP_INSTALL_FAILED`, `X_ADMIN_FLATTENER_VIOLATION`, `X_ERROR_RENDER_UNSAFE`, `X_ERROR_STATUS_MISSING`, `X_ERROR_STATUS_BACKLOG_STALE`, `X_ERROR_STATUS_UNKNOWN_CODE`, `X_CATALOG_KEY_UNREACHABLE`, `X_PUBLISH_LIST_INCOMPLETE`, `X_PUBLISH_LIST_UNKNOWN`, `X_BENCH_CLAIM_STALE`, `X_WIKI_TABLE_MALFORMED`, `X_FRAME_DOCS_STALE`, `X_CHART_VERSION_STALE`, `X_IMAGE_LIBC_MISMATCH`, `X_IMAGE_GUARD_MISSING`, `X_DOC_FIX_UNRUNNABLE`, `X_DOC_FIX_UNSCANNED`, `X_DOC_COMMAND_UNKNOWN`, `X_DOC_COMMAND_ALLOWANCE_STALE`, `X_DOC_COMMAND_UNSCANNED`, `X_DOC_COMMAND_PIN_STALE`, `X_DOC_GATE_STEPS_STALE`, `X_DOC_GATE_STEPS_UNSCANNED`, `X_README_EXAMPLE_UNCOMPILED`, `X_README_EXAMPLE_PIN_STALE`, `X_README_EXAMPLE_UNSCANNED`, `X_VERSION_STAMP_STALE`, `X_VERSION_STAMP_DUPLICATE`, `X_VERSION_LOCKSTEP_BROKEN`, `X_VERSION_STAMP_UNSCANNED`, `X_COVERAGE_BELOW`, `X_COVERAGE_PIN_STALE`, `X_COVERAGE_UNMEASURED`, `X_TEST_TYPECHECK_REGRESSED`, `X_TEST_TYPECHECK_PIN_STALE`, `X_TEST_TYPECHECK_UNSCANNED`, `X_DOC_FILE_COUNT_STALE`, `X_DOC_FILE_COUNT_UNSCANNED`, `X_DOC_RELEASE_FACT_STALE`, `X_DOC_RELEASE_FACT_UNSCANNED`, `X_TEST_FIX_UNRUNNABLE`, `X_TEST_FIX_PIN_STALE`, `X_TEST_FIX_UNSCANNED`, `X_TEST_THROW_NOT_THROWN`, `X_TEST_BARE_ERROR`, `X_TEST_BARE_ERROR_PIN_STALE`, `X_TEST_BARE_ERROR_UNSCANNED`, `X_RELEASE_VERSION_UNSTATED`, `X_RELEASE_FLAG_UNKNOWN`, `X_DOC_CHANGELOG_SECTION_INVALID`, `X_DOC_CHANGELOG_UNRELEASED_BREAKING`, `X_DOC_MIGRATION_COUNT_STALE`, `X_DOC_MIGRATION_UNSCANNED`, `X_RELEASE_UNRELEASED_MISSING`, `X_LOCKFILE_STALE`, `X_REGISTRY_BOOTSTRAP_OWED`, `X_REGISTRY_UNATTESTED`, `X_REGISTRY_UNREACHABLE`, `X_REGISTRY_VERSION_BEHIND`, `X_GATE_CODE_UNDOCUMENTED`, `X_GATE_CODE_BACKLOG_STALE`) never ship, so no package may own them. See [Troubleshooting](Troubleshooting) for symptom-first triage and [CLI reference](CLI-Reference) for the commands named in the fixes. ## Core and runtime @@ -363,7 +363,7 @@ Seven codes because each sends the reader somewhere different. Full walkthrough: | Code | Means | Typical cause | Fix | |---|---|---|---| -| `X_ROUTE_MODE_INVALID` | render mode not allowed on this surface | `stream` on a `site/` route | use `static`, `isr` or `ssr` in `site/`; `stream`, `spa` or `ssr` in `app/` | +| `X_ROUTE_MODE_INVALID` | render mode not allowed on this surface, or not a render mode at all | `stream` on a `site/` route; `render: 'spa'`, deleted in 6.0.0 | use `static`, `isr` or `ssr` in `site/`; `stream` or `ssr` in `app/`. For `spa`, the migration is one line — `render: 'ssr'` — in [Upgrading](Upgrading) | | `X_ROUTE_OFFLINE_MISSING` | the route's offline strategy is missing or contradictory | `precache` on an `ssr` route | set a compatible `offline`, or change the render mode | | `X_ROUTE_META_MISSING` | required metadata missing | no `meta.title`, or no `description` on a `site/` route | add it to `meta` in the route file | | `X_ROUTE_UNNORMALIZED` | a route was registered without `defineRoute` | `registerRoute({ config })` was handed the author's own object, so `meta` and `budget` were never normalized and every descriptor reader would read them wrong | wrap it: `registerRoute({ file, config: defineRoute({ … }) })` | @@ -678,6 +678,28 @@ Two sets override the table, in `failures.ts`: | `X_VERSION_STAMP_DUPLICATE` | more than one page carries a version stamp | a second page restated a fact one page owns, so the two can disagree. The wiki footer renders on every page, which is why it is the one that says it | delete the stamp the finding names, keeping its `As of` date | | `X_VERSION_LOCKSTEP_BROKEN` | the publishable workspaces do not all carry one version | a release bumped some manifests and not others — the state nine untagged releases already reached once. Versioning is lockstep even though publication is not | `bun run scripts/version-stamps.ts --json` lists each workspace and its version; `scripts/release.ts` rewrites them together | | `X_VERSION_STAMP_UNSCANNED` | no workspace manifest carried a version, so there was nothing to compare against | the check ran outside the repo, or `packages/*/package.json` matched nothing | `bun run scripts/version-stamps.ts --json` from the repo root | +| `X_COVERAGE_BELOW` | a package's own `src/` is under the coverage floor | the floor is **95%** lines and functions, or that package's lower entry in `scripts/lib/coverage-pins.ts` — a pinned package may not fall further. Scoped to the package's own files on purpose: `bun test packages/` loads everything it imports and Bun's summary averages over all of it, which read `@ultimat3/cache` at 35% while its own sources were at 98.8% | cover the gap with tests beside the source, then `bun run scripts/coverage-gate.ts --package --json`. For a pinned package the finding says what the pin recorded, and restoring that is the edit | +| `X_COVERAGE_PIN_STALE` | a package now clears its coverage pin and the pin is still there | either it reached the 95% target outright, or it is more than **1.5 points** above its pin — a ratchet measured against last quarter catches nothing | delete that package's entry from `COVERAGE_PINS` in `scripts/lib/coverage-pins.ts`, or raise it to the measured numbers; `bun run scripts/coverage-gate.ts --all --json` prints both | +| `X_COVERAGE_UNMEASURED` | coverage was read from a suite that did not measure it | three shapes: no lcov record names the package at all, so the percentage is over nothing; the suite wrote no report because it never finished; or files with executable code have **no** lcov record, which makes them absent from the percentage rather than counted as zero — the one direction an unmeasured file must never move it | `bun test --coverage packages/` and fix what it reports. For unimported files the finding lists them: import each from a test beside it | +| `X_DOC_FILE_COUNT_STALE` | a page states a file count for a generator that the generator no longer emits | the count is hand-copied and the template list moved. `x new` 125, `--no-example` 99, `x g resource` 27, `--live --admin` 29 and `x g action` 9 were held by nobody; five had gone stale at once, and `wiki/Tutorial-02-First-Feature.md` was two further out because it states its numbers in prose rather than a table | `bun run scripts/generator-counts.ts --json` — each finding carries the number to write. Re-derive it yourself with `x g --dry-run --json` or `x new --dry-run --json` and count `data.files` | +| `X_DOC_FILE_COUNT_UNSCANNED` | no page states a generator file count, so the rule read nothing | the doc globs stopped matching, or the phrasing the rule recognises changed. Reported rather than skipped: a rule with no input answers exactly what a clean tree answers, which is a false green | check `DOC_GLOBS` in `scripts/doc-commands.ts` still matches the pages, and `CLAIM` in `scripts/generator-counts.ts` still matches how they state a count | +| `X_DOC_RELEASE_FACT_STALE` | a page states how many packages ship and the tree has a different number | the count is restated across ten files — `CLAUDE.md`, `README.md`, `AGENTS.md`, `SECURITY.md`, `PUBLISHING.md`, five wiki pages and three under `docs/idea` — and hand-copied every time. `SECURITY.md` claimed 28 packages and a supported line of `1.0.x`, two majors stale | `bun run scripts/release-facts.ts --json` names the file and the number to write; `bun run scripts/list-workspaces.ts` is where the number comes from | +| `X_DOC_RELEASE_FACT_UNSCANNED` | no page states a package count, so the rule read nothing | the globs or the phrasing moved. Reported rather than skipped: silence over ten pages that restate one number reads identically to agreement | edit `FACT_GLOBS` in `scripts/release-facts.ts` so it matches how the pages state a count | +| `X_LOCKFILE_STALE` | `bun.lock` records an `@ultimat3/*` range that the `package.json` it was generated from no longer declares | a lockstep bump. `bun install` refreshes a workspace block only when that workspace's own manifest changed, and `--frozen-lockfile` accepts every stale one because a workspace edge resolves by **name** — the range is never read back. 90 entries sat at 1.2.0 and 2.0.0 against manifests that all said 3.0.0 | `bun run scripts/lockfile-pins.ts --write`, then `bun install --frozen-lockfile` to confirm. Not `rm bun.lock && bun install`, which also drags every external dependency to its newest match — measured once as Biome 2.5.5 → 2.5.9 and `@types/node` 26.1.1 → 26.2.0 | +| `X_RELEASE_FLAG_UNKNOWN` | `scripts/release.ts` was passed a flag it does not declare | a typo or an invented flag. It declares `--version`, `--bump`, `--check`, `--dry-run` and `--json` and nothing else, and refuses **before** anything is decided — the mistake this guard exists for rewrote 47 manifests | `bun run scripts/release.ts --bump patch --dry-run --json`; the cause lists every flag the script knows | +| `X_RELEASE_VERSION_UNSTATED` | a release was asked for and neither `--version` nor `--bump` was given | the absent case is a **refusal**, not a patch bump. It used to default to `patch`, so a typo'd flag name still produced a release of some version | `bun run scripts/release.ts --bump patch --dry-run --json`, or `--version ` — the cause names the version this repo is currently at | +| `X_TEST_BARE_ERROR` | a test reports its own verdict by throwing a bare `Error` | `CLAUDE.md` says never throw a bare `Error` and `checkErrorFixes` skips test files, so in tests the rule was prose — 422 sites accumulated under a green gate, up from 295 when the issue was opened. A ratchet per package: the count may fall and may never rise. A `new Error` handed to the code under test is **input**, not a verdict, and is never reported | replace the throw the finding names with `expect.unreachable('')` — its `never` return also narrows the variable, so the cast below it goes away | +| `X_TEST_BARE_ERROR_PIN_STALE` | a package is pinned above the number of bare-`Error` throws it actually has | tests were repaired and the pin was not lowered, so the ratchet holds slack a new throw would spend for free | `bun run scripts/test-bare-error.ts --unpin ` — it only lowers, never raises | +| `X_TEST_BARE_ERROR_UNSCANNED` | no test file was read, so every package reported zero | a glob that matches nothing makes the ratchet enforce nothing and reads exactly like a clean tree — which is why it is a finding rather than a pass | edit `TEST_GLOBS` in `scripts/test-fix-citations.ts` so it matches this repo's test layout; that one glob list feeds this scanner too | +| `X_TEST_FIX_UNRUNNABLE` | a `fix:` a test writes or asserts cites a command this build cannot run | `checkErrorFixes` reads `src/` and skips tests, so a fixture error, a helper that builds one and an assertion pinning a fix string were all unchecked — and this repo has shipped a `fix:` naming a command that does not exist more than once (`x schema show`, `x logs tail`). A test is where the next one is copied from. Per-package ratchet. A command inside a comment or inside another string is not a citation — that is how `error-contract.test.ts` writes seven fixtures on purpose | rewrite the fix the finding names as an invocation this build ships; `x help --json` lists every command, subcommand and flag | +| `X_TEST_FIX_PIN_STALE` | a package is pinned above the number of unrunnable test `fix:` lines it has | the ratchet may only fall, and a pin nobody lowers is one nobody reads | `bun run scripts/test-fix-citations.ts --unpin ` | +| `X_TEST_FIX_UNSCANNED` | no test file matched, so the rule reported over a file set it never read | the globs stopped matching the layout. A rule with no input is a false green, not a pass | edit `TEST_GLOBS` in `scripts/test-fix-citations.ts` so it matches this repo's test layout | +| `X_TEST_THROW_NOT_THROWN` | `expect(fn).toThrow(…)` is given a callback that **returns** an error instead of throwing one | bun's synchronous `toThrow` **passes** on a returned `Error` — `expect(() => new Boom('x')).toThrow(Boom)` is green, in the bare, class and string-matcher forms alike — so the assertion cannot fail. This repo exports 196 functions that return an error beside a matching set that throws, and `expect(() => sendFailed(…))` is the shape. A returned non-error is reported correctly by bun, and `rejects.toThrow` is unaffected. Only certain shapes are reported: an error construction, or a call to a function this repo declares as returning one | wrap it in a throw at the site the finding names — `expect(() => { throw ; }).toThrow(…)` — or assert the value directly with `expect().toBeInstanceOf(…)` | +| `X_TEST_TYPECHECK_REGRESSED` | a package's own tests carry more `tsc` errors than the ratchet pins | every package `tsconfig.json` excludes `src/**/*.test.ts`, so `bun run typecheck` reads none of them and the gate was green over all **984** of them (`find packages -path '*/src/*' -name '*.test.ts*' -not -path '*/dist/*' \| wc -l`, `As of 2026-08`). `tsconfig.tests.json` is the program that compiles them, `noEmit`. The count may only fall | fix the diagnostic the finding names — `bun run scripts/test-typecheck-gate.ts --json` prints every one — or raise that package's entry in `scripts/lib/test-typecheck-pins.ts` deliberately, which is a decision and not a default | +| `X_TEST_TYPECHECK_PIN_STALE` | a pin in the test-typecheck ratchet is above what remains, or names a directory that is not a package | errors were fixed and the pin was not lowered; or a package was renamed and its pin outlived it, excusing nothing and hiding that nobody is checking it | `bun run scripts/test-typecheck-gate.ts --unpin [,]` — the `fix` carries the exact line | +| `X_TEST_TYPECHECK_UNSCANNED` | the test program did not compile, so the rule reported over tests it never read | no package directory was found, or the compiler would not start. `tsc` reports no semantic diagnostics at all once a program holds a syntax error, so reading that as "nothing wrong" would go green over every package at once | `bun install`, then `bun run scripts/test-typecheck-gate.ts --json` — the compiler is this repo's own `node_modules/.bin/tsc` and the program is `tsconfig.tests.json` | +| `X_GATE_CODE_UNDOCUMENTED` | a code the never-ships list names has no table row, or a code `scripts/` declares is missing from that list | the list is a hand-copy of a derived set and nothing read it. `checkErrorCodeDocs` is satisfied by any `` `X_*` `` in backticks **anywhere** on this page (`documentedCodes` is one whole-file regex), so being named inside the parenthesis counted as being documented — 20 codes had a name here and no row. From the other side `checkErrorCodeRegistry` exempts gate codes by scanning `scripts/`, never by reading the list, so four `X_REGISTRY_*` codes had rows above [Reserved codes](#reserved-codes) that `x errors explain` refuses | `bun run scripts/gate-codes.ts --json` — each finding names the row to write or the code to add to the list | +| `X_GATE_CODE_BACKLOG_STALE` | a code pinned in `scripts/gate-codes-backlog.ts` that this page is no longer wrong about | the row was written or the code was added to the list and the pin was not deleted — a pin nobody removes is a pin nobody reads | delete that code from `scripts/gate-codes-backlog.ts`; the `fix` line names it verbatim | ## Reserved codes diff --git a/wiki/Getting-Started.md b/wiki/Getting-Started.md index b3b11cc8..3d71c654 100644 --- a/wiki/Getting-Started.md +++ b/wiki/Getting-Started.md @@ -169,7 +169,7 @@ $ x verify | [Policies and authz](Policies-And-Authz) | one authz system, tenancy, denials | | [Queries and live queries](Queries-And-Live-Queries) | reads, `live: true` | | [Jobs and workflows](Jobs-And-Workflows) | durable steps, idempotency | -| [Routes and render modes](Routes-And-Render-Modes) | `static` / `isr` / `ssr` / `stream` / `spa` | +| [Routes and render modes](Routes-And-Render-Modes) | `static` / `isr` / `ssr` / `stream` | | [Testing](Testing) | six test types, DB-clone parallelism | | [CLI reference](CLI-Reference) | every command, every `--json` | | [Error codes](Error-Codes) | code → cause → fix | diff --git a/wiki/Known-Gaps.md b/wiki/Known-Gaps.md index fe53c2bf..524a11b1 100644 --- a/wiki/Known-Gaps.md +++ b/wiki/Known-Gaps.md @@ -17,9 +17,11 @@ npm view @ultimat3/core version A row that says **fixed on `main`** is fixed in the repository and in **no published release**; if you are on an earlier version, treat those rows as open and take the workaround. `As of 2026-08-20` there -are none — 4.0.0 published every one of them, and `[Unreleased]` in -[`CHANGELOG.md`](https://github.com/developerz-ai/ultimate/blob/main/CHANGELOG.md) is empty. That -section, not this page, is the source of truth for what is in the next release. +is exactly one — "No test file was typechecked", under [Closed](#closed). `[Unreleased]` in +[`CHANGELOG.md`](https://github.com/developerz-ai/ultimate/blob/main/CHANGELOG.md) is **not** empty: +it holds the async-context seam's six adoption sites, the route vocabulary's move to tier 0 with two +type renames, and 21 `Object.freeze` tables that accepted an unknown key. That section, not this +page, is the source of truth for what is in the next release — those three were never rows here. **Publication is not a gap.** All 30 workspaces are on the registry `As of 2026-08-20`, checked by `scripts/registry-audit.ts` daily; `@ultimat3/scraping` was the last never-published package and was bootstrapped by hand at 2.0.0 @@ -43,8 +45,7 @@ section, not this page, is the source of truth for what is in the next release. | Nothing checks `.env.example` against the schema | `assertEnvExample(schema, text)` ships and throws `X_ENV_EXAMPLE_DRIFT` for a declared key the file is missing — and has no shipped caller: no `x verify` step, no CLI command and no boot hook runs it, `As of 2026-08-19` | call it from a test of your own — three lines, and it is then a gate step: `assertEnvExample(schema, await Bun.file(ENV_EXAMPLE_PATH).text())` → [Configuration](Configuration) | | HPAs read `` without a metrics adapter | the chart half is done — `values.yaml` declares `metricsPort: 9090`, every role but `migrate` emits the container port, `service.yaml` publishes it and `templates/servicemonitor.yaml` ships the scrape target. Two things still stand and neither is the chart's to fix: `serviceMonitor.enabled` defaults **false**, because a cluster without the Prometheus operator has no such CRD and `helm install` fails on an unknown kind; and turning scraped series into the `Pods` metrics an HPA reads needs a **custom-metrics adapter**, which is the cluster's | set `serviceMonitor.enabled: true` and install a metrics adapter; until then disable the HPAs and pin `replicas`. **Do not hand-add a metrics container port** — the chart emits one and a duplicate is rejected by the API server → [Observability](Observability) | | `--target binary` has never been served from a bare VM | the target compiles and boots — `x build --target binary` passes `--define ULTIMATE_FRAMEWORK_VERSION`, `docker/Dockerfile` passes it too, and the image build ends in `/out/app --version` so a binary that cannot answer fails the build. What is unmeasured is the rest: no scaffolded app has been compiled, copied to a VM with no Bun on it, and served under systemd | prove it for your own app before you depend on it — build, `scp`, run `./app --version`, then `ROLE=web PORT=3000 ./app` and hit `/readyz`. Report what breaks → [Deployment](Deployment) | -| `X_ROUTE_MODE_INVALID`'s fix line offers two edits and one does not work | it reads "wrap the data-dependent part of `` in `` or change render to `'ssr'`" ([`packages/render/src/modes.ts:218`](https://github.com/developerz-ai/ultimate/blob/main/packages/render/src/modes.ts)). Only the second half works: Solid's `` throws `getContextId cannot be used under non-hydrating context` under this renderer at any Solid version — the server JSX factory is inert by design and is not a Solid renderer | take the second branch, `render: 'ssr'`. Async data needs no boundary at all: `renderToHtml` awaits async components and promise children | -| No test file is typechecked | all **30** package `tsconfig.json`s carry `"exclude": ["src/**/*.test.ts"]`, so `bun run typecheck` — a `tsc -b` — reads no `.test.ts` under `packages/` and the gate's `typecheck` step reports green over every one of them. `packages/*/e2e/**` is in no package's `include` either, so those directories compile nowhere. Re-derive the config count with `grep -l 'src/\*\*/\*.test.ts' packages/*/tsconfig.json \| wc -l`. **A fix is landing, not landed**: a root `tsconfig.tests.json` with a per-package ratchet is open in #208 and is not on `main` `As of 2026-08-19` — the file does not exist in the tree. Its own measurement is **446** errors across the 30 packages; this page carried **282 across 110 files in 24 packages** measured `As of 2026-08`, over 29 configs. Both are that PR's and this page's own numbers respectively and neither has been re-derived here — the command that settles it is the one on the right. `scripts/` is already exempt: it carries no such `exclude`, so its tests do typecheck | nothing to work around at runtime — the tests run, they are simply not compiler-checked. Typecheck one package's tests directly: `bunx tsc --noEmit` over a copy of that package's config with the `exclude` dropped | +| `X_ROUTE_MODE_INVALID`'s fix line offers two edits and one does not work | it reads "wrap the data-dependent part of `` in `` or change render to `'ssr'`" ([`packages/render/src/modes.ts:193`](https://github.com/developerz-ai/ultimate/blob/main/packages/render/src/modes.ts)). Only the second half works: Solid's `` throws `getContextId cannot be used under non-hydrating context` under this renderer at any Solid version — the server JSX factory is inert by design and is not a Solid renderer | take the second branch, `render: 'ssr'`. Async data needs no boundary at all: `renderToHtml` awaits async components and promise children | ## Open by decision @@ -74,6 +75,7 @@ the release that fixed it. Upgrading is the fix, and the breaking entries are th | Gap | Fixed in | If you are pinned below that | |---|---|---| +| No test file was typechecked | **`main`** | All **30** package `tsconfig.json`s carry `"exclude": ["src/**/*.test.ts"]` — they still do — so `bun run typecheck`, a `tsc -b`, reads none of the **984** test files under `packages/*/src` and the gate's `typecheck` step was green over every one. Re-derive both: `grep -l 'src/\*\*/\*.test.ts' packages/*/tsconfig.json \| wc -l` and `find packages -path '*/src/*' -name '*.test.ts*' -not -path '*/dist/*' \| wc -l`. **Closed by a second program, not by editing the 30:** `tsconfig.tests.json` compiles the tests with `noEmit`, `scripts/test-typecheck-gate.ts` runs it on a per-package ratchet that may only fall, and it rides the gate's `manifest` step ([`scripts/verify.ts:247`](https://github.com/developerz-ai/ultimate/blob/main/scripts/verify.ts)) rather than `typecheck`, which takes no host findings. It landed at **446 errors over 161 files in 27 packages** (measured 2026-08-19) and `scripts/lib/test-typecheck-pins.ts` now pins **2**, with 29 of 30 packages at zero — so a new test compiles the day it is written. The residue is one named defect, both errors in `packages/entity/src/pg-driver.test.ts`: `Repo`'s full-row write members take the ROW type where money's WRITE type belongs, and two attempted fixes were reverted with evidence rather than silenced — that pins file carries the argument. On any published release: nothing to work around at runtime, the tests run either way. Typecheck one package's tests yourself with `bunx tsc --noEmit` over a copy of its config with the `exclude` dropped | | A live query re-delivered the raw table row, and mis-ordered a projected window | **5.0.1** | Two defects with one cause: a `ChangeEvent` carries the whole TABLE row, and a live query's result set is whatever its `sql` returned. **The leak** — every patch forwarded the change row unnarrowed, so a column the projection dropped went out on the socket the moment it CHANGED; `examples/dummy`'s feed projects ten columns and one publish delivered `updatedAt`, and a column like a salary or a private note would have gone the same way. The per-subscriber gate could not help: it decides whether a ROW is delivered, never which of its columns. **The mis-ordering** — `match()` decided position by comparing the change row against the rows the WINDOW holds, so an `orderBy` on a column the projection omits measured a real value against nothing: every update read as a move, and an arriving row landed wherever `undefined` sorted. Now a patch row is narrowed to the columns the query actually returned, and a position the window cannot answer for is a `refill` — one re-read and a re-snapshot — rather than a guess. A DELETE still patches incrementally, because it decides no position. [#230](https://github.com/developerz-ai/ultimate/issues/230). On 5.0.0 and below: give a live query's rows the key they are ordered by, and do not rely on a projection to withhold a column from a live subscriber | | `jobs.driver` selected no driver | **5.0.0** | [`JobsConfig.driver`](https://github.com/developerz-ai/ultimate/blob/main/packages/core/src/config.ts) accepted `'postgres' \| 'redis' \| 'nats'` and had **no reader anywhere** — boot always built `createPgDriver`, and [`packages/jobs/src/driver.ts`](https://github.com/developerz-ai/ultimate/blob/main/packages/jobs/src/driver.ts)'s own header already said so. `jobs: { driver: 'redis' }` therefore did not boot-and-then-throw as this wiki once claimed: it changed nothing and you silently got Postgres — the same shape as `realtime.heartbeatMs`, and worse, because it failed silently in the dangerous direction. Five shipped `fix:` lines named it as the repair for `X_NOT_IMPLEMENTED`, which is a `fix:` that is a no-op; those were corrected in 4.1.0 and the field itself is **deleted in 5.0.0**, along with the `JobsDriver` type nothing else used. On 4.1.0 and below the field still typechecks and still does nothing — swap the driver with `setJobDriver(createPgDriver({ executor }))`, or `setJobDriver(createMemoryDriver())` in a test, and never through `app.config.ts` | | A caller-controlled `string` could add a line to the 3-line error format | **5.0.0** | `bun run error-render` refuses a parameter typed `unknown`/`any`; a value already typed `string` renders without throwing, so nothing objected — while a newline in one writes a second line an operator, a CI log or the dev overlay's `
` reads as a genuine framework message. Three holes shipped in `@ultimat3/auth` under a green check, the worst reachable by an unauthenticated stranger with one crafted OIDC token. The first fix escaped at each of the six RENDERERS, which could not hold: six is a number that only goes up, and it covered none of the renderers an app writes. Now `UltimateError`'s and `SchemaError`'s **constructors** escape `code`, `title`, `cause`, `fix` and `docs` — so `.message`, `.cause`, `format()`, `toJSON()` and any renderer anyone writes are one line by construction, and `singleLine()` is idempotent so a call site that already escaped is unharmed. On 4.1.0 and below, pass `error.cause` through `singleLine()` from `@ultimat3/core` before you render it yourself |
diff --git a/wiki/PWA-And-Offline.md b/wiki/PWA-And-Offline.md
index 6bf0fdce..bdaacb8f 100644
--- a/wiki/PWA-And-Offline.md
+++ b/wiki/PWA-And-Offline.md
@@ -14,7 +14,7 @@ The edit an agent should make is the route's `offline` field, then `x build`. No
 
 ```ts
 export const config = defineRoute({
-  render:     'isr',                  // static | isr | ssr | stream | spa
+  render:     'isr',                  // static | isr | ssr | stream
   revalidate: { tags: [tag.post] },
   prerender:  () => db.posts.slugs(),
   offline:    'precache',             // precache | runtime | network-only
@@ -33,7 +33,6 @@ export const config = defineRoute({
 | The JS/CSS chunks those routes import | real bundle graph, not a glob |
 | Fonts, icons, and `priority` images they reference | asset graph |
 | The offline fallback route | required (see below) |
-| The app shell for `spa` routes | build output |
 
 Excluded always: `api/` responses, anything under an authenticated path unless `offline: 'precache'` is explicit, and any asset over the configured single-file cap.
 
@@ -41,13 +40,21 @@ Total precache size is a **budget** — exceeding it fails `x verify` rather tha
 
 ### Runtime strategy from render mode
 
-| `render` | `offline` default | Strategy | Rationale |
-|---|---|---|---|
-| `static` | `precache` | cache-first, revalidate on build ID change | immutable per build |
-| `isr` | `runtime` | stale-while-revalidate | matches ISR's own semantics exactly |
-| `ssr` | `network-only` | network, offline fallback on failure | caching a per-request render is a correctness bug |
-| `stream` | `runtime` | network-first for the document, cache-first for chunks | shell freshness matters; chunks are content-hashed |
-| `spa` | `precache` | shell cache-first, data network-only | the shell is static; the data never is |
+`MODE_STRATEGY` in `packages/pwa/src/strategies.ts`, read by `strategyFor()` and keyed on
+`Record` — a mode with no row and a row for a mode that does not exist
+are both compile errors, `As of 2026-08`.
+
+| `render` | Strategy | Rationale |
+|---|---|---|
+| `static` | `cache-first` | immutable per build |
+| `isr` | `stale-while-revalidate` | matches ISR's own semantics exactly |
+| `ssr` | `network-first` | caching a per-request render is a correctness bug; the offline fallback answers on failure |
+| `stream` | `stale-while-revalidate` | the shell is the cacheable part and the holes re-fetch anyway |
+
+**There is no per-mode `offline` default** — `offline` is required by `defineRoute`'s type and again
+at runtime (`X_ROUTE_OFFLINE_MISSING`). It is read *before* the mode: `offline: 'network-only'` is a
+declaration that this URL is never answered from a cache, and `strategyFor` returns `network-only`
+without consulting the table. A per-route `strategy` overrides both.
 
 Overriding `offline` is allowed. Contradictions are **not** rejected `As of 2026-08`: `offline: 'precache'` on a `render: 'ssr'` route is accepted, and `X_SW_UNCACHEABLE` is a reserved name nothing raises ([Error codes → Not thrown yet](Error-Codes#not-thrown-yet)). The scope half *is* enforced — `X_SW_SCOPE_INVALID`, when the service-worker scope cannot serve the routes it precaches. Until the coherence check ships, review the pairing yourself: a per-request render has no cacheable body, so `precache` on `ssr` means the shell is served stale.
 
diff --git a/wiki/Project-Layout.md b/wiki/Project-Layout.md
index 0ba595c9..b28dce3f 100644
--- a/wiki/Project-Layout.md
+++ b/wiki/Project-Layout.md
@@ -7,7 +7,7 @@ myapp/
   apps/
     web/                  # the Ultimate app — the three surfaces live here
       site/               # static/isr, 0kb JS baseline, SEO-critical
-      app/                # auth'd, stream/spa, realtime, heavy
+      app/                # auth'd, stream/ssr, realtime, heavy
       api/                # actions only, no rendering
       shared/             # tokens, primitives, entity types, policies
     admin/                # generated admin dashboard (Ultimate app, role=web)
@@ -35,7 +35,7 @@ Four directories, two bundle graphs, one hard boundary.
 | Surface | Audience | Default render | JS baseline | Auth | May import |
 |---|---|---|---|---|---|
 | `site/` | anonymous, crawlers | `static` / `isr` | **0kb** | none | `shared/` |
-| `app/` | signed-in users | `stream` (or `spa`) | whatever the budget allows | required | `shared/`, `api/` types |
+| `app/` | signed-in users | `stream` (or `ssr`) | whatever the budget allows | required | `shared/`, `api/` types |
 | `api/` | programs, agents, the typed client | none | n/a | policy per action | `shared/` |
 | `shared/` | both | n/a | must stay 0-dep-heavy | n/a | nothing app-local |
 
diff --git a/wiki/Routes-And-Render-Modes.md b/wiki/Routes-And-Render-Modes.md
index c1f009bb..0c3fb966 100644
--- a/wiki/Routes-And-Render-Modes.md
+++ b/wiki/Routes-And-Render-Modes.md
@@ -8,7 +8,7 @@ A `route` is a URL + render mode + metadata + offline strategy. Render mode is a
 
 ```ts
 export const config = defineRoute({
-  render:     'isr',                  // static | isr | ssr | stream | spa
+  render:     'isr',                  // static | isr | ssr | stream
   revalidate: { tags: [tag.post] },
   prerender:  () => db.posts.slugs(),
   offline:    'precache',             // precache | runtime | network-only
@@ -45,14 +45,14 @@ Validation runs at **module evaluation**. `defineRoute` checks the shape and the
 |---|---|---|
 | `offline` present and a known strategy | `defineRoute` | `X_ROUTE_OFFLINE_MISSING` |
 | `meta` is a function | `defineRoute` | `X_ROUTE_META_MISSING` |
-| mode-local: known `render` and `hydrate`; `static` with a `policy` or a `revalidate`; `isr` with no trigger; `ssr` with a `prerender`; `spa` with no `policy` | `defineRoute` | `X_ROUTE_MODE_INVALID` |
+| mode-local: known `render` and `hydrate`; `static` with a `policy` or a `revalidate`; `isr` with a `policy` or with no trigger; `ssr` with a `prerender` | `defineRoute` | `X_ROUTE_MODE_INVALID` |
 | surface-dependent: mode allowed on the surface; `site/` hydration without `budget.js`; `stream` with no ``; `prerender` on a non-prerenderable mode | `registerRoute` | `X_ROUTE_MODE_INVALID` |
 | the config came from `defineRoute` and not straight from the author | `registerRoute` | `X_ROUTE_UNNORMALIZED` |
 | two files claiming one URL | `registerRoute` | `X_ROUTE_DUPLICATE` |
 
 The split is about what is knowable, not about strictness: everything decidable from the config alone is decided at import; the rest needs the file's surface, which only the route table knows.
 
-## Five render modes
+## Four render modes
 
 | Mode | Behavior | Use |
 |---|---|---|
@@ -60,19 +60,32 @@ The split is about what is knowable, not about strictness: everything decidable
 | `isr` | static + background regen on tag/TTL | catalogs, profiles |
 | `ssr` | per-request full render | fresh SEO pages |
 | `stream` | static shell flushed instantly, holes streamed | **default for app pages** |
-| `spa` | shell only, client fetches | dashboards behind auth |
 
 | Surface | Default | Allowed |
 |---|---|---|
 | `site/` | `static` | `static`, `isr`, `ssr` |
-| `app/` | `stream` | `stream`, `spa`, `ssr` |
+| `app/` | `stream` | `stream`, `ssr` |
 | `api/` | n/a | no rendering at all |
 
 A mode outside a surface's allowed set is a build error, not a runtime fallback. Surfaces and their boundaries: [Project layout](Project-Layout).
 
+**`spa` was the fifth mode and was deleted in 6.0.0.** It served `
` for the framework's whole history — 200, correct headers, blank page — because `renderSpa` preloaded a `chunks` array no build ever produced and never read the route's component. `render: 'spa'` is now `X_ROUTE_MODE_INVALID` at `defineRoute` time; the migration is one line, `render: 'ssr'`, in [Upgrading](Upgrading). + +## One declaration of the vocabulary, at tier 0 + +`As of 2026-08` the three closed sets a route is declared in — `RENDER_MODES`, `OFFLINE_STRATEGIES`, `HYDRATE_STRATEGIES` — are declared **once**, in `@ultimat3/core`, with each union derived from its array so the pair cannot disagree. + +| Import it from | When | +|---|---| +| `@ultimat3/core` | anywhere. It is tier 0, so no package is below it | +| `@ultimat3/render` | you are already importing `defineRoute` — it re-exports all three, types and arrays | +| `@ultimat3/http` · `@ultimat3/seo` · `@ultimat3/manifest` · `@ultimat3/pwa` | that package's own signatures take one, so it re-exports what it takes | + +**Re-export, never restate.** Six packages each kept a hand-written copy until 2026-08 — 14 declarations in all — because imports only go down tiers and copying was the move available. `'spa'` was then deleted from one of them and five went on admitting it under a green project-wide typecheck; `@ultimat3/pwa`'s copy mapped it to `cache-first`, the one strategy that gives an `app/` route a **shared** cache entry. `bun run scripts/render-modes.ts --json` refuses a second declaration, matching on the literal set rather than the name — the copy that did the damage was called `PwaRenderMode`. + ## Why `stream` is the app default -An authed page needs fresh data and fast first paint. `ssr` gives freshness and a slow TTFB (the whole page waits for the slowest query). `spa` gives an instant shell, a spinner farm, and no HTML for anything. `stream` gives both halves: shell now, data as it resolves. +An authed page needs fresh data and fast first paint. `ssr` gives freshness and a slow TTFB — the whole page waits for the slowest query. `stream` gives both halves: shell now, data as it resolves. A page whose body belongs in the browser declares an `island({ src })`, budgeted in real bytes; there is no client-side-only mode and no client router. ```tsx export default function Dashboard() { diff --git a/wiki/The-Eight-Primitives.md b/wiki/The-Eight-Primitives.md index e565d567..1770e0c7 100644 --- a/wiki/The-Eight-Primitives.md +++ b/wiki/The-Eight-Primitives.md @@ -184,7 +184,7 @@ A URL + render mode + metadata + offline strategy. ```ts export const config = defineRoute({ - render: 'isr', // static | isr | ssr | stream | spa + render: 'isr', // static | isr | ssr | stream revalidate: { tags: [tag.post] }, prerender: () => db.posts.slugs(), offline: 'precache', // precache | runtime | network-only @@ -201,7 +201,7 @@ export const config = defineRoute({ | Owns | render mode, hydration timing, metadata, offline strategy | | Never | touch the DB directly, hold business logic, or omit `meta.description` in `site/` — that is a build error, `X_SEO_META_MISSING` — the same code for a missing title, with `cause` naming the field | -Render modes: `static` (built once), `isr` (static + background regen), `ssr` (per-request), `stream` (shell flushed instantly, holes streamed — **default for app pages**), `spa` (shell only). Table in [Routes and render modes](Routes-And-Render-Modes). +Render modes: `static` (built once), `isr` (static + background regen), `ssr` (per-request), `stream` (shell flushed instantly, holes streamed — **default for app pages**). Four, declared once in `@ultimat3/core` as `RENDER_MODES`; `spa` was the fifth until 6.0.0. Table in [Routes and render modes](Routes-And-Render-Modes). ## `task` diff --git a/wiki/Tutorial-01-First-App.md b/wiki/Tutorial-01-First-App.md index ae93d333..8e0c3c40 100644 --- a/wiki/Tutorial-01-First-App.md +++ b/wiki/Tutorial-01-First-App.md @@ -218,7 +218,7 @@ bunx x routes ```text path surface render hydrate offline file / site static never precache apps/web/site/page.tsx - /admin app spa idle network-only apps/admin/app/admin/page.tsx + /admin app ssr never network-only apps/admin/app/admin/page.tsx /dashboard app ssr visible runtime apps/web/app/dashboard/page.tsx /posts app ssr visible runtime apps/web/app/posts/page.tsx ✓ 4 routes diff --git a/wiki/Tutorial-03-Auth-And-Admin.md b/wiki/Tutorial-03-Auth-And-Admin.md index e8dd7881..877ff97d 100644 --- a/wiki/Tutorial-03-Auth-And-Admin.md +++ b/wiki/Tutorial-03-Auth-And-Admin.md @@ -166,26 +166,23 @@ Two different things share the word. | `/_x` | the **dev** dashboard from `@ultimat3/admin`, 11 panels | dev-only, never mounted in `ROLE=web` | in `x dev`, immediately | | `apps/admin/` | a generated Ultimate app running `ROLE=web` | `admin:read` on the route config | a one-page shell; you build the screens | -The scaffolded shell: +The scaffolded shell, `As of 2026-08`: ```ts -// apps/admin/app/page.tsx +// apps/admin/app/admin/page.tsx export const config = defineRoute({ - render: 'spa', + render: 'ssr', hydrate: 'idle', offline: 'network-only', - // A spa renders no data, so the shell itself must be gated — @ultimat3/render requires it. + // Behind auth, and `ssr` is the one mode that can be: it renders per request, so the guard runs + // on the server before the page does. `static` and `isr` refuse a policy outright. policy: { permission: 'admin:read' }, - budget: { js: '120kb', lcp: 3000 }, - meta: () => ({ title: t('admin.home.title'), description: t('admin.home.description') }), + budget: { js: '120kb' }, + meta: ({ t }) => ({ title: t('admin.home.title'), description: t('admin.home.description') }), }); ``` -It claims `/`, colliding with the site landing page — `X_ROUTE_DUPLICATE`. Move it, because the directory is the URL: - -```bash -mv apps/admin/app/page.tsx apps/admin/app/admin/page.tsx -``` +**`app/admin/page.tsx`, not `app/page.tsx`** — the directory is the URL relative to the surface root, so the shallower path resolves to `/` and collides with `apps/web/site/page.tsx`: `x dev` loads both surfaces into one route table, and the scaffold used to fail its own `x routes` with `X_ROUTE_DUPLICATE`. `x new` now writes the deeper path, and `/admin` is also `@ultimat3/admin`'s own `basePath` default, so the two agree rather than merely not clashing. Nothing to move. ### Per-entity screens diff --git a/wiki/Upgrading.md b/wiki/Upgrading.md index 32c85bd7..566ec554 100644 --- a/wiki/Upgrading.md +++ b/wiki/Upgrading.md @@ -17,7 +17,7 @@ An entry is a line `CHANGELOG.md` marks `BREAKING —`. The count is derived, ne ```sh grep -cE '^(- \*\*|### )BREAKING —' CHANGELOG.md -# 77 As of 2026-08 — every one inside the section of the major that shipped it +# 79 As of 2026-08 — 77 inside the section of the major that shipped it, 2 under [Unreleased] ``` Each entry changes a surface the table below covers. @@ -31,6 +31,28 @@ Each entry changes a surface the table below covers. | that the tarball is attested | `npm view @ultimat3/core dist.attestations` | a `provenance` object | | every name that must move together | `bun run scripts/release-workflow.ts --json` | the 30 derived names — check each | +## Unreleased — two renames, no version yet + +**Not on npm and not in a major.** These two entries sit under `## [Unreleased]` in [`CHANGELOG.md`](https://github.com/developerz-ai/ultimate/blob/main/CHANGELOG.md) and have no section in the table above, because the version that ships them does not exist yet. They are here so the edit is written down where the rest of them are. + +Both are **type-only renames in `@ultimat3/pwa`**, both compile errors the moment you upgrade, both mechanical. No member changed — only the name the type is declared under. + +| Was | Is | Members, unchanged | +|---|---|---| +| `PwaRenderMode` | `RenderMode` | `'static' \| 'isr' \| 'ssr' \| 'stream'` | +| `PwaOfflineStrategy` | `OfflineStrategy` | `'precache' \| 'runtime' \| 'network-only'` | + +```diff +- import type { PwaRenderMode, PwaOfflineStrategy } from '@ultimat3/pwa'; ++ import type { RenderMode, OfflineStrategy } from '@ultimat3/pwa'; +``` + +`@ultimat3/pwa` re-exports both under the canonical name, so the import path does not have to move — `@ultimat3/core` is where they are declared and is equally correct. + +**Why the alias existed and why it could not stay.** Tier 4 may not import tier 4, so `@ultimat3/pwa` wrote its own copy of a set `@ultimat3/render` already had. That copy is what kept `spa` mapped to `cache-first` after `spa` was deleted in 6.0.0 — the one strategy that gives an `app/` route a **shared** cache entry, i.e. one signed-in member's HTML served to the next. The vocabulary is now declared once at tier 0, in `@ultimat3/core`, and `bun run scripts/render-modes.ts --json` refuses a second declaration anywhere in `packages/*/src`. + +Nothing else in this batch costs an edit: `asyncContext` is a new export, and the `Object.freeze` and async-context repairs changed no exported name. + ## 5.x → 6.0.0, entry by entry **Nothing here is installable until `npm view @ultimat3/core version` answers `6.0.0`.** Run that first; `As of 2026-08` it does not. This section is written as each change lands rather than at the tag, so entries are **appended** — re-read it when `latest` moves.