From e89f70ac69a9f65d9d9fb8378d3f125b3cf0264b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 17:10:39 +0000 Subject: [PATCH] fix(scripts): doc-authoring Rule 3 reaches a GENERATED guidance table A guidance table does not have to be written as an object literal. Two in packages/spec are BUILT -- one prescription filed under each of a list of keys via `Object.fromEntries(keys.map((k) => [k, '...']))`: - SEPARATOR_NAV_ITEM_GUIDANCE (ui/app.zod.ts), spread into the `guidance` of the per-variant nav options table; - the container-key prescription at `guidance:` in ui/view.zod.ts, generated inline at the key itself. Both are printed verbatim at a refusing author and both sat outside the rule's population entirely: the climb died on the unrecognised `map` / `fromEntries` call between the callback and its sink, so the fourth population's factory clause -- which teaches the climb to leave a function that BUILDS an options record -- never got the chance to fire. Measured by planting a distinct id in every literal of the nav options table and the five tables it references: 48 of 49 candidate ids red, and the whole of SEPARATOR_NAV_ITEM_GUIDANCE (four prose literals) silent. `fromEntries` / `map` / `flatMap` join TRANSPARENT_CALLS. Transparency only lets the climb CONTINUE -- it must still terminate at a recognised position -- so a `.map()` in an ordinary helper stays silent, pinned as a precision case beside the two new red/green pairs. Population +19 strings (strictObject 3349 -> 3364, functionBuilt 217 -> 221), 0 new violations: none of the newly reached prose carries an internal id. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CPrUz21stTFhJRUirdc4yw --- scripts/check-doc-authoring.mjs | 119 ++++++++++++++++++++++++++++++-- 1 file changed, 114 insertions(+), 5 deletions(-) diff --git a/scripts/check-doc-authoring.mjs b/scripts/check-doc-authoring.mjs index 0efd4a7a2f..0f8df7db3f 100644 --- a/scripts/check-doc-authoring.mjs +++ b/scripts/check-doc-authoring.mjs @@ -514,11 +514,47 @@ const STRICT_OBJECT_CALLS = new Set(['strictObject', 'strictObjectError']); const TOMBSTONE_CALLS = new Set(['retiredKey']); /** - * Wrappers that pass their argument through unchanged, so the climb continues - * rather than stopping. `Object.freeze({ … })` around a guidance table is the - * measured case; a stop here would drop the whole table. + * Wrappers whose VALUE is the text the climb is already carrying, so the climb + * continues rather than stopping. `Object.freeze({ … })` around a guidance + * table is the original measured case; a stop here would drop the whole table. + * + * ## The GENERATED table: `Object.fromEntries(keys.map((k) => [k, '…']))` + * + * A guidance table does not have to be WRITTEN as an object literal. Two in + * this tree are BUILT — one prescription filed under each of a list of keys, + * because every one of those keys has the same answer: + * + * - `SEPARATOR_NAV_ITEM_GUIDANCE` (`ui/app.zod.ts`), spread into the + * `guidance` of the per-variant nav options table; + * - the container-key prescription at `guidance:` in `ui/view.zod.ts`, + * generated inline at the key itself. + * + * Both are printed verbatim at a refusing author, and both sat OUTSIDE THE + * POPULATION ENTIRELY — measured by planting a distinct id in every literal of + * the nav options table and the five tables it references: 48 of 49 candidate + * ids red, and the whole of `SEPARATOR_NAV_ITEM_GUIDANCE` — four prose + * literals — silent. It is the fourth population's defect one shape over: the + * factory clause taught the climb to leave a function that BUILDS an options + * record, while a table built by a `.map()` INSIDE one stayed unreachable, + * because the climb died on the unrecognised `map` / `fromEntries` call sitting + * between the callback and its sink. + * + * ⛔ This does NOT make `.map()` a text position. Transparency only lets the + * climb CONTINUE; it must still terminate at a recognised position — a + * `message:` / `error:`, a `.describe()`, a tombstone argument, a + * STRICT_OPTION_KEYS key under a `strictObject` call, or a + * {@link collectTextSinkConsts} sink. A `.map()` in an ordinary helper reaches + * none of those and stays silent, exactly as before. What transparency buys is + * that a table's SPELLING — literal vs generated — stops deciding whether its + * prose is judged, which is the property this rule was missing. + * + * `flatMap` rides with `map` rather than waiting for a live case: the pair is + * one spelling of one idea, and a set that knows only half of it is the next + * silent miss (`data/driver/config-registry.zod.ts` builds its alias tables + * with `flatMap` today — values, not prose, so it moves no verdict, but it is + * the shape arriving). */ -const TRANSPARENT_CALLS = new Set(['freeze']); +const TRANSPARENT_CALLS = new Set(['freeze', 'fromEntries', 'map', 'flatMap']); /** * zod validators whose trailing positional argument is a refusal message. @@ -1472,7 +1508,7 @@ function selfTest() { console.error(`\n✗ check-doc-authoring self-test failed:\n${failures.join('\n')}\n`); process.exit(1); } - console.log('✓ check-doc-authoring self-test: scope wiring (.claude and the live docs/ corpus in, .claude/worktrees and docs/{audits,handoff,plans} out), detection, the dead-root hard error (red when a ROOT is renamed, green when restored), the empty-scan hard error (red when a root yields nothing and when the whole scan does, green when restored), the published-catalog internal-id rule (red on a planted id in prose, in a fenced comment and in the repo#NNNN spelling, green when removed; hex colours, version numbers, HTTP codes, array indices and the "#1" ordinal all pass; references/ reached, generated artifacts and the internal roots out; the `#` placeholder passes while the concrete ids it replaced stay red, with no exemption to reach for), the spec customer-facing-text internal-id rule (red on an id planted on a LATER line of a concatenated message — the shape a line-oriented census cannot see, proven here — and in a template chain, a positional validator message, the repo#NNNN spelling, a nested strictObject `guidance` prescription, a HOISTED guidance const, a `KeySetGuidance` const consumed only CROSS-MODULE in both the annotated and the `as const satisfies` spelling, a HOISTED refusal message, a `retiredKey()` tombstone, `new Map` and `Object.freeze` guidance tables, `.describe()` prose, and the nested `guidance` of a whole options table written `satisfies StrictObjectOptions`; green when removed; an ADR id on a tombstone, a `.default()` VALUE, `history`/`guidance` outside a strictObject options position, `extraKeys` key names and an inferred local that merely MENTIONS `KeySetGuidance` all pass; test bodies out; the seen floor is PER BUCKET so one matcher rotting while the others carry the total still reds; and the two TYPE ANCHORS are pinned on the predicate itself — the annotation, `satisfies` and `as const satisfies` spellings all read as a strictObject options position while some other satisfied type does not, and the `*_STRICT_OPTIONS` NAME branch still fires where no type is written at all — which is the only place they can be told apart, since end to end they are redundant), the fourth population — customer-facing text BUILT INSIDE A FUNCTION (red on an id in an inline `error: () =>` callback, in a const the callback only dispatches to, inside a `message:` builder function, RETURNED from a tombstone-prescription builder, in a `: StrictObjectOptions` options factory, and in a plain `error:` string; ⛔ the body of an ordinary helper and a local inside a recognised factory stay unswept, because the climb crosses a function only when the FUNCTION sits in a recognised position; and `functionBuilt` carries its own blindness floor, since an unrecognised spelling produces no flag SILENTLY) and the dispatch-gates declaration (every separator-less ROOT declared as a subtree, nothing declared this gate does not walk, the over-claim bounded to SKIP_PATHS) all hold.'); + console.log('✓ check-doc-authoring self-test: scope wiring (.claude and the live docs/ corpus in, .claude/worktrees and docs/{audits,handoff,plans} out), detection, the dead-root hard error (red when a ROOT is renamed, green when restored), the empty-scan hard error (red when a root yields nothing and when the whole scan does, green when restored), the published-catalog internal-id rule (red on a planted id in prose, in a fenced comment and in the repo#NNNN spelling, green when removed; hex colours, version numbers, HTTP codes, array indices and the "#1" ordinal all pass; references/ reached, generated artifacts and the internal roots out; the `#` placeholder passes while the concrete ids it replaced stay red, with no exemption to reach for), the spec customer-facing-text internal-id rule (red on an id planted on a LATER line of a concatenated message — the shape a line-oriented census cannot see, proven here — and in a template chain, a positional validator message, the repo#NNNN spelling, a nested strictObject `guidance` prescription, a HOISTED guidance const, a `KeySetGuidance` const consumed only CROSS-MODULE in both the annotated and the `as const satisfies` spelling, a HOISTED refusal message, a `retiredKey()` tombstone, `new Map` and `Object.freeze` guidance tables, `.describe()` prose, and the nested `guidance` of a whole options table written `satisfies StrictObjectOptions`; green when removed; an ADR id on a tombstone, a `.default()` VALUE, `history`/`guidance` outside a strictObject options position, `extraKeys` key names and an inferred local that merely MENTIONS `KeySetGuidance` all pass; test bodies out; the seen floor is PER BUCKET so one matcher rotting while the others carry the total still reds; and the two TYPE ANCHORS are pinned on the predicate itself — the annotation, `satisfies` and `as const satisfies` spellings all read as a strictObject options position while some other satisfied type does not, and the `*_STRICT_OPTIONS` NAME branch still fires where no type is written at all — which is the only place they can be told apart, since end to end they are redundant), the fourth population — customer-facing text BUILT INSIDE A FUNCTION (red on an id in an inline `error: () =>` callback, in a const the callback only dispatches to, inside a `message:` builder function, RETURNED from a tombstone-prescription builder, in a `: StrictObjectOptions` options factory, and in a plain `error:` string; ⛔ the body of an ordinary helper and a local inside a recognised factory stay unswept, because the climb crosses a function only when the FUNCTION sits in a recognised position; and `functionBuilt` carries its own blindness floor, since an unrecognised spelling produces no flag SILENTLY), the GENERATED table — a prescription filed under each of a list of keys by `Object.fromEntries(keys.map(…))` rather than written as an object literal (red both HOISTED into a const spread into an options factory\'s `guidance` and generated INLINE at the `guidance:` key itself, green when the id is removed; ⛔ and a generated VALUE table reaching no sink stays unswept, because `.map()` is TRANSPARENT to the climb and never a position of its own) and the dispatch-gates declaration (every separator-less ROOT declared as a subtree, nothing declared this gate does not walk, the over-claim bounded to SKIP_PATHS) all hold.'); } /** @@ -1992,6 +2028,59 @@ function selfTestRule3(expect) { expect('a plain `error:` string is a MESSAGE, not function-built', r.violations[0]?.bucket, 'message'); + // RED #20 — a GENERATED guidance table: one prescription filed under each + // of a list of keys via `Object.fromEntries(keys.map(…))`, hoisted into a + // const and spread into an options FACTORY's `guidance`. This is + // `SEPARATOR_NAV_ITEM_GUIDANCE` (`ui/app.zod.ts`), reduced. Measured live: + // every literal of the surrounding nav options table was reachable and this + // whole table was not, because the climb died on `map` / `fromEntries` + // between the callback and its sink. + const GENERATED_TABLE = (prose) => [ + "import { z } from 'zod';", + "import { strictObject } from '../shared/strict-object';", + "import type { StrictObjectOptions } from '../shared/strict-object';", + 'const SEPARATOR_GUIDANCE: Readonly> = Object.fromEntries(', + " ['label', 'icon'].map((key) => [key, `\\`${key}\\` " + prose + '`]),', + ');', + 'const navItemSurface = (variant: string): StrictObjectOptions => ({', + ' surface: `this \\`${variant}\\` navigation item`,', + " guidance: { ...(variant === 'separator' ? SEPARATOR_GUIDANCE : {}) },", + '});', + "export const S = strictObject(navItemSurface('separator'), { id: z.string() });", + ].join('\n'); + writeFileSync(target, GENERATED_TABLE('is not a separator key (#4286).')); + r = scan(); + expect('an id in a GENERATED guidance table reaching an options factory is RED', + r.violations.length, 1); + expect('the generated-table red names the sink it travelled through', + r.violations[0]?.where, 'via SEPARATOR_GUIDANCE (built in a function)'); + writeFileSync(target, GENERATED_TABLE('is not a separator key.')); + expect('...and green once the id is gone', scan().violations.length, 0); + + // RED #21 — the same generation written INLINE at the `guidance:` key + // rather than hoisted, so it reaches its position with no sink const in the + // path at all. This is `ui/view.zod.ts`'s container-key prescription, + // reduced. Both spellings are live, and a fix that closed only one of them + // would leave the other silent with nothing to say so. + const INLINE_GENERATED = (prose) => [ + "import { z } from 'zod';", + "import { strictObject } from '../shared/strict-object';", + 'export const S = strictObject({', + " surface: 'this view container',", + ' guidance: Object.fromEntries(', + " ['type', 'columns'].map((k) => [k, `\\`${k}\\` " + prose + '`]),', + ' ),', + '}, { name: z.string() });', + ].join('\n'); + writeFileSync(target, INLINE_GENERATED('belongs to a single VIEW (#4286).')); + r = scan(); + expect('an id in a guidance table generated INLINE at the key is RED', + r.violations.length, 1); + expect('the inline generated-table red names the position', + r.violations[0]?.where, 'strictObject guidance (built in a function)'); + writeFileSync(target, INLINE_GENERATED('belongs to a single VIEW.')); + expect('...and green once the id is gone', scan().violations.length, 0); + // ── Precision: what must NEVER fire ───────────────────────────────────── // // ⛔ The clause is NOT "climb through function bodies". These three are the @@ -2012,6 +2101,26 @@ function selfTestRule3(expect) { ].join('\n')); expect('precision — an ordinary helper\'s body is NOT swept', scan().violations.length, 0); + // ⭐ The precision case the TRANSPARENT_CALLS widening owes: `.map()` is + // TRANSPARENT, never a position. The same `Object.fromEntries(keys.map(…))` + // generation whose prose is RED above must stay silent when what it builds + // is a VALUE table nothing customer-facing consumes — a `.map()` is one of + // the commonest expressions in this tree, and a widening that swept every + // string returned from one would report identifiers, slugs and enum members + // as refusal prose. The climb terminating at a recognised position is the + // only thing holding that line, so it is pinned rather than assumed. + writeFileSync(target, [ + "import { z } from 'zod';", + "const SLUGS: Readonly> = Object.fromEntries(", + " ['draft', 'live'].map((k) => [k, `${k}-#4286`]),", + ');', + "const TOKENS = ['a', 'b'].flatMap((k) => [`${k}#4286`]);", + 'export const S = z.object({ a: z.string() });', + 'export const T = { SLUGS, TOKENS };', + ].join('\n')); + expect('precision — a generated VALUE table reaching no sink is NOT swept', + scan().violations.length, 0); + // A function that IS recognised still only yields its recognised POSITIONS. // A local inside the factory, and a key that is not a STRICT_OPTION_KEY, // stay unreachable — crossing the function boundary does not turn the body