From 0446dc32928c8b60da410a43905517030e5adade Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 04:44:21 +0000 Subject: [PATCH 1/2] fix(scripts): doc-authoring Rule 3 sees a guidance const consumed only cross-module `collectTextSinkConsts` anchored a hoisted const on in-file evidence alone: a `strictObject(...)` call, a `StrictObjectOptions`-typed const, or a `*_STRICT_OPTIONS` name. A `KeySetGuidance` table declared in a shared module and handed to `guidanceSets:` from OTHER files has none of those in its own file, so the whole const walked free while its prescription was printed verbatim at refusing authors. The type is the anchor: `KeySetGuidance` exists solely to be handed to `guidanceSets:`, so it is as unambiguous as `StrictObjectOptions`. Anchoring on the type closes the CLASS rather than one instance -- a named list of guidance modules would have to be edited again for the next one, which is the same blind spot moved one level up. Both spellings count. `declaredTypeText()` reads the annotation, or the trailing `satisfies` when there is none, because this tree writes guidance tables both ways and a detector blind to one of them reopens the gap for the next const written the modern way. The INITIALIZER is deliberately not searched: `new Set()` mentions the type without being one. Three `--self-test` cases, per the gate's own extend-the-detector rule: the cross-module const in the annotated spelling, the same in `as const satisfies`, and the precision pin that an inferred local merely MENTIONING the type is not a sink. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CPrUz21stTFhJRUirdc4yw --- scripts/check-doc-authoring.mjs | 134 ++++++++++++++++++++++++++++++-- 1 file changed, 128 insertions(+), 6 deletions(-) diff --git a/scripts/check-doc-authoring.mjs b/scripts/check-doc-authoring.mjs index 2fe37466b7..c2b5ad0b41 100644 --- a/scripts/check-doc-authoring.mjs +++ b/scripts/check-doc-authoring.mjs @@ -705,15 +705,69 @@ function identifiersIn(node, ts, out = new Set()) { return out; } +/** + * The type a const DECLARES, in either spelling TypeScript offers. + * + * `const X: T = …` and `const X = … satisfies T` are the same statement about + * the same const, and this tree writes guidance tables both ways — + * `COMPONENT_LEVEL_GUIDANCE: readonly KeySetGuidance[]` (`ui/component.zod.ts`) + * and `WIDGET_GUIDANCE_SETS = […] as const satisfies readonly KeySetGuidance[]` + * (`ui/dashboard.zod.ts`). A type anchor that reads only `decl.type` sees the + * first and is silently blind to the second — and silence is the failure mode + * this whole file exists to prevent. `as const` is walked THROUGH rather than + * stopped at, because the `satisfies` sits outside it in that spelling. + * + * The INITIALIZER is deliberately not searched for the type name: a local like + * `new Set()` (`shared/suggestions.zod.ts`, inside the error + * builder) mentions the type without being one, and reporting the runtime's own + * bookkeeping as authoring prose is how a gate gets routed around. Pinned in + * `--self-test`. + */ +function declaredTypeText(decl, ts) { + if (decl.type) return decl.type.getText(); + const parts = []; + let cur = decl.initializer; + for (let hops = 0; cur && hops < 8; hops++) { + if (!ts.isSatisfiesExpression(cur) && !ts.isAsExpression(cur)) break; + parts.push(cur.type.getText()); + cur = cur.expression; + } + return parts.join(' '); +} + /** * Module-local const names whose CONTENTS reach a customer-facing sink. * * The blind spot this closes is argued in the Rule 3 header: the guidance maps * and a good share of the refusal messages are hoisted into a named const and * referenced from the sink, so a matcher that only reads a literal's own - * position never reaches them. Seeded from every recognised sink and from the - * two naming conventions, then closed to a FIXED POINT so a const referenced by - * a const referenced by a `guidance:` is covered too. + * position never reaches them. Seeded from every recognised sink, from the + * naming conventions and from the TYPE anchors below, then closed to a FIXED + * POINT so a const referenced by a const referenced by a `guidance:` is covered + * too. + * + * ## The cross-module sink: a const whose only consumer is another file + * + * Seeding from in-file sinks alone leaves one shape unreachable BY + * CONSTRUCTION. A guidance table declared in a shared module and handed to + * `guidanceSets:` from OTHER files has no recognised anchor in its own file, so + * the whole const walks free — measured live on + * `SELECT_OPTION_EDITABILITY_GUIDANCE` (`shared/editability-boundary.ts`), + * whose prescription is printed verbatim at a refusing author on both the + * object-field face and the form-view face, and which this rule reported clean + * from the day it landed. Its neighbour `EDITABILITY_BOUNDARY_GUIDANCE`, same + * file and same shape, was reachable only by ACCIDENT: it happens to be + * consumed in-module by a `StrictObjectOptions` const. The gap is a property of + * the CONSUMPTION SITE, not of the const — so no amount of care at the + * declaration would have avoided it. + * + * A const whose declared type is `KeySetGuidance` is therefore a sink in its own + * right, on the same footing as `StrictObjectOptions`: the type exists solely to + * be handed to `guidanceSets:`, and `strictUnknownKeyError` prints the + * `prescription` it carries verbatim at the author. Anchoring on the TYPE rather + * than on a named list of guidance modules closes the CLASS — a named list + * would have to be edited again for the next shared guidance const, which is + * this same blind spot moved one level up. * * Name-based within one module rather than a scope analysis, deliberately: a * shadowed local of the same name would be a false positive, which costs an @@ -733,8 +787,12 @@ function collectTextSinkConsts(sf, ts) { if (ts.isVariableDeclaration(n) && ts.isIdentifier(n.name) && n.initializer) { decls.set(n.name.text, n.initializer); if (/_RETIRED_KEY_GUIDANCE$/.test(n.name.text)) sinks.set(n.name.text, 'tombstone'); - const ty = n.type ? n.type.getText() : ''; - if (/_STRICT_OPTIONS$/.test(n.name.text) || /\bStrictObjectOptions\b/.test(ty)) { + const ty = declaredTypeText(n, ts); + if ( + /_STRICT_OPTIONS$/.test(n.name.text) + || /\bStrictObjectOptions\b/.test(ty) + || /\bKeySetGuidance\b/.test(ty) + ) { sinks.set(n.name.text, 'strictObject'); } } @@ -1263,7 +1321,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 HOISTED refusal message, a `retiredKey()` tombstone, `new Map` and `Object.freeze` guidance tables, and `.describe()` prose; green when removed; an ADR id on a tombstone, a `.default()` VALUE, `history`/`guidance` outside a strictObject options position and `extraKeys` key names all pass; test bodies out, and the seen floor is PER BUCKET so one matcher rotting while the others carry the total still reds) 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, and `.describe()` prose; 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, and the seen floor is PER BUCKET so one matcher rotting while the others carry the total still reds) 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.'); } /** @@ -1519,6 +1577,53 @@ function selfTestRule3(expect) { expect('the describe red names the position', r.violations[0]?.where, '.describe()'); expect('the describe red names the bucket', r.violations[0]?.bucket, 'describe'); + // RED #11 — the CROSS-MODULE guidance const. Typed `KeySetGuidance`, + // exported, and consumed by NO sink in its own file: every anchor this pass + // had before was in-file, so a shared guidance table handed to + // `guidanceSets:` from other modules walked free while its prescription was + // printed at refusing authors. Measured live on + // `SELECT_OPTION_EDITABILITY_GUIDANCE`, which this rule reported clean from + // the day it landed. Written here in the spelling the tree really uses — + // the id on a LATER operand than the type annotation that anchors it. + writeFileSync(target, [ + "import type { KeySetGuidance } from '../shared/suggestions.zod';", + 'export const OPTION_EDITABILITY_GUIDANCE: KeySetGuidance = {', + " name: 'OPTION_EDITABILITY_KEYS',", + " keys: ['disabled', 'readonly'],", + ' prescription:', + " 'Editability is not a per-OPTION concern — a deliberate boundary, not a '", + " + 'missing key (#8201): withdraw the option with `visibleWhen` instead.',", + '};', + ].join('\n')); + r = scan(); + expect('an id in a `KeySetGuidance` const consumed only CROSS-MODULE is RED', + r.violations.length, 1); + expect('the cross-module red names the const it travelled through', + r.violations[0]?.where, 'via OPTION_EDITABILITY_GUIDANCE'); + expect('the cross-module red is bucketed as a strictObject option', + r.violations[0]?.bucket, 'strictObject'); + + // RED #12 — the same const in the `as const satisfies` spelling, which this + // tree also uses (`WIDGET_GUIDANCE_SETS`, `ui/dashboard.zod.ts`). A type + // anchor reading only the ANNOTATION is blind to it, so the next shared + // guidance const written the modern way would reopen RED #11's gap — the + // same blind spot one spelling over. + writeFileSync(target, [ + "import type { KeySetGuidance } from '../shared/suggestions.zod';", + 'export const OPTION_GUIDANCE_SETS = [', + ' {', + " name: 'OPTION_EDITABILITY_KEYS',", + " keys: ['disabled'],", + " prescription: 'not a missing key (#8201) — withdraw the option instead.',", + ' },', + '] as const satisfies readonly KeySetGuidance[];', + ].join('\n')); + r = scan(); + expect('an id in an `as const satisfies readonly KeySetGuidance[]` const is RED', + r.violations.length, 1); + expect('the satisfies-spelling red names its const', + r.violations[0]?.where, 'via OPTION_GUIDANCE_SETS'); + // ── Precision: what must NEVER fire ───────────────────────────────────── // A validator's VALUE argument is not prose. `.min(3, …)` takes a message; @@ -1567,6 +1672,23 @@ function selfTestRule3(expect) { ].join('\n')); expect('precision — `extraKeys` is key names, not prose', scan().violations.length, 0); + // A local that MENTIONS `KeySetGuidance` is not one. `shared/suggestions.zod.ts` + // really writes `const firedSets = new Set()` inside the + // error builder, so a type anchor that searched the initializer's text + // instead of the DECLARED type would report the runtime's own bookkeeping + // as authoring prose — and a gate that reports values as prose is one + // authors route around. + writeFileSync(target, [ + "import type { KeySetGuidance } from '../shared/suggestions.zod';", + 'export function fire(sets: readonly KeySetGuidance[]) {', + ' const fired = new Set();', + " for (const s of sets) if (s.name === '#4286') fired.add(s);", + ' return fired;', + '}', + ].join('\n')); + expect('precision — an inferred local whose INITIALIZER mentions `KeySetGuidance` is not a sink', + scan().violations.length, 0); + // GREEN again from the same scan, so every red above was the id and nothing // else about the tree. writeFileSync(target, CLEAN); From cdcf4ec54592dc05e184fb1c1394eaa3e87609d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 04:44:21 +0000 Subject: [PATCH 2/2] fix(spec): drop the bare internal issue id from the select-option editability refusal The prescription an author reads when they write `disabled` / `readonly` / `editable` on a select option carried a tracker id that resolves to nothing for the customer it is printed at. Stripped under the gate's own criterion: the citation keeps its durable half, ADR-0049 and ADR-0068 being named in the same sentence, so nothing is lost by dropping the id. The verdict, the vocabulary and the rest of the wording are unchanged -- this curates a message, it does not move the shape. The internal anchor survives in the module's own TSDoc, which is a comment and out of the rule's population by construction. Also pins the absence: the twin now asserts the prescription matches no issue id and still names both ADRs -- the negative pin the gate's remediation text asks for. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CPrUz21stTFhJRUirdc4yw --- .../select-option-editability-guidance-bare-id.md | 12 ++++++++++++ .../spec/src/shared/editability-boundary.test.ts | 15 +++++++++++++++ packages/spec/src/shared/editability-boundary.ts | 2 +- 3 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 .changeset/select-option-editability-guidance-bare-id.md diff --git a/.changeset/select-option-editability-guidance-bare-id.md b/.changeset/select-option-editability-guidance-bare-id.md new file mode 100644 index 0000000000..4ce29bfd4c --- /dev/null +++ b/.changeset/select-option-editability-guidance-bare-id.md @@ -0,0 +1,12 @@ +--- +"@objectstack/spec": patch +--- + +The select-option editability refusal no longer prints a bare internal issue id. + +Writing `disabled` / `readonly` / `editable` (or their `*When` forms) on a select +option is refused with a prescription explaining that editability is not a +per-option concern. That sentence carried a tracker id an author outside this +repository cannot resolve. It is gone; the citation keeps its durable half — +ADR-0049 and ADR-0068 are named in the same sentence — and the verdict, the +vocabulary and the rest of the wording are unchanged. diff --git a/packages/spec/src/shared/editability-boundary.test.ts b/packages/spec/src/shared/editability-boundary.test.ts index 6e864fefd5..567aaad05c 100644 --- a/packages/spec/src/shared/editability-boundary.test.ts +++ b/packages/spec/src/shared/editability-boundary.test.ts @@ -415,6 +415,21 @@ describe('#8201 — an option is offered or withheld, never shown-but-unselectab expect(prescription).not.toContain('disabledWhen'); }); + it('carries no bare internal issue id — the reader has no tracker', () => { + // `check:doc-authoring` Rule 3 owns this repo-wide, but it could not SEE + // this const until its hoisted-const pass learned to anchor on the + // `KeySetGuidance` type: the table is consumed only cross-module + // (`data/field.zod.ts`, `ui/view.zod.ts`), so nothing in its own file + // anchored it and the gate reported it clean from the day it landed. The + // durable half of the citation stays — ADR-0049 and ADR-0068 are in the + // same sentence and resolve for a reader outside this tracker. + const m = unknownKeyMessage(SelectOptionSchema, { ...OPTION, disabled: true }); + const prescription = m.slice(m.indexOf('\n • ')); + expect(prescription).not.toMatch(/#\d{3,5}/); + expect(prescription).toContain('ADR-0049'); + expect(prescription).toContain('ADR-0068'); + }); + it('says what is true TODAY and leaves the decision open', () => { // Triage left real product pull for non-selectable field options open as a // maintainer decision that would widen the accepted set. The prescription diff --git a/packages/spec/src/shared/editability-boundary.ts b/packages/spec/src/shared/editability-boundary.ts index 3023ea6fba..4d4a9a0d57 100644 --- a/packages/spec/src/shared/editability-boundary.ts +++ b/packages/spec/src/shared/editability-boundary.ts @@ -132,7 +132,7 @@ export const SELECT_OPTION_EDITABILITY_GUIDANCE: KeySetGuidance = { keys: EDITABILITY_BOUNDARY_KEYS, prescription: 'Editability is not a per-OPTION concern — a deliberate boundary, not a missing ' - + 'key (#8201): an option declares WHICH value may be picked and WHEN it is offered, ' + + 'key: an option declares WHICH value may be picked and WHEN it is offered, ' + 'and nothing in the field pipeline reads a per-option enabled/disabled flag today ' + '(the select and radio widgets treat the FIELD-level state as the single ' + 'authority), so a key here would be metadata the renderer never honours '