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 ' 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);