Skip to content

Commit ce66b83

Browse files
committed
fix(qa): check-platform-checklist judges retired by its own rule, not the active one
statusFieldProblems had one branch for `planned`; every other status, `retired` included, fell through to the active rule (`since` a release, `steps` non-empty). A planned item retired before it was implemented - `since: null`, no steps, exactly what the README's Retire step leaves it with - therefore had no legal shape. The retired branch judges by the README's Retire step: `retiredReason` a non-empty string, `since` null or release-shaped, `steps` unjudged (no selector runs a retired item). The walk's own `retiredReason` check moves into the branch rather than being duplicated, so it now rides the call site the binding pin already holds. The planned and active rules are unchanged. Self-test: R1-R12 in the status battery, floor 42 -> 54. Claude-Session: https://claude.ai/code/session_01CinRQcE6T8SvMbhH8D3NhS Co-authored-by: Claude <noreply@anthropic.com>
1 parent 712361b commit ce66b83

1 file changed

Lines changed: 71 additions & 18 deletions

File tree

‎scripts/check-platform-checklist.mjs‎

Lines changed: 71 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -149,24 +149,36 @@ const RELEASE_RE = /^v\d+(\.\d+)?$/;
149149

150150
/**
151151
* The field rules an item's `status` implies, as a pure function so the battery
152-
* can drive every status through it with no tree to read.
152+
* can drive every status through it with no tree to read. Three rules: one for
153+
* `planned`, one for `retired`, and the active rule for `active` and `draft`.
153154
*
154-
* `planned` is the only status that RELAXES anything, and it relaxes exactly
155-
* the three fields that cannot honestly exist before the capability does:
156-
* `since` (no release has introduced it), `steps` (nothing to drive) and
157-
* `acceptance` (no oracle to consult — handled at its own site below). In
158-
* exchange it REQUIRES `personas`: who the capability is for is what makes a
159-
* gap readable to the next sweep, and it is knowable the day the gap is found.
155+
* `planned` relaxes exactly the three fields that cannot honestly exist before
156+
* the capability does: `since` (no release has introduced it), `steps`
157+
* (nothing to drive) and `acceptance` (no oracle to consult — handled at its
158+
* own site below). In exchange it REQUIRES `personas`: who the capability is
159+
* for is what makes a gap readable to the next sweep, and it is knowable the
160+
* day the gap is found.
161+
*
162+
* `retired` is judged by the README's Retire step — `status: "retired"` plus
163+
* `retiredReason`, and nothing else — because an item leaves service from
164+
* either side of `active`. One retired after it shipped keeps its release and
165+
* its steps; one retired while still `planned` never had either. So
166+
* `retiredReason` must be a non-empty string, `since` is `null` or
167+
* release-shaped, and `steps` is not judged at all: no selector runs a retired
168+
* item (`scripts/checklist-select.mjs` puts it in neither the runnable pool nor
169+
* the planned one). `supersededBy` is not read here — whether it lands on a
170+
* real id is a whole-ledger question, answered after the walk.
160171
*
161172
* ⚠️ On `since` the claim is kept as narrow as the enforcement. What is checked
162173
* is the SHAPE — `null`, or a release-looking string. Whether the release named
163174
* has already SHIPPED is not checkable here and is not checked: this ledger
164175
* holds no release timeline and `RELEASE_RE` is a spelling rule. A planned item
165-
* targeting a release that is already out is an authoring error, and it passes.
166-
* Saying otherwise in the refusal would advertise a check that does not exist,
167-
* which is the one thing a refusal must never do.
176+
* targeting a release that is already out is an authoring error, and it passes;
177+
* so does a retired item whose `null` should have been the release that
178+
* shipped it. Saying otherwise in the refusal would advertise a check that does
179+
* not exist, which is the one thing a refusal must never do.
168180
*
169-
* @param {{status?: string, since?: unknown, steps?: unknown, personas?: unknown}} item
181+
* @param {{status?: string, since?: unknown, steps?: unknown, personas?: unknown, retiredReason?: unknown}} item
170182
* @returns {string[]}
171183
*/
172184
function statusFieldProblems(item) {
@@ -187,6 +199,18 @@ function statusFieldProblems(item) {
187199
return problems;
188200
}
189201

202+
// Its own rule, not the active one: a capability retired before it was
203+
// implemented never shipped and has nothing to drive.
204+
if (item.status === 'retired') {
205+
if (typeof item.retiredReason !== 'string' || !item.retiredReason) {
206+
problems.push('a retired item must carry "retiredReason" as a non-empty string');
207+
}
208+
if (!(item.since === null || isRelease)) {
209+
problems.push('"since" on a retired item must be null (retired before any release introduced it) or the release that did, e.g. "v16". ⛔ Only the SHAPE is checked here: this ledger holds no release timeline, so which of the two is true is an authoring fact no gate can see');
210+
}
211+
return problems;
212+
}
213+
190214
if (!isRelease) problems.push('"since" must be the release that introduced the capability, e.g. "v16" or "v16.0"');
191215
if (!hasSteps) problems.push('"steps" must be a non-empty array of strings');
192216
return problems;
@@ -340,7 +364,7 @@ function statusBindingProblems(source) {
340364
};
341365
bound(
342366
/for \(const msg of statusFieldProblems\(item\)\) where\(msg\);/g,
343-
'the item walk does not consume `statusFieldProblems(item)` — every status-keyed field rule (a planned item\'s `since`/`steps`/`personas`, and the release-and-steps rules for every other status) is then declared and never applied',
367+
'the item walk does not consume `statusFieldProblems(item)` — every status-keyed field rule (a planned item\'s `since`/`steps`/`personas`, a retired item\'s `retiredReason`/`since`, and the release-and-steps rules for active and draft) is then declared and never applied',
344368
);
345369
bound(
346370
/const \{ problems, bearing \} = coverageEntryProblems\(entry\.items, statusOf\);/g,
@@ -1030,7 +1054,7 @@ const BATTERY_UNREFERENCED_RECIPES = 'selfTestUnreferencedRecipes: the reverse d
10301054
const BATTERY_META_CALL_SPELLING = 'selfTestMetaCallSpelling: the folded `/meta` plural, read from the live contract';
10311055
const BATTERY_LINE_CITATION_BINDING = 'selfTestLineCitationBinding: the corpus declaration, the absent fork, and the binding driven both ways';
10321056
const BATTERY_SYMBOL_ANCHORS = 'selfTestSymbolAnchors: the corpus registration, the binding to the shared resolver, the residual and the floor';
1033-
const BATTERY_PLANNED_STATUS = 'selfTestPlannedStatus: the `planned` accept set, the fields it relaxes, and the coverage ratchet driven BOTH ways';
1057+
const BATTERY_PLANNED_STATUS = 'selfTestPlannedStatus: the `planned` accept set, the fields it relaxes, the `retired` rule beside it, and the coverage ratchet driven BOTH ways';
10341058

10351059
const SELF_TEST_BATTERIES = Object.freeze({
10361060
[BATTERY_TRAP_VOCABULARY]: 22,
@@ -1073,7 +1097,11 @@ const SELF_TEST_BATTERIES = Object.freeze({
10731097
// G12 records in an assertion what the pin still cannot see. Measured on this
10741098
// branch at `b835196bc1`: reverting the mask reds G9, G10 and G11 and nothing
10751099
// else.
1076-
[BATTERY_PLANNED_STATUS]: 42,
1100+
//
1101+
// 42 → 54: R1–R12, the `retired` rule. Before it a retired item fell through
1102+
// to the active rule, so a planned item retired before it was implemented had
1103+
// no legal shape. Dropping the branch reds R1 and R3–R9.
1104+
[BATTERY_PLANNED_STATUS]: 54,
10771105
});
10781106
const SELF_TEST_BATTERY_FLOOR = 7;
10791107

@@ -2354,6 +2382,30 @@ function selfTestPlannedStatus() {
23542382
t('F16 and the refusal says so, so an author is never told this gate checks a timeline it cannot read',
23552383
planned({ since: 'someday' })[0]?.includes('Only the SHAPE is checked'));
23562384

2385+
// ── the `retired` rule — the README's Retire step, from either side of `active`
2386+
// Before this rule a retired item fell through to the active one, so a
2387+
// planned item retired before it was implemented — `since: null`, no steps,
2388+
// exactly what the README's Retire step leaves it with — had no legal shape.
2389+
const retired = (over = {}) => statusFieldProblems({ status: 'retired', since: null, retiredReason: 'ruled out, no successor', ...over });
2390+
2391+
t('R1 a planned item retired before it was implemented — `since: null`, no steps — is clean', retired().length === 0, retired().join('; '));
2392+
t('R2 an item retired AFTER it shipped keeps its release and its steps and still passes — the shape the two live precedents carry',
2393+
retired({ since: 'v15.1', steps: ['open the report'] }).length === 0, retired({ since: 'v15.1', steps: ['open the report'] }).join('; '));
2394+
t('R3 an empty steps array passes too — steps are not judged on an item no selector runs', retired({ steps: [] }).length === 0);
2395+
t('R4 a retired item with no `retiredReason` is refused', retired({ retiredReason: undefined }).length === 1);
2396+
t('R5 an empty `retiredReason` is refused the same way', retired({ retiredReason: '' }).length === 1);
2397+
t('R6 and that refusal names the field', retired({ retiredReason: undefined })[0]?.includes('"retiredReason"'));
2398+
t('R7 a `since` that is neither null nor a release is refused', retired({ since: 'someday' }).length === 1);
2399+
t('R8 and that message names both legal spellings and says only the SHAPE is checked',
2400+
retired({ since: 'someday' })[0]?.includes('null') && retired({ since: 'someday' })[0]?.includes('Only the SHAPE is checked'));
2401+
t('R9 a retired item with NO `since` key is refused — the rule admits null, not absence, as the planned one does',
2402+
retired({ since: undefined }).length === 1);
2403+
t('R10 `supersededBy` passes — whether it resolves is the referential pass\'s question, not this rule\'s',
2404+
retired({ supersededBy: 'area.next' }).length === 0);
2405+
t('R11 a retired item owes no personas — the planned requirement did not travel', retired({ personas: undefined }).length === 0);
2406+
t('R12 the rule is keyed on `retired` alone — a DRAFT item with `since: null` and no steps is still refused by the active rule, twice',
2407+
statusFieldProblems({ status: 'draft', since: null }).length === 2);
2408+
23572409
// ── the coverage ratchet, both directions ────────────────────────────────
23582410
// A miniature ledger: one kind's worth of ids, each with a status.
23592411
const LEDGER = new Map([
@@ -2547,7 +2599,8 @@ if (process.argv.slice(2).includes('--self-test')) {
25472599
' and the `/meta` call-spelling refusal reads its vocabulary out of the live generated contract, fires on every folded spelling a `call` can instruct, and stays silent on the canonical singular, on parameter placeholders, and on the `why`/`expect`/`source`/`requires` prose that narrates the fold;' +
25482600
' and the line-citation limb DETECTS NOTHING ITSELF EITHER: the last forked grammar in this file went into the shared core at #18592, so what is pinned here is the BINDING — the corpus declaring `pathlessLineCitations`, a source read finding no citation regex and no detector while the same read DOES find the declaration, the binding driven ON and OFF against ONE text so the green is the declaration working rather than a text that would have matched anyway, the DARK case that a citation both grammars already agreed on keeps its verdict either way, the refusal to over-fire on this ledger\'s own HTTP statuses, config literals, URL ports, clock times and quoted JSON, and the live zero with the control that says it is a reading;' +
25492601
' and the symbol-anchor limb DETECTS NOTHING AND RESOLVES NOTHING ITSELF: it is a registered corpus (#18107), so the grammar, the walk and the verdict are all `scripts/symbol-anchors.mjs`\'s, pinned here by a source read that finds no local extension set, no anchor regex and no detector while the same read DOES find the registration, by the anchorable-extension vocabulary being the shared OBJECT rather than a copy of it, by the `runs/` exclusion driven three ways on the live corpus (the subtree holds files, none is swept, the areas beside it still are, and dropping the exclusion puts them back), and by the #16898 binding re-taken through the registration — a call site / import / local parameter / string-substring all reading ABSENT, the positive control that a declaration and a complete quoted token still resolve, a `.json` key resolving where a `.json` value does not, an INLINE object-literal key reading absent where one at the start of a line resolves — with the closed, grow-never residual and the per-file anchor floor held in both directions beside it;' +
2550-
` and the \`planned\` status is driven on fixtures — the accept set widened without losing its closure, \`since: null\`/no-steps/personas relaxed for planned alone while the ${plannedStatus.liveItems} live items are judged exactly as before, and the coverage ratchet held BOTH ways: a planned item beside an active one is silent, a kind whose only items are planned is UNMAPPED, and the bearing set is pinned NOT to contain \`planned\`; and the two CALL SITES those rules ride on are pinned by a source read over comment-MASKED source driven ON and OFF, because severing either one — by deletion OR by commenting it out in place — left this very self-test green; \u26d4 that pin is a TEXT pin and G12 records the three semantic severings it cannot see.`,
2602+
` and the \`planned\` status is driven on fixtures — the accept set widened without losing its closure, \`since: null\`/no-steps/personas relaxed for planned alone while the ${plannedStatus.liveItems} live items are judged exactly as before, and the coverage ratchet held BOTH ways: a planned item beside an active one is silent, a kind whose only items are planned is UNMAPPED, and the bearing set is pinned NOT to contain \`planned\`; and the two CALL SITES those rules ride on are pinned by a source read over comment-MASKED source driven ON and OFF, because severing either one — by deletion OR by commenting it out in place — left this very self-test green; \u26d4 that pin is a TEXT pin and G12 records the three semantic severings it cannot see;` +
2603+
' and the `retired` status is judged by its own rule, driven both ways \u2014 a non-empty `retiredReason`, `since` null or release-shaped, steps unjudged \u2014 so a planned item retired before it was implemented is legal, an item retired after it shipped stays legal, and a retired item with no reason or a non-release `since` is refused.',
25512604
);
25522605
process.exit(0);
25532606
}
@@ -2780,9 +2833,9 @@ for (const { file, stem, doc } of parsed) {
27802833
}
27812834
}
27822835

2783-
if (item.status === 'retired') {
2784-
if (typeof item.retiredReason !== 'string' || !item.retiredReason) where('retired items must carry "retiredReason"');
2785-
} else {
2836+
// A retired item owes no acceptance clause; its `retiredReason` is judged
2837+
// by `statusFieldProblems` above, with the rest of its status's rule.
2838+
if (item.status !== 'retired') {
27862839
if (!Array.isArray(item.acceptance) || item.acceptance.length === 0) {
27872840
// A planned item has no oracle to consult yet — that is what `planned`
27882841
// MEANS. Requiring a clause here would buy one written against a

0 commit comments

Comments
 (0)