From 2f0fdbd213f1d6840a5e45bed812c7e1deb83b8d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 08:16:04 +0000 Subject: [PATCH 1/4] =?UTF-8?q?test(console,layout):=20parity=20=E9=97=A8?= =?UTF-8?q?=E6=8C=89=20ADR-0087=20=E5=A2=93=E7=A2=91=E6=94=B6=E7=AA=84?= =?UTF-8?q?=E6=8E=A5=E5=8F=97=E9=9B=86,=E8=AF=86=E5=88=AB=E5=81=9A?= =?UTF-8?q?=E6=88=90=E5=85=B1=E4=BA=AB=20judge=20(#3809)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `retiredKey()` 退役一个键不是删条目 —— 它把成员换成 `z.never().optional()`, 所以退役键仍留在 `.shape` 里。任何从 `Object.keys(shape)` 推导「spec 接受的键」 的门,回答的都不是它名字宣称的那个问题。 `registry-inputs-spec-parity.test.ts` 的两个方向读同一个集合,于是在墓碑上朝 相反方向失效:正方向(不得声明 spec 拒绝的键)把墓碑算作「接受」→ 假绿; 反方向(spec 声明的键必须可发现)把它算作「声明」→ 假红,要求本仓去发布一个 契约按名拒绝的键。一处收窄 `specTopLevelKeys` 同时修掉两向。 卡里说「今天休眠」已经过期:它写于 rc.5(`ComponentPropsMap` 零墓碑),而 rc.6 pin(#4167)带来八个 —— page:header.icon、page:card.actions、 page:card.body、page:tabs.type、record:details.layout,加 element:record_picker 的 displayField / searchFields / multiple。反方向的红从那次 pin 起就是活的,由 八条显式豁免逐键吸收,每条都写明只有本卡能解。八条一并删除:不是手挑,而是收窄 后原有的 dangling + stale 两道检查各自点名了全部八条。此后自清 —— 上游之后退役 的键一进 shape 就离开接受集,顺带带走覆盖它的豁免,不再需要后续 issue。 判据收进 `@object-ui/test-support`(私有、不发布):同一判断此前在四处手写,副本 已经漂移(两处只有结构通道,一处压根没有 —— 就是本卡的假绿)。共享 judge 把结构 通道与 `[REMOVED]` 描述通道 OR 起来,任一条被改坏都不会静默变宽;校准只做一次, 且是把结构判据钉在契约自身 `safeParse` 的实际拒绝上。`packages/layout` 的 page-header 门按它自己注释的要求换用共享判据。 两处消费者都断言了推导的前提(退役保留成员),所以上游哪天改成真删键,这些过滤器 会被判成死码而不是静默过滤零个。plugin-detail 与 app-shell 还各有一份本地副本, 今天都是对的,#4947 跟踪转换。 Fixes #3809 Co-authored-by: Claude --- .../tombstone-aware-spec-parity-3809.md | 39 ++ apps/console/package.json | 1 + .../registry-inputs-spec-parity.test.ts | 444 +++++++++++------- packages/layout/package.json | 1 + .../page-header-authorable-keys.test.tsx | 63 ++- packages/test-support/README.md | 19 + packages/test-support/package.json | 4 +- .../src/__tests__/spec-tombstones.test.ts | 260 ++++++++++ packages/test-support/src/index.ts | 11 + packages/test-support/src/spec-tombstones.ts | 227 +++++++++ pnpm-lock.yaml | 12 + 11 files changed, 898 insertions(+), 183 deletions(-) create mode 100644 .changeset/tombstone-aware-spec-parity-3809.md create mode 100644 packages/test-support/src/__tests__/spec-tombstones.test.ts create mode 100644 packages/test-support/src/spec-tombstones.ts diff --git a/.changeset/tombstone-aware-spec-parity-3809.md b/.changeset/tombstone-aware-spec-parity-3809.md new file mode 100644 index 0000000000..d6e0d32667 --- /dev/null +++ b/.changeset/tombstone-aware-spec-parity-3809.md @@ -0,0 +1,39 @@ +--- +--- + +Test-only (objectui#3809). The gates that ask "does `@objectstack/spec` accept this key?" now +subtract ADR-0087 D2 tombstones instead of reading raw `Object.keys(schema.shape)`, and the +judgement lives in one place: `@object-ui/test-support`'s `spec-tombstones` module. + +Retiring an authorable key upstream does not delete it. `retiredKey()` replaces the member with +`z.never().optional()` on purpose — a deleted key is silently stripped by a non-strict parse, +while a `never` member fails `tsc` at the authoring site and raises the upgrade prescription on +parse. So a retired key stays in the shape, and every gate deriving "the accepted keys" from raw +`Object.keys` was answering a different question than its name claimed. In +`apps/console/src/__tests__/registry-inputs-spec-parity.test.ts` both parity directions read that +one set and failed opposite ways on it: the forward direction (a block may not declare a key the +spec rejects) counted the tombstone as accepted and went falsely GREEN, while the reverse +direction (every spec key must be discoverable) counted it as declared and went falsely RED, +demanding that a block publish a key the contract refuses by name. + +Not dormant, contrary to the issue's premise: it was filed against `17.0.0-rc.5`, which carried no +tombstone in `ComponentPropsMap`, and the `17.0.0-rc.6` pin (objectui#4167) brought eight — +`page:header.icon`, `page:card.actions`, `page:card.body`, `page:tabs.type`, +`record:details.layout` and the `element:record_picker` `displayField` / `searchFields` / +`multiple` trio. The reverse direction's red was live from that pin, absorbed key by key by eight +explicit exemptions that each named this issue as the only thing that could clear them. All eight +are deleted here — not by hand-picking, but because the narrowing makes the existing +dangling-and-stale checks report every one of them. The mechanism is self-clearing from now on: a +key upstream retires later leaves the accepted set on arrival and takes any exemption covering it +with it, no follow-up issue required. + +`packages/layout/src/__tests__/page-header-authorable-keys.test.tsx` drops its local copy of the +probe for the shared one, as its own note asked; the shared judge adds a second recognition +channel (the `[REMOVED]` marker `retiredKey()` stamps on the description, OR-ed with the +structural criterion so neither can quietly go permissive) and is calibrated once against what +the installed contract's `safeParse` really rejects. Both consuming gates assert the derivation's +premise — that a retirement KEEPS the member — so if upstream ever retires by deleting keys, the +filters are judged dead code instead of silently narrowing nothing. Two further local copies of +the same judgement remain in `packages/plugin-detail` and `packages/app-shell`, both correct +today; objectui#4947 tracks converting them. No published behaviour changes, so this declares no +release. diff --git a/apps/console/package.json b/apps/console/package.json index 92f5fa8386..7c1c6f7116 100644 --- a/apps/console/package.json +++ b/apps/console/package.json @@ -88,6 +88,7 @@ "@object-ui/plugin-view": "workspace:*", "@object-ui/providers": "workspace:*", "@object-ui/react": "workspace:*", + "@object-ui/test-support": "workspace:*", "@object-ui/types": "workspace:*", "@objectstack/client": "^17.0.0-rc.6", "@objectstack/spec": "^17.0.0-rc.6", diff --git a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts index 05681e83fc..3301467b4a 100644 --- a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts +++ b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts @@ -98,8 +98,8 @@ * exemption red, so the list cannot rot into a permanent allowlist. * * LIMIT — worth knowing before trusting a pass. This gate compares TOP-LEVEL - * KEY NAMES and nothing else. Three things it therefore cannot see, all of them - * real and all filed: + * KEY NAMES and nothing else. Two things it therefore cannot see, both real and + * both filed: * * - member shapes. An `inputs` entry of type `array`/`object` declares no * member shape (`ComponentInput` has no slot for one), so a drifted key @@ -109,22 +109,42 @@ * per-block tests next to their renderers. PR #3795's open question; * - types. `ComponentInput.type` is one coarse control kind and cannot spell a * spec union, so a key can be in perfect NAME parity while publishing a - * narrower type than the contract accepts (objectui#3832); - * - `retiredKey()` tombstones. `Object.keys(shape)` still contains a key the - * spec rejects BY NAME, and the two directions then fail opposite ways — - * forward reads the tombstone as "accepted" and goes falsely GREEN, reverse - * reads it as "declared" and would demand the block publish it, going - * falsely RED. Dormant today (zero tombstones in the pinned rc.5) and fixed - * in one place — narrowing `specTopLevelKeys` — for both directions at once: - * objectui#3809. Until then the reverse direction's exemptions for the - * `element:record_picker` trio are what absorb the red, and they say so. + * narrower type than the contract accepts (objectui#3832). * * A pass means the top-level key names are in parity, nothing more. + * + * `retiredKey()` TOMBSTONES USED TO BE THE THIRD — CLOSED, objectui#3809. + * ADR-0087 D2 retirement replaces a member with `z.never().optional()` instead + * of deleting it, so raw `Object.keys(shape)` reported a key the spec rejects BY + * NAME as though the contract accepted it. Both directions read that one set, + * and they failed OPPOSITE ways on it: forward went falsely GREEN on a block + * publishing a retired key, reverse went falsely RED demanding that a block + * publish one. `specTopLevelKeys` now subtracts tombstones, which is the single + * point that fixes both, and the derivation's own premise — that a retirement + * KEEPS the member — is asserted rather than assumed (`the tombstone premise + * still holds`), so the day upstream starts deleting keys outright this filter + * is judged dead code instead of silently narrowing nothing. + * + * The blind spot was NOT dormant by the time it was fixed, which is worth + * recording because the issue was filed believing it was. It was written against + * `@objectstack/spec@17.0.0-rc.5`, where `ComponentPropsMap` carried no + * tombstone at all; the rc.6 pin (objectui#4167) brought EIGHT, and the reverse + * direction's red was live from that moment — absorbed, key by key, by the eight + * `UNPUBLISHED_EXEMPTIONS` entries that named this issue as the only thing that + * could resolve them. Those eight are deleted with this change; the pin below + * (`the eight tombstoned keys are recognised, not exempted`) is what keeps their + * deletion from being quietly undone by re-exempting a key instead. */ import { describe, it, expect } from 'vitest'; import { ComponentRegistry } from '@object-ui/core'; import { ComponentPropsMap } from '@objectstack/spec/ui'; +import { + authorableShapeKeys, + isShapeKeyTombstoned, + listedShapeKeys, + tombstonedShapeKeys, +} from '@object-ui/test-support'; // The two graphs whose registrations this file reads, at module scope rather // than in a hook: their cold transform is billed to the import phase, which has @@ -132,22 +152,49 @@ import { ComponentPropsMap } from '@objectstack/spec/ui'; import '@object-ui/components'; import '../register-plugins'; +/** This block's spec props schema, or `undefined` when this pin has none. */ +const specSchema = (type: string): unknown => (ComponentPropsMap as Record)[type]; + /** - * Top-level keys `ComponentPropsMap[type]` accepts. + * Top-level keys `ComponentPropsMap[type]` ACCEPTS — tombstones excluded + * (objectui#3809). * - * Reads `.shape` through the same two spellings PR #3795's single-block version - * uses, so a `lazySchema()`-wrapped entry (every `element:*`) and a plain - * `z.object` both resolve. Zod-internals access is confined to this function. + * This one function is where both directions of this file get their notion of + * "the contract's authoring surface", which is why narrowing it here fixes two + * opposite defects at once. It used to be raw `Object.keys(shape)`, and an + * ADR-0087 D2 retirement does not delete the key — it replaces the member with + * `z.never().optional()` — so a key the spec rejects BY NAME kept answering + * "declared". Forward that reads as GREEN on a block publishing a retired key; + * reverse it reads as RED demanding a block publish one. Same set, opposite + * failures. + * + * The judgement itself lives in `@object-ui/test-support` rather than here: it + * had been hand-written four times across this repo's gates, the copies had + * already drifted (two structural-only, one absent — this file), and the shared + * one is calibrated once against what the contract's own `safeParse` rejects + * (`spec-tombstones.test.ts`). Zod-internals access now happens in exactly one + * module repo-wide. */ function specTopLevelKeys(type: string): string[] { - const schema = (ComponentPropsMap as Record)[type] as - | { shape?: unknown; _def?: { shape?: unknown } } - | undefined; - const shape = schema?.shape ?? schema?._def?.shape; - const resolved = typeof shape === 'function' ? (shape as () => object)() : shape; - return resolved && typeof resolved === 'object' ? Object.keys(resolved) : []; + return authorableShapeKeys(specSchema(type)); } +/** + * Every top-level key the schema still LISTS — tombstones INCLUDED. + * + * Deliberately kept alongside the narrowed set, because two questions in this + * file are about the RELEASE rather than about the authoring surface, and a + * retired key must answer YES to them: "did this pin ever carry the key at all" + * (`isDormantOnThisPin`) and "is the tombstone premise still true" + * (`the tombstone premise still holds` below). Using the narrowed set for + * either would be the same conflation in a new place — a retired key would read + * as a key the pin never had. + */ +const specListedKeys = (type: string): string[] => listedShapeKeys(specSchema(type)); + +/** The listed top-level keys this block's spec schema rejects by name. */ +const specTombstonedKeys = (type: string): string[] => tombstonedShapeKeys(specSchema(type)); + /** Declared input names for a registered block, or `null` when not registered. */ function declaredInputs(type: string): string[] | null { const config = ComponentRegistry.getConfig(type); @@ -392,148 +439,96 @@ const OFF_SPEC_EXEMPTIONS: Record = {}; * configuration the platform silently drops (the objectui#3797 direction, in * reverse) — the choice between wiring it and declaring it with a KNOWN GAP * is a contract decision, not an implementation detail; - * - the spec rejects it by name upstream already and only a stale pin still - * lists it; + * - the installed pin does not declare the key yet, so declaring the input + * would fail this file's own forward direction today; * - the key is out of the dispatched scope of the change that added this gate, * and its own issue owns it. * + * ONE CLASS IS GONE, and it is worth knowing which, because it used to be the + * biggest: "the spec rejects this key by name upstream already". A key the spec + * REJECTS needs no exemption at all since objectui#3809 — it leaves the accepted + * set on its own, so nothing demands it and nothing has to license not + * publishing it. Eight entries of that class were harvested (see the comment at + * the top of the map). An entry whose reason reduces to "upstream retired it" is + * therefore the one thing that may never be ADDED here again: it would go + * dangling-and-stale in the same run that wrote it. + * * Every reason cites an issue, which `references a tracking issue` asserts. * Verified against renderer read sites at objectui `origin/main` @ `c25222758` * with `@objectstack/spec@17.0.0-rc.6` — not assumed from the spec's wording. - * (The four `…-rc.5` mentions left in the entries below are the stale-pin - * entries' own prose and belong to their issues, not to this header.) */ const UNPUBLISHED_EXEMPTIONS: Record = { - // ── page:header.icon / page:card.actions — retired upstream (2 keys) ─────── - // These two used to be a MENU: objectui#3829 filed them as a three-way fork - // (wire them; declare them with a KNOWN GAP per the - // `record:activity.showSubscriptionToggle` precedent; retire them upstream) - // and this entry listed all three so no implementing agent would guess. The - // fork is closed. The maintainer ruled route (c) on 2026-08-09 — - // zero producers, zero consumers, zero demand — and objectstack#6946 / - // PR objectstack#7115 executed it: both keys are ADR-0087 D2 tombstones in - // `@objectstack/spec` 17.0.0, live on the rc.6 this repo installs. So the - // class here is no longer B (undecided) but the same one as - // `record:details.layout` below: the spec rejects the key BY NAME, and the - // reverse direction demands it anyway because the tombstone is still a member - // of the shape. - // - // Read the upstream prescriptions before touching either key — they say what - // replaces it, which is why neither is coming back. A header's identity is - // drawn by the record chrome (`recordChrome`, on by default) plus each - // action's own `icon`; a card's buttons are authored as components in - // `children` or `footer` (`element:button`, `record:quick_actions`). - // - // DO NOT DELETE THESE TWO ENTRIES YET, and the reason is the one this file - // already writes out twice above: D2 retirement REPLACES the member with - // `z.never()` rather than deleting it, so `Object.keys(shape)` still reports - // both keys as declared and `carries no stale unpublished-key exemption` - // still needs the cover. They resolve when objectui#3809's tombstone - // recognition narrows `specTopLevelKeys` — not on a pin bump, and not by - // declaring the inputs. - // - // The objectui half of route (c) is otherwise complete (objectui#3829). - // `page:card.actions` had no producer at all; `page:header.icon` had exactly - // one — the metadata-admin designer's BLOCK_CONFIG field for the CANONICAL - // `page:header`, which kept offering authors an icon box whose value rc.6 - // rejects by name — and it was removed with its two i18n keys in the same - // change that rewrote these entries. The `layout:page-header` ALIAS keeps its - // `icon` input deliberately: that is a different renderer with a real read - // point (`packages/layout/src/PageHeader.tsx`), so the two are opposite facts, - // not an inconsistency. - 'page:header.icon': - 'Retired upstream by objectstack#6946 / PR objectstack#7115 (ADR-0087 D2 tombstone) — PageHeaderRenderer never had a read point: `icon` inside containers.tsx:973-1677 is only ever per-action (`action.icon`, :1428/:1472) or a nav item (:641/:816), and the spec now rejects the key by name, prescribing the record chrome plus per-action icons instead. Unlike the stale-pin entries below this one is LIVE at @objectstack/spec@17.0.0-rc.6: the tombstone stays in `Object.keys(shape)`, so the reverse direction demands a key the contract refuses. Resolves via objectui#3809 tombstone recognition, not by declaring the input — objectui#3829.', - 'page:card.actions': - 'Retired upstream by objectstack#6946 / PR objectstack#7115 (ADR-0087 D2 tombstone) — PageCardRenderer (containers.tsx:703-745) builds its card from title/body/children/footer and never had an actions area, and the spec now rejects the key by name, prescribing buttons authored as components in `children` or `footer` (`element:button`, `record:quick_actions`). Unlike the stale-pin entries below this one is LIVE at @objectstack/spec@17.0.0-rc.6: the tombstone stays in `Object.keys(shape)`, so the reverse direction demands a key the contract refuses. Resolves via objectui#3809 tombstone recognition, not by declaring the input — objectui#3829.', - - // ── page:tabs.type — the carrier collision, from the other side ──────────── - // The mirror image of the `page:tabs.tabStyle` exemption in - // `OFF_SPEC_EXEMPTIONS` above, and the same single fact seen twice: the spec - // spells this concept `type`, the flat SDUI carrier cannot express it (a flat - // node is `{ type: 'page:tabs', … }` where `type` is the dispatch tag, and - // `SchemaRenderer.tsx:251-270` deliberately refuses to hoist - // `properties.type`), and `validate.ts` lists `'type'` in `BASE_PROPS` so it - // is skipped as a base prop and could not be validated as an input even if - // declared. Publishing it would advertise a key this repo's own parser cannot - // check, on a spelling the carrier cannot carry. Convergence is upstream. - 'page:tabs.type': - "Spec's spelling of the tabStyle concept; unpublishable in the flat carrier (`type` is the dispatch key, SchemaRenderer.tsx:251-270) and unvalidatable as an input (validate.ts BASE_PROPS). The renderer does read it when it survives as `properties.type` (containers.tsx:381). Upstream contract decision: objectstack#6776.", - - // ── element:record_picker — retired upstream, stale pin only (3 keys) ────── - // objectstack#5775 (ADR-0087 D2) turned these three into `retiredKey()` - // tombstones, converging on the `labelField` / `valueField` this renderer - // actually reads (`renderers/basic/record-picker.tsx:80-81`). Declaring a key - // the spec has retired is the objectui#3797 direction again. - // - // TWO THINGS THE PIN BUMP WILL DO HERE, and objectui#3808 got the first of - // them wrong, so it is written out: - // 1. these three do NOT vanish from `Object.keys(shape)`. ADR-0087 D2 - // retirement REPLACES the entry with `z.never().optional()`, it does not - // delete it — so they stay "declared" to this gate and these exemptions - // stay live rather than going stale. They resolve when objectui#3809's - // tombstone recognition narrows `specTopLevelKeys`, not when the pin - // moves; - // 2. `sort` / `limit` / `emptyText` — which #5775 ADDS and this renderer - // already reads (`record-picker.tsx:79/80` and `:170`) — become brand-new - // A-class gaps, and this gate will go RED demanding them. That red is - // correct and wanted: it is the pin bump's own reminder to declare them, - // the way `record:details.hideFields` was declared here. - 'element:record_picker.displayField': - 'Retired upstream by objectstack#5775 (ADR-0087 D2 tombstone, converging on the `labelField` this renderer reads); declaring it would publish a key the spec rejects by name. Listed here only because the pinned @objectstack/spec@17.0.0-rc.5 predates the retirement. Resolves via objectui#3809, not via the pin bump.', - 'element:record_picker.searchFields': - 'Retired upstream by objectstack#5775 (ADR-0087 D2 tombstone); declaring it would publish a key the spec rejects by name. Listed here only because the pinned @objectstack/spec@17.0.0-rc.5 predates the retirement. Resolves via objectui#3809, not via the pin bump.', - 'element:record_picker.multiple': - 'Retired upstream by objectstack#5775 (ADR-0087 D2 tombstone); declaring it would publish a key the spec rejects by name. Listed here only because the pinned @objectstack/spec@17.0.0-rc.5 predates the retirement. Resolves via objectui#3809, not via the pin bump.', - - // ── page:card.body — retired upstream, stale pin only (1 key) ───────────── - // The fourth ADR-0087 D2 tombstone from the same upstream issue as the three - // above, and it withdraws here for the same reason: objectstack#5775 - // (PR objectstack#6281) replaced `PageCardProps.body` with `children`, the - // spelling every other container uses and the one this renderer reads - // (`containers.tsx`, `schema?.body ?? schema?.children`). Continuing to - // publish `body` was objectui#4027 — a designer teaching a key the contract - // rejects by name. - // - // The renderer's `body` READ deliberately survives the declaration's removal: - // documents stored under the old contract keep rendering until the ADR-0087 D2 - // conversion rewrites the key at load time. A back-compat read is not an - // authoring surface, so it does not belong in `inputs` — the same split the - // `page-header-subtitle-alias` sequencing already established in - // `packages/layout`. - // - // Like the record_picker trio, this entry does NOT go stale when the pin - // moves: D2 retirement replaces the entry with `z.never().optional()` rather - // than deleting it, so `Object.keys(shape)` still reports `body` as declared. - // It resolves when objectui#3809's tombstone recognition narrows - // `specTopLevelKeys`. - 'page:card.body': - 'Retired upstream by objectstack#5775 / PR objectstack#6281 (ADR-0087 D2 tombstone, converging on the `children` this renderer reads and now publishes); declaring it would publish a key the spec rejects by name — objectui#4027. Listed here only because the pinned @objectstack/spec@17.0.0-rc.5 predates the retirement. Resolves via objectui#3809, not via the pin bump.', - - // ── record:details.layout — retired upstream AND withdrawn here (1 key) ─── - // The fifth D2 tombstone, and the first one whose objectui half has actually - // landed — so it is here for a DIFFERENT reason than the four above, and the - // difference is worth reading before treating it as more of the same. - // - // Those four are stale-pin cover: the key is still published in this repo and - // the entry says "the pin predates the retirement". This one is the opposite. - // objectui#3818 DELETED the `record:details` `layout` input (the spec's - // `auto` | `custom` semantics were never implemented — the renderer's only - // read tested `inline` | `compact`, values the schema never permitted, so both - // legal values took the same branch and the key selected nothing), which is - // exactly what this gate's forward direction wants. The entry exists because - // the REVERSE direction then demands the key back: `specTopLevelKeys` reads - // raw `Object.keys(shape)`, the ADR-0087 D2 tombstone is still an entry in - // that shape, and so a key the spec rejects by name reads as "declared, and - // you failed to publish it". - // - // That is objectui#3809's blind spot seen from the other side — it predicted a - // false GREEN in the forward direction, and this is the same root cause - // producing a false RED in the reverse one. Both vanish together when #3809 - // narrows `specTopLevelKeys` to non-tombstone members; this entry then goes - // stale and `carries no stale unpublished-key exemption` will name it, along - // with the four above. Do not resolve it by re-adding the input. - 'record:details.layout': - 'Retired upstream by objectstack#6946 (ADR-0087 D2 tombstone) and withdrawn here by objectui#3818 — its published `auto` | `custom` semantics were never implemented, and the spec now rejects the key by name, so publishing it again would teach a key the contract refuses. Unlike the stale-pin entries above this one is live at @objectstack/spec@17.0.0-rc.6: the tombstone stays in `Object.keys(shape)`, so the reverse direction demands a key the forward direction forbids. Resolves via objectui#3809 tombstone recognition, not by declaring the input.', + /* + * EIGHT TOMBSTONE ENTRIES HARVESTED HERE — objectui#3809, and they were + * designed to die exactly this way. + * + * `page:header.icon`, `page:card.actions`, `page:tabs.type`, + * `element:record_picker.displayField` / `.searchFields` / `.multiple`, + * `page:card.body` and `record:details.layout`. Every one of them existed for + * the same reason, said so in its own words, and named this issue as the only + * thing that could resolve it: the key is an ADR-0087 D2 tombstone — retired + * upstream, and STILL a member of the spec's shape, because D2 retirement + * replaces the member with `z.never().optional()` rather than deleting it. So + * the reverse direction, reading raw `Object.keys(shape)`, demanded that this + * repo publish a key the contract rejects by name, and each entry was cover + * for that false red. + * + * They are not deleted by hand-picking. `specTopLevelKeys` now subtracts + * tombstones, and the two checks that police this list did the rest: the key + * is no longer in the accepted set, so `every unpublished-key exemption names a + * key the spec really declares` reports each as DANGLING, and + * `carries no stale unpublished-key exemption` reports each as STALE. Both + * name all eight. Deleting them is the only way to get green, which is the + * discipline this file's header promises working end to end. + * + * THE FIVE UPSTREAM RETIREMENTS these eight came from, kept for the reader who + * needs to know why none of the keys is coming back — the prescriptions are + * upstream's, not this repo's: + * + * - objectstack#5775 / PR objectstack#6281 — the `element:record_picker` + * trio converges on `labelField` / `valueField` (which this renderer reads + * and this repo publishes); `PageCardProps.body` converges on `children`, + * the spelling every other container uses and the one `page:card` now + * publishes (objectui#4027); + * - objectstack#6946 / PR objectstack#7115 — `page:header.icon` (a header's + * identity is the record chrome plus each action's own icon), + * `page:card.actions` (buttons are authored as components in `children` or + * `footer`), and `record:details.layout` (withdrawn here by objectui#3818: + * its published `auto` | `custom` semantics were never implemented); + * - objectstack#6776 — `page:tabs.type`. This one resolves DIFFERENTLY from + * its own entry's prediction, and the difference is worth a sentence. The + * entry read it as a live spec key that the flat SDUI carrier cannot + * express (a node is `{ type: 'page:tabs', … }`, where `type` is the + * dispatch tag, and `validate.ts` lists `'type'` in `BASE_PROPS`), and + * called convergence "upstream". Upstream converged: it retired the `type` + * spelling in favour of `tabStyle`, which this repo already publishes. So + * the carrier collision is not tolerated any more, it is gone — the + * contract now has one spelling, and it is the publishable one. + * + * Renderer READS of these keys are untouched and stay untouched. A stored + * document written against the old contract keeps rendering until an ADR-0087 + * D2 conversion rewrites it at load time; a back-compat read is not an + * authoring surface, so it never belonged in `inputs` (the split + * `page-header-subtitle-alias` established in `packages/layout`). This harvest + * withdraws EXEMPTIONS, not capability. + * + * WHAT HAPPENS AT THE NEXT PIN BUMP, so nobody reads the next red as a + * regression: the mechanism is now self-clearing. A key upstream retires after + * this change enters the shape as a tombstone, leaves the accepted set on + * arrival, and any exemption covering it goes dangling-and-stale in the same + * run — no issue needed, no filter to remember. Two of the entries still below + * are already queued for it: objectstack `origin/main` tombstones + * `targetVariable` on BOTH `element:text_input` and `element:record_picker` + * (measured on `main` @ `23abe2782`, absent from the pinned rc.6), so the pin + * that carries those retirements will name both entries here. Deleting them is + * the fix — objectui#3834's "should we publish an intent-only key" question is + * answered upstream by then, in the negative. + * + * DO NOT resolve a tombstone red by declaring the input. That publishes a key + * the contract rejects by name and fails the forward direction immediately; + * the two directions of this file are a vice on exactly that move, which is + * why one of them could not be fixed without the other. + */ // `element:record_picker.filter` was the ninth entry here — a real A-class gap // that fell out of objectui#3808's three-class triage, exempted only because it @@ -707,11 +702,20 @@ const splitExemptionKey = (exemptionKey: string): [string, string] => { * Is this a GA-pending entry the installed spec does not carry? Such an entry * is judged by neither the dangling nor the stale check — both of those ask * questions about a key that does not exist on this pin. + * + * `specListedKeys`, NOT the narrowed accepted set, and the difference is the one + * objectui#3809 is about (see that function's own note). Dormancy is a question + * about the RELEASE: does this pin know the key at all? A tombstone answers YES + * — the release knows it and refuses it — so an exemption covering a retired key + * must stay LIVE and be reported as dangling-and-stale, which is what forces its + * deletion. Asking the narrowed set here would call every future retirement + * "dormant" and hand a retired key's exemption a permanent hiding place, which + * is the same blind spot one layer down. */ const isDormantOnThisPin = (exemptionKey: string): boolean => { if (!GA_PENDING_UNPUBLISHED_KEYS.includes(exemptionKey)) return false; const [type, specKey] = splitExemptionKey(exemptionKey); - return !specTopLevelKeys(type).includes(specKey); + return !specListedKeys(type).includes(specKey); }; /* @@ -791,6 +795,55 @@ describe('registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide) } }); + it('the tombstone premise still holds — a retired key stays IN the shape', () => { + // THE PREMISE, asserted rather than assumed (objectui#3809). Every tombstone + // filter in this file — in BOTH directions, since they share one accepted + // set — is built on one property of ADR-0087 D2: retirement REPLACES the + // member with `z.never().optional()` and leaves the entry listed. If + // upstream ever retires by DELETING the key instead, the filter stops + // narrowing anything and every assertion here goes on passing. That is dead + // code nobody can see, and it is the failure mode objectui#3809's own text + // warned about before the fix existed. + // + // So the shape of this assertion is deliberate: it is not "tombstones are + // handled correctly", it is "there is still something for the handling to + // do". A red here does not mean the gate is wrong; it means the premise + // expired, and the filter plus this test plus the harvest comment above are + // all now archaeology to be removed together. + const listedButRejected = covered.flatMap((type) => + specTombstonedKeys(type).map((key) => `${type}.${key}`), + ); + expect( + listedButRejected.length, + 'no covered block lists a tombstoned key: either this pin predates every ADR-0087 D2 ' + + 'retirement, or upstream now DELETES retired keys — in which case the tombstone ' + + 'narrowing in `specTopLevelKeys` is dead code and must be removed, not kept', + ).toBeGreaterThan(0); + + // …and the narrowing is not a no-op, per block. `listed` must strictly + // exceed `accepted` exactly where a tombstone was found — the third way this + // could rot is a judge that reports tombstones while the subtraction quietly + // stops using its answer. + for (const type of covered) { + const tombstoned = specTombstonedKeys(type); + if (tombstoned.length === 0) continue; + expect(specListedKeys(type).length, `${type} accepted set did not narrow`).toBeGreaterThan( + specTopLevelKeys(type).length, + ); + for (const key of tombstoned) { + expect(specListedKeys(type), `${type}.${key} is not even listed`).toContain(key); + expect(specTopLevelKeys(type), `${type}.${key} survived the narrowing`).not.toContain(key); + } + } + + // Non-vacuity for the judge itself, in the direction the loops above cannot + // reach: a probe that answered "tombstone" for EVERYTHING would satisfy all + // of them. `page:card.title` is live contract and this repo publishes it, so + // it is the control. + expect(isShapeKeyTombstoned(specSchema('page:card'), 'title')).toBe(false); + expect(specTopLevelKeys('page:card')).toContain('title'); + }); + it.each(covered)('%s declares no top-level input the spec does not accept', (type) => { const exempt = new Set(exemptedFor(type)); const unregistered = offSpecInputs(type).filter((name) => !exempt.has(name)); @@ -1007,10 +1060,13 @@ describe('registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide) it('carries no stale unpublished-key exemption — a published key must lose its entry', () => { // Keeps the reverse list from rotting the same way. An entry goes stale when - // the block declares the input (objectui#3829/#3830/#3834 landing) or when - // the spec genuinely deletes the key — note that ADR-0087 D2 retirement is - // NOT a deletion, so the `element:record_picker` trio does not go stale on - // the pin bump; objectui#3809 is what resolves those. + // the block declares the input (objectui#3829/#3830/#3834 landing), when the + // spec genuinely deletes the key, or — since objectui#3809 — when the spec + // RETIRES it: a tombstone leaves the accepted set, so the reverse direction + // stops demanding the key and any entry covering it stops describing + // anything. That last arm is what harvested the eight entries named in the + // comment above, and it is why no future retirement needs an issue of its + // own to clean up after it. const stale = Object.keys(UNPUBLISHED_EXEMPTIONS) .filter((key) => !isDormantOnThisPin(key)) .filter((key) => { @@ -1020,6 +1076,74 @@ describe('registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide) expect(stale).toEqual([]); }); + it('the eight tombstoned keys are recognised, not exempted — and not published either', () => { + // The pin the harvest leaves behind (objectui#3809). Deleting eight + // exemptions is only half the change: the derived assertions above would go + // green just as readily if a future edit RE-EXEMPTED one of these keys, or + // if the tombstone narrowing stopped working and the entry came back to + // absorb the red again. Both moves restore the exact state this issue + // existed to end, and neither shows up as a failure anywhere else — which is + // the same reason the `#3808 / #3830` and `rc.6 record_picker` pins next door + // are written by name. + // + // Five upstream retirements, eight keys, three facts each. The list is + // pin-dependent by construction and that is the point: it is the measurement + // (`@objectstack/spec@17.0.0-rc.6`) this change was made against, so a pin + // that un-retires one of them fails HERE, naming the key, instead of + // resurfacing as an unexplained red in a derived loop. + const HARVESTED: Array<[string, string]> = [ + ['element:record_picker', 'displayField'], + ['element:record_picker', 'multiple'], + ['element:record_picker', 'searchFields'], + ['page:card', 'actions'], + ['page:card', 'body'], + ['page:header', 'icon'], + ['page:tabs', 'type'], + ['record:details', 'layout'], + ]; + + for (const [type, key] of HARVESTED) { + // Still LISTED: the release knows the key. This is the assertion that + // distinguishes "retired" from "this pin never had it", and without it the + // three below would pass just as well on a key that simply does not exist. + expect(specListedKeys(type), `${type} no longer lists ${key} at all`).toContain(key); + expect( + isShapeKeyTombstoned(specSchema(type), key), + `${type}.${key} is listed but no longer reads as a tombstone — did upstream un-retire it?`, + ).toBe(true); + // Not demanded by the reverse direction any more, which is what made the + // exemption unnecessary… + expect( + undiscoverableSpecKeys(type), + `${type}.${key} is being demanded again; the narrowing is not being applied`, + ).not.toContain(key); + // …and not covered by one either. + expect( + Object.keys(UNPUBLISHED_EXEMPTIONS), + `${type}.${key} is exempted again — a tombstone needs no cover`, + ).not.toContain(`${type}.${key}`); + // The other resolution the two directions exist to forbid: publishing the + // key. The forward direction would red on it, but stating it here is what + // makes THIS test the one place a reader learns both halves. + expect( + declaredInputs(type) ?? [], + `${type} publishes ${key}, a key the contract rejects by name`, + ).not.toContain(key); + } + + // Completeness, derived rather than restated: no OTHER tombstoned key on a + // covered block may carry an exemption. An entry for one would be dangling + // (the checks above name it), but this states the rule positively so the + // next retirement is not resolved by writing an entry that then has to be + // harvested a second time. + const exemptedTombstones = covered.flatMap((type) => + specTombstonedKeys(type) + .filter((key) => Object.keys(UNPUBLISHED_EXEMPTIONS).includes(`${type}.${key}`)) + .map((key) => `${type}.${key}`), + ); + expect(exemptedTombstones).toEqual([]); + }); + it('the five A-class keys objectui#3808 / #3830 declared are discoverable, block by block', () => { // Named, not just covered by the derived loop above. The derived assertion // would also pass if these five were added to `UNPUBLISHED_EXEMPTIONS` diff --git a/packages/layout/package.json b/packages/layout/package.json index 05f9d82b49..95dca40c7b 100644 --- a/packages/layout/package.json +++ b/packages/layout/package.json @@ -44,6 +44,7 @@ }, "devDependencies": { "@object-ui/sdui-parser": "workspace:*", + "@object-ui/test-support": "workspace:*", "@vitejs/plugin-react": "^6.0.5", "react-router-dom": "^7.18.2", "vite": "^8.2.1", diff --git a/packages/layout/src/__tests__/page-header-authorable-keys.test.tsx b/packages/layout/src/__tests__/page-header-authorable-keys.test.tsx index e9385b8a17..fa21d79bc1 100644 --- a/packages/layout/src/__tests__/page-header-authorable-keys.test.tsx +++ b/packages/layout/src/__tests__/page-header-authorable-keys.test.tsx @@ -59,36 +59,51 @@ * did, which is why upstream retired the key at all). The carve-out is a named, * issue-backed, self-clearing list rather than a silent pass — see * `RENDERER_OWN_DECLARED`. + * + * THE JUDGE IS SHARED NOW — objectui#3809, and this file's own note asked for it + * ("the criterion here is deliberately the same one objectui#3809 converges on + * repo-wide; when that lands, this local helper is what it replaces"). The local + * `z.never()` probe is gone, replaced by `@object-ui/test-support`'s. Three + * things change with it, none of them a loosening: + * + * - the same criterion now serves the repo-wide parity gate + * (`apps/console/src/__tests__/registry-inputs-spec-parity.test.ts`), where + * its absence was a live FALSE GREEN in one direction and a FALSE RED in the + * other. This file had the fix and could not lend it — that is what a copied + * judgement costs; + * - recognition gained a second channel (the `[REMOVED]` marker `retiredKey()` + * stamps on the description), OR-ed with the structural one, so a Zod + * internals rework can no longer silently turn the probe permissive; + * - the probe is calibrated once, centrally, against what the contract's own + * `safeParse` actually rejects — `packages/test-support/src/__tests__/ + * spec-tombstones.test.ts`. The local non-vacuity test below is KEPT anyway: + * it pins this file's own subject (`icon` retired, `title` live), which no + * amount of central calibration can state. */ import { describe, it, expect, beforeAll } from 'vitest'; import { render, screen } from '@testing-library/react'; import { ComponentRegistry } from '@object-ui/core'; import { PageHeaderProps as SpecPageHeaderProps } from '@objectstack/spec/ui'; +import { + authorableShapeKeys, + isShapeKeyTombstoned, + listedShapeKeys, + shapeMemberTypeName, +} from '@object-ui/test-support'; import { registerLayout, PageHeader } from '../index'; /** Every key `PageHeaderProps` still LISTS — ADR-0087 tombstones included. */ -const specDeclaredKeys = new Set(Object.keys(SpecPageHeaderProps.shape)); +const specDeclaredKeys = new Set(listedShapeKeys(SpecPageHeaderProps)); -/** - * One `.shape` member's type, unwrapped past `.optional()`. - * - * Walking Zod internals is the only way to ask this question, so the probe is - * guarded by its own non-vacuity test below rather than trusted. - */ -const shapeMemberType = (key: string): string | undefined => { - const shape = SpecPageHeaderProps.shape as unknown as Record; - const member = shape[key] as { unwrap?: () => unknown } | undefined; - const inner = (typeof member?.unwrap === 'function' ? member.unwrap() : member) as - | { _def?: { type?: string }; def?: { type?: string } } - | undefined; - return inner?._def?.type ?? inner?.def?.type; -}; +/** One `.shape` member's type, unwrapped past `.optional()`. */ +const shapeMemberType = (key: string): string | undefined => + shapeMemberTypeName(SpecPageHeaderProps, key); /** - * Is this key an ADR-0087 D2 tombstone — still listed, but typed `never` so the - * contract rejects every value by name with a migration message? + * Is this key an ADR-0087 D2 tombstone — still listed, but rejected by name with + * a migration message? * * The distinction is the whole reason this file changed in objectui#3829. A D2 * retirement does NOT delete the key from the shape; it REPLACES the member with @@ -96,14 +111,12 @@ const shapeMemberType = (key: string): string | undefined => { * the spec refuses — and every assertion below that derived its truth from raw * `Object.keys` was therefore FALSE GREEN for `icon` from the moment * @objectstack/spec 17.0.0 retired `PageHeaderProps.icon` (objectstack#6946 / - * PR objectstack#7115). The criterion here is deliberately the same one - * objectui#3809 converges on repo-wide; when that lands, this local helper is - * what it replaces. + * PR objectstack#7115). */ -const isTombstoned = (key: string): boolean => shapeMemberType(key) === 'never'; +const isTombstoned = (key: string): boolean => isShapeKeyTombstoned(SpecPageHeaderProps, key); /** Authorable keys of the spec node this renderer serves — tombstones excluded. */ -const specKeys = new Set([...specDeclaredKeys].filter((key) => !isTombstoned(key))); +const specKeys = new Set(authorableShapeKeys(SpecPageHeaderProps)); /** * Keys this ALIAS declares on a renderer-read fact the spec no longer carries. @@ -176,6 +189,12 @@ describe('the `page-header` registration declares the spec key, not a dialect', expect(shapeMemberType('title')).toBeTruthy(); expect(isTombstoned('title')).toBe(false); expect(isTombstoned('icon')).toBe(true); + // THE PREMISE, stated locally (objectui#3809): a D2 retirement KEEPS the + // member. Everything above narrows a set that only needs narrowing while + // that is true — if upstream ever retires by deleting the key, `icon` drops + // out of the listed set, this line reds, and the right response is to remove + // the narrowing rather than to keep filtering nothing. + expect(specDeclaredKeys.has('icon')).toBe(true); // …and the narrowing is not a no-op, which is the third way this could rot. expect(specKeys.size).toBeLessThan(specDeclaredKeys.size); }); diff --git a/packages/test-support/README.md b/packages/test-support/README.md index 72168b857d..43b09ed702 100644 --- a/packages/test-support/README.md +++ b/packages/test-support/README.md @@ -46,6 +46,25 @@ code imports — nothing in `src/` of a released package may import this. `packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx`. - `src/__tests__/dom-leak-judge.test.tsx` — the calibration fixtures that prove the judge, once, for both gates. +- `src/spec-tombstones.ts` — the ADR-0087 D2 tombstone judge: + `authorableShapeKeys`, `listedShapeKeys`, `tombstonedShapeKeys`, + `isShapeKeyTombstoned`, `tombstoneEvidence`, `shapeMemberTypeName`, + `resolvePropsShape`. Answers "does `@objectstack/spec` still ACCEPT this key, + or does it list a tombstone that rejects it by name?" — the question raw + `Object.keys(schema.shape)` cannot answer, because a retired key stays in the + shape (objectui#3809). Consumed by + `apps/console/src/__tests__/registry-inputs-spec-parity.test.ts` (both parity + directions) and `packages/layout/src/__tests__/page-header-authorable-keys.test.tsx`. + Two more local copies of the same judgement still exist, in + `packages/plugin-detail/src/__tests__/recordDetailsInputs.spec-parity.test.ts` + and `packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config.test.ts` + — both correct today, both structural-channel-only, and both tracked for + conversion by objectui#4947. New gates import this module; they do not add a + fifth copy. +- `src/__tests__/spec-tombstones.test.ts` — the calibration for that judge: one + synthetic fixture per recognition channel (so neither can quietly stop + working), plus a cross-check of the structural verdict against what the + installed contract's own `safeParse` actually rejects. ## Conventions diff --git a/packages/test-support/package.json b/packages/test-support/package.json index e324e9891a..c20caf6e5a 100644 --- a/packages/test-support/package.json +++ b/packages/test-support/package.json @@ -18,7 +18,9 @@ "lint": "eslint ." }, "devDependencies": { - "typescript": "^6.0.3" + "@objectstack/spec": "^17.0.0-rc.6", + "typescript": "^6.0.3", + "zod": "^4.4.3" }, "repository": { "type": "git", diff --git a/packages/test-support/src/__tests__/spec-tombstones.test.ts b/packages/test-support/src/__tests__/spec-tombstones.test.ts new file mode 100644 index 0000000000..4023e5e1bb --- /dev/null +++ b/packages/test-support/src/__tests__/spec-tombstones.test.ts @@ -0,0 +1,260 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * THE TOMBSTONE JUDGE PROVES ITSELF — once, for every gate that imports it + * (objectui#3809). + * + * Two halves, and neither is optional: + * + * - SYNTHETIC fixtures exercise each recognition channel ALONE. The judge + * OR-s a structural criterion with a descriptive one precisely so that + * breaking one does not go quietly permissive, and a fixture carrying both + * (which every real tombstone does) cannot tell whether either still works. + * So there is a `never`-typed member with no marker, and a marker-carrying + * member that is not `never`, and each must be recognised on its own. + * - the REAL `@objectstack/spec` half asks the only question that matters + * about the structural criterion: does its verdict equal the CONTRACT'S + * behaviour? A tombstone is not "a member that looks odd in the internals", + * it is a key the parse rejects BY NAME with the upgrade prescription. So + * every key this judge calls a tombstone is fed to `safeParse` and must come + * back with an issue AT THAT KEY'S PATH, and a live key must not. That + * cross-check is what keeps a Zod-internals rework from silently turning the + * probe into a coin flip that every consuming gate then trusts. + * + * The real half is deliberately DERIVED, with no key names pinned here. The + * consuming gates pin their own key sets (`apps/console/src/__tests__/ + * registry-inputs-spec-parity.test.ts` and `packages/layout/src/__tests__/ + * page-header-authorable-keys.test.tsx`), each against the surface it judges; + * a third copy of those names in this file would only add a place to forget. + */ + +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; +import { ComponentPropsMap } from '@objectstack/spec/ui'; + +import { + RETIRED_DESCRIPTION_PREFIX, + authorableShapeKeys, + isShapeKeyTombstoned, + listedShapeKeys, + resolvePropsShape, + shapeMemberTypeName, + tombstoneEvidence, + tombstonedShapeKeys, +} from '../spec-tombstones'; + +/** + * The shape `retiredKey()` produces, rebuilt here rather than imported. + * + * `@objectstack/spec` exports the FACTORY but no predicate, and it does not + * export the factory from a subpath this repo can reach either — which is the + * reason this judge exists at all rather than being one upstream call. Rebuilt + * from the same three calls (`z.never` + `.optional()` + `.describe()` carrying + * the marker) so the fixture drifts only if upstream changes the construction, + * which is exactly when a consuming gate wants to hear about it. + */ +const retiredLikeMember = (guidance: string) => + z + .never({ error: () => guidance }) + .optional() + .describe(`${RETIRED_DESCRIPTION_PREFIX} ${guidance}`); + +describe('spec-tombstone judge — synthetic fixtures, one per channel', () => { + const schema = z.object({ + live: z.string().optional(), + liveRequired: z.string(), + // Channel 1 alone: unwrappable to `never`, and NO marker on the description. + structuralOnly: z.never().optional(), + // A retirement that never got wrapped in `.optional()`. Not the shape + // `retiredKey()` builds, but the unwrap step must be a no-op rather than a + // miss, or a hand-rolled retirement reads as live. + bareNever: z.never(), + // Channel 2 alone: a perfectly ordinary string member whose description + // opens with the marker. Not a real retirement shape — it exists so the + // descriptive channel is proven to stand without the structural one. + descriptiveOnly: z.string().optional().describe(`${RETIRED_DESCRIPTION_PREFIX} moved to \`other\`.`), + // Both channels, as every real tombstone carries them. + fullyRetired: retiredLikeMember('`fullyRetired` was removed in a later release.'), + }); + + it('recognises a tombstone from the structural channel alone', () => { + expect(shapeMemberTypeName(schema, 'structuralOnly')).toBe('never'); + expect(tombstoneEvidence(schema, 'structuralOnly')).toEqual({ + listed: true, + typedNever: true, + describedRemoved: false, + }); + expect(isShapeKeyTombstoned(schema, 'structuralOnly')).toBe(true); + expect(isShapeKeyTombstoned(schema, 'bareNever')).toBe(true); + }); + + it('recognises a tombstone from the descriptive channel alone', () => { + // The structural half says "live" here, so a judge that AND-ed the two + // would call this key authorable — the quiet-permissive failure the OR + // exists to prevent. + expect(shapeMemberTypeName(schema, 'descriptiveOnly')).not.toBe('never'); + expect(tombstoneEvidence(schema, 'descriptiveOnly')).toEqual({ + listed: true, + typedNever: false, + describedRemoved: true, + }); + expect(isShapeKeyTombstoned(schema, 'descriptiveOnly')).toBe(true); + }); + + it('leaves a live key alone, in both channels', () => { + for (const key of ['live', 'liveRequired']) { + expect(tombstoneEvidence(schema, key), `${key} reads as retired`).toEqual({ + listed: true, + typedNever: false, + describedRemoved: false, + }); + expect(isShapeKeyTombstoned(schema, key)).toBe(false); + } + // …and the type probe reads a REAL type for them, which is the non-vacuity + // half: a reader returning `undefined` for everything would also "not say + // never" and would pass the lines above while seeing nothing. + expect(shapeMemberTypeName(schema, 'live')).toBe('string'); + expect(shapeMemberTypeName(schema, 'liveRequired')).toBe('string'); + }); + + it('subtracts exactly the tombstones, and keeps the listed set intact', () => { + expect(listedShapeKeys(schema).sort()).toEqual([ + 'bareNever', + 'descriptiveOnly', + 'fullyRetired', + 'live', + 'liveRequired', + 'structuralOnly', + ]); + expect(tombstonedShapeKeys(schema).sort()).toEqual([ + 'bareNever', + 'descriptiveOnly', + 'fullyRetired', + 'structuralOnly', + ]); + expect(authorableShapeKeys(schema).sort()).toEqual(['live', 'liveRequired']); + }); + + it('reports a key the shape does not list as absent, not as retired', () => { + // The distinction a pin-difference reader depends on: "this release never + // heard of the key" is not "this release rejects the key by name". + expect(tombstoneEvidence(schema, 'neverExisted')).toEqual({ + listed: false, + typedNever: false, + describedRemoved: false, + }); + expect(isShapeKeyTombstoned(schema, 'neverExisted')).toBe(false); + expect(listedShapeKeys(schema)).not.toContain('neverExisted'); + }); + + it('resolves the thunk and internals spellings, and reports failure as null', () => { + const shape = { live: z.string() }; + // `lazySchema()`-wrapped spec entries hand over `.shape` as a THUNK. + expect(Object.keys(resolvePropsShape({ shape: () => shape }) ?? {})).toEqual(['live']); + expect(Object.keys(resolvePropsShape({ _def: { shape } }) ?? {})).toEqual(['live']); + expect(Object.keys(resolvePropsShape({ def: { shape } }) ?? {})).toEqual(['live']); + // A schema this reader cannot read is `null` — distinguishable from a real + // empty shape, which is what stops a broken probe reading as "no keys". + expect(resolvePropsShape(undefined)).toBeNull(); + expect(resolvePropsShape({ notAShape: 1 })).toBeNull(); + expect(resolvePropsShape(z.string())).toBeNull(); + expect(resolvePropsShape(z.object({}))).toEqual({}); + // …and the key-listing wrapper flattens that to `[]`, which every consumer + // pairs with its own non-vacuity assertion. + expect(listedShapeKeys(undefined)).toEqual([]); + }); +}); + +describe('spec-tombstone judge — against the installed @objectstack/spec', () => { + /** + * Every `(type, key)` this judge calls a tombstone across `ComponentPropsMap`. + * + * Derived, and the premise assertion below is what keeps it honest: the day + * upstream retires keys by DELETING them, this list empties and every + * tombstone filter in the repo starts narrowing nothing. That must fail here + * — loudly, in the one file whose subject is the judge — rather than pass as + * dead code in each consuming gate. + */ + const found = Object.keys(ComponentPropsMap) + .flatMap((type) => + tombstonedShapeKeys(ComponentPropsMap[type as keyof typeof ComponentPropsMap]).map( + (key) => [type, key] as const, + ), + ) + .sort(); + + it('finds tombstones at all — the premise every consuming gate stands on', () => { + expect(Object.keys(ComponentPropsMap).length).toBeGreaterThan(0); + expect( + found.length, + 'no ADR-0087 D2 tombstone anywhere in ComponentPropsMap: either the pin predates every ' + + 'retirement, or upstream now DELETES retired keys — in which case every tombstone filter ' + + 'in this repo (see the module docblock) is dead code and must be reviewed, not kept', + ).toBeGreaterThan(0); + }); + + it('both channels agree on every tombstone the installed spec carries', () => { + // Recognition is an OR by design, so disagreement does not break a gate — + // it means one channel moved, and the next change to touch it deserves to + // know which. Named per key rather than counted, because "seven of eight" + // is not a fact anyone can act on. + const disagreements = found + .map(([type, key]) => [type, key, tombstoneEvidence(ComponentPropsMap[type as keyof typeof ComponentPropsMap], key)] as const) + .filter(([, , evidence]) => evidence.typedNever !== evidence.describedRemoved) + .map(([type, key, evidence]) => `${type}.${key} (never=${evidence.typedNever}, marked=${evidence.describedRemoved})`); + expect(disagreements).toEqual([]); + }); + + it('the contract itself rejects every key this judge calls a tombstone', () => { + // THE cross-check: the structural criterion is an internals read, and this + // is the only assertion that ties it to observable behaviour. A tombstone + // rejects EVERY value, so one sentinel is enough — and the verdict is read + // per KEY PATH rather than from `success`, because these schemas have + // required members of their own and a whole-parse failure would prove + // nothing about the key under test. + const issuePaths = (type: string, payload: Record): string[] => { + const result = ComponentPropsMap[type as keyof typeof ComponentPropsMap].safeParse(payload); + return result.success ? [] : result.error.issues.map((issue) => String(issue.path[0])); + }; + + for (const [type, key] of found) { + expect( + issuePaths(type, { [key]: 'tombstone-probe' }), + `${type}.${key} reads as a tombstone but the contract raised no issue on it`, + ).toContain(key); + } + + // The control, and it is not optional: a probe that reported an issue for + // EVERY key would satisfy the loop above while proving nothing. An + // authorable key of the same schema, carrying a value it accepts, must come + // back clean at its own path. + const [controlType] = found[0]; + const controlKey = authorableShapeKeys( + ComponentPropsMap[controlType as keyof typeof ComponentPropsMap], + ).find((key) => shapeMemberTypeName(ComponentPropsMap[controlType as keyof typeof ComponentPropsMap], key) === 'string'); + expect(controlKey, `${controlType} has no authorable string key to use as a control`).toBeTruthy(); + expect( + issuePaths(controlType, { [controlKey as string]: 'a string' }), + `${controlType}.${controlKey} is authorable and string-typed, yet the contract rejected it`, + ).not.toContain(controlKey); + }); + + it('narrows the authorable set on every block that carries a tombstone', () => { + for (const [type] of found) { + const schema = ComponentPropsMap[type as keyof typeof ComponentPropsMap]; + expect( + authorableShapeKeys(schema).length, + `${type} authorable set did not narrow below its listed set`, + ).toBeLessThan(listedShapeKeys(schema).length); + // Not narrowed to nothing, either: a block whose every key read as + // retired would be a broken probe, not a retired block. + expect(authorableShapeKeys(schema).length, `${type} has no authorable key left`).toBeGreaterThan(0); + } + }); +}); diff --git a/packages/test-support/src/index.ts b/packages/test-support/src/index.ts index b0b6b1de41..a5348761b2 100644 --- a/packages/test-support/src/index.ts +++ b/packages/test-support/src/index.ts @@ -29,3 +29,14 @@ export { leakReport, } from './dom-leak-judge'; export type { Leak } from './dom-leak-judge'; + +export { + RETIRED_DESCRIPTION_PREFIX, + authorableShapeKeys, + isShapeKeyTombstoned, + listedShapeKeys, + resolvePropsShape, + shapeMemberTypeName, + tombstoneEvidence, + tombstonedShapeKeys, +} from './spec-tombstones'; diff --git a/packages/test-support/src/spec-tombstones.ts b/packages/test-support/src/spec-tombstones.ts new file mode 100644 index 0000000000..022abc568f --- /dev/null +++ b/packages/test-support/src/spec-tombstones.ts @@ -0,0 +1,227 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * ADR-0087 D2 TOMBSTONE RECOGNITION — one judge for every gate that asks + * "does the contract accept this key?" (objectui#3809). + * + * ## The fact every caller here needs + * + * Retiring an authorable key in `@objectstack/spec` does NOT delete it from the + * Zod object. `packages/spec/src/shared/retired-key.ts` replaces the member with + * + * z.never({ error: () => guidance }).optional().describe(`[REMOVED] …`) + * + * on purpose: a deleted key would be SILENTLY STRIPPED by a non-strict parse, + * while a `never` member fails `tsc` at the authoring site and raises the + * upgrade prescription itself on parse (upstream #3855 / ADR-0104). The removal + * has to be audible, so the entry stays. + * + * The consequence for a consumer is the whole reason this module exists: a + * tombstone is STILL a member of `.shape`, so `Object.keys(shape)` reports a key + * the contract rejects BY NAME as though it were an authoring surface. Every + * gate that derived "the keys the spec accepts" from raw `Object.keys` was + * therefore answering a different question than the one its name claimed, and + * the error is not symmetric — it lands as a FALSE GREEN or a FALSE RED + * depending on which side of the subtraction the key falls: + * + * - `declared − listed` (does this repo publish a key the spec refuses?) puts + * the tombstone on the "accepted" side: a block may publish a retired key + * and the gate stays GREEN; + * - `listed − declared` (is every spec key discoverable?) puts it on the + * "declared" side: the gate goes RED demanding that a block publish a key + * the contract refuses — and a repo that obeys then fails the first check. + * + * Two directions, one root cause, one fix: ask this module instead of + * `Object.keys`. + * + * ## Why it lives in `@object-ui/test-support` + * + * Because the same judgement was being written out by hand in four places, and + * the copies had already drifted: two carried the structural criterion only, + * one carried none at all (which is objectui#3809's false green), and none of + * them carried the description channel below. That is the exact condition this + * private package exists for — see the README. Nothing shipped imports it. + * + * ## Two channels, deliberately OR-ed + * + * A tombstone is recognised when EITHER holds: + * + * 1. STRUCTURAL — the member, unwrapped past `.optional()`, is a `never` + * type. This is the criterion with teeth: it is what makes the parse + * reject every value, so it cannot be true of a live key; + * 2. DESCRIPTIVE — the member's own description opens with the marker + * `retiredKey()` stamps on it (`RETIRED_DESCRIPTION_PREFIX`). + * + * Either channel alone identifies today's tombstones (measured: all eight in + * `@objectstack/spec@17.0.0-rc.6`'s `ComponentPropsMap` satisfy both), so the OR + * is not about coverage — it is about which channel a future change breaks. A + * Zod internals rework can silence (1) without touching the contract; a + * tombstone hand-written without `retiredKey()` silences (2). Recognition that + * needs both would go quietly permissive on either event, and "quietly + * permissive" is the failure this module was written to end. Callers that want + * to know the channels AGREE — a drift pin worth having — ask + * `tombstoneEvidence` and compare them; the recognition itself never requires + * agreement. + * + * ## The premise these callers stand on, and how it dies + * + * All of the above is true only while retirement KEEPS the member. If upstream + * ever switches to deleting keys outright, every tombstone filter here narrows + * nothing — and would keep passing, silently, as dead code. So a gate that + * consumes this module owes one assertion that the premise still holds: some + * key is still LISTED and NOT authorable. `listedShapeKeys` exists for that + * assertion as much as for the subtraction itself. + */ + +/** The prefix `retiredKey()` puts at the front of a tombstone's description. */ +export const RETIRED_DESCRIPTION_PREFIX = '[REMOVED]'; + +/** The two `.shape` spellings, plus the `lazySchema()` thunk. */ +interface ShapeCarrier { + shape?: unknown; + def?: { shape?: unknown }; + _def?: { shape?: unknown }; +} + +/** A Zod node, as far as the questions below need to see it. */ +interface MemberNode { + unwrap?: () => unknown; + description?: unknown; + def?: { type?: unknown; description?: unknown }; + _def?: { type?: unknown; description?: unknown }; +} + +/** + * Resolve a props schema's `.shape`, or `null` when it does not resolve. + * + * Three spellings, because `ComponentPropsMap` carries all three: a plain + * `z.object` exposes `.shape`; a `lazySchema()`-wrapped entry (every + * `element:*`) exposes it as a THUNK that must be called; and Zod's internals + * are reachable as either `def` or `_def` depending on the version. Reaching + * into internals is confined to this module so that a gate never has to. + * + * `null` rather than `{}` on failure: "this schema has no keys" and "this reader + * broke" are different facts, and a caller that cannot tell them apart turns a + * broken probe into a green run over nothing. + */ +export function resolvePropsShape(schema: unknown): Record | null { + const carrier = schema as ShapeCarrier | undefined; + const shape = carrier?.shape ?? carrier?.def?.shape ?? carrier?._def?.shape; + const resolved = typeof shape === 'function' ? (shape as () => unknown)() : shape; + return resolved && typeof resolved === 'object' ? (resolved as Record) : null; +} + +/** + * Every key the schema still LISTS — tombstones INCLUDED. + * + * This is raw `Object.keys(shape)` and it is exported under a name that says so, + * because the answer is genuinely wanted in two places: pinning that the + * tombstone premise still holds, and asking "did this pin of the spec ever hear + * of this key" (a question about the RELEASE, not about the authoring surface — + * a retired key must answer YES there, or an exemption for it reads as dormant + * and escapes every staleness check). + * + * `[]` for a schema that does not resolve, which every caller in-tree relies on + * for keys their pin does not carry at all; pair it with a non-vacuity + * assertion, never with a bare "it came back empty, fine". + */ +export function listedShapeKeys(schema: unknown): string[] { + const shape = resolvePropsShape(schema); + return shape ? Object.keys(shape) : []; +} + +/** One member of the shape, unwrapped past `.optional()` when it is wrapped. */ +function unwrapMember(member: unknown): MemberNode | undefined { + const node = member as MemberNode | undefined; + return (typeof node?.unwrap === 'function' ? (node.unwrap() as MemberNode) : node) ?? undefined; +} + +/** + * The Zod type name of one member, unwrapped past `.optional()`. + * + * `undefined` means the probe could not read it — which is a broken reader, not + * a live key. Callers pin it against a key they know is live (see + * `tombstoneEvidence`'s note on non-vacuity). + */ +export function shapeMemberTypeName(schema: unknown, key: string): string | undefined { + const shape = resolvePropsShape(schema); + if (!shape) return undefined; + const inner = unwrapMember(shape[key]); + const type = inner?._def?.type ?? inner?.def?.type; + return typeof type === 'string' ? type : undefined; +} + +/** + * The description carried by one member, read BEFORE unwrapping. + * + * `retiredKey()` applies `.describe()` last — after `.optional()` — so the + * marker sits on the outer optional wrapper, not on the inner `never`. Reading + * the unwrapped node here would find nothing and silently disarm the + * descriptive channel. + */ +function memberDescription(schema: unknown, key: string): string | undefined { + const shape = resolvePropsShape(schema); + if (!shape) return undefined; + const node = shape[key] as MemberNode | undefined; + const description = node?.description ?? node?.def?.description ?? node?._def?.description; + return typeof description === 'string' ? description : undefined; +} + +/** + * What each recognition channel says about one key — the evidence, unreduced. + * + * Exposed separately from the verdict so a gate can pin the two channels + * AGREEING on today's tombstones. That pin is what turns a single-channel drift + * (a Zod rework, a hand-rolled retirement) into a red line naming the channel + * that moved, instead of a silent narrowing of what the gate can see. + * + * `listed` is part of the evidence because a key absent from the shape is + * neither authorable nor tombstoned — it simply is not in this release, and a + * caller that cannot tell those apart mis-reports a pin difference as a + * retirement. + */ +export function tombstoneEvidence( + schema: unknown, + key: string, +): { listed: boolean; typedNever: boolean; describedRemoved: boolean } { + const shape = resolvePropsShape(schema); + const listed = Boolean(shape) && Object.prototype.hasOwnProperty.call(shape, key); + if (!listed) return { listed: false, typedNever: false, describedRemoved: false }; + return { + listed: true, + typedNever: shapeMemberTypeName(schema, key) === 'never', + describedRemoved: (memberDescription(schema, key) ?? '').startsWith(RETIRED_DESCRIPTION_PREFIX), + }; +} + +/** + * Is this key an ADR-0087 D2 tombstone — still listed, but rejected by name? + * + * A key the shape does not list at all is NOT a tombstone: it is absent, and + * `listedShapeKeys` is the question to ask about that. + */ +export function isShapeKeyTombstoned(schema: unknown, key: string): boolean { + const { typedNever, describedRemoved } = tombstoneEvidence(schema, key); + return typedNever || describedRemoved; +} + +/** The listed keys that are tombstones — what the narrowing below removes. */ +export function tombstonedShapeKeys(schema: unknown): string[] { + return listedShapeKeys(schema).filter((key) => isShapeKeyTombstoned(schema, key)); +} + +/** + * The keys that are a real authoring surface: listed, minus the tombstones. + * + * This is the set a parity gate means when it says "the keys the spec accepts", + * in BOTH subtraction directions — publish-side and discoverability-side alike. + */ +export function authorableShapeKeys(schema: unknown): string[] { + return listedShapeKeys(schema).filter((key) => !isShapeKeyTombstoned(schema, key)); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4fa60f60e4..ad6943d794 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -263,6 +263,9 @@ importers: '@object-ui/react': specifier: workspace:* version: link:../../packages/react + '@object-ui/test-support': + specifier: workspace:* + version: link:../../packages/test-support '@object-ui/types': specifier: workspace:* version: link:../../packages/types @@ -1394,6 +1397,9 @@ importers: '@object-ui/sdui-parser': specifier: workspace:* version: link:../sdui-parser + '@object-ui/test-support': + specifier: workspace:* + version: link:../test-support '@vitejs/plugin-react': specifier: ^6.0.5 version: 6.0.5(vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.10)(yaml@2.9.0)) @@ -2780,9 +2786,15 @@ importers: packages/test-support: devDependencies: + '@objectstack/spec': + specifier: ^17.0.0-rc.6 + version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) typescript: specifier: ^6.0.3 version: 6.0.3 + zod: + specifier: ^4.4.3 + version: 4.4.3 packages/types: dependencies: From dc0636d363f0ae83dacde37487ced2398b9e967c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 08:43:02 +0000 Subject: [PATCH 2/4] =?UTF-8?q?fix(test-support):=20=E8=B7=9F=E9=9A=8F=201?= =?UTF-8?q?7.0.0=20GA=20pin=20=E9=87=8D=E6=B5=8B=E5=A2=93=E7=A2=91?= =?UTF-8?q?=E9=9B=86,=E4=BF=AE=E5=A5=BD=20lockfile=20(#3809)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 合并 origin/main 带进了 @objectstack/spec 17.0.0 GA pin(#4636 / PR #4639), 本分支的基线还在 rc.6 上,于是 CI 的 merge 结果里 pnpm-lock.yaml 半更新、 frozen install 报 ERR_PNPM_LOCKFILE_MISSING_DEPENDENCY。合并后完整重解析, `pnpm install --frozen-lockfile` 本地复现通过。 test-support 的 spec devDependency 对齐仓库的 ^17.0.0。 GA 上重测墓碑集:仍是同样八个(`ComponentPropsMap` 条目从 31 增到 37,墓碑数不变), 两条识别通道在 GA 上依然同时成立,所以收割钉子的八个键名与 premise 断言都无需改动 —— 只把散文里的「实测于 rc.6」更正为两个 pin 都测过。`targetVariable` 那两个在 GA 上 仍是活键(上游 main 已退役),下一次 pin 升级仍会按自清机制点名它们。 Co-authored-by: Claude --- .../tombstone-aware-spec-parity-3809.md | 4 +++- .../registry-inputs-spec-parity.test.ts | 19 +++++++++++-------- packages/test-support/package.json | 2 +- packages/test-support/src/spec-tombstones.ts | 5 +++-- pnpm-lock.yaml | 4 ++-- 5 files changed, 20 insertions(+), 14 deletions(-) diff --git a/.changeset/tombstone-aware-spec-parity-3809.md b/.changeset/tombstone-aware-spec-parity-3809.md index d6e0d32667..c2814df311 100644 --- a/.changeset/tombstone-aware-spec-parity-3809.md +++ b/.changeset/tombstone-aware-spec-parity-3809.md @@ -20,7 +20,9 @@ Not dormant, contrary to the issue's premise: it was filed against `17.0.0-rc.5` tombstone in `ComponentPropsMap`, and the `17.0.0-rc.6` pin (objectui#4167) brought eight — `page:header.icon`, `page:card.actions`, `page:card.body`, `page:tabs.type`, `record:details.layout` and the `element:record_picker` `displayField` / `searchFields` / -`multiple` trio. The reverse direction's red was live from that pin, absorbed key by key by eight +`multiple` trio. The `17.0.0` GA pin (objectui#4636), which landed while this change was in +flight, carries the same eight; both pins were measured. The reverse direction's red was live from +rc.6 onward, absorbed key by key by eight explicit exemptions that each named this issue as the only thing that could clear them. All eight are deleted here — not by hand-picking, but because the narrowing makes the existing dangling-and-stale checks report every one of them. The mechanism is self-clearing from now on: a diff --git a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts index 3301467b4a..aba947501b 100644 --- a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts +++ b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts @@ -128,8 +128,9 @@ * The blind spot was NOT dormant by the time it was fixed, which is worth * recording because the issue was filed believing it was. It was written against * `@objectstack/spec@17.0.0-rc.5`, where `ComponentPropsMap` carried no - * tombstone at all; the rc.6 pin (objectui#4167) brought EIGHT, and the reverse - * direction's red was live from that moment — absorbed, key by key, by the eight + * tombstone at all; the rc.6 pin (objectui#4167) brought EIGHT, the 17.0.0 GA + * pin (objectui#4636 / PR objectui#4639) carries the same eight, and the reverse + * direction's red was live from rc.6 onward — absorbed, key by key, by the eight * `UNPUBLISHED_EXEMPTIONS` entries that named this issue as the only thing that * could resolve them. Those eight are deleted with this change; the pin below * (`the eight tombstoned keys are recognised, not exempted`) is what keeps their @@ -519,8 +520,9 @@ const UNPUBLISHED_EXEMPTIONS: Record = { * run — no issue needed, no filter to remember. Two of the entries still below * are already queued for it: objectstack `origin/main` tombstones * `targetVariable` on BOTH `element:text_input` and `element:record_picker` - * (measured on `main` @ `23abe2782`, absent from the pinned rc.6), so the pin - * that carries those retirements will name both entries here. Deleting them is + * (measured on `main` @ `23abe2782`; both keys are still LIVE in the installed + * 17.0.0, whose tombstone set is the same eight rc.6 carried), so the pin that + * carries those retirements will name both entries here. Deleting them is * the fix — objectui#3834's "should we publish an intent-only key" question is * answered upstream by then, in the negative. * @@ -1086,11 +1088,12 @@ describe('registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide) // the same reason the `#3808 / #3830` and `rc.6 record_picker` pins next door // are written by name. // - // Five upstream retirements, eight keys, three facts each. The list is + // Five upstream retirements, eight keys, several facts each. The list is // pin-dependent by construction and that is the point: it is the measurement - // (`@objectstack/spec@17.0.0-rc.6`) this change was made against, so a pin - // that un-retires one of them fails HERE, naming the key, instead of - // resurfacing as an unexplained red in a derived loop. + // (`@objectstack/spec@17.0.0`, and the same eight on the rc.6 that preceded + // it — this change was verified on both), so a pin that un-retires one of + // them fails HERE, naming the key, instead of resurfacing as an unexplained + // red in a derived loop. const HARVESTED: Array<[string, string]> = [ ['element:record_picker', 'displayField'], ['element:record_picker', 'multiple'], diff --git a/packages/test-support/package.json b/packages/test-support/package.json index c20caf6e5a..10b55c0bb9 100644 --- a/packages/test-support/package.json +++ b/packages/test-support/package.json @@ -18,7 +18,7 @@ "lint": "eslint ." }, "devDependencies": { - "@objectstack/spec": "^17.0.0-rc.6", + "@objectstack/spec": "^17.0.0", "typescript": "^6.0.3", "zod": "^4.4.3" }, diff --git a/packages/test-support/src/spec-tombstones.ts b/packages/test-support/src/spec-tombstones.ts index 022abc568f..a541ee342c 100644 --- a/packages/test-support/src/spec-tombstones.ts +++ b/packages/test-support/src/spec-tombstones.ts @@ -59,8 +59,9 @@ * `retiredKey()` stamps on it (`RETIRED_DESCRIPTION_PREFIX`). * * Either channel alone identifies today's tombstones (measured: all eight in - * `@objectstack/spec@17.0.0-rc.6`'s `ComponentPropsMap` satisfy both), so the OR - * is not about coverage — it is about which channel a future change breaks. A + * `@objectstack/spec@17.0.0`'s `ComponentPropsMap` satisfy both — the same eight + * the preceding `17.0.0-rc.6` carried), so the OR is not about coverage — it is + * about which channel a future change breaks. A * Zod internals rework can silence (1) without touching the contract; a * tombstone hand-written without `retiredKey()` silences (2). Recognition that * needs both would go quietly permissive on either event, and "quietly diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index adb48ef51f..fec551e7ea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2787,8 +2787,8 @@ importers: packages/test-support: devDependencies: '@objectstack/spec': - specifier: ^17.0.0-rc.6 - version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) + specifier: ^17.0.0 + version: 17.0.0(ai@7.0.65(zod@4.4.3)) typescript: specifier: ^6.0.3 version: 6.0.3 From 4a349232f7d1759a2a24e59ab82a2193765a60f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 08:53:34 +0000 Subject: [PATCH 3/4] =?UTF-8?q?docs(test-support):=20=E4=BF=AE=E4=B8=80?= =?UTF-8?q?=E5=A4=84=E6=B3=A8=E9=87=8A=E6=8A=98=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude --- packages/test-support/src/spec-tombstones.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/test-support/src/spec-tombstones.ts b/packages/test-support/src/spec-tombstones.ts index a541ee342c..338cc4fd20 100644 --- a/packages/test-support/src/spec-tombstones.ts +++ b/packages/test-support/src/spec-tombstones.ts @@ -61,8 +61,8 @@ * Either channel alone identifies today's tombstones (measured: all eight in * `@objectstack/spec@17.0.0`'s `ComponentPropsMap` satisfy both — the same eight * the preceding `17.0.0-rc.6` carried), so the OR is not about coverage — it is - * about which channel a future change breaks. A - * Zod internals rework can silence (1) without touching the contract; a + * about which channel a future change breaks. A Zod internals rework can + * silence (1) without touching the contract; a * tombstone hand-written without `retiredKey()` silences (2). Recognition that * needs both would go quietly permissive on either event, and "quietly * permissive" is the failure this module was written to end. Callers that want From 21602288294ad60ee9dfb2167fe2e46121ee8b3e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 09:07:55 +0000 Subject: [PATCH 4/4] =?UTF-8?q?refactor(test-support):=20=E5=A2=93?= =?UTF-8?q?=E7=A2=91=E5=88=A4=E6=8D=AE=E6=94=B6=E6=88=90=E5=8D=95=E4=B8=80?= =?UTF-8?q?=20choke=20point,shape=20=E5=8F=AA=E8=A7=A3=E6=9E=90=E4=B8=80?= =?UTF-8?q?=E6=AC=A1=20(#3809)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两件事,都是给自己的门补上和它给别人补的同一种保障: 1. 批量函数原来在 `.filter()` 谓词里逐键重解析 shape —— 对 `lazySchema()` 条目 等于每个键重跑一次 thunk。正是 AGENTS.md §测试纪律 记下的那条(`keysOf(x)` 写在谓词里,7.51s → 25ms)。改成一次解析、一趟 partition。 2. 判据(两条通道的 OR)原来在单键谓词与批量 partition 里各写了一遍。变异测试 立刻暴露了代价:把单键谓词改成恒 false,批量那份仍在收窄,于是消费门的 premise 断言照绿 —— 半个 judge 被静默关掉而没人红。现在两处都走同一个 `isTombstone`,再跑同一变异:premise 断言与反方向五个 block 一起红(15 红), 与重构前的方向一致。 Co-authored-by: Claude --- packages/test-support/src/spec-tombstones.ts | 80 ++++++++++++++------ 1 file changed, 56 insertions(+), 24 deletions(-) diff --git a/packages/test-support/src/spec-tombstones.ts b/packages/test-support/src/spec-tombstones.ts index 338cc4fd20..30d9ed2cec 100644 --- a/packages/test-support/src/spec-tombstones.ts +++ b/packages/test-support/src/spec-tombstones.ts @@ -61,9 +61,9 @@ * Either channel alone identifies today's tombstones (measured: all eight in * `@objectstack/spec@17.0.0`'s `ComponentPropsMap` satisfy both — the same eight * the preceding `17.0.0-rc.6` carried), so the OR is not about coverage — it is - * about which channel a future change breaks. A Zod internals rework can - * silence (1) without touching the contract; a - * tombstone hand-written without `retiredKey()` silences (2). Recognition that + * about which channel a future change breaks. A Zod internals rework can silence + * (1) without touching the contract; a tombstone hand-written without + * `retiredKey()` silences (2). Recognition that * needs both would go quietly permissive on either event, and "quietly * permissive" is the failure this module was written to end. Callers that want * to know the channels AGREE — a drift pin worth having — ask @@ -159,21 +159,46 @@ export function shapeMemberTypeName(schema: unknown, key: string): string | unde } /** - * The description carried by one member, read BEFORE unwrapping. + * Both channels' verdicts on ONE already-resolved member. * - * `retiredKey()` applies `.describe()` last — after `.optional()` — so the - * marker sits on the outer optional wrapper, not on the inner `never`. Reading - * the unwrapped node here would find nothing and silently disarm the - * descriptive channel. + * Takes the member rather than `(schema, key)` so the bulk functions below can + * resolve the shape once and walk it, instead of re-resolving per key inside a + * predicate — which for a `lazySchema()` entry means re-running the thunk for + * every key it declares. That is the AGENTS.md §测试纪律 note about whole-set + * computations inside `.filter()` (`all-locales-key-parity`, 7.51s to 25ms), + * applied before it can bite. + * + * The description is read from the member AS GIVEN, not from the unwrapped inner + * type: `retiredKey()` applies `.describe()` last, after `.optional()`, so the + * marker sits on the outer optional wrapper. Unwrapping first would find nothing + * and silently disarm the descriptive channel. */ -function memberDescription(schema: unknown, key: string): string | undefined { - const shape = resolvePropsShape(schema); - if (!shape) return undefined; - const node = shape[key] as MemberNode | undefined; +function memberEvidence(member: unknown): { typedNever: boolean; describedRemoved: boolean } { + const node = member as MemberNode | undefined; + const inner = unwrapMember(member); + const type = inner?._def?.type ?? inner?.def?.type; const description = node?.description ?? node?.def?.description ?? node?._def?.description; - return typeof description === 'string' ? description : undefined; + return { + typedNever: type === 'never', + describedRemoved: + typeof description === 'string' && description.startsWith(RETIRED_DESCRIPTION_PREFIX), + }; } +/** + * The verdict, in ONE place — the OR every export below routes through. + * + * Deliberately not re-spelled at each call site, and the reason is a mutation + * test rather than tidiness: while the single-key predicate and the bulk + * partition each carried their own `typedNever || describedRemoved`, disabling + * recognition in one left the other still narrowing — so a mutation could + * silence half the judge while the consuming gates' premise pins stayed green. + * One choke point means any change to what counts as a tombstone moves every + * consumer at once, which is what those pins exist to detect. + */ +const isTombstone = (evidence: { typedNever: boolean; describedRemoved: boolean }): boolean => + evidence.typedNever || evidence.describedRemoved; + /** * What each recognition channel says about one key — the evidence, unreduced. * @@ -192,13 +217,10 @@ export function tombstoneEvidence( key: string, ): { listed: boolean; typedNever: boolean; describedRemoved: boolean } { const shape = resolvePropsShape(schema); - const listed = Boolean(shape) && Object.prototype.hasOwnProperty.call(shape, key); - if (!listed) return { listed: false, typedNever: false, describedRemoved: false }; - return { - listed: true, - typedNever: shapeMemberTypeName(schema, key) === 'never', - describedRemoved: (memberDescription(schema, key) ?? '').startsWith(RETIRED_DESCRIPTION_PREFIX), - }; + if (!shape || !Object.prototype.hasOwnProperty.call(shape, key)) { + return { listed: false, typedNever: false, describedRemoved: false }; + } + return { listed: true, ...memberEvidence(shape[key]) }; } /** @@ -208,13 +230,23 @@ export function tombstoneEvidence( * `listedShapeKeys` is the question to ask about that. */ export function isShapeKeyTombstoned(schema: unknown, key: string): boolean { - const { typedNever, describedRemoved } = tombstoneEvidence(schema, key); - return typedNever || describedRemoved; + return isTombstone(tombstoneEvidence(schema, key)); +} + +/** One pass over the shape, splitting its keys by the tombstone verdict. */ +function partitionShapeKeys(schema: unknown): { authorable: string[]; tombstoned: string[] } { + const shape = resolvePropsShape(schema); + const authorable: string[] = []; + const tombstoned: string[] = []; + for (const [key, member] of Object.entries(shape ?? {})) { + (isTombstone(memberEvidence(member)) ? tombstoned : authorable).push(key); + } + return { authorable, tombstoned }; } /** The listed keys that are tombstones — what the narrowing below removes. */ export function tombstonedShapeKeys(schema: unknown): string[] { - return listedShapeKeys(schema).filter((key) => isShapeKeyTombstoned(schema, key)); + return partitionShapeKeys(schema).tombstoned; } /** @@ -224,5 +256,5 @@ export function tombstonedShapeKeys(schema: unknown): string[] { * in BOTH subtraction directions — publish-side and discoverability-side alike. */ export function authorableShapeKeys(schema: unknown): string[] { - return listedShapeKeys(schema).filter((key) => !isShapeKeyTombstoned(schema, key)); + return partitionShapeKeys(schema).authorable; }