From aad60ef745c5b7fb21bc165106a7be908ed983ec Mon Sep 17 00:00:00 2001 From: Evan Date: Mon, 21 Sep 2026 15:27:21 +0900 Subject: [PATCH] fix: enforce the component contract, and stop two other tables lying about their types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings were left over from the audit and they were one finding seen three times: a value typed at the boundary and trusted afterwards, with nothing between the model's answer and the code that acts on it. `lib/catalog.ts` calls itself the component contract and its header claims nothing outside it can appear in a generated page. That held for component names and not for the props under them. `catalog.validate()` checks the spec's shape and stops there — a `Pricing` element whose `tiers` is the string `"免费"` returns `success: true` from it, measured. The zod schemas were therefore a description of what the LLM ought to send rather than a check on what it did send, and the only thing between a wrong-typed field and `(props.tiers as ...).map` was `isReady`, which asks whether a value is present and non-blank, not whether it is the right kind of thing. `validateProps` closes that, reading the very schemas the file declares, so the contract is enforced by the file that states it and no second description of any block's shape exists to drift. `buildSpec` consults it alongside `isReady`, and a block that fails holds its place as a skeleton — the same thing the page already does for copy that has not arrived, which is the honest description of copy that came back unusable. The two client-side splices in `applyEdit` get the same guard, since a restyle and an add assemble their element locally and never pass through compose. The catalog lookup is by `Object.hasOwn`: the component name can arrive from a model, and `constructor` is not a component. Fixing that exposed a smaller thing doing real damage. `elementsFor` coerced a non-array `tiers` to `[]` so its eager `slice(0, 1).map(...)` could not throw. That stops the crash and defeats the check, because an empty array is a legal `tiers`: the block satisfied its schema, rendered, and drew a price section with a heading, a button and no prices. The guard now covers the operation and not the payload, so the value the model actually sent reaches the layer that knows what a tier list looks like. `layoutRules` read `.length` off fields it declared as `unknown[]` and `JSON.parse` filled with whatever the model said. `"免费"` is two characters long, so a business with no tiers at all got the three-column comparison layout — a rule whose whole job is counting, silently counting the wrong thing. It counts with `Array.isArray` now, and the parameter says `unknown` so nothing else can be written. `themeVars` answers an unrecognised key with forest and says nothing. That is right for a total function and wrong as the only place anyone finds out: the page rendered green while the decision log, the theme picker and the exported tokens all named something else. `isThemeKey` narrows the Choice answer where it enters, so the fallback stays available for the MCP tool that wants it and labels it, and becomes unreachable in the app. One mutation stayed green on the first pass and is worth recording: laundering `tiers` back to an empty array in `pricing_multi` failed no test, because a wrong-typed `tiers` stops `layoutRules` firing and the unpicked-slot fallback then chooses `pricing_single`, so the multi path was never exercised. `lib/content.test.ts` now drives both variants by name off `SLOTS` rather than by hand. --- CONTRIBUTING.md | 2 +- README.md | 4 +-- app/page.tsx | 9 +++++- docs/CONTRIBUTING.zh.md | 2 +- docs/README.de.md | 4 +-- docs/README.es.md | 4 +-- docs/README.fr.md | 4 +-- docs/README.ja.md | 4 +-- docs/README.ko.md | 4 +-- docs/README.pt.md | 4 +-- docs/README.zh.md | 4 +-- lib/catalog.test.ts | 71 +++++++++++++++++++++++++++++++++++++++++ lib/catalog.ts | 33 +++++++++++++++++++ lib/compose.test.ts | 45 +++++++++++++++++++++++--- lib/compose.ts | 12 +++++-- lib/content.test.ts | 46 ++++++++++++++++++++++++++ lib/content.ts | 12 ++++--- lib/plan.test.ts | 9 ++++++ lib/plan.ts | 16 ++++++---- lib/themes.test.ts | 10 +++++- lib/themes.ts | 9 ++++++ 21 files changed, 270 insertions(+), 38 deletions(-) create mode 100644 lib/catalog.test.ts create mode 100644 lib/content.test.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0f98e42..4041ad5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,7 +6,7 @@ Get it running first, so you know the environment is sound: ```bash npm install -npm test # 347 of them, ~1.5s, no network +npm test # 362 of them, ~1.5s, no network npm run dev # demo mode; no keys needed ``` diff --git a/README.md b/README.md index ea9a391..0bb2a6b 100644 --- a/README.md +++ b/README.md @@ -336,12 +336,12 @@ The audit needs a running server and `npx playwright install chromium`, so it st ```bash -npm test # 347 of them, ~1.5s, no network +npm test # 362 of them, ~1.5s, no network ``` They cover **the three rules taken back from the model** — selling-point count decides grid vs list, tier count decides single vs comparison, whether there is a UI screenshot decides the hero layout. If any of these drift, the decision quietly goes back to a model that cannot make it, so they are the ones that must not move. -Also covered: readiness gating (an empty array counts as missing, not ready), `asSettled` completion ordering, and the JSX serializer's sharp edges — custom properties need `as CSSProperties`, semicolons inside a gradient are not separators, text containing braces must be wrapped, and the `blk-in` animation class and `