Skip to content

Commit c700fc9

Browse files
claude[bot]claude
andauthored
fix(qa): close the platform-checklist traps vocabulary against RUNNER.md, with a parser that refuses rather than fails open (#10682)
`check-platform-checklist` enforced a closed vocabulary for `status`, `priority`, `surface`, `oracle` and `blocked.by` — and never read `traps` at all. So an item could carry any trap name and the validator stayed green, which is how eight undocumented values drifted in (#10416), and how a TYPO in a documented one (`hydration-races` for `hydration-race`, which is on 79 of the 205 items) lands as simply a twentieth trap that no runner rules out. The vocabulary is READ from RUNNER.md's `### Trap vocabulary` table, not copied into the script: a sixth hardcoded `Set` would only move the drift one level up, between the script and RUNNER.md, with nothing watching that seam. The load-bearing part is therefore not the parser but its positive control — `extractTrapVocabulary` refuses on a table it cannot recognise (heading renamed, table moved, zero rows, a row that lost its backticks) instead of returning an empty allow-list, and a 22-assertion fixture battery proves the refusal still fires. The battery runs inline on every invocation, not only behind `--self-test`, because this gate is not CI-wired by maintainer decision and its `pnpm` alias lives in the fenced root package.json (#9465) — a self-test nothing runs is the #10574/#10573 defect. The OK line now states what the parse read: `traps: 19 documented, 19 in use (extractor control: 22 assertions)`. Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt Co-authored-by: Claude <noreply@anthropic.com>
1 parent db4341d commit c700fc9

2 files changed

Lines changed: 305 additions & 2 deletions

File tree

docs/qa/platform-checklist/README.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,8 @@ next-sequential numbers do.
7171
"variants": [""], // enumerable-surface matrix (field types, chart types, flow
7272
// nodes, operators…) — derived from the spec's own Zod enums,
7373
// source cited; one clause requires per-variant verification
74-
"traps": ["hydration-race"], // known false-positive risks (vocabulary in RUNNER.md)
74+
"traps": ["hydration-race"], // known false-positive risks — CLOSED vocabulary,
75+
// enforced against RUNNER.md's trap table
7576
"automated": { "kind": "e2e", "ref": "path/to/pinning.test.ts" }, // set when a permanent test pins it
7677
"blocked": { "by": "fixture", "ref": "#NNNN" }, // standing blocker, waive-with-a-reference
7778
"source": ["#3358 §1"], // where the expectation comes from
@@ -88,6 +89,15 @@ Design notes:
8889
- **Every clause names its oracle.** The oracle hierarchy and the anti-false-positive
8990
rules live in [RUNNER.md](./RUNNER.md); the validator only enforces that an oracle is
9091
declared — an oracle-free clause is an invitation to tick on vibes.
92+
- **`traps` is a closed vocabulary, and RUNNER.md is where it is defined.** The
93+
validator extracts the trap names from [RUNNER.md](./RUNNER.md)'s
94+
`### Trap vocabulary` table and rejects any `traps` entry that table does not define,
95+
in both directions (used-but-undocumented, documented-but-unused). Document a new trap
96+
in that table *first* — name, what it fakes, the counter — then use it. The check that
97+
matters most here is the one against a **typo** in a documented trap: `hydration-races`
98+
reads as a real trap right up until someone greps the table for it, and RUNNER.md rule
99+
3 asks a runner to rule each listed trap out. If that table cannot be parsed, the
100+
validator **refuses** rather than validating against an empty allow-list.
91101
- **`automated` is the 🤖 lane** of the 15.1 plan: once a permanent test pins an item,
92102
runs may satisfy it by executing that test and citing its output as evidence, instead
93103
of re-driving the browser.

scripts/check-platform-checklist.mjs

Lines changed: 294 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@
3232
// its oracle — a clause with no oracle is an invitation to tick on vibes,
3333
// which is the exact AI-accuracy failure the RUNNER.md protocol exists to
3434
// prevent.
35+
// - every `traps` entry is a trap RUNNER.md's `### Trap vocabulary` table
36+
// actually defines, and every documented trap is used by some item — the
37+
// vocabulary is READ from that table, never copied into this file, and a
38+
// table this script cannot parse is a refusal rather than an empty
39+
// allow-list (see the trap-vocabulary block below for why that matters).
3540
//
3641
// It does NOT judge whether an item is testable or its oracle sufficient — no
3742
// static check can. It guarantees the *structure* a run can be trusted against.
@@ -54,6 +59,270 @@ const BLOCKED_BY = new Set(['fixture', 'environment', 'dependency', 'product-bug
5459
const errors = [];
5560
const err = (file, id, msg) => errors.push(`${file}${id ? ` · ${id}` : ''}: ${msg}`);
5661

62+
// ── Trap vocabulary ─────────────────────────────────────────────────────────
63+
// Every other enum-ish field above is a hardcoded `Set`. `traps` deliberately
64+
// is NOT, and that choice is the whole design of this block.
65+
//
66+
// RUNNER.md rule 3 tells a runner to "check the `traps` field and rule each
67+
// listed trap out". The definitions that make that instruction executable live
68+
// in ONE place — RUNNER.md's `### Trap vocabulary` table — and nothing used to
69+
// hold the items and the table together: the string `traps` did not appear in
70+
// this file at all. Measured on `main` at 6b0be02209: 19 distinct traps in use
71+
// across 205 items, 11 documented, 8 used-but-undocumented (#10416 wrote the
72+
// eight definitions; this check is why they cannot come back). Two drift
73+
// shapes, and the validator saw neither:
74+
//
75+
// 1. a value nobody ever defined, exactly as the eight arrived;
76+
// 2. a TYPO in a documented one — `hydration-races`, `wrong-persona ` —
77+
// which is the likelier and the worse of the two, because it reads as a
78+
// documented trap right up until someone greps the table for it.
79+
// `hydration-race` is on 79 of the 205 items; one mistyped instance is
80+
// simply a twentieth trap that no runner rules out and nobody notices.
81+
//
82+
// Both close the same way — check the vocabulary — and a hardcoded `TRAPS` set
83+
// would close NEITHER honestly: it only moves the drift one level up, between
84+
// this script and RUNNER.md, with nothing watching that seam either.
85+
//
86+
// ## Why the parser carries a positive control that runs on EVERY invocation
87+
//
88+
// A markdown-table extractor has one failure mode that matters: it reads zero
89+
// rows — heading renamed, table moved, a row's backtick spelling changed — and
90+
// every item then validates against an empty allow-list. Zero violations. A
91+
// green line indistinguishable from the green a working parse prints. That is
92+
// the silent-success direction this tree treats as worse than no check at all
93+
// (#4690), so `extractTrapVocabulary` REFUSES on a table it cannot recognise
94+
// and never returns an empty vocabulary with no complaint — and a fixture
95+
// battery proves the refusal still fires.
96+
//
97+
// The battery runs inline, on every invocation, not only behind `--self-test`,
98+
// because a `--self-test` here would otherwise execute NOWHERE: this gate is
99+
// not CI-wired by maintainer decision (README "Operating cadence"), and its
100+
// `pnpm` alias lives in root package.json, declared territory of the
101+
// @changesets/cli v3 lane (#9465) while that runs. A self-test nothing runs is
102+
// the documented defect of #10574/#10573 — CI enforcing the spelling of a
103+
// guarantee while never once checking the guarantee still holds. The battery
104+
// is in-memory string work (~1 ms of a ~270 ms run), so "always" costs nothing
105+
// worth naming, and its assertion count is printed on the OK line: the green
106+
// states how many rows it read and that its own control passed.
107+
108+
const RUNNER_FILE = join(ROOT, 'docs/qa/platform-checklist/RUNNER.md');
109+
const TRAP_HEADING = '### Trap vocabulary';
110+
111+
/**
112+
* Extract the trap vocabulary from RUNNER.md's `### Trap vocabulary` table.
113+
*
114+
* Returns `{ traps, duplicates, refusal }`. A non-null `refusal` means the
115+
* table could not be recognised and the caller MUST treat it as a hard
116+
* failure. This never returns an empty `traps` with a null `refusal`: an empty
117+
* vocabulary and a working parse are not allowed to look alike.
118+
*/
119+
function extractTrapVocabulary(md) {
120+
const no = (refusal) => ({ traps: [], duplicates: [], refusal });
121+
const lines = String(md).split('\n');
122+
123+
const h = lines.findIndex((l) => l.trimEnd().startsWith(TRAP_HEADING));
124+
if (h === -1) {
125+
return no(`the "${TRAP_HEADING}" heading is not in the file — renamed, moved or removed. Restore it: this check reads the vocabulary from that table and refuses to guess.`);
126+
}
127+
128+
let i = h + 1;
129+
for (; i < lines.length; i++) {
130+
if (/^#{1,6}\s/.test(lines[i])) return no(`no markdown table under "${TRAP_HEADING}" — the next heading arrives first`);
131+
if (lines[i].trimStart().startsWith('|')) break;
132+
}
133+
if (i >= lines.length) return no(`no markdown table under "${TRAP_HEADING}" — the file ends first`);
134+
135+
const header = lines[i].trim();
136+
if (!/^\|\s*trap\s*\|/i.test(header)) {
137+
return no(`the first table under "${TRAP_HEADING}" is not the trap table — expected a "| trap | … |" header row, found ${JSON.stringify(header.slice(0, 60))}`);
138+
}
139+
if (!/^\|[\s:|-]+\|$/.test((lines[i + 1] ?? '').trim())) {
140+
return no(`the trap table's header row is not followed by a markdown separator row — found ${JSON.stringify((lines[i + 1] ?? '').trim().slice(0, 60))}. This parser cannot read that shape, and reading it wrong would shrink the vocabulary silently.`);
141+
}
142+
143+
const rows = [];
144+
const unreadable = [];
145+
for (i += 2; i < lines.length && lines[i].trimStart().startsWith('|'); i++) {
146+
const m = lines[i].trim().match(/^\|\s*`([^`]+)`\s*\|/);
147+
if (m) rows.push(m[1]);
148+
else unreadable.push(lines[i].trim().slice(0, 60));
149+
}
150+
151+
if (unreadable.length) {
152+
return no(`${unreadable.length} row(s) of the trap table do not name a single backticked trap in the first cell — e.g. ${JSON.stringify(unreadable[0])}. Every row must read "| \`trap-name\` | what it fakes | counter |"; a row this parser cannot read would drop that trap out of the vocabulary without a word.`);
153+
}
154+
if (rows.length === 0) {
155+
return no(`the trap table under "${TRAP_HEADING}" has a header but ZERO rows. An empty vocabulary would make every item's \`traps\` validate against nothing and report zero problems, so this is a refusal — never an empty allow-list.`);
156+
}
157+
158+
const seen = new Set();
159+
const duplicates = [];
160+
for (const t of rows) {
161+
if (seen.has(t)) duplicates.push(t);
162+
seen.add(t);
163+
}
164+
return { traps: [...seen], duplicates, refusal: null };
165+
}
166+
167+
/** Levenshtein, for the did-you-mean that makes drift shape 2 readable. */
168+
function editDistance(a, b) {
169+
let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
170+
for (let i = 1; i <= a.length; i++) {
171+
const cur = [i];
172+
for (let j = 1; j <= b.length; j++) {
173+
cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
174+
}
175+
prev = cur;
176+
}
177+
return prev[b.length];
178+
}
179+
180+
function didYouMean(name, vocabulary) {
181+
let best = null;
182+
let bestD = Infinity;
183+
for (const t of vocabulary) {
184+
const d = editDistance(name, t);
185+
if (d < bestD) {
186+
bestD = d;
187+
best = t;
188+
}
189+
}
190+
return best !== null && bestD <= 3 ? ` — did you mean \`${best}\`?` : '';
191+
}
192+
193+
/** Problems with one item's `traps`, as message strings. Pure; battery-tested below. */
194+
function trapProblems(item, vocabulary) {
195+
const out = [];
196+
if (item.traps === undefined) return out; // optional field: 8 of 205 items carry none
197+
if (!Array.isArray(item.traps)) {
198+
out.push(`"traps" must be an array of trap names from RUNNER.md's \`${TRAP_HEADING}\` table`);
199+
return out;
200+
}
201+
const seen = new Set();
202+
item.traps.forEach((t, i) => {
203+
if (typeof t !== 'string' || !t.trim()) {
204+
out.push(`traps[${i}] must be a non-empty string`);
205+
return;
206+
}
207+
if (t !== t.trim()) {
208+
out.push(`traps[${i}] ${JSON.stringify(t)} has surrounding whitespace — a padded name is a different string to everyone who greps the table for it`);
209+
}
210+
const name = t.trim();
211+
if (seen.has(name)) out.push(`traps[${i}] \`${name}\` is listed twice`);
212+
seen.add(name);
213+
if (!vocabulary.has(name)) {
214+
out.push(
215+
`traps[${i}] \`${name}\` is not in RUNNER.md's \`${TRAP_HEADING}\` table${didYouMean(name, vocabulary)}` +
216+
` — either it is a typo of a documented trap, or it is a new one; document it in that table (name · what it fakes · the counter) before using it, so the runner told to "rule each listed trap out" has something to rule out.`,
217+
);
218+
}
219+
});
220+
return out;
221+
}
222+
223+
/**
224+
* The positive control. Proves the extractor reads a good table AND refuses an
225+
* empty / renamed / reshaped one, and that the item-side checker catches both
226+
* drift shapes. Zero I/O — every subject is a literal fixture.
227+
*/
228+
function selfTestTrapVocabulary() {
229+
const failures = [];
230+
let checked = 0;
231+
const t = (what, ok) => {
232+
checked++;
233+
if (!ok) failures.push(what);
234+
};
235+
236+
const table = (...rows) => ['prose above', '', `${TRAP_HEADING} (\`traps\` field)`, '', '| trap | what it fakes | counter |', '|---|---|---|', ...rows, '', '## Next section', '| not | a | trap |'].join('\n');
237+
238+
const good = extractTrapVocabulary(table('| `hydration-race` | empty nav | settle, then read |', '| `stale-dist` | src edits with no effect | rebuild |'));
239+
t('P1 a well-formed table yields exactly its rows', good.refusal === null && good.traps.join(',') === 'hydration-race,stale-dist');
240+
t('P2 the row scan stops at the table, not at the next table in the file', !good.traps.includes('not'));
241+
t('P3 a clean parse reports no duplicates', good.duplicates.length === 0);
242+
243+
const dup = extractTrapVocabulary(table('| `stale-dist` | a | b |', '| `stale-dist` | c | d |'));
244+
t('P4 a trap documented twice is reported', dup.refusal === null && dup.duplicates.join(',') === 'stale-dist' && dup.traps.length === 1);
245+
246+
// ── the refusals: each of these, returning an empty vocabulary quietly, is
247+
// the fail-open this whole block exists to make impossible ──────────────
248+
const refusals = [
249+
['R1 a table with a header and ZERO rows', table()],
250+
['R2 the heading renamed away', table('| `stale-dist` | a | b |').replace(TRAP_HEADING, '### Traps you may hit')],
251+
['R3 no table under the heading (prose, then the next heading)', ['', TRAP_HEADING, '', 'See the skill for the list.', '', '## Next section', ''].join('\n')],
252+
['R4 a row that lost its backticks', table('| `hydration-race` | a | b |', '| stale-dist | c | d |')],
253+
['R5 a different table sitting under the heading', table('| `stale-dist` | a | b |').replace('| trap | what it fakes | counter |', '| oracle | when | why |')],
254+
['R6 an empty file', ''],
255+
['R7 the separator row missing', ['', TRAP_HEADING, '', '| trap | what it fakes | counter |', '| `stale-dist` | a | b |', ''].join('\n')],
256+
['R8 the heading present but the file ends', ['', TRAP_HEADING, ''].join('\n')],
257+
];
258+
for (const [what, md] of refusals) {
259+
const r = extractTrapVocabulary(md);
260+
t(`${what} is REFUSED, not read as an empty vocabulary`, typeof r.refusal === 'string' && r.refusal.length > 0 && r.traps.length === 0);
261+
}
262+
263+
// The invariant behind every refusal above, asserted as an invariant rather
264+
// than case by case: no input may yield "nothing to check" without saying so.
265+
const allInputs = [...refusals.map(([, md]) => md), table('| `x` | a | b |'), '| trap |\n|---|\n| `y` |'];
266+
t(
267+
'R9 no input yields an empty vocabulary with no refusal',
268+
allInputs.every((md) => {
269+
const r = extractTrapVocabulary(md);
270+
return r.traps.length > 0 || (typeof r.refusal === 'string' && r.refusal.length > 0);
271+
}),
272+
);
273+
274+
// ── the item side: both drift shapes the card names ───────────────────────
275+
const vocab = new Set(['hydration-race', 'stale-dist', 'wrong-persona']);
276+
t('C1 a documented trap passes', trapProblems({ traps: ['hydration-race'] }, vocab).length === 0);
277+
t('C2 an item with no traps is fine (optional field)', trapProblems({}, vocab).length === 0);
278+
const invented = trapProblems({ traps: ['totally-invented-trap'] }, vocab);
279+
t('C3 drift shape 1 — an undocumented trap is flagged', invented.length === 1 && invented[0].includes('totally-invented-trap'));
280+
const typo = trapProblems({ traps: ['hydration-races'] }, vocab);
281+
t('C4 drift shape 2 — a TYPO of a documented trap is flagged', typo.length === 1 && typo[0].includes('hydration-races'));
282+
t('C5 the typo message names the trap that was meant', typo.length === 1 && typo[0].includes('did you mean `hydration-race`'));
283+
const padded = trapProblems({ traps: ['wrong-persona '] }, vocab);
284+
t('C6 a trailing-space spelling is flagged, not trimmed away', padded.some((m) => m.includes('whitespace')));
285+
t('C7 a non-array "traps" is flagged', trapProblems({ traps: 'hydration-race' }, vocab).length === 1);
286+
t('C8 an empty-string trap is flagged', trapProblems({ traps: [''] }, vocab).length === 1);
287+
t('C9 a trap listed twice on one item is flagged', trapProblems({ traps: ['stale-dist', 'stale-dist'] }, vocab).some((m) => m.includes('twice')));
288+
289+
return { checked, failures };
290+
}
291+
292+
if (process.argv.slice(2).includes('--self-test')) {
293+
const r = selfTestTrapVocabulary();
294+
if (r.failures.length === 0) {
295+
console.log(`✓ check-platform-checklist --self-test: ${r.checked} assertions — the trap-table extractor reads a good table and REFUSES an empty/renamed/reshaped one.`);
296+
process.exit(0);
297+
}
298+
console.error(`✗ check-platform-checklist --self-test — ${r.failures.length} failure(s)\n`);
299+
for (const f of r.failures) console.error(` • ${f}`);
300+
process.exit(1);
301+
}
302+
303+
// The extractor's own positive control, before it is trusted with anything.
304+
const trapControl = selfTestTrapVocabulary();
305+
if (trapControl.failures.length) {
306+
console.error("check-platform-checklist: the trap-vocabulary extractor's own positive control FAILED — this check cannot be trusted, and a green from it would mean nothing.\n");
307+
for (const f of trapControl.failures) console.error(` ✗ ${f}`);
308+
process.exit(1);
309+
}
310+
311+
if (!existsSync(RUNNER_FILE)) {
312+
console.error(`check-platform-checklist: missing ${RUNNER_FILE} — the trap vocabulary lives in its "${TRAP_HEADING}" table and \`traps\` has nothing to validate against.`);
313+
process.exit(1);
314+
}
315+
const trapTable = extractTrapVocabulary(readFileSync(RUNNER_FILE, 'utf8'));
316+
if (trapTable.refusal) {
317+
console.error(`check-platform-checklist: cannot read the trap vocabulary out of docs/qa/platform-checklist/RUNNER.md — ${trapTable.refusal}`);
318+
console.error('\nThis is a REFUSAL, not a pass: with no vocabulary, every item\'s `traps` would validate against an empty set and report zero problems.');
319+
process.exit(1);
320+
}
321+
const TRAPS = new Set(trapTable.traps);
322+
for (const d of trapTable.duplicates) {
323+
err('RUNNER.md', null, `\`${TRAP_HEADING}\` lists \`${d}\` twice — one trap, one row, one definition`);
324+
}
325+
57326
if (!existsSync(AREAS_DIR)) {
58327
console.error(`check-platform-checklist: missing ${AREAS_DIR}`);
59328
process.exit(1);
@@ -120,6 +389,8 @@ for (const file of files) {
120389

121390
if (!Array.isArray(item.steps) || item.steps.length === 0) where('"steps" must be a non-empty array of strings');
122391

392+
for (const msg of trapProblems(item, TRAPS)) where(msg);
393+
123394
if (item.status === 'retired') {
124395
if (typeof item.retiredReason !== 'string' || !item.retiredReason) where('retired items must carry "retiredReason"');
125396
} else {
@@ -219,6 +490,25 @@ for (const { file, item } of allItems) {
219490
}
220491
}
221492

493+
// Trap vocabulary, the other direction. Bidirectional on purpose, mirroring
494+
// the coverage ratchet's UNCLASSIFIED/ORPHAN pair: a documented trap nobody
495+
// lists is a definition the runner is never asked to rule out, and the usual
496+
// reason for one is that the item carrying it was retyped or retired.
497+
const usedTraps = new Set();
498+
for (const { item } of allItems) {
499+
if (!Array.isArray(item.traps)) continue;
500+
for (const t of item.traps) if (typeof t === 'string' && t.trim()) usedTraps.add(t.trim());
501+
}
502+
for (const t of TRAPS) {
503+
if (!usedTraps.has(t)) {
504+
err(
505+
'RUNNER.md',
506+
null,
507+
`\`${TRAP_HEADING}\` documents \`${t}\` but no checklist item lists it — put it on the items it protects, or drop the row; a definition nothing points at is one no run will ever rule out`,
508+
);
509+
}
510+
}
511+
222512
// ── Capability-coverage ratchet ─────────────────────────────────────────────
223513
// "凡是有的能力, 都要测试" made mechanical: the universe of governed metadata
224514
// kinds is derived from packages/spec/liveness/*.json (the ADR-0049 ledger
@@ -293,4 +583,7 @@ if (errors.length) {
293583

294584
const total = allItems.length;
295585
const active = allItems.filter(({ item }) => item.status === 'active').length;
296-
console.log(`check-platform-checklist: OK — ${files.length} areas, ${total} items (${active} active); coverage: ${mappedCount} kinds mapped, ${waivedCount} waived.`);
586+
console.log(
587+
`check-platform-checklist: OK — ${files.length} areas, ${total} items (${active} active); coverage: ${mappedCount} kinds mapped, ${waivedCount} waived;` +
588+
` traps: ${TRAPS.size} documented, ${usedTraps.size} in use (extractor control: ${trapControl.checked} assertions).`,
589+
);

0 commit comments

Comments
 (0)