Skip to content

Commit 794177d

Browse files
fix(qa): check-platform-checklist judges retired by its own rule, not the active one (#22995)
Fixes #22987 Clause-②: no — a repository gate script; no published schema, accept set or public surface is touched Unblocks #22869: PR #22982 (the retire of `records-forms.picklist-org-append`) lands after this PR. #22869 remains open until PR #22982 lands. ## The rule added `scripts/check-platform-checklist.mjs#statusFieldProblems` had one branch, for `planned`. Every other status, `retired` included, fell through to the active rule (`since` a release, `steps` non-empty). So a planned item retired before it was implemented had no legal shape: `since: null` and no steps is exactly what the README's Retire step (`docs/qa/platform-checklist/README.md`, Lifecycle) leaves it with. The new `retired` branch follows that Retire step, and it returns early the way the `planned` branch does, so the active rule now covers `active` and `draft` only: - `retiredReason` must be a non-empty string. Refusal: `a retired item must carry "retiredReason" as a non-empty string`. - `since` must be `null` or release-shaped (`RELEASE_RE` reused). The refusal names both legal spellings and says only the SHAPE is checked, because this ledger holds no release timeline. - `steps` is not judged. Absent, empty, or the steps the item ran with while active all pass. No selector runs a retired item: `scripts/checklist-select.mjs` builds its runnable pool from `status === 'active'` and its planned pool from `'planned'`. - `supersededBy` is not read here. The existing referential pass after the walk still checks that it lands on a real id. The planned and active rules are unchanged; F1–F16 are untouched and green. No new required field. **Change from the card's premise:** the item walk already refused a retired item that had no `retiredReason`, using its own message outside `statusFieldProblems`. Adding the rule to the branch while keeping that check would report the problem twice, and the self-test can only drive the pure function. So the check moved into the branch, and the walk now only skips the acceptance requirement for retired items. As a side effect, the rule now sits behind the `statusFieldProblems(item)` call site that G1/G3 already pin. **Battery:** R1–R12 go into the existing status battery (`selfTestPlannedStatus`), and its floor rises from 42 to 54. I did not add a new battery function, because a new compound-named self-test function needs a row in `COMPOUND_ANCHOR_LEDGER` in `scripts/pm/dispatch-gates.data.mjs`, which this card keeps frozen. ## Readings (this repo; base `712361b7a`, final head `1901eba70`) `pnpm check:platform-checklist`, verdict line of the tree step: - base `712361b7a`, ledger as on main, unfixed checker: `check-platform-checklist: 8 problem(s)` - unfixed checker (`712361b7a`) with PR #22982's `records-forms.json` + `coverage.json` applied, uncommitted: `check-platform-checklist: 10 problem(s)`. The two extra lines are both on `records-forms.picklist-org-append`: the active rule's `"since" must be the release that introduced the capability…` and `"steps" must be a non-empty array of strings`. - fixed checker (`1901eba70`), ledger as on main: `check-platform-checklist: 8 problem(s)`. Diffed line for line, these are the same 8 lines as base. - fixed checker (`1901eba70`) with PR #22982's two ledger files applied, uncommitted: `check-platform-checklist: 8 problem(s)`. Same 8 lines as base; no line names `picklist-org-append`. The 8 are the open main-red symbol-anchor set (#22924: `access-security`, `attachments-storage`, `search`), and this PR does not touch it. I applied PR #22982's files with `git restore --source` from a worktree-private ref at `57ef232b7`; main has not touched `docs/qa/platform-checklist/` since that PR's merge-base `117817ec2`, so the files carry exactly that PR's delta. I restored them with `git checkout HEAD --`. Restore proof, both times: `git diff HEAD` is empty, `git status --porcelain` is empty, and `git hash-object` equals the HEAD blob for both files (`707de16a36d7…`, `542fd62c23ce…`). No ledger file is committed here. Self-test: - `712361b7a`: `✓ check-platform-checklist --self-test: 221 assertions — …` - `1901eba70`: `✓ check-platform-checklist --self-test: 233 assertions — …`. `✓ checklist-select self-test: 31 cases pass.` runs ahead of it. **Ablation** (on the committed fix at `ce66b832d`, through `scripts/ablation-replace.mjs`): anchor `if (item.status === 'retired') {` replaced by `if (false && item.status === 'retired') {`. On disk the anchor went x1 to x0 and the marker x0 to x1; blob `6064a9e249a9` became `2469abeb42c0`, then was restored to `6064a9e249a9`, equal to HEAD, with `git diff HEAD` empty. Reading: ``` ✗ check-platform-checklist --self-test — 10 failure(s) ``` R1 and R3–R11 go red. R2 and R12 stay green because the active rule accepts both. The G-rows (the call-site text pin that the header above `statusBindingProblems` describes) also stay green under this mutation. G12 already discloses this: a dead branch is a semantic severing that a text pin cannot see. What holds the branch is the new fixture rows, and the floor comment says so. ## Gates (final head `1901eba70`) `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, run from the worktree with no paths, derives 30 commands. All 30 were run at `1901eba70`, each with its exit code recorded. Reconciliation: ``` ✓ dispatch-gates --ran: 30 derived famil(ies) accounted for — 30 run, 0 NOT-MEASURED (a DERIVED zero — all 30 recorded an exit code and none of them is 3). ``` 29 exit 0. `pnpm check:platform-checklist` exits 1, and that is the base 8 above. Selected verdict lines: - `pnpm check:pm-dispatch-gates`: `✓ dispatch-gates self-test (fast tier): 1828 cases pass; 6 slow section(s) deferred.` (exit 0) - `pnpm check:nul-bytes`: `check-nul-bytes: OK (scanned 10999 text file(s) -- 10999 tracked, 0 untracked-not-ignored; skipped 9 binary; no raw ASCII control bytes).` - `pnpm check:entry-guard`: `✓ check:entry-guard: 285 scripts/ file(s) — every entry guard goes through invoked-as.mjs; …` - `node scripts/check-comment-mask-corpus.mjs`: `✓ comment-mask corpus sweep [scripts/js-comment-mask.mjs]: 8836 files, 0 disagree, 0 unparseable, …` The script's own tests, beyond the derived families: no `scripts/**/*.test.*` or `scripts/__tests__/**` file names it (the pathspec matches 2 files, with 0 hits). The watchdog does not import this script; it only names the path as a string. I ran it anyway: `OK check-platform-checklist-watchdog self-test: 42 assertions across 8 floored batteries, …`. Lint (narrowed to the one touched file; CI runs the full `pnpm lint`): `eslint --no-inline-config --format json scripts/check-platform-checklist.mjs` checks 1 file with 0 errors and 0 warnings. `--print-config` shows the effective config applies 2 rules (`no-restricted-imports`, `comment-swallow/no-code-inside-block-comment`), with parserOptions `ecmaVersion: latest, sourceType: module` and no `parserOptions.project`. Type-aware linting is not enabled anywhere in `eslint.config.mjs`, so this diff cannot change any untouched file's verdict. Changeset: none; this PR carries `skip-changeset`. The root package is private, and no package manifest ships `scripts/`. ## Acceptance notes - The active rule's refusal `"steps" must be a non-empty array of strings` names a string check that does not exist: only array-ness and length are checked. That is the kind of advertising this function's docblock forbids. Left alone because the card keeps the active rule frozen. This is a read-only inference, not filed. - The dispatch assumed that the header-described mutation battery would go red when the new branch is dropped. Measured, it does not (text pin, G12). The new R-rows go red instead; see the ablation above. - The card read `origin/main` at `246edf5b3`; this branch is cut from `712361b7a`. The base reading of 8 problems is the same on both. --- _Generated by [Claude Code](https://claude.ai/code/session_01CinRQcE6T8SvMbhH8D3NhS)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 67baccc commit 794177d

1 file changed

Lines changed: 75 additions & 18 deletions

File tree

‎scripts/check-platform-checklist.mjs‎

Lines changed: 75 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,15 @@ 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. Measured at `ce66b832d` (this repo): making the branch dead
1104+
// (`if (false && …)`) reds R1 and R3–R11 — ten rows; R2 and R12 stay green
1105+
// because the active rule accepts both. The G-rows stay green too: a dead
1106+
// branch is one of the semantic severings G12 discloses, so these fixture
1107+
// rows, not the text pin, are what hold the branch.
1108+
[BATTERY_PLANNED_STATUS]: 54,
10771109
});
10781110
const SELF_TEST_BATTERY_FLOOR = 7;
10791111

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

2389+
// ── the `retired` rule — the README's Retire step, from either side of `active`
2390+
// Before this rule a retired item fell through to the active one, so a
2391+
// planned item retired before it was implemented — `since: null`, no steps,
2392+
// exactly what the README's Retire step leaves it with — had no legal shape.
2393+
const retired = (over = {}) => statusFieldProblems({ status: 'retired', since: null, retiredReason: 'ruled out, no successor', ...over });
2394+
2395+
t('R1 a planned item retired before it was implemented — `since: null`, no steps — is clean', retired().length === 0, retired().join('; '));
2396+
t('R2 an item retired AFTER it shipped keeps its release and its steps and still passes — the shape `dashboards.saved-report-ownership` carries',
2397+
retired({ since: 'v15.1', steps: ['open the report'] }).length === 0, retired({ since: 'v15.1', steps: ['open the report'] }).join('; '));
2398+
t('R3 an empty steps array passes too — steps are not judged on an item no selector runs', retired({ steps: [] }).length === 0);
2399+
t('R4 a retired item with no `retiredReason` is refused', retired({ retiredReason: undefined }).length === 1);
2400+
t('R5 an empty `retiredReason` is refused the same way', retired({ retiredReason: '' }).length === 1);
2401+
t('R6 and that refusal names the field', retired({ retiredReason: undefined })[0]?.includes('"retiredReason"'));
2402+
t('R7 a `since` that is neither null nor a release is refused', retired({ since: 'someday' }).length === 1);
2403+
t('R8 and that message names both legal spellings and says only the SHAPE is checked',
2404+
retired({ since: 'someday' })[0]?.includes('null') && retired({ since: 'someday' })[0]?.includes('Only the SHAPE is checked'));
2405+
t('R9 a retired item with NO `since` key is refused — the rule admits null, not absence, as the planned one does',
2406+
retired({ since: undefined }).length === 1);
2407+
t('R10 `supersededBy` passes — whether it resolves is the referential pass\'s question, not this rule\'s',
2408+
retired({ supersededBy: 'area.next' }).length === 0);
2409+
t('R11 a retired item owes no personas — the planned requirement did not travel', retired({ personas: undefined }).length === 0);
2410+
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',
2411+
statusFieldProblems({ status: 'draft', since: null }).length === 2);
2412+
23572413
// ── the coverage ratchet, both directions ────────────────────────────────
23582414
// A miniature ledger: one kind's worth of ids, each with a status.
23592415
const LEDGER = new Map([
@@ -2547,7 +2603,8 @@ if (process.argv.slice(2).includes('--self-test')) {
25472603
' 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;' +
25482604
' 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;' +
25492605
' 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.`,
2606+
` 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;` +
2607+
' 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.',
25512608
);
25522609
process.exit(0);
25532610
}
@@ -2780,9 +2837,9 @@ for (const { file, stem, doc } of parsed) {
27802837
}
27812838
}
27822839

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

0 commit comments

Comments
 (0)