From ca5b8b993fdf5dfc652c73853d19e0b12c50ab5a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 03:48:03 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(spec):=20strictness=20=E5=8F=B0?= =?UTF-8?q?=E8=B4=A6=E6=96=B0=E5=A2=9E=E7=AC=AC=E4=B9=9D=E5=88=A4=E5=AE=9A?= =?UTF-8?q?=E8=AF=8D=20`covered`=20(#5249)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Class` 列是机读的,而八个既有判定对 `ui/app.zod.ts` 的 `BaseNavItemSchema` 全部返回错误的**动作**而非仅仅不精确的标签: 两轴表把 carrier 缺席 + parse 缺席解析成 `no door`,其规定的 ADR-0049 退役在这里是破坏性的 —— 九个导航分支共享这个基底的键, 且各自 `.strict()`。维护者 2026-08-06 裁 A:加词,与批 15 加 `no gate` 同一条理由。 `covered` = carrier 缺席、parse 缺席,但词汇在每个消费者处被完整 把守;后续动作:无。它拿到自己的桶而非并入 `no door` —— 小计是 工作清单,两行规定的工作相反。 `verify` 的语义与归桶完全不动,继续为下一个需要挂起的站点保留。 改判范围是测量出来的:判据是键必须通过 `...X.shape` 展开到达消费者 (展开不继承姿态,基底惰性;`.extend()` 继承,基底是真门)。对五个 已分诊目录的全部 197 个 strip 站点跑过,只有本行是展开的。另外三个 模块私有基底维持原判:`FormFieldBaseSchema` 与 `BaseQuerySchema` 均 被 `.extend()`,`EmptyProps` 挂在十一个 carrier 键下。 `.counts.md` 走 `gen:strictness-ledger` 整体重算:全局 authorable 43 → 42、covered 0 → 1;总数仍 197,分桶仍恰好划分。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011M7UwH25Unfi73UHim7ajY --- .../strictness-ledger-covered-verdict.md | 25 ++++++ ...07-unknown-key-strictness-ledger.counts.md | 9 ++- .../2026-07-unknown-key-strictness-ledger.md | 53 +++++++++---- .../spec/scripts/lib/strictness-ledger-doc.ts | 67 +++++++++++++--- .../scripts/strictness-ledger-doc.test.ts | 78 +++++++++++++++++-- .../src/ui/app-strictness-batch19.test.ts | 11 ++- packages/spec/src/ui/app.zod.ts | 9 ++- 7 files changed, 212 insertions(+), 40 deletions(-) create mode 100644 .changeset/strictness-ledger-covered-verdict.md diff --git a/.changeset/strictness-ledger-covered-verdict.md b/.changeset/strictness-ledger-covered-verdict.md new file mode 100644 index 0000000000..2375857137 --- /dev/null +++ b/.changeset/strictness-ledger-covered-verdict.md @@ -0,0 +1,25 @@ +--- +'@objectstack/spec': patch +--- + +tooling: strictness 台账新增第九个判定词 `covered` —— 给「无门、无 parse,但每个消费者都已把守」的形状片段一个诚实的格子 (#5249) + +`docs/audits/2026-07-unknown-key-strictness-ledger.md` 的 `Class` 列是**机读**的(按类小计是对它做算术),枚举值此前只有八个。`ui/app.zod.ts` 的 `BaseNavItemSchema` 八个都不合适,而这不是标签精度问题 —— **是词汇表返回了错误的动作**: + +- 两轴表(carrier / parse)把「carrier 缺席 + parse 缺席」解析成 `no door`,其规定的后续动作是 ADR-0049 退役。**在这里是破坏性的**:这个基底的键被九个导航分支共享,九个分支各自 `.strict()` 并带 `navItemUnknownKeyError`,退役等于删掉九个活着的分支的共享键。 +- `no gate` 反向错(门是存在的,就在成员上)。`authorable` 是 `view.zod.ts` 里 `FormFieldBaseSchema` 的先例,但**那个基底真的被 `.extend()`**,姿态会继承,所以它是一扇真门;把这个也记成 `authorable`,等于邀请下一轮 sweep 去「把活干完」—— 收紧一个没有任何 parse 的形状。 +- `verify` 的语义是「待检查」,而检查在批 19 就做完了。 + +维护者 2026-08-06 裁定取 A:**加词,而不是四舍五入到最近的错误答案** —— 与批 15 加 `no gate` 同一条理由。同一个测量第二次返回相反方向的后续动作,说明缺的是一格判定,不是这个站点特殊。这一格的读者主要是后续 agent,一个指向错误**动作**的分类,会被执行它的人放大,正是「消费者侧宽容」在分类层的镜像。 + +**`covered` 的定义**:carrier 缺席、parse 缺席,但词汇在**每个消费者处都被完整把守**;后续动作是**无**。它拿到自己的桶而不是并入 `no door`:两者测量相同,但小计是一张工作清单,而两行规定的工作相反 —— 合并会把刚刚消除的歧义原样搬到上一层。 + +改动面: + +- `packages/spec/scripts/lib/strictness-ledger-doc.ts` —— `VERDICTS` / `BUCKETS` / `BUCKET_OF` / `emptyBuckets()` / 渲染标签。`verify` 的**语义与归桶完全不动**(仍计入 authorable),它继续为下一个需要挂起的站点保留。 +- 台账 `ui/app.zod.ts` 行 `verify` → `covered`;头部散文补上词表变更的出处一行(批 13 `no door` / 批 15 `no gate` / #5249 `covered`),分类表与两轴表各补一行。 +- `.counts.md` 走 `gen:strictness-ledger` 整体重算:全局 authorable 43 → 42、新增 `covered` 1;`ui/` authorable 34 → 33、`covered` 1。总数仍是 197,分桶仍恰好划分。 + +**改判范围是测量出来的,不是走过场。** 判据是机械的:`covered` 要求键通过 `...X.shape` **展开**到达消费者 —— 展开把逐键 schema 复制进一个全新的 `z.object`,姿态是新对象自己的,所以基底是惰性的;而 `.extend()` / `.merge()` / `.omit()` **继承**姿态,基底就还是一扇真门。对五个已分诊目录的全部 **197** 个 strip 站点跑了这条判据,**只有一个**站点是展开的,就是本行。另外三个模块私有的 strip 基底各有归宿且**维持原判**:`view.zod.ts` 的 `FormFieldBaseSchema` 在 `:1475` 被 `.extend()`(姿态继承 → 真门 → 仍 `authorable`);`query.zod.ts` 的 `BaseQuerySchema` 在 `:485` 被 `.extend()` 成 `QuerySchema`(同理 → 仍 `open`);`component.zod.ts` 的 `EmptyProps` 作为**值**挂在 `ComponentPropsMap` 的十一个 carrier 键下(carrier 存在 → 根本不满足「carrier 缺席」)。其余约 50 个站点是属性下的内联嵌套字面量,天然自带 carrier,不可能是 `covered`。 + +不改任何 schema 姿态 —— 批 19 已测定关掉这个基底是保证的 no-op,而 #4583 明确 no-op 收紧并非中性(*"a precisely-validated dead slot is the more convincing lie"*)。 diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md index c360ecd3c5..2ce08dc069 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md @@ -29,11 +29,12 @@ Remaining strip sites by class: | Bucket | Sites | |---|---| -| authorable — the ruling's forced scope | 43 | +| authorable — the ruling's forced scope | 42 | | unresolved — needs a per-schema verdict | 33 | | wire / open — out of forced scope | 107 | | no door — no carrier, ADR-0049 territory | 14 | | no gate — carrier live, no parse | 0 | +| covered — no carrier, no parse, guarded at every consumer | 1 | ## Posture, per triaged directory @@ -171,11 +172,12 @@ over it is here. | Bucket | Sites | |---|---| -| authorable — the ruling's forced scope | 34 | +| authorable — the ruling's forced scope | 33 | | unresolved — needs a per-schema verdict | 0 | | wire / open — out of forced scope | 3 | | no door — no carrier, ADR-0049 territory | 14 | | no gate — carrier live, no parse | 0 | +| covered — no carrier, no parse, guarded at every consumer | 1 | ### `data/` — open @@ -208,6 +210,7 @@ over it is here. | wire / open — out of forced scope | 65 | | no door — no carrier, ADR-0049 territory | 0 | | no gate — carrier live, no parse | 0 | +| covered — no carrier, no parse, guarded at every consumer | 0 | ### `automation/` — open @@ -229,6 +232,7 @@ over it is here. | wire / open — out of forced scope | 26 | | no door — no carrier, ADR-0049 territory | 0 | | no gate — carrier live, no parse | 0 | +| covered — no carrier, no parse, guarded at every consumer | 0 | ### `security/` — open @@ -247,6 +251,7 @@ over it is here. | wire / open — out of forced scope | 13 | | no door — no carrier, ADR-0049 territory | 0 | | no gate — carrier live, no parse | 0 | +| covered — no carrier, no parse, guarded at every consumer | 0 | ### `studio/` — open diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.md b/docs/audits/2026-07-unknown-key-strictness-ledger.md index b984d1dcb9..1a69466e28 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.md @@ -42,11 +42,21 @@ prose conflicted once, and that conflict was meaningful. So the numbers left. are arithmetic over it), so it has a grammar: ` [(p)] [· , …]` where `` is one of `authorable` · `verify` · `mixed` · `split` · `wire` · `open` · `no door` · - `no gate`. `verify` counts as authorable (a readiness flag, not a class). A - `mixed`/`split` row still carrying `(p)` counts as *unresolved*; one that has - been resolved **must** state its own split (`mixed · 6 authorable`) — the gate - refuses to guess, because a guess would be published as a confident subtotal. - The `Class` column in the **triage** table is free prose and is not parsed. + `no gate` · `covered`. `verify` counts as authorable (a readiness flag, not a + class). A `mixed`/`split` row still carrying `(p)` counts as *unresolved*; one + that has been resolved **must** state its own split (`mixed · 6 authorable`) — + the gate refuses to guess, because a guess would be published as a confident + subtotal. The `Class` column in the **triage** table is free prose and is not + parsed. +- **Vocabulary changes, and where they came from.** The list above is a machine-read + contract, so it grows only by ruling, and only when an existing word would + prescribe the WRONG ACTION rather than merely an imprecise label: `no door` at + 批 13, `no gate` at 批 15, and **`covered` at [#5249](https://github.com/objectstack-ai/objectstack/issues/5249)** + (maintainer ruling 2026-08-06, option A) for a shape that is mechanically + `no door` but whose vocabulary is fully gated at every consumer — where + `no door`'s prescribed ADR-0049 retirement would delete a live shared key. + Adding a word is cheaper than the sweep a wrong word invites; rounding to the + nearest verdict is the failure mode this list exists to prevent. ## Classification rule @@ -59,6 +69,7 @@ One question decides the class: **who writes this schema's input?** | **open** | Deliberately schemaless user data (record bodies, per-node-type `config`, React props) | stay open; a *sibling* contract validates it (e.g. a node executor's `configSchema`, #4027/#4040) | | **no door** | **Nobody — nothing parses it.** The shape is exported and typed, but no schema declares a carrier key for it, so it is unreachable from every metadata-type root and from `defineStack`, and nothing calls `.parse()` on it outside its own test. Added at 批 13, when the first run of files resolved its `(p)` this way | **out of this ratchet's scope.** `.strict()` is a property of a PARSE; with no parse it enforces nothing and only makes a dead slot look load-bearing — *"a precisely-validated dead slot is the more convincing lie"* (#4583). The live question is ADR-0049 enforce-or-remove — retire the vocabulary or give it a carrier — so a row here points at an issue, never at a batch (#4988, #5015) | | **no gate** | **An author — through a carrier this protocol does not PARSE.** The carrier key exists and is live (authors write it, a renderer reads it), but no `.parse()` sits between them; whatever checking exists re-derives the schema's rules by hand. Added at 批 15 on `ChartAggregateSchema` (``); 批 17 then found the same shape at scale — all 29 sites of `ui/component.zod.ts`, behind `PageComponentSchema.properties`, making this the largest class in `ui/` | **out of this ratchet's scope, for the opposite reason.** Same absent parse, so closing it still enforces nothing — but the vocabulary is ALIVE, so the fix is to wire the parse at the carrier's own gate, not to retire anything. A row here points at that wiring issue | +| **covered** | **An author — but never through THIS site.** A module-private shape FRAGMENT with no carrier key and no `.parse()` of its own, whose keys reach authors only after being copied into consumers that each gate them. The copy must be a `...X.shape` SPREAD, because a spread lands the keys in a fresh `z.object` whose posture is its own — `.extend()` / `.merge()` / `.omit()` INHERIT the base's posture, which makes the base a real door and puts it back in `authorable` (finding 16, and `view.zod.ts`'s `FormFieldBaseSchema` one directory over). Added at #5249 on `ui/app.zod.ts`'s `BaseNavItemSchema` | **out of this ratchet's scope, and the follow-up is NOTHING.** Same absent parse, so closing it enforces nothing — and unlike `no door` the vocabulary is fully ALIVE and fully GATED, at every consumer, so retirement would delete keys those consumers still accept and check. This is the one verdict that prescribes no next step, which is exactly why it needed its own word: a row here is DONE, not queued | A fourth answer to "who writes this input" is **nobody**, and it is only reachable by measurement rather than by reading the file: `no door` was added at @@ -88,20 +99,28 @@ now — the file no longer splits, because the half that made it split is gone. 批 15 then found that the answer splits again, and that the split decides the follow-up. Both `no door` and `no gate` fail the same measurement — no parse, so -no strictness — but they fail it from opposite directions: - -| | carrier key | parse | right next step | -|---|---|---|---| -| **`no door`** | absent | absent | ADR-0049 enforce-or-remove — there is no author to protect | -| **`no gate`** | **live** | absent | wire the parse at the carrier's own gate — retiring it would break a working feature | +no strictness — but they fail it from opposite directions. #5249 then found a +THIRD direction off the same two axes, and it is the one the two-axis table read +backwards: a shape that is carrier-absent AND parse-absent, i.e. mechanically +`no door`, whose keys are nonetheless fully alive and fully gated because a +consumer spread them: + +| | carrier key | parse | vocabulary at the consumers | right next step | +|---|---|---|---|---| +| **`no door`** | absent | absent | absent too — nothing spreads or reads it | ADR-0049 enforce-or-remove — there is no author to protect | +| **`no gate`** | **live** | absent | live, **ungated** — a renderer reads what nothing checked | wire the parse at the carrier's own gate — retiring it would break a working feature | +| **`covered`** | absent | absent | **live and fully gated** — every consumer spread the shape and applied its own `.strict()` | **nothing.** The keys are already enforced everywhere they are reachable; closing this fragment is a guaranteed no-op and retiring it deletes a live shared key | Read the wrong one and the prescribed action is not merely wasteful but destructive: retiring a `no gate` vocabulary deletes something authors use and -renderers run. So the measurement has to report the CARRIER and the PARSE -separately; "unreachable from the metadata roots" alone cannot tell them apart, -because a react-tier prop is a real authoring door that no metadata BFS can see. - -Both are verdicts, not TODOs. What they share is the discipline that produced +renderers run, and retiring a `covered` one deletes a key its consumers still +accept. So the measurement has to report the CARRIER, the PARSE **and the +consumers' own posture** separately; "unreachable from the metadata roots" alone +cannot tell any of the three apart, because a react-tier prop is a real authoring +door that no metadata BFS can see, and a spread fragment has no door of its own +while its keys have nine. + +All three are verdicts, not TODOs. What they share is the discipline that produced them: a verification step's correct output includes "this was never the ratchet's job", and a batch unable to return that answer will close things to look finished. @@ -849,7 +868,7 @@ next person to open that file will look. | `widget.zod.ts` | **no door** | ⛔ **not strictness work** — the whole file measured unreachable from every authoring root (#4001 批 16), with no carrier key and zero parse in all three repos. ADR-0049 triage is **#5055**. See the triage row above, including why the campaign's own BFS said otherwise first (**#5056**) | | `chart.zod.ts` | **authorable** | **was `no gate` until #5020** (the cell carries one verdict on purpose — it is the machine-readable input to the generated subtotal, so the history lives here in the evidence). `ChartAggregateSchema` + `ChartGroupBySchema`'s object arm. Config / axis / series / annotation / interaction closed at 批 15; these two were held OUT of the ratchet as `no gate` — carrier live, no parse — because closing them would have gated nothing (#4583). **#5020 wired the parse, so the hold is over and these two are ordinary strictness work again.** `packages/lint/src/validate-react-page-props.ts` now calls `ChartAggregateSchema.safeParse()` on a static `aggregate={{…}}` literal, and the hand-derived `CHART_FUNCTIONS` list + count/field refinement twin are deleted. That is the path **#5022 demonstrated on one key** and this row was blocked on: `ChartDrillDownSchema` arrived with its gate already wired, parsing instead of re-deriving, while `aggregate` beside it did the opposite. ⚠️ **The flip is `no gate` → `authorable`, NOT → closed.** Both sites still STRIP: the parse the gate runs drops `groupby` / `dateGranularty` rather than reporting them, so the ADR-0078 failure mode survives until the posture changes. Converting the two object arms to `strictObject` is **#5583** (Blocked-by resolved; a sub-issue of #4001), which is also where the two `chart.test.ts` "still STRIPS — deliberate" pins invert and where the one product question lands — `groupBy` is declared REQUIRED here and in the published react-blocks type while the renderer honours its absence, so #5020's gate reports that single case at `warning` instead of gating a shape the platform delivers | | `i18n.zod.ts` | **split** · 5 no door | **批 16 closed the one real door**: `AriaPropsSchema` (`strictObject`, carried as `aria:` on ~30 shapes under six metadata-type roots — it was returning `aria: {}` for a legacy-spelled block). The 5 left are `I18nObject` / `PluralRule` / `NumberFormat` / `DateFormat` / `LocaleConfig`, all **no door** (#5055) — ⛔ **do not close them**. This row shrinks without disappearing, the third such in the ledger after `flow` (批 11) and `etl` (批 12): the reverse pin fires on ZERO, so a row parked at a deliberate floor looks exactly like a row nobody finished, and only the `Class` column separates them | -| `app.zod.ts` | verify | **批 19 ran the check and it came back NEGATIVE — no posture change, and the row's `Class` is held at `verify` deliberately (see below).** `BaseNavItemSchema`. The instruction here was to confirm the members' strictness was not already covering it before touching; it is, and the premise this row carried was wrong twice. (1) **The members do not `.extend()` the base — they spread `...BaseNavItemSchema.shape`.** That is a different mechanism, and the difference is the whole of finding 16: `.extend()` clones INHERIT the base's posture (which is how closing two `view` authoring schemas silently closed the Studio round-trip overlay), while a `...shape` spread copies the per-key schemas into a FRESH `z.object` whose posture is its own. Measured in both directions rather than read off the source, because *"closing the base closes the members"* and *"closing the base is a no-op"* are opposite claims: `strictBase.extend({…})` rejects an unknown key, `z.object({...strictBase.shape})` accepts it, `z.object({...openBase.shape}).strict()` rejects it. (2) **All nine branches already apply their own `.strict()`** with the curated `navItemUnknownKeyError` — asserted per branch through the real door (`AppSchema.navigation`, a `discriminatedUnion` on `type`), with a positive control (every base-contributed key, incl. `requiresService` which no branch declares itself, is ACCEPTED) and a negative control (an undeclared key is REJECTED) in the same run. The base is also module-private and has zero `.parse()` anywhere, so `.strict()` here would be a property of a parse that does not exist. Closing it is therefore a guaranteed no-op, and #4583 is explicit that a no-op closure is not neutral. ⚠️ **The open question is the VOCABULARY, not the measurement** — which is why the `Class` cell was not changed, since it is machine-read and a guess here would be published as a confident subtotal. The two-axis table above resolves carrier-absent + parse-absent to `no door`, whose prescribed follow-up is ADR-0049 retirement — and that prescription is *destructive* here: the vocabulary is fully ALIVE and fully GATED at nine consumers, so retiring the base would delete nine branches' shared keys. `no gate` is wrong for the mirror reason (the gate exists, at the members). `authorable` is the `FormFieldBaseSchema` precedent one row over in `view.zod.ts` — but that base really is `.extend()`ed, so closing it WOULD change behaviour, and calling this one `authorable` invites exactly the later sweep that "finishes the job" on a shape nothing parses. None of the eight enumerated verdicts is honest for a shape that is neither a door nor dead, and adding a ninth changes a machine-read contract — so the decision is the maintainer's (**#5249**). Recorded in three places (the `BaseNavItemSchema` JSDoc + `app-strictness-batch19.test.ts` + this row); the pin includes a guard that fails if any branch ever stops rejecting unknown keys, which is the one change that would make this verdict need re-taking | +| `app.zod.ts` | covered | **批 19 ran the check and it came back NEGATIVE — no posture change; the `Class` was held at `verify` pending #5249 and is now `covered`, the verdict that ruling created (see below).** `BaseNavItemSchema`. The instruction here was to confirm the members' strictness was not already covering it before touching; it is, and the premise this row carried was wrong twice. (1) **The members do not `.extend()` the base — they spread `...BaseNavItemSchema.shape`.** That is a different mechanism, and the difference is the whole of finding 16: `.extend()` clones INHERIT the base's posture (which is how closing two `view` authoring schemas silently closed the Studio round-trip overlay), while a `...shape` spread copies the per-key schemas into a FRESH `z.object` whose posture is its own. Measured in both directions rather than read off the source, because *"closing the base closes the members"* and *"closing the base is a no-op"* are opposite claims: `strictBase.extend({…})` rejects an unknown key, `z.object({...strictBase.shape})` accepts it, `z.object({...openBase.shape}).strict()` rejects it. (2) **All nine branches already apply their own `.strict()`** with the curated `navItemUnknownKeyError` — asserted per branch through the real door (`AppSchema.navigation`, a `discriminatedUnion` on `type`), with a positive control (every base-contributed key, incl. `requiresService` which no branch declares itself, is ACCEPTED) and a negative control (an undeclared key is REJECTED) in the same run. The base is also module-private and has zero `.parse()` anywhere, so `.strict()` here would be a property of a parse that does not exist. Closing it is therefore a guaranteed no-op, and #4583 is explicit that a no-op closure is not neutral. ⚠️ **The open question was the VOCABULARY, not the measurement** — which is why 批 19 left the cell alone, since it is machine-read and a guess here would be published as a confident subtotal. The two-axis table above resolved carrier-absent + parse-absent to `no door`, whose prescribed follow-up is ADR-0049 retirement — and that prescription is *destructive* here: the vocabulary is fully ALIVE and fully GATED at nine consumers, so retiring the base would delete nine branches' shared keys. `no gate` is wrong for the mirror reason (the gate exists, at the members). `authorable` is the `FormFieldBaseSchema` precedent one row over in `view.zod.ts` — but that base really is `.extend()`ed, so closing it WOULD change behaviour, and calling this one `authorable` invites exactly the later sweep that "finishes the job" on a shape nothing parses. ✅ **RESOLVED at #5249 (maintainer ruling 2026-08-06, option A): the vocabulary grew a ninth verdict, `covered`, and this row is its first and — as of the sweep below — its ONLY instance.** The ruling took the same route 批 15 took for `no gate` rather than rounding to the nearest wrong answer, on the ground that the cell's readers are later agents and a verdict naming the wrong ACTION is amplified by whoever acts on it. The re-review the ruling required was run over all **197** strip sites in the five triaged directories, not just this file, and it is mechanical rather than a reading: `covered` requires the keys to reach consumers by `...X.shape` SPREAD (a spread lands them in a fresh `z.object` with its own posture, so the base is inert), whereas `.extend()`/`.merge()`/`.omit()` inherit posture and keep the base a real door. Exactly **one** of the 197 sites spreads — this one, into eight of the nine branches (`SeparatorNavItemSchema` declares its own two keys and spreads nothing, and is `.strict()` all the same). The three other module-private strip bases all resolve elsewhere and stay put: `view.zod.ts`'s `FormFieldBaseSchema` is `.extend()`ed at `:1475` → posture inherits → a real door → stays `authorable`; `query.zod.ts`'s `BaseQuerySchema` is `.extend()`ed at `:485` into `QuerySchema` → same → stays `open`; `component.zod.ts`'s `EmptyProps` is used as a VALUE under eleven `ComponentPropsMap` carrier keys → carrier present → not carrier-absent at all. The remaining ~50 sites are inline nested literals under a property, so they carry a carrier by construction and cannot be `covered`. Recorded in three places (the `BaseNavItemSchema` JSDoc + `app-strictness-batch19.test.ts` + this row); the pin includes a guard that fails if any branch ever stops rejecting unknown keys, which is the one change that would make this verdict need re-taking | | `action-params.zod.ts` | wire | **out of scope** — `ActionSessionSchema`, the action-body `ctx.session` the runtime hands a body (#5697). Tolerant on purpose, same disposition as `data/hook.zod.ts`'s `HookContextSchema`. What this surface needed was never a closed door but a gate that RUNS: its consistency with the real producer is pinned in `packages/runtime/src/action-session-shape-contract.test.ts`, which asserts that a non-strict parse of the built object returns it UNCHANGED — so a key the builder starts producing without declaring it here is stripped, and the pin goes red | `sharing.zod.ts` and `notification.zod.ts` left this table at **#5015** by a route no other row has taken: not by being CLOSED, but by having their remaining sites REMOVED. Both were `no door` — ADR-0049 territory, explicitly out of this ratchet's scope — and the enforce-or-remove call came back REMOVE, so `EmbedConfigSchema` and `NotificationActionSchema` are gone rather than strict. Read the reverse pin carefully here, because it fires on zero either way and cannot tell the two routes apart: the `sharing.zod.ts` row said in as many words that it *"shrinks without disappearing — the first `no door` floor"*, and that was true right up until the floor was retired out from under it. A deliberate floor and a retired one look identical from the count; only the `Class` column and this paragraph separate them. `sharing.zod.ts` keeps its TRIAGE row above, because `SharingConfigSchema` is still there and still strict — the file is closed, not empty. `notification.zod.ts` keeps no row anywhere: it has zero object sites left. diff --git a/packages/spec/scripts/lib/strictness-ledger-doc.ts b/packages/spec/scripts/lib/strictness-ledger-doc.ts index 129eae2353..3f7b97cd34 100644 --- a/packages/spec/scripts/lib/strictness-ledger-doc.ts +++ b/packages/spec/scripts/lib/strictness-ledger-doc.ts @@ -54,10 +54,29 @@ import { analyzeSites, listSchemaFiles, type Posture } from './strictness-ledger /** * The `Class` vocabulary the ledger uses. Every one of these was introduced by a - * measurement that needed it, and two of them (`no door` / `no gate`) imply - * OPPOSITE follow-ups — retiring a `no gate` shape deletes something authors use. - * So the parser rejects anything outside this set rather than bucketing it as - * "other". + * measurement that needed it, and three of them (`no door` / `no gate` / + * `covered`) imply MUTUALLY EXCLUSIVE follow-ups off the SAME measurement — + * retiring a `no gate` shape deletes something authors use, and retiring a + * `covered` one deletes a key its consumers still gate. So the parser rejects + * anything outside this set rather than bucketing it as "other". + * + * ## Why the list grows instead of rounding to the nearest word (#5249) + * + * `covered` is the third verdict added because the existing vocabulary returned + * the WRONG ACTION rather than merely an imprecise label, and that is the only + * bar a new word has to clear. Twice now the two-axis read (carrier / parse) has + * produced one answer for two situations whose right next steps are opposite: + * + * - 批 15 added `no gate` — parse absent, but the carrier is LIVE, so the fix + * is to wire the parse, not to retire the vocabulary; + * - #5249 adds `covered` — carrier absent AND parse absent (mechanically `no + * door`), but the vocabulary is fully guarded at every consumer, so the + * follow-up is NOTHING. `no door` would have pointed the next agent at + * ADR-0049 retirement of a key nine live nav branches share. + * + * The cell is read by future agents, so a verdict that names the wrong action is + * the classification-layer mirror of a lenient consumer: the error does not stop + * at the cell, it is amplified by whoever acts on it. */ export const VERDICTS = [ 'authorable', @@ -68,20 +87,38 @@ export const VERDICTS = [ 'open', 'no door', 'no gate', + 'covered', ] as const; export type Verdict = (typeof VERDICTS)[number]; /** The buckets the subtotals report. */ -export const BUCKETS = ['authorable', 'unresolved', 'wire/open', 'no door', 'no gate', 'unclassified'] as const; +export const BUCKETS = [ + 'authorable', + 'unresolved', + 'wire/open', + 'no door', + 'no gate', + 'covered', + 'unclassified', +] as const; export type Bucket = (typeof BUCKETS)[number]; /** * Verdict → bucket, for the verdicts that name exactly one class. * - * `verify` counts as **authorable**: `ui/app.zod.ts`'s `BaseNavItemSchema` is held - * pending the finding-16 `.extend()` check, which is a readiness flag on an - * authorable site, not a third class — and the ledger's published subtotal - * (`view` 6 + `app` 1 = 7) already counted it that way. + * `verify` counts as **authorable** — a readiness flag on an authorable site, not + * a third class, and the ledger's published subtotal (`view` 6 + `app` 1 = 7) + * counted it that way from the start. The mapping is unchanged at #5249; what + * changed is that its one instance (`ui/app.zod.ts`'s `BaseNavItemSchema`, held + * pending the finding-16 `.extend()` check) has since been checked and moved to + * `covered`. `verify` keeps both its meaning and its bucket for the next site + * that needs holding. + * + * `covered` gets its **own** bucket rather than sharing `no door`'s. Both are + * carrier-absent + parse-absent, so an arithmetic merge would be defensible — but + * the subtotal is a worklist readout, and the two rows prescribe opposite work + * (`no door` → ADR-0049 retirement; `covered` → none). Folding them would restore + * exactly the ambiguity the verdict was added to remove, one layer up. * * `mixed` / `split` name no single class and are resolved separately: with an * explicit breakdown when the per-schema read has been done, or as `unresolved` @@ -94,6 +131,7 @@ const BUCKET_OF: Partial> = { open: 'wire/open', 'no door': 'no door', 'no gate': 'no gate', + covered: 'covered', }; export interface ParsedClass { @@ -220,7 +258,15 @@ export function bucketize( } export function emptyBuckets(): Record { - return { authorable: 0, unresolved: 0, 'wire/open': 0, 'no door': 0, 'no gate': 0, unclassified: 0 }; + return { + authorable: 0, + unresolved: 0, + 'wire/open': 0, + 'no door': 0, + 'no gate': 0, + covered: 0, + unclassified: 0, + }; } /* -------------------------------------------------------------- ledger parse */ @@ -464,6 +510,7 @@ const BUCKET_LABEL: Record = { 'wire/open': 'wire / open — out of forced scope', 'no door': 'no door — no carrier, ADR-0049 territory', 'no gate': 'no gate — carrier live, no parse', + covered: 'covered — no carrier, no parse, guarded at every consumer', unclassified: '⚠️ unclassified — no ledger row', }; diff --git a/packages/spec/scripts/strictness-ledger-doc.test.ts b/packages/spec/scripts/strictness-ledger-doc.test.ts index d502bcd490..9c67f75113 100644 --- a/packages/spec/scripts/strictness-ledger-doc.test.ts +++ b/packages/spec/scripts/strictness-ledger-doc.test.ts @@ -58,15 +58,67 @@ describe('Class cell grammar', () => { }); it('maps `verify` to authorable, because that is what the ledger has always counted', () => { - // `ui/app.zod.ts`'s single site: held pending the finding-16 `.extend()` - // check, and counted in the published `view` 6 + `app` 1 = 7 all the same. - // A readiness flag, not a class. + // A readiness flag, not a class — counted in the published `view` 6 + + // `app` 1 = 7 from the start. #5249 moved its one instance to `covered` and + // deliberately left this mapping alone: the word still means "held pending a + // check" and the next site that needs holding must count the same way. const parsed = parseClassCell('verify'); expect(parsed).not.toBeNull(); expect(bucketize(parsed!, 1)).toEqual({ buckets: { ...zero(), authorable: 1 } }); }); }); +describe('`covered` — the ninth verdict (#5249)', () => { + it('parses, and lands in a bucket of its own', () => { + const parsed = parseClassCell('covered'); + expect(parsed).toMatchObject({ verdict: 'covered', provisional: false, breakdown: null }); + expect(bucketize(parsed!, 1)).toEqual({ buckets: { ...zero(), covered: 1 } }); + }); + + it('does NOT merge into `no door`, which is the whole reason it exists', () => { + // Both are carrier-absent + parse-absent, so an arithmetic merge would look + // harmless. It is not: the subtotal is a worklist readout and the two rows + // prescribe OPPOSITE work — `no door` sends the next agent to ADR-0049 + // retirement, `covered` sends them nowhere because every consumer already + // gates the keys. Retiring `ui/app.zod.ts`'s `BaseNavItemSchema` on a + // `no door` reading would delete nine live nav branches' shared keys. + const covered = bucketize(parseClassCell('covered')!, 3); + const noDoor = bucketize(parseClassCell('no door')!, 3); + expect(covered).not.toEqual(noDoor); + expect('buckets' in covered && covered.buckets['no door']).toBe(0); + expect('buckets' in noDoor && noDoor.buckets.covered).toBe(0); + }); + + it('is a single class, so a split may be declared in terms of it', () => { + // The ledger's `ui/i18n.zod.ts` row is `split · 5 no door`; a file that ever + // mixes a covered fragment with live sites must be able to say so, and + // `bucketize` rejects breakdown parts that name no single class. + const parsed = parseClassCell('split · 2 covered, 1 authorable')!; + expect(bucketize(parsed, 3)).toEqual({ buckets: { ...zero(), covered: 2, authorable: 1 } }); + }); + + it('is reported under a label that names the measurement, not just the word', () => { + // The counts artifact is read by people who never open this file, so the + // bucket label has to carry the three-part test (`no carrier, no parse, + // guarded at every consumer`) rather than a bare `covered`. + const { rendered } = loadLedger(REPO, SRC); + expect(rendered).toContain('covered — no carrier, no parse, guarded at every consumer'); + }); + + it('has exactly one instance in the tree, and it is `ui/app.zod.ts`', () => { + // The re-review #5249's ruling required, pinned rather than narrated: the + // verdict was created for one measured site, and a second row appearing + // without a measurement is the drift this asserts against. `covered` + // requires the keys to reach consumers by `...X.shape` SPREAD — `.extend()` + // INHERITS posture, which makes the base a real door (`FormFieldBaseSchema`, + // `BaseQuerySchema`) rather than an inert fragment. + const { parsed, model } = loadLedger(REPO, SRC); + const rows = parsed.strip.filter((r) => parseClassCell(r.classCell)?.verdict === 'covered'); + expect(rows.map((r) => `${r.dir}/${r.file}`)).toEqual(['ui/app.zod.ts']); + expect(model.global.buckets.covered).toBe(1); + }); +}); + describe('bucketize refuses to guess', () => { it('rejects a resolved mixed/split row that states no split', () => { const parsed = parseClassCell('mixed')!; @@ -161,11 +213,15 @@ describe('the ledger documents the grammar the parser enforces', () => { it('names every verdict the parser accepts, and no others', () => { // Two copies of a vocabulary drift, and the direction they drift in is the // one where an author writes what the doc says and the gate rejects it. + // + // The alternation is built FROM `VERDICTS` rather than transcribed. It used + // to be a third hand-written copy of the list, which meant adding a verdict + // (#5249's `covered`) failed here for the one reason this test is not about: + // a word missing from the regex is reported as a word missing from the doc. const md = fs.readFileSync(path.join(REPO, LEDGER_PATH), 'utf-8'); const block = md.split('## Classification rule')[0]; - const documented = new Set( - [...block.matchAll(/`(authorable|verify|mixed|split|wire|open|no door|no gate)`/g)].map((m) => m[1]), - ); + const alternation = VERDICTS.map((v) => v.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|'); + const documented = new Set([...block.matchAll(new RegExp(`\`(${alternation})\``, 'g'))].map((m) => m[1])); expect([...documented].sort()).toEqual([...VERDICTS].sort()); }); @@ -177,5 +233,13 @@ describe('the ledger documents the grammar the parser enforces', () => { }); function zero(): Record { - return { authorable: 0, unresolved: 0, 'wire/open': 0, 'no door': 0, 'no gate': 0, unclassified: 0 }; + return { + authorable: 0, + unresolved: 0, + 'wire/open': 0, + 'no door': 0, + 'no gate': 0, + covered: 0, + unclassified: 0, + }; } diff --git a/packages/spec/src/ui/app-strictness-batch19.test.ts b/packages/spec/src/ui/app-strictness-batch19.test.ts index 773936e622..adeb6095c9 100644 --- a/packages/spec/src/ui/app-strictness-batch19.test.ts +++ b/packages/spec/src/ui/app-strictness-batch19.test.ts @@ -4,7 +4,12 @@ * #4001 批 19 — `ui/app.zod.ts`'s last strip site, measured. * * The file has ONE site left: `BaseNavItemSchema`. The ledger carried it as - * `verify` — held pending a check, not scheduled work — with this instruction: + * `verify` — held pending a check, not scheduled work — and, once this check + * came back, held it there a second time while the VOCABULARY question it + * raised went to the maintainer (#5249). That ruling landed on 2026-08-06 and + * added a ninth verdict, `covered`, which the row now carries; the measurement + * below is what `covered` means, and is unchanged by the renaming. The original + * instruction was: * * > `BaseNavItemSchema` — the base the strict discriminated-union members * > extend. Closing a base that is `.extend()`ed is the #4001 trap that bit @@ -37,7 +42,9 @@ * than an omission — the third place it is recorded, beside the schema comment * and the `ui/` ledger row. If a future member is ever written as * `BaseNavItemSchema.extend({...})` WITHOUT its own `.strict()`, the last test - * here is the one that should start failing. + * here is the one that should start failing — and that is also the one change + * that would take the row back out of `covered`, since `covered` rests on the + * spread (posture not inherited) plus every consumer's own `.strict()`. */ import { describe, it, expect } from 'vitest'; diff --git a/packages/spec/src/ui/app.zod.ts b/packages/spec/src/ui/app.zod.ts index d332804ae6..0ea1da7d29 100644 --- a/packages/spec/src/ui/app.zod.ts +++ b/packages/spec/src/ui/app.zod.ts @@ -201,8 +201,13 @@ const actionDefUnknownKeyError = strictUnknownKeyError({ * nothing while making a shape fragment look load-bearing (#4583: *"a * precisely-validated dead slot is the more convincing lie"*). * - * The `Class` cell it should carry is an OPEN question (#5249): the ledger's - * enumerated vocabulary has no word for a shape that is neither a door nor dead. + * The `Class` cell it should carry was an open question (#5249), because the + * ledger's enumerated vocabulary had no word for a shape that is neither a door + * nor dead. **Ruled 2026-08-06: the vocabulary grew one — `covered`** (carrier + * absent, parse absent, vocabulary fully gated at every consumer; follow-up: + * none), and this schema is its only instance in the five triaged directories — + * a spread is what makes the base inert, and it is the only strip site in all + * 197 that is spread rather than `.extend()`ed or carried under a key. * * Pinned in `app-strictness-batch19.test.ts`, including the mechanism itself * (`.extend()` inherits posture, `...shape` does not) and a guard that fails if From 49533f1f429e9aae724374710d32da2f244c952e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 04:29:13 +0000 Subject: [PATCH 2/2] =?UTF-8?q?chore(spec):=20=E5=90=88=E5=85=A5=20main=20?= =?UTF-8?q?=E5=90=8E=E6=8C=89=E5=90=88=E5=B9=B6=E6=A0=91=E9=87=8D=E7=AE=97?= =?UTF-8?q?=20strictness=20counts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `git merge origin/main` 把 counts.md **干净且错误**地合并了:本分支侧 的 `shared/ | 25` 胜出,而 main 侧 #6078 退役 `FieldMappingTransform` 五成员联合后该目录已是 20。两侧改动不重叠(本分支动分桶表,main 动 未分诊目录表),所以文本合并无冲突 —— 正是 #5107 记录的那种失败: 与谁都不冲突的数字合并干净、两边都错。 按台账自己的规矩从**合并树**整体重算(`gen:strictness-ledger`), `shared/` 25 → 20。分桶与 197 的总数不受影响。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011M7UwH25Unfi73UHim7ajY --- docs/audits/2026-07-unknown-key-strictness-ledger.counts.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md index 2ce08dc069..b111f49fef 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md @@ -273,5 +273,5 @@ directory rather than per file. | `integration/` | 10 | | `kernel/` | 319 | | `qa/` | 6 | -| `shared/` | 25 | +| `shared/` | 20 | | `system/` | 366 |