From 3c9b2d7f46abfaa0fcab80df15718215f615c49f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 19 Sep 2026 01:13:57 +0000 Subject: [PATCH 1/5] =?UTF-8?q?feat(platform-checklist):=20carry=20impleme?= =?UTF-8?q?ntation=20STATUS=20in=20the=20ledger=20=E2=80=94=20`status:=20p?= =?UTF-8?q?lanned`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An area item gains `status: planned` — a capability the North Star's definition requires that the platform does not yet verify. The ledger could record only what already works, so a capability gap had nowhere to live: the backlog sweep had nothing to point a `Path:` at, and the platform's implementation status lived in nobody's head. `planned` relaxes exactly the three fields that cannot honestly exist before the capability does — `since` (null, or the TARGET release), `steps` (none until it is implementable) and `acceptance` (no oracle to consult) — and adds one: `personas`, which is knowable the day the gap is found and is what makes the gap readable to the next sweep. The load-bearing half is the capability-coverage ratchet: a planned item is a legal map target and carries ZERO coverage, so a kind whose only items are planned is UNMAPPED. Otherwise `planned` would be the cheapest way to green an untested kind and the ratchet would measure intentions instead of tests. Both directions are pinned on fixtures in a new self-test battery, because the live ledger carries zero planned items and cannot tell a working rule from a deleted one. Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude --- scripts/check-platform-checklist.mjs | 283 ++++++++++++++++++++++++--- 1 file changed, 260 insertions(+), 23 deletions(-) diff --git a/scripts/check-platform-checklist.mjs b/scripts/check-platform-checklist.mjs index 91d4962fe9..af7ab50717 100644 --- a/scripts/check-platform-checklist.mjs +++ b/scripts/check-platform-checklist.mjs @@ -127,12 +127,108 @@ function familyFiles(dir, prefix = '') { return out; } -const STATUSES = new Set(['active', 'draft', 'retired']); +const STATUSES = new Set(['active', 'draft', 'planned', 'retired']); + +// ── Which statuses CARRY coverage ─────────────────────────────────────────── +// The capability-coverage ratchet below asks "does the checklist test this +// governed metadata kind?". `planned` is the ledger's answer to "the definition +// requires this capability and nothing verifies it yet" — a promise, not a +// test. So a planned item is a legal MAP TARGET (that is how a capability-gap +// card gets somewhere to point) and contributes ZERO coverage: a kind whose +// only items are planned is UNMAPPED, exactly as if the entry were empty. +// ⛔ Folding `planned` in here is the one edit that would turn this ratchet +// into a way to green a kind by promising to test it. +const COVERAGE_BEARING_STATUSES = new Set(['active', 'draft']); + const PRIORITIES = new Set(['P0', 'P1', 'P2']); const SURFACES = new Set(['browser', 'api', 'cli', 'build', 'mixed']); const ORACLES = new Set(['api', 'network', 'screenshot', 'dom', 'log', 'test', 'build']); const BLOCKED_BY = new Set(['fixture', 'environment', 'dependency', 'product-bug']); +const RELEASE_RE = /^v\d+(\.\d+)?$/; + +/** + * The field rules an item's `status` implies, as a pure function so the battery + * can drive every status through it with no tree to read. + * + * `planned` is the only status that RELAXES anything, and it relaxes exactly + * the three fields that cannot honestly exist before the capability does: + * `since` (no release has introduced it), `steps` (nothing to drive) and + * `acceptance` (no oracle to consult — handled at its own site below). In + * exchange it REQUIRES `personas`: who the capability is for is what makes a + * gap readable to the next sweep, and it is knowable the day the gap is found. + * + * @param {{status?: string, since?: unknown, steps?: unknown, personas?: unknown}} item + * @returns {string[]} + */ +function statusFieldProblems(item) { + const problems = []; + const isRelease = typeof item.since === 'string' && RELEASE_RE.test(item.since); + const hasSteps = Array.isArray(item.steps) && item.steps.length > 0; + + if (item.status === 'planned') { + if (!(item.since === null || isRelease)) { + problems.push('"since" on a planned item must be null (no target release chosen yet) or the TARGET release, e.g. "v18" — never a release that already shipped without it'); + } + if (hasSteps) { + problems.push('a planned item carries NO "steps" — there is nothing to drive yet. Steps arrive in the PR that implements the capability, in the same edit that promotes it to "active"'); + } + if (!Array.isArray(item.personas) || item.personas.length === 0) { + problems.push('a planned item must name its "personas" — who the capability is for is what makes the gap readable before anything exists to run'); + } + return problems; + } + + if (!isRelease) problems.push('"since" must be the release that introduced the capability, e.g. "v16" or "v16.0"'); + if (!hasSteps) problems.push('"steps" must be a non-empty array of strings'); + return problems; +} + +/** + * One `coverage.json` entry's `items` list, judged. Pure, and the ONE place the + * ratchet decides what counts — so the battery can drive both directions of the + * planned rule without a tree, and so there is no second opinion to drift from. + * + * The two directions that matter, and why the second is the load-bearing one: + * + * - a kind mapped to an ACTIVE item is covered, and stays covered when a + * planned item is listed beside it (the planned id is where the next + * capability-gap card points; it must not turn a green kind red); + * - a kind whose ONLY items are planned is UNMAPPED. The platform has the + * capability on its definition list, the checklist records that nothing + * verifies it, and the ratchet must say so — otherwise `planned` becomes + * the cheapest way to green an untested kind, and the ratchet measures + * intentions instead of tests. + * + * @param {string[]} ids the entry's `items` + * @param {(id: string) => string|undefined} statusOf item id -> status, undefined when unknown + * @returns {{problems: string[], bearing: number}} `bearing` = items that CARRY coverage + */ +function coverageEntryProblems(ids, statusOf) { + const problems = []; + let bearing = 0; + for (const id of ids) { + const status = statusOf(id); + if (status === undefined) { + problems.push(`maps to unknown item id "${id}"`); + continue; + } + if (status === 'retired') { + problems.push(`maps to retired item "${id}" — point at its successor or re-waive the kind`); + continue; + } + if (COVERAGE_BEARING_STATUSES.has(status)) bearing += 1; + } + if (bearing === 0) { + problems.push( + 'UNMAPPED — nothing here CARRIES coverage: every item mapped to this kind is `planned` (or does not resolve).' + + ' A planned item records that the definition requires the capability and that nothing verifies it yet — it is a promise, not a test,' + + ' and counting it would let any kind go green by promising to cover it. Add an item that RUNS, or waive the kind with a reason.', + ); + } + return { problems, bearing }; +} + const errors = []; const err = (file, id, msg) => errors.push(`${file}${id ? ` · ${id}` : ''}: ${msg}`); @@ -775,6 +871,7 @@ let unreferencedReachedVerdict = false; let metaCallReachedVerdict = false; let lineCitationsReachedVerdict = false; let symbolAnchorsReachedVerdict = false; +let plannedStatusReachedVerdict = false; // ── The self-test's own battery roster and floor (#13489, adopted here) ──── // @@ -805,6 +902,7 @@ const BATTERY_UNREFERENCED_RECIPES = 'selfTestUnreferencedRecipes: the reverse d const BATTERY_META_CALL_SPELLING = 'selfTestMetaCallSpelling: the folded `/meta` plural, read from the live contract'; const BATTERY_LINE_CITATION_BINDING = 'selfTestLineCitationBinding: the corpus declaration, the absent fork, and the binding driven both ways'; const BATTERY_SYMBOL_ANCHORS = 'selfTestSymbolAnchors: the corpus registration, the binding to the shared resolver, the residual and the floor'; +const BATTERY_PLANNED_STATUS = 'selfTestPlannedStatus: the `planned` accept set, the fields it relaxes, and the coverage ratchet driven BOTH ways'; const SELF_TEST_BATTERIES = Object.freeze({ [BATTERY_TRAP_VOCABULARY]: 22, @@ -828,8 +926,15 @@ const SELF_TEST_BATTERIES = Object.freeze({ // exclusion in both directions, and every re-judged #16898 case, which // ⛔ survives the transplant unchanged in verdict. [BATTERY_SYMBOL_ANCHORS]: 42, + // New with the `planned` status. Set at its landed count (headroom 0, the + // convention every entry above uses). The load-bearing third of it is the + // coverage direction: the live ledger carries ZERO planned items today, so + // nothing but these fixtures can tell a working ratchet rule from a deleted + // one — the unreferenced-recipe argument, applied to a rule whose subject + // population is empty on purpose rather than by luck. + [BATTERY_PLANNED_STATUS]: 28, }); -const SELF_TEST_BATTERY_FLOOR = 6; +const SELF_TEST_BATTERY_FLOOR = 7; /** * @param {Record} ran battery name -> assertions it reported @@ -2005,6 +2110,121 @@ function selfTestSymbolAnchors() { return { checked, failures }; } +/** + * The `planned` status, both of its halves, and the ratchet direction that is + * the whole point of it. + * + * ## Why this battery exists at all + * + * `planned` RELAXES an authored surface: an area JSON carrying it is refused by + * the landed gate and accepted by this one. Every relaxation buys a way to be + * wrong, and here the dangerous one is not the schema — it is the coverage + * ratchet. If a planned item ever counted as coverage, "凡是有的能力, 都要测试" + * would become "凡是有的能力, 都要打算测试", and the ratchet would go green on + * a kind nothing runs against. So the ratchet direction is pinned BOTH ways, + * on fixtures, not on the tree: the live ledger carries zero planned items and + * is expected to for a while, which means the real data cannot tell "this rule + * works" from "this rule was deleted" — the same silent-success argument the + * unreferenced-recipe battery above makes. + */ +function selfTestPlannedStatus() { + const failures = []; + let checked = 0; + const t = (what, ok, note = '') => { + checked++; + if (!ok) failures.push(`${what}${note ? ` — ${note}` : ''}`); + }; + + // ── the accept set ──────────────────────────────────────────────────────── + t('S1 `planned` is an accepted status — the widening this rule is', STATUSES.has('planned')); + t('S2 the statuses that were accepted before still are — a widening that narrowed something else is a different change', + ['active', 'draft', 'retired'].every((s) => STATUSES.has(s))); + t('S3 the set is still CLOSED — a typo like `planed` is refused, not read as a fourth status', !STATUSES.has('planed')); + + // ── the field rules `planned` relaxes, and the one it adds ──────────────── + const planned = (over = {}) => statusFieldProblems({ status: 'planned', since: null, personas: ['admin'], ...over }); + const active = (over = {}) => statusFieldProblems({ status: 'active', since: 'v16', steps: ['do a thing'], ...over }); + + t('F1 a planned item with `since: null`, no steps and personas is clean', planned().length === 0, planned().join('; ')); + t('F2 `since` may instead name the TARGET release', planned({ since: 'v18' }).length === 0); + t('F3 a `since` that is neither null nor a release is refused', planned({ since: 'someday' }).length === 1); + t('F4 and that message names the two legal spellings rather than only the release one', + planned({ since: 'someday' })[0]?.includes('null') && planned({ since: 'someday' })[0]?.includes('TARGET release')); + t('F5 steps on a planned item are refused — nothing is implemented to drive', planned({ steps: ['open the page'] }).length === 1); + t('F6 and that message sends them to the promotion edit, not to a workaround', + planned({ steps: ['open the page'] })[0]?.includes('promotes it to "active"')); + t('F7 an empty steps array is not steps — a planned item may carry the key', planned({ steps: [] }).length === 0); + t('F8 a planned item with no personas is refused — the gap must say who it is for', planned({ personas: undefined }).length === 1); + t('F9 an empty personas array is refused the same way', planned({ personas: [] }).length === 1); + + t('F10 an ACTIVE item is judged exactly as before — release `since`, non-empty steps', active().length === 0, active().join('; ')); + t('F11 an active item may NOT use `since: null` — the relaxation is scoped to planned', active({ since: null }).length === 1); + t('F12 an active item still owes steps', active({ steps: [] }).length === 1); + t('F13 an active item owes NO personas — this battery did not widen a requirement onto the 264 live items', + active({ personas: undefined }).length === 0); + t('F14 a planned item is never asked for steps AND a release at once — the two relaxations compose', + planned({ since: null, steps: undefined }).length === 0); + + // ── the coverage ratchet, both directions ──────────────────────────────── + // A miniature ledger: one kind's worth of ids, each with a status. + const LEDGER = new Map([ + ['area.runs', 'active'], + ['area.drafted', 'draft'], + ['area.promised', 'planned'], + ['area.promised-two', 'planned'], + ['area.gone', 'retired'], + ]); + const cov = (ids) => coverageEntryProblems(ids, (id) => LEDGER.get(id)); + + const activeOnly = cov(['area.runs']); + t('C1 DIRECTION A — a kind mapped to an active item is covered, silently', activeOnly.problems.length === 0 && activeOnly.bearing === 1, + activeOnly.problems.join('; ')); + const mixed = cov(['area.runs', 'area.promised']); + t('C2 a planned item listed BESIDE an active one changes nothing — that is where a capability-gap card points, and it must not red a covered kind', + mixed.problems.length === 0 && mixed.bearing === 1, mixed.problems.join('; ')); + + const plannedOnly = cov(['area.promised']); + t('C3 DIRECTION B — a kind whose ONLY item is planned is UNMAPPED', plannedOnly.problems.length === 1 && plannedOnly.bearing === 0); + t('C4 and it is reported as UNMAPPED, in the vocabulary the unclassified-kind message already uses', + plannedOnly.problems[0]?.startsWith('UNMAPPED')); + t('C5 the message says WHY, so the cheap fix (promote it) is visibly not the fix', + plannedOnly.problems[0]?.includes('promise, not a test')); + const plannedTwo = cov(['area.promised', 'area.promised-two']); + t('C6 two planned items are not one active item — coverage does not accumulate from promises', + plannedTwo.problems.length === 1 && plannedTwo.bearing === 0); + + t('C7 a draft item still carries coverage — this change moved ONE status, not the ratchet\'s meaning', + cov(['area.drafted']).problems.length === 0 && cov(['area.drafted']).bearing === 1); + const retiredOnly = cov(['area.gone']); + t('C8 a retired-only mapping keeps its own message AND is now also reported as uncovered', + retiredOnly.problems.length === 2 && retiredOnly.problems.some((p) => p.includes('retired item')) && retiredOnly.bearing === 0); + const unknown = cov(['area.never-existed']); + t('C9 an unresolvable id is still named as unknown', unknown.problems.some((p) => p.includes('unknown item id')) && unknown.bearing === 0); + t('C10 ⛔ the bearing set does not contain `planned` — folding it in is the ONE edit that turns this ratchet into a way to green an untested kind', + !COVERAGE_BEARING_STATUSES.has('planned') && !COVERAGE_BEARING_STATUSES.has('retired')); + + // ── the live control ────────────────────────────────────────────────────── + // The fixtures above prove the rule; this reads the ledger the gate actually + // validates and proves the rule is pointed at IT. Every assertion above would + // pass just as well against a `planned` no area file could ever carry. + // ⛔ Read here rather than from the item walk below: this battery runs before + // that walk on every invocation, and behind `--self-test` the walk never runs. + const liveStatuses = new Set(); + let liveItems = 0; + for (const f of readdirSync(AREAS_DIR).filter((n) => n.endsWith('.json'))) { + for (const it of JSON.parse(readFileSync(join(AREAS_DIR, f), 'utf8')).items ?? []) { + liveItems += 1; + liveStatuses.add(it.status); + } + } + t('L1 every status on the live ledger is one this gate accepts — the control that says the assertions above are about THIS ledger', + liveItems > 0 && [...liveStatuses].every((s) => STATUSES.has(s)), + `${liveItems} items, statuses: ${[...liveStatuses].sort().join(', ')}`); + + plannedStatusReachedVerdict = true; + return { checked, failures }; +} + if (process.argv.slice(2).includes('--self-test')) { const trap = selfTestTrapVocabulary(); const prov = selfTestProvisioningUse(); @@ -2012,12 +2232,14 @@ if (process.argv.slice(2).includes('--self-test')) { const metaCall = selfTestMetaCallSpelling(); const cites = selfTestLineCitationBinding(); const anchors = selfTestSymbolAnchors(); + const plannedStatus = selfTestPlannedStatus(); requireReachedVerdict('selfTestTrapVocabulary', trapReachedVerdict); requireReachedVerdict('selfTestProvisioningUse', provisioningReachedVerdict); requireReachedVerdict('selfTestUnreferencedRecipes', unreferencedReachedVerdict); requireReachedVerdict('selfTestMetaCallSpelling', metaCallReachedVerdict); requireReachedVerdict('selfTestLineCitationBinding', lineCitationsReachedVerdict); requireReachedVerdict('selfTestSymbolAnchors', symbolAnchorsReachedVerdict); + requireReachedVerdict('selfTestPlannedStatus', plannedStatusReachedVerdict); const rosterFailures = batteryRosterFailures({ [BATTERY_TRAP_VOCABULARY]: trap.checked, [BATTERY_PROVISIONING_USE]: prov.checked, @@ -2025,16 +2247,18 @@ if (process.argv.slice(2).includes('--self-test')) { [BATTERY_META_CALL_SPELLING]: metaCall.checked, [BATTERY_LINE_CITATION_BINDING]: cites.checked, [BATTERY_SYMBOL_ANCHORS]: anchors.checked, + [BATTERY_PLANNED_STATUS]: plannedStatus.checked, }); - const failures = [...trap.failures, ...prov.failures, ...unref.failures, ...metaCall.failures, ...cites.failures, ...anchors.failures, ...rosterFailures]; + const failures = [...trap.failures, ...prov.failures, ...unref.failures, ...metaCall.failures, ...cites.failures, ...anchors.failures, ...plannedStatus.failures, ...rosterFailures]; if (failures.length === 0) { console.log( - `✓ check-platform-checklist --self-test: ${trap.checked + prov.checked + unref.checked + metaCall.checked + cites.checked + anchors.checked} assertions — the trap-table extractor reads a good table and REFUSES an empty/renamed/reshaped one;` + + `✓ check-platform-checklist --self-test: ${trap.checked + prov.checked + unref.checked + metaCall.checked + cites.checked + anchors.checked + plannedStatus.checked} assertions — the trap-table extractor reads a good table and REFUSES an empty/renamed/reshaped one;` + ' `fixtures.provisioning.use` resolves both spellings (own-area key and `:`) and fires on all three dangling shapes;' + ' the unreferenced-recipe direction fires on a recipe nobody uses while leaving a cross-area consumer, a retired consumer and a `$`-annotation alone;' + ' 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;' + ' 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;' + - ' 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.', + ' 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;' + + ' and the `planned` status is driven on fixtures rather than on a ledger that carries none of it — the accept set widened without losing its closure, `since: null`/no-steps/personas relaxed for planned alone while the 264 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`.', ); process.exit(0); } @@ -2113,6 +2337,18 @@ if (symbolAnchorControl.failures.length) { for (const f of symbolAnchorControl.failures) console.error(` ✗ ${f}`); process.exit(1); } +// And for the `planned` status. Its schema half is exercised by the tree the +// moment anyone authors a planned item; its COVERAGE half is not, and will not +// be for as long as the ledger's planned count is the 0 this gate prints. A +// deleted ratchet rule and an honest ledger print the same green, so the +// fixtures below it are the only thing that can tell them apart. +const plannedStatusControl = selfTestPlannedStatus(); +requireReachedVerdict('selfTestPlannedStatus', plannedStatusReachedVerdict); +if (plannedStatusControl.failures.length) { + console.error("check-platform-checklist: the `planned` status check's own positive control FAILED — a metadata kind whose only checklist items are PLANNED would report as covered, which turns this ratchet from 'the platform tests what it has' into 'the platform intends to'.\n"); + for (const f of plannedStatusControl.failures) console.error(` ✗ ${f}`); + process.exit(1); +} const inlineRosterFailures = batteryRosterFailures({ [BATTERY_TRAP_VOCABULARY]: trapControl.checked, [BATTERY_PROVISIONING_USE]: provisioningControl.checked, @@ -2120,6 +2356,7 @@ const inlineRosterFailures = batteryRosterFailures({ [BATTERY_META_CALL_SPELLING]: metaCallControl.checked, [BATTERY_LINE_CITATION_BINDING]: citationControl.checked, [BATTERY_SYMBOL_ANCHORS]: symbolAnchorControl.checked, + [BATTERY_PLANNED_STATUS]: plannedStatusControl.checked, }); if (inlineRosterFailures.length) { console.error('check-platform-checklist: the self-test battery roster FAILED — assertions stopped running, and every leg below would read the smaller count as a pass.\n'); @@ -2219,9 +2456,7 @@ for (const { file, stem, doc } of parsed) { if (!STATUSES.has(item.status)) where(`"status" must be one of ${[...STATUSES].join('|')}`); if (!PRIORITIES.has(item.priority)) where(`"priority" must be one of ${[...PRIORITIES].join('|')}`); if (!SURFACES.has(item.surface)) where(`"surface" must be one of ${[...SURFACES].join('|')}`); - if (typeof item.since !== 'string' || !/^v\d+(\.\d+)?$/.test(item.since)) { - where('"since" must be the release that introduced the capability, e.g. "v16" or "v16.0"'); - } + for (const msg of statusFieldProblems(item)) where(msg); if (!Number.isInteger(item.revision) || item.revision < 1) where('"revision" must be an integer >= 1'); if (!Array.isArray(item.history) || item.history.length === 0) { @@ -2239,8 +2474,6 @@ for (const { file, stem, doc } of parsed) { } } - if (!Array.isArray(item.steps) || item.steps.length === 0) where('"steps" must be a non-empty array of strings'); - for (const msg of trapProblems(item, TRAPS)) where(msg); const useProblems = provisioningProblems(item, stem, recipesByArea); @@ -2262,7 +2495,12 @@ for (const { file, stem, doc } of parsed) { if (typeof item.retiredReason !== 'string' || !item.retiredReason) where('retired items must carry "retiredReason"'); } else { if (!Array.isArray(item.acceptance) || item.acceptance.length === 0) { - where('active/draft items must have at least one acceptance clause'); + // A planned item has no oracle to consult yet — that is what `planned` + // MEANS. Requiring a clause here would buy one written against a + // capability nobody has implemented, which is the ticking-on-vibes this + // ledger exists to refuse. Clauses it DOES carry are still validated + // below, so an early draft of the acceptance cannot rot unchecked. + if (item.status !== 'planned') where('active/draft items must have at least one acceptance clause'); } else { item.acceptance.forEach((c, i) => { if (typeof c.clause !== 'string' || !c.clause) where(`acceptance[${i}] missing "clause"`); @@ -2443,16 +2681,10 @@ if (!existsSync(COVERAGE_FILE)) { continue; } if (hasItems) { - mappedCount++; - for (const id of entry.items) { - if (!allIds.has(id)) err('coverage.json', kind, `maps to unknown item id "${id}"`); - else { - const mapped = allItems.find((r) => r.item.id === id); - if (mapped?.item.status === 'retired') { - err('coverage.json', kind, `maps to retired item "${id}" — point at its successor or re-waive the kind`); - } - } - } + const statusOf = (id) => allItems.find((r) => r.item.id === id)?.item.status; + const { problems, bearing } = coverageEntryProblems(entry.items, statusOf); + for (const msg of problems) err('coverage.json', kind, msg); + if (bearing > 0) mappedCount++; } else { waivedCount++; } @@ -2605,18 +2837,23 @@ if (errors.length) { const total = allItems.length; const active = allItems.filter(({ item }) => item.status === 'active').length; +// Printed beside `active` so the ledger's implementation status is visible from +// the gate itself, not only from `pnpm gen:checklist-status`. A planned count +// that climbs while `active` stands still is the ledger doing its job; one that +// climbs while coverage stays green would be this gate failing at its. +const planned = allItems.filter(({ item }) => item.status === 'planned').length; // Counted, not inferred. On this path it necessarily equals `recipeTotal` — // an unreferenced recipe would have exited above — but a line that RESTATES a // constant reports nothing, and this direction's whole risk is a green that // looks the same whether it ran or not. const recipesReferenced = [...recipesByArea].reduce((n, [area, keys]) => n + keys.filter((k) => referencedByArea.get(area)?.has(k)).length, 0); console.log( - `check-platform-checklist: OK — ${files.length} areas, ${total} items (${active} active); coverage: ${mappedCount} kinds mapped, ${waivedCount} waived;` + + `check-platform-checklist: OK — ${files.length} areas, ${total} items (${active} active, ${planned} planned); coverage: ${mappedCount} kinds mapped, ${waivedCount} waived;` + ` traps: ${TRAPS.size} documented, ${usedTraps.size} in use;` + ` provisioning: ${recipeTotal} area recipes, ${recipeRefs} item references resolved (${qualifiedRefs} area-qualified), ${recipesReferenced}/${recipeTotal} recipes referenced;` + ` meta-URL spelling: ${metaCallsScanned} \`call\` strings scanned against ${FOLDED_META_SPELLINGS.size} folded spellings;` + ` line citations: 0 survive across ${sweep.counts.docs} swept documents — \`file:line\`, a bare \`:NNN\` continuation and an \`L\` pin are all judged by \`symbol-anchors.mjs\`, through the same registration;` + ` symbol anchors: ${anchorsResolved}/${anchorsScanned} resolved by \`symbol-anchors.mjs\` (the ONE resolver, reached as a REGISTERED corpus) across ${sweep.counts.docs} swept documents against ${sweep.counts.citedSources} cited sources` + `, ${anchorsResidual} on the named #16898 residual, ${Object.keys(anchorFloors).length} file floors held;` + - ` (self-checks: ${trapControl.checked} trap-vocabulary + ${provisioningControl.checked} provisioning-resolve + ${unreferencedControl.checked} unreferenced-recipe + ${metaCallControl.checked} meta-call-spelling + ${citationControl.checked} line-citation-binding + ${symbolAnchorControl.checked} symbol-anchor assertions).`, + ` (self-checks: ${trapControl.checked} trap-vocabulary + ${provisioningControl.checked} provisioning-resolve + ${unreferencedControl.checked} unreferenced-recipe + ${metaCallControl.checked} meta-call-spelling + ${citationControl.checked} line-citation-binding + ${symbolAnchorControl.checked} symbol-anchor + ${plannedStatusControl.checked} planned-status assertions).`, ); From d09bfb8744e4f9a0be801b718ba591ee2cedae65 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 19 Sep 2026 01:20:54 +0000 Subject: [PATCH 2/5] =?UTF-8?q?feat(platform-checklist):=20planned=20items?= =?UTF-8?q?=20=E2=80=94=20selector,=20status=20command,=20RUNNER/README/sk?= =?UTF-8?q?ills?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The selector resolves one selector against two pools: the runnable one it has always returned, and the planned one it now reports beside it. A planned id must never reach a runner (whatever verdict came back would be about nothing), but dropping it silently would answer 'nothing here' about an area whose gap the ledger is deliberately carrying. `pnpm gen:checklist-status` is the 平台功能清单 + 实现状态 view: per-area active/planned counts and every planned id, plus `--out ` rendering the wiki page set — one index and one page per area, planned first. Published on a schedule to the wiki, never committed: a generated page in the tree is a third artifact to keep fresh whose stale copy reads exactly as authoritative as a current one. Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude --- .claude/skills/checklist-author/SKILL.md | 9 + .claude/skills/checklist-test/SKILL.md | 4 + docs/qa/platform-checklist/README.md | 61 +++- docs/qa/platform-checklist/RUNNER.md | 25 +- package.json | 1 + scripts/checklist-select.mjs | 93 +++++- scripts/gen-checklist-status.mjs | 385 +++++++++++++++++++++++ 7 files changed, 571 insertions(+), 7 deletions(-) create mode 100644 scripts/gen-checklist-status.mjs diff --git a/.claude/skills/checklist-author/SKILL.md b/.claude/skills/checklist-author/SKILL.md index e488841d6c..b838b838e2 100644 --- a/.claude/skills/checklist-author/SKILL.md +++ b/.claude/skills/checklist-author/SKILL.md @@ -41,6 +41,15 @@ metadata: 3. **本技能的简报是假设,源码才是真相** —— 每个测试项按 README.md「Item anatomy」 的契约写;缺 fixture 记 `blocked`/`knownGaps`,永不伪造覆盖。 +## 缺项写成 `planned` 项,不写成注释 + +sweep 发现「定义要求、平台还没有」的能力,写成该区的 `status: "planned"` 项(title · +priority · personas,`since: null` 或目标 release,⛔ 无 steps);注释没有 id,派发时指 +不到。它是 `coverage.json` 的合法映射目标但⛔ 不算覆盖:只映射到它的种类仍报 UNMAPPED。 + +**`planned → active` 只由一次判 pass 的运行记录兑现** —— steps/acceptance 随实现 PR 落 +地,跑过才翻状态(bump `revision`、追 `history`);⛔ 代码落地就翻 = 账本开始虚报覆盖。 + ## 规模指引 一轮全量 sweep ≈ 5 个 hunter + 8 个 writer agent。范围化的问题(「X 有测试吗?」) diff --git a/.claude/skills/checklist-test/SKILL.md b/.claude/skills/checklist-test/SKILL.md index 36f0d3b5dd..cc2793c6b5 100644 --- a/.claude/skills/checklist-test/SKILL.md +++ b/.claude/skills/checklist-test/SKILL.md @@ -55,6 +55,10 @@ node scripts/checklist-select.mjs --json `blocked` 时才传 `--include-blocked`。**把解析器报出的 `revision` 钉进运行记录**: 判定只对它运行时所对的那个 revision 有效。 +**`planned` 项任何选择器都不返回,也没有开关让它返回**:能力不存在,无 steps 可驱动、 +无 oracle 可查,解析器单独列出命中项。运行记录逐项记 `planned`,⛔ 永不 pass / fail / +blocked / not-run,⛔ 不进标题计数;与 blocked 的分界是 fixture 债 vs 平台债。 + ## 1. 规划这一轮 —— 只构建需要的,钉住的先跑 读命中项的 `surface`: diff --git a/docs/qa/platform-checklist/README.md b/docs/qa/platform-checklist/README.md index be6258a7a6..d5cfa4a248 100644 --- a/docs/qa/platform-checklist/README.md +++ b/docs/qa/platform-checklist/README.md @@ -46,7 +46,7 @@ next-sequential numbers do. "id": "approvals.per-group-signoff", // "." — immutable, globally unique, never reused "title": "Per-group sign-off (会签) needs one approval from EACH group", "since": "v16", // release that introduced the capability - "status": "active", // active | draft | retired + "status": "active", // active | draft | planned | retired (see "Implementation status") "revision": 1, // bumps on any semantic edit "priority": "P1", // P0 = release-gating smoke · P1 = core · P2 = extended "surface": "browser", // browser | api | cli | build | mixed (the 15.1 plan's 🖥/🔌 lanes) @@ -297,6 +297,65 @@ Three things worth knowing before you meet it: showcase-side fixture gaps #3358 uncovered (#3408, #3409, #3415) each cost a sweep to rediscover; recording the gap on the item is what stops that. +## Implementation status — `planned` is how the ledger holds a capability gap + +The ledger used to record only capabilities that already work, so a missing piece of a +listed capability had nowhere to live: the backlog sweep had nothing to point a `Path:` +at, and the platform's implementation status lived in nobody's head. The North Star's +definition line (「做出来的是什么」…「缺任何一样就不是这个应用」) makes such a piece a +**requirement**, so it belongs on the ledger — as a fourth status, not as a second +document. A separate feature list would drift against this one with no gate able to say +which is wrong (the ADR-0136 lesson), and the reading entry carries numbers only because +a command produces them. + +**`status: "planned"`** — the definition requires this capability and the platform does +not yet implement or verify it. What a planned item carries, and what it deliberately +does not: + +| field | on a planned item | +|:---|:---| +| `id` · `title` · `priority` · `surface` · `revision` · `history` | as for any item — an id is picked once and is immutable, so it can be pointed at from the day the gap is found | +| `personas` | **required** — who the capability is for is knowable the day the gap is found, and is what makes the gap readable to the next sweep | +| `since` | `null` (no target release chosen) **or the TARGET release** — never a release that already shipped without it | +| `steps` | **none.** There is nothing to drive. Steps arrive in the PR that implements the capability, in the same edit that promotes the item | +| `acceptance` | not required — no oracle can be consulted yet. Clauses drafted early are still validated | + +Three consequences, all mechanical: + +- **A planned item never runs.** `scripts/checklist-select.mjs` resolves it in no + selector's runnable set, and there is no flag that makes it one. The selector reports + it separately and the run record carries the verdict `planned` — ⛔ never `pass`, + `fail` or `blocked` ([RUNNER.md](./RUNNER.md) "Verdicts"). A blocked item is a real + test the environment cannot run today; a planned item has nothing to run at all. +- **A planned item is not coverage.** It is a legal `coverage.json` map target — that is + where a capability-gap card points — and it contributes zero: a kind whose *only* + items are planned is reported **UNMAPPED**, exactly as if the entry were empty. + Otherwise `planned` would be the cheapest way to green an untested kind, and the + ratchet would measure intentions instead of tests. +- **Promotion `planned → active` is earned, not declared.** It takes a run record in + which the item **passed**: steps and acceptance arrive with the implementation, the + item is run, and only then does the status flip (bumping `revision` and appending to + `history` like any other semantic edit). ⛔ Flipping the status because the code + landed is how a ledger starts reporting coverage it does not have. + +**Reading the status** — one command, and the human entry point it publishes: + +```bash +pnpm gen:checklist-status # per-area active/planned counts + every planned id +pnpm gen:checklist-status --out # also render the wiki pages into +``` + +The wiki index **[Platform-Checklist](https://github.com/objectstack-ai/objectstack/wiki/Platform-Checklist)** +is the 「平台功能清单 + 实现状态」 reading entry: one row per area with its active and +planned counts, linking to one `Checklist-` page per area that lists every item +(`id · title · priority · status · personas`) with the planned ones in their own section +first. It is regenerated on a schedule by +[`.github/workflows/checklist-status.yml`](../../../.github/workflows/checklist-status.yml) +and is never committed here — ⛔ there is no `STATUS.md` in this tree and no gate paired +with one, by maintainer ruling: staleness is tolerated, and a generated page in the tree +is a third artifact to keep fresh whose stale copy reads exactly as authoritative as a +current one. + ## Capability coverage — every capability the platform has gets tested `coverage.json` makes "凡是有的能力, 都要测试" mechanical instead of aspirational. The diff --git a/docs/qa/platform-checklist/RUNNER.md b/docs/qa/platform-checklist/RUNNER.md index ba9804a8b4..c491b3b5b6 100644 --- a/docs/qa/platform-checklist/RUNNER.md +++ b/docs/qa/platform-checklist/RUNNER.md @@ -35,12 +35,28 @@ Per **item**, derived — never hand-assigned: - `partial` — some passed, none failed (the "proved half, left it unticked" state from #3358, now first-class instead of a prose apology); - `fail` — any clause failed; -- `blocked` / `not-run` — nothing consulted. +- `blocked` / `not-run` — nothing consulted; +- `planned` — the item's `status` is `planned`: the definition requires the capability + and the platform does not verify it yet. Read off the item, never derived from a run. **No verdict without evidence.** A clause with no captured artifact is `not-run`, not `pass`. Evidence means: the API/network trace, the screenshot, the log excerpt, or the test-run output the clause's `evidence` field names. +**A `planned` item is never driven, and never scores.** `scripts/checklist-select.mjs` +returns it in no selector's runnable set — it prints the matches under a separate +PLANNED heading and hands the runner only what can run. Carry each one into the run +record as `planned`, with no clause table: there are no `steps` to follow and no +`acceptance` to consult, so ⛔ it is never `pass`, never `fail`, never `blocked` and +never `not-run`. The distinction that matters is against `blocked`: a blocked item is a +**real test** the environment cannot run today, so its gap is a fixture debt and the +right answer is to provision it; a planned item has **nothing to run at all**, so its +gap is a platform debt and the right answer is to build the capability. Scoring one as +the other sends the next sweep to fix the wrong thing. A planned item earns `active` +only through a run record in which it passed — see the README's "Implementation status". +The completion criterion is unchanged by them: an area is run when every item the +selector handed you has a verdict; planned ids are listed, not chased. + ## The accuracy rules 1. **Oracle hierarchy** — server truth (`api`, `network`, `build`, `test`) outranks @@ -429,6 +445,13 @@ QA run · (/) · · · < **An omitted bucket means "not declared", never zero** — the roll-up renders the two differently, so write `0 FAIL` when you mean zero. +**⛔ `planned` items appear nowhere in this title** — not inside ``, and not as a +sixth bucket. The counts vocabulary is closed at the five verdicts above, and a planned +item was never a judgeable unit of this run: putting one in `` deflates the +coverage claim the record is read on, and inventing a `PLANNED` segment is a deviation +the roll-up refuses to parse. They are named in the body, under the scope section that +already records the selector. + **Retired phrasings — ⛔ none of these may be written again:** `(FULL area)`, `(N items)`, `(N of M items)`, `(N/M items consulted)`, omitting the parenthetical entirely, and the trailing `(11 not-run)` spelling of NOT-RUN. `(FULL area)` is why the ruling exists: it diff --git a/package.json b/package.json index 57ab936bea..b7ee605bd1 100644 --- a/package.json +++ b/package.json @@ -107,6 +107,7 @@ "check:scripts-symbol-anchors": "node scripts/symbol-anchors.mjs --self-test && node scripts/check-scripts-symbol-anchors.mjs --self-test && node scripts/check-scripts-symbol-anchors.mjs", "check:spec-docblock-symbol-anchors": "node scripts/symbol-anchors.mjs --self-test && node scripts/check-spec-docblock-symbol-anchors.mjs --self-test && node scripts/check-spec-docblock-symbol-anchors.mjs", "check:platform-checklist": "node scripts/checklist-select.mjs --self-test && node scripts/check-platform-checklist.mjs --self-test && node scripts/check-platform-checklist.mjs", + "gen:checklist-status": "node scripts/gen-checklist-status.mjs --self-test && node scripts/gen-checklist-status.mjs", "check:org-identifier": "node scripts/check-org-identifier.mjs --self-test && node scripts/check-org-identifier.mjs", "check:runner-env-posture": "node scripts/check-runner-env-posture.mjs --self-test && node scripts/check-runner-env-posture.mjs", "check:cli-test-child-env": "node scripts/check-cli-test-child-env.mjs --self-test && node scripts/check-cli-test-child-env.mjs", diff --git a/scripts/checklist-select.mjs b/scripts/checklist-select.mjs index a7d75bcb8a..b6425ed639 100644 --- a/scripts/checklist-select.mjs +++ b/scripts/checklist-select.mjs @@ -31,6 +31,13 @@ // on stock fixtures. Pass --include-blocked to list them too (the runner records them as // blocked with their fixture reason, per RUNNER.md). // +// PLANNED items (`status: "planned"`) are never runnable by any selector and there is no +// flag that makes them so — the capability does not exist yet, so there is nothing to +// drive and no oracle to consult. They are REPORTED instead: the same selector resolves +// against them and they are printed under a PLANNED heading for the run record to carry +// as the verdict `planned`. ⛔ A planned id must not reach a runner; whatever verdict +// came back would be about nothing. +// // Output: a table (id · priority · surface · blocked?) to stderr for humans, and — with // --json — a machine list to stdout for the runner to fan out over. @@ -55,10 +62,23 @@ function loadItems(areasDir = AREAS_DIR) { /** * Resolve a selector string against a set of items (+ optional coverage map). * Pure and side-effect-free so the self-test can exercise it directly. + * + * `opts.status` picks WHICH pool the same selector resolves against, and + * defaults to the runnable one. A `planned` item records a capability the + * definition requires and the platform does not yet verify: there is nothing to + * drive, so it must never reach a runner — but it must not vanish either, or a + * selector answers "nothing here" about an area whose gap the ledger is + * deliberately carrying. Resolving one selector against both pools is what lets + * the CLI below report a planned item AS planned while running nothing for it. + * + * @param {string} selector + * @param {object[]} items + * @param {{metadataKinds?: Record}} [coverage] + * @param {{status?: string}} [opts] * @returns {object[]} the matched items (order: as declared) */ -export function selectItems(selector, items, coverage = { metadataKinds: {} }) { - const active = items.filter((it) => it.status === 'active'); +export function selectItems(selector, items, coverage = { metadataKinds: {} }, opts = {}) { + const active = items.filter((it) => it.status === (opts.status ?? 'active')); const byId = (id) => active.filter((it) => it.id === id); if (selector === 'all') return active; @@ -104,7 +124,10 @@ export function selectItems(selector, items, coverage = { metadataKinds: {} }) { if (inArea.length) return inArea; } if (raw.includes('/') || /\.(tsx?|jsx?|mjs|cjs)$/.test(base)) { - return selectItems(`file:${raw}`, items, coverage); + // `opts` rides along: a prefix-less path resolved against the planned + // pool must stay in the planned pool, or the convenience form silently + // answers from a different ledger than the one asked about. + return selectItems(`file:${raw}`, items, coverage, opts); } const asId = byId(val); if (asId.length) return asId; @@ -113,6 +136,20 @@ export function selectItems(selector, items, coverage = { metadataKinds: {} }) { return []; // unknown prefix } +/** + * The same selector, resolved against the PLANNED pool. The runner reports what + * this returns as `planned` and drives none of it (RUNNER.md "Verdicts") — a + * planned item is never `pass`, never `fail`, never `blocked`, because no + * oracle was consulted and none could have been. + * + * ⛔ Not a variant of `--include-blocked`: a blocked item is a real test the + * environment cannot run today, and hiding it is a fixture problem. A planned + * item has nothing to run at all. + */ +export function selectPlanned(selector, items, coverage = { metadataKinds: {} }) { + return selectItems(selector, items, coverage, { status: 'planned' }); +} + function isBlocked(it) { return it.blocked !== undefined; } @@ -145,8 +182,12 @@ function isBlocked(it) { // The count is a FLOOR, not an equality — adding cases is ordinary work and must // not red. A battery BELOW its floor means cases stopped running; the remedy is // to find what stopped registering. +// 17 → 31: the `planned` status. Every selector shape is driven against the +// runnable pool to prove a planned item is in NONE of them, and against the +// planned pool to prove it is still reachable — the two halves of "skipped by +// every selector, reported as `planned`". const SELF_TEST_BATTERIES = Object.freeze({ - 'checklist-select self-test': 17, + 'checklist-select self-test': 31, }); // DELETING an entry silences that battery's floor exactly as effectively as @@ -180,9 +221,11 @@ function selfTest() { { id: 'a.two', status: 'active', priority: 'P1', surface: 'api', since: 'v16.1', source: ['#3358'], blocked: { by: 'fixture', ref: '#1' } }, { id: 'b.three', status: 'active', priority: 'P0', surface: 'api', since: 'v15', source: ['packages/foo/baz.ts'] }, { id: 'b.gone', status: 'retired', priority: 'P0', surface: 'api', since: 'v15', retiredReason: 'x' }, + { id: 'b.promised', status: 'planned', priority: 'P1', surface: 'api', since: null, personas: ['admin'], source: ['packages/foo/baz.ts'] }, ]; - const COV = { metadataKinds: { hook: { items: ['a.one'] } } }; + const COV = { metadataKinds: { hook: { items: ['a.one'] }, widget: { items: ['b.promised'] } } }; const ids = (sel) => selectItems(sel, FIX, COV).map((i) => i.id).sort(); + const plannedIds = (sel) => selectPlanned(sel, FIX, COV).map((i) => i.id).sort(); // Counted, never transcribed (#15305): the success line below used to carry a // hand-typed `17`, a number nothing derived and nothing compared — accurate on // the day it was typed and silently wrong the first time a case is added or @@ -212,6 +255,27 @@ function selfTest() { eq(ids('packages/foo/bar.ts'), ['a.one'], 'bare source path (has /) → file: mode'); eq(ids('bar.ts'), ['a.one'], 'bare source basename (code ext) → file: mode'); eq(ids('missing.json'), [], 'unmatched .json name → empty, no throw'); + // ── planned items: skipped by every selector, reported by their own ─────── + // The runnable pool is what a runner drives, so the FIRST direction is that a + // planned item never appears in it — by any spelling of any selector, which + // is why each shape is driven rather than the one that happens to be handy. + eq(ids('all'), ['a.one', 'a.two', 'b.three'], 'all excludes planned as well as retired'); + eq(ids('area:b'), ['b.three'], 'area: excludes planned'); + eq(ids('b.promised'), [], 'a planned item asked for BY ID is still not runnable'); + eq(ids('priority:P1'), ['a.two'], 'priority: excludes planned'); + eq(ids('surface:api'), ['a.two', 'b.three'], 'surface: excludes planned'); + eq(ids('capability:widget'), [], 'capability: excludes planned — a kind mapped only to planned items resolves to nothing runnable'); + eq(ids('file:packages/foo/baz.ts'), ['b.three'], 'file: excludes planned'); + // The second direction — and the one that makes the first safe. If planned + // items were only dropped, a selector would answer "nothing here" about an + // area whose gap the ledger is deliberately carrying. + eq(plannedIds('all'), ['b.promised'], 'the planned pool is reachable by the same selector'); + eq(plannedIds('area:b'), ['b.promised'], 'area: resolves against the planned pool'); + eq(plannedIds('b.promised'), ['b.promised'], 'a bare planned id resolves in the planned pool'); + eq(plannedIds('capability:widget'), ['b.promised'], 'capability: resolves against the planned pool — this is where a capability-gap card points'); + eq(plannedIds('packages/foo/baz.ts'), ['b.promised'], 'the prefix-less path form keeps the pool it was asked about'); + eq(plannedIds('area:a'), [], 'an area with no planned items reports none — the pool is not a fallback'); + eq(plannedIds('b.gone'), [], 'retired is not planned — two different absences, not one'); // ── The floor: every declared battery RAN, and ran its cases (#13489) ──── // // Evaluated after every battery has had its chance and BEFORE the verdict, so @@ -286,6 +350,7 @@ function main() { let matched = selectItems(selector, items, coverage); const droppedBlocked = includeBlocked ? [] : matched.filter(isBlocked); if (!includeBlocked) matched = matched.filter((it) => !isBlocked(it)); + const planned = selectPlanned(selector, items, coverage); if (json) { process.stdout.write(JSON.stringify(matched.map((it) => ({ id: it.id, priority: it.priority, surface: it.surface, since: it.since, revision: it.revision })), null, 2) + '\n'); @@ -298,7 +363,25 @@ function main() { if (droppedBlocked.length) { console.error(`\n hidden (blocked): ${droppedBlocked.map((i) => i.id).join(', ')}`); } + // Reported, never returned. The JSON the runner fans out over stays the + // RUNNABLE list — a planned id reaching a runner would be driven, and + // whatever verdict came back would be about nothing. What the run record owes + // these ids is the verdict `planned`, which no oracle is consulted for. + if (planned.length) { + console.error(`\n ${planned.length} PLANNED item(s) matched this selector — not run, and not runnable: the definition requires the capability and the platform does not verify it yet.`); + console.error(' Record each as `planned` in the run record (⛔ never pass/fail/blocked — no oracle was consulted), and do not drive any of them.'); + for (const it of planned) { + console.error(` ${it.priority} ${String(it.surface ?? '-').padEnd(8)} ${it.id}${it.since ? ` → target ${it.since}` : ''}`); + } + } if (matched.length === 0) { + // A selector that matched ONLY planned items is answered, not refused: the + // ledger has something to say about it and said it above. "Nothing matched" + // would send the caller off to fix a selector that is working. + if (planned.length) { + console.error('\n (nothing to run: every match is planned — report them as `planned` and stop)'); + return; + } console.error(' (nothing matched — check the selector; try `all` or `area:`)'); process.exit(1); } diff --git a/scripts/gen-checklist-status.mjs b/scripts/gen-checklist-status.mjs new file mode 100644 index 0000000000..64d1be112a --- /dev/null +++ b/scripts/gen-checklist-status.mjs @@ -0,0 +1,385 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// gen-checklist-status — the platform's capability list AND its implementation +// status, read off the one ledger that already holds both. +// +// node scripts/gen-checklist-status.mjs # the status report, to stdout +// node scripts/gen-checklist-status.mjs --out # ALSO render the wiki pages into +// node scripts/gen-checklist-status.mjs --self-test +// +// ## Why this is a command and not a page +// +// The maintainer's ask was 「一个可以人工阅读确认的入口」 for 「平台真的功能清单, +// 以及实现状态」. The trap it is one step away from is a SECOND document: a +// hand-maintained feature list drifts against the ledger the moment either is +// edited, and the two then disagree with no gate able to say which is wrong. +// One ledger, one id space, one status axis — and this command is the only +// thing that ever states a number. ⛔ Nothing downstream types a count; the +// wiki pages below carry numbers exactly because they are generated from here. +// +// ## Where the output goes, and why NOT into the tree +// +// A generated page committed to the repo is a third artifact to keep fresh, and +// a stale one is indistinguishable from a current one at reading time. So the +// pages are published to the repository WIKI on a schedule +// (`.github/workflows/checklist-status.yml`) — one stable URL, outside branch +// protection and the merge queue, regenerated wholesale every run. There is no +// `STATUS.md` in this tree and no gate pairing one, by maintainer ruling. +// +// ## The page set +// +// One INDEX page (`Platform-Checklist`) — a row per area, which is a row per +// definition item: area, active count, planned count, link — plus one page per +// area (`Checklist-`) listing every item as `id · title · priority · +// status · personas`, with the `planned` items in their OWN section, FIRST. +// Planned first is the whole point of the page: the reader came to find out +// what the platform does not do yet, and a gap listed after 39 active items is +// a gap nobody reads. + +import { readdirSync, readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; +import { join, basename } from 'node:path'; + +import { isEntrypoint } from './invoked-as.mjs'; + +const ROOT = new URL('..', import.meta.url).pathname; +const AREAS_DIR = join(ROOT, 'docs/qa/platform-checklist/areas'); + +/** The wiki page name of the index. Referenced by README.md and the workflow. */ +const INDEX_PAGE = 'Platform-Checklist'; +/** One area JSON -> one wiki page, by this rule and no other. */ +const areaPage = (area) => `Checklist-${area}`; + +// ── reading the ledger ────────────────────────────────────────────────────── + +/** + * Every area file, in filename order, with its items. + * @param {string} [dir] + * @returns {{area: string, title: string, items: object[]}[]} + */ +function readAreas(dir = AREAS_DIR) { + return readdirSync(dir) + .filter((f) => f.endsWith('.json')) + .sort() + .map((f) => { + const doc = JSON.parse(readFileSync(join(dir, f), 'utf8')); + return { area: doc.area ?? basename(f, '.json'), title: doc.title ?? '', items: doc.items ?? [] }; + }); +} + +/** + * The status census. Every number this command or its pages print comes from + * here — ⛔ there is no second counter and nothing downstream may add one. + * + * `other` is deliberately not folded into either column: a `draft` or `retired` + * item is neither a verified capability nor a declared gap, and adding it to + * one of the two totals would make the headline sentence quietly false. + * + * @param {{area: string, title: string, items: object[]}[]} areas + */ +export function census(areas) { + const rows = areas.map(({ area, title, items }) => { + const byStatus = (s) => items.filter((it) => it.status === s); + return { + area, + title, + active: byStatus('active'), + planned: byStatus('planned'), + other: items.filter((it) => it.status !== 'active' && it.status !== 'planned'), + }; + }); + return { + rows, + areas: rows.length, + active: rows.reduce((n, r) => n + r.active.length, 0), + planned: rows.reduce((n, r) => n + r.planned.length, 0), + }; +} + +/** + * The one sentence every reader of this ledger is owed, and the ONE place it is + * spelled. The wiki index prints this exact string, so a page and the command + * can never report different totals. + */ +export function headline(c) { + return `${c.active} active · ${c.planned} planned across ${c.areas} areas`; +} + +// ── the terminal report ───────────────────────────────────────────────────── + +/** @returns {string} */ +export function renderReport(c) { + const w = Math.max(4, ...c.rows.map((r) => r.area.length)); + const lines = [ + 'Platform checklist — capabilities and implementation status', + '', + ` ${'area'.padEnd(w)} active planned`, + ` ${'-'.repeat(w)} ------ -------`, + ]; + for (const r of c.rows) { + lines.push(` ${r.area.padEnd(w)} ${String(r.active.length).padStart(6)} ${String(r.planned.length).padStart(7)}`); + } + lines.push(` ${'-'.repeat(w)} ------ -------`); + lines.push(` ${'TOTAL'.padEnd(w)} ${String(c.active).padStart(6)} ${String(c.planned).padStart(7)}`); + lines.push(''); + + const withPlanned = c.rows.filter((r) => r.planned.length); + if (withPlanned.length) { + lines.push('planned — the definition requires these and the platform does not verify them yet:'); + lines.push(''); + for (const r of withPlanned) { + for (const it of r.planned) { + lines.push(` ${it.priority ?? '--'} ${it.id}${it.since ? ` → target ${it.since}` : ''}`); + } + } + lines.push(''); + } else { + lines.push('planned: none — every item on the ledger records a capability the platform verifies.'); + lines.push(''); + } + lines.push(headline(c)); + return lines.join('\n'); +} + +// ── the wiki pages ────────────────────────────────────────────────────────── + +const GENERATED_NOTE = (page) => + `> Generated by \`pnpm gen:checklist-status\` from \`docs/qa/platform-checklist/areas/*.json\`.` + + ` ⛔ Do not edit this page — edits go to the area JSON and this page follows on the next run.` + + ` This page is \`${page}\`.`; + +const personasOf = (it) => (Array.isArray(it.personas) && it.personas.length ? it.personas.join(', ') : '—'); +// A pipe inside a table cell ends the cell. Item titles are prose written by +// whoever found the capability, so they are escaped rather than trusted. +const cell = (s) => String(s ?? '').replace(/\|/g, '\\|'); + +/** One area page: every item, planned FIRST. @returns {string} */ +export function renderAreaPage(row) { + const page = areaPage(row.area); + const lines = [`# ${row.area}`, '', GENERATED_NOTE(page), '']; + if (row.title) lines.push(`${cell(row.title)}`, ''); + lines.push(`${row.active.length} active · ${row.planned.length} planned`, ''); + + const table = (items) => { + const out = ['| id | title | priority | status | personas |', '|---|---|---|---|---|']; + for (const it of items) { + out.push(`| \`${cell(it.id)}\` | ${cell(it.title)} | ${cell(it.priority)} | ${cell(it.status)} | ${cell(personasOf(it))} |`); + } + return out; + }; + + // ⛔ Planned first, unconditionally when non-empty. The reader of this page + // came for what is missing; a gap below the implemented list is not found. + if (row.planned.length) { + lines.push(`## Planned (${row.planned.length})`, ''); + lines.push('The definition requires these capabilities and the platform does not verify them yet.'); + lines.push('They are skipped by every runner selector and reported as `planned` — never pass, fail or blocked.', ''); + lines.push(...table(row.planned), ''); + } + lines.push(`## Active (${row.active.length})`, ''); + lines.push(...table(row.active), ''); + if (row.other.length) { + lines.push(`## Other (${row.other.length})`, ''); + lines.push('Neither a verified capability nor a declared gap — counted in neither column above.', ''); + lines.push(...table(row.other), ''); + } + return `${lines.join('\n').trimEnd()}\n`; +} + +/** The index page: one row per area. @returns {string} */ +export function renderIndexPage(c) { + const lines = [ + '# Platform checklist — capabilities and implementation status', + '', + GENERATED_NOTE(INDEX_PAGE), + '', + 'One row per area. `active` = the platform has the capability and the checklist verifies it.', + '`planned` = the definition requires it and nothing verifies it yet.', + '', + '| area | active | planned | page |', + '|---|---:|---:|---|', + ]; + for (const r of c.rows) { + lines.push(`| ${cell(r.area)} | ${r.active.length} | ${r.planned.length} | [${areaPage(r.area)}](${areaPage(r.area)}) |`); + } + lines.push(`| **total** | **${c.active}** | **${c.planned}** | ${c.areas} areas |`); + lines.push('', headline(c), ''); + return `${lines.join('\n').trimEnd()}\n`; +} + +/** + * Every page this command publishes, as `name -> markdown`. One index plus one + * page per area: the count is a PROPERTY of the ledger, never a constant. + */ +export function renderPages(c) { + const pages = new Map([[INDEX_PAGE, renderIndexPage(c)]]); + for (const r of c.rows) pages.set(areaPage(r.area), renderAreaPage(r)); + return pages; +} + +// ── self-test ─────────────────────────────────────────────────────────────── +// +// The defect class here is a SILENT NUMBER. Every page below carries counts, and +// a renderer that dropped a section, mis-summed a column or stopped reading an +// area file would publish a page that reads exactly as authoritative as a +// correct one — to a human, on a wiki, with no gate downstream (by ruling) to +// disagree with it. So the counts are driven off a fixture ledger whose answers +// are known, and the index is held against the per-area pages rather than +// against a second count of the same thing. + +const BATTERY_CENSUS = 'census: the counts, the headline, and the areas they are read from'; +const BATTERY_PAGES = 'pages: one index + one page per area, planned first, index equal to the pages'; +const SELF_TEST_BATTERIES = Object.freeze({ + [BATTERY_CENSUS]: 12, + [BATTERY_PAGES]: 16, +}); +// DELETING an entry silences that battery's floor exactly as effectively as +// zeroing it, so the roster's own size is pinned beside the per-battery floors. +const SELF_TEST_BATTERY_FLOOR = 2; + +let selfTestReachedVerdict = false; + +function selfTest() { + const failures = []; + const ran = {}; + let open = null; + const battery = (name) => { open = name; ran[name] = ran[name] ?? 0; }; + const t = (what, ok, note = '') => { + ran[open ?? '(no battery open)'] = (ran[open ?? '(no battery open)'] ?? 0) + 1; + if (!ok) failures.push(`${what}${note ? ` — ${note}` : ''}`); + }; + + const item = (id, status, over = {}) => ({ id, title: `t ${id}`, status, priority: 'P1', personas: ['admin'], ...over }); + const FIX = [ + { area: 'alpha', title: 'Alpha area', items: [item('alpha.one', 'active'), item('alpha.two', 'active'), item('alpha.gap', 'planned', { since: null })] }, + { area: 'beta', title: 'Beta area', items: [item('beta.one', 'active'), item('beta.old', 'retired')] }, + { area: 'gamma', title: 'Gamma area', items: [item('gamma.gap', 'planned', { since: 'v18' })] }, + ]; + + battery(BATTERY_CENSUS); + const c = census(FIX); + t('N1 one row per area file', c.rows.length === 3 && c.areas === 3, `${c.rows.length}`); + t('N2 active is counted, not assumed', c.active === 3, `${c.active}`); + t('N3 planned is counted apart from active', c.planned === 2, `${c.planned}`); + t('N4 a retired item is in NEITHER total — folding it in would make the headline quietly false', + c.active === 3 && c.planned === 2 && c.rows[1].other.length === 1); + t('N5 per-area counts sum to the totals — a total read off its own pass could not see one area stop being read', + c.rows.reduce((n, r) => n + r.active.length, 0) === c.active && c.rows.reduce((n, r) => n + r.planned.length, 0) === c.planned); + t('N6 the headline states all three numbers', headline(c) === '3 active · 2 planned across 3 areas', headline(c)); + t('N7 the headline MOVES with the ledger — a constant would pass N6 forever', + headline(census([{ area: 'solo', title: '', items: [item('solo.one', 'active')] }])) === '1 active · 0 planned across 1 areas'); + t('N8 an area with only planned items still counts as an area', c.rows[2].planned.length === 1 && c.rows[2].active.length === 0); + t('N9 an empty ledger reports zeros rather than throwing', headline(census([])) === '0 active · 0 planned across 0 areas'); + const report = renderReport(c); + t('N10 the report ends with the headline, so the terminal and the pages cannot disagree', report.trimEnd().endsWith(headline(c))); + t('N11 the report lists the planned ids — the counts alone do not tell a sweep where to point', + report.includes('alpha.gap') && report.includes('gamma.gap')); + t('N12 a ledger with no planned items says so in words rather than printing an empty heading', + renderReport(census([{ area: 'solo', title: '', items: [item('solo.one', 'active')] }])).includes('planned: none')); + + battery(BATTERY_PAGES); + const pages = renderPages(c); + t('P1 one index plus one page per area', pages.size === 4, `${pages.size}`); + t('P2 the index is named for the wiki page README links', pages.has(INDEX_PAGE)); + t('P3 every area has its own page, named by the one rule', ['alpha', 'beta', 'gamma'].every((a) => pages.has(`Checklist-${a}`))); + t('P4 the page count is a PROPERTY of the ledger, not a constant', renderPages(census(FIX.slice(0, 1))).size === 2); + const alpha = pages.get('Checklist-alpha'); + t('P5 an area page lists every item — id, title, priority, status and personas', + ['alpha.one', 'alpha.two', 'alpha.gap', 'P1', 'active', 'planned', 'admin'].every((s) => alpha.includes(s))); + t('P6 ⛔ planned comes FIRST — a gap listed after the implemented items is a gap nobody reads', + alpha.indexOf('## Planned') > -1 && alpha.indexOf('## Planned') < alpha.indexOf('## Active')); + t('P7 the planned section says what a planned item is', alpha.includes('does not verify them yet')); + t('P8 and that the runner reports it as `planned`, never pass/fail/blocked', alpha.includes('never pass, fail or blocked')); + const beta = pages.get('Checklist-beta'); + t('P9 an area with no planned items carries NO planned section — an empty heading reads as a missing one', + !beta.includes('## Planned') && beta.includes('## Active')); + t('P10 a retired item is still listed, under its own heading — "every item" means every item', + beta.includes('## Other') && beta.includes('beta.old')); + const index = pages.get(INDEX_PAGE); + t('P11 the index carries one row per area, with its counts and a link to its page', + ['| alpha | 2 | 1 |', '| beta | 1 | 0 |', '| gamma | 0 | 1 |'].every((r) => index.includes(r)), index); + t('P12 ⭐ the index counts EQUAL the area pages\' own — the acceptance this page set is read for', + c.rows.every((r) => pages.get(`Checklist-${r.area}`).includes(`${r.active.length} active · ${r.planned.length} planned`))); + t('P13 the index totals equal the sum of its rows', index.includes(`| **total** | **3** | **2** |`)); + t('P14 the index ends with the same headline the command prints', index.includes(headline(c))); + t('P15 every page says it is generated and where edits go — a wiki page is editable by anyone who can read it', + [...pages.values()].every((p) => p.includes('Do not edit this page') && p.includes('area JSON'))); + t('P16 a `|` in an authored title cannot break the table it is rendered into', + renderAreaPage(census([{ area: 'x', title: '', items: [item('x.one', 'active', { title: 'a | b' })] }]).rows[0]).includes('a \\| b')); + + const declared = Object.keys(SELF_TEST_BATTERIES); + if (declared.length < SELF_TEST_BATTERY_FLOOR) { + failures.push(`the roster declares ${declared.length} batteries but the floor is ${SELF_TEST_BATTERY_FLOOR} — deleting an entry silences its floor exactly as effectively as zeroing it`); + } + for (const name of declared) if (!(name in ran)) failures.push(`declared battery "${name}" did not run`); + for (const name of Object.keys(ran)) if (!(name in SELF_TEST_BATTERIES)) failures.push(`battery "${name}" ran but is not declared`); + for (const [name, floor] of Object.entries(SELF_TEST_BATTERIES)) { + if (typeof ran[name] === 'number' && ran[name] < floor) { + failures.push(`battery "${name}" reported ${ran[name]} assertions but its floor is ${floor} — cases stopped running; find what stopped registering (⛔ MAINTAINER-ONLY: lowering a floor is not the repair)`); + } + } + + if (failures.length) { + console.error(`✗ gen-checklist-status --self-test — ${failures.length} failure(s)\n`); + for (const f of failures) console.error(` • ${f}`); + process.exit(1); + } + const total = Object.values(ran).reduce((n, x) => n + x, 0); + console.log( + `✓ gen-checklist-status --self-test: ${total} assertions — the census counts active and planned apart and leaves draft/retired out of both,` + + ' the headline moves with the ledger rather than reading as a constant, the page set is one index plus one page per area,' + + ' planned sections come FIRST and are absent rather than empty, and the index counts EQUAL the area pages they link to.', + ); + selfTestReachedVerdict = true; +} + +// ── CLI ───────────────────────────────────────────────────────────────────── + +function main() { + const args = process.argv.slice(2); + const outAt = args.indexOf('--out'); + const out = outAt === -1 ? null : args[outAt + 1]; + if (outAt !== -1 && (!out || out.startsWith('--'))) { + console.error('gen-checklist-status: --out needs a directory'); + process.exit(2); + } + if (!existsSync(AREAS_DIR)) { + console.error(`gen-checklist-status: ${AREAS_DIR} not found — this command reads the ledger and has nothing to report without it.`); + process.exit(1); + } + + const c = census(readAreas()); + // A refusal, not a pass: an empty ledger and a walk that stopped reading it + // print the same "0 active · 0 planned" otherwise, and the second one would + // be published to the wiki as the platform's capability list. + if (c.areas === 0) { + console.error(`gen-checklist-status: read ZERO area files from ${AREAS_DIR}.`); + console.error('\nThis is a REFUSAL, not a pass: "the platform has no capabilities" and "this command stopped reading the ledger" render as the same page.'); + process.exit(1); + } + + console.log(renderReport(c)); + + if (out) { + mkdirSync(out, { recursive: true }); + const pages = renderPages(c); + for (const [name, body] of pages) writeFileSync(join(out, `${name}.md`), body); + console.log(`\nwrote ${pages.size} page(s) to ${out}: ${INDEX_PAGE} + ${c.areas} area page(s)`); + } +} + +if (isEntrypoint(import.meta.url)) { + if (process.argv.includes('--self-test')) { + selfTest(); + if (!selfTestReachedVerdict) { + console.error( + '\n✗ gen-checklist-status --self-test: selfTest() returned without reaching its verdict,\n' + + 'so no success line was printed. Exiting 0 here would report a self-test\n' + + 'that never finished as a self-test that passed.\n', + ); + process.exit(1); + } + process.exit(0); + } + main(); +} From 22453417e1e9cf0b5373d4fb566ecd71ccef23de Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 19 Sep 2026 01:40:40 +0000 Subject: [PATCH 3/5] chore(merge-driver,dispatch-gates): record `gen:checklist-status`'s disposition and classify its battery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two ledgers the new generator and its self-test battery owe a row in, each found by its own gate rather than by inspection: - `scripts/regen-artifacts.mjs`: `gen:checklist-status` writes NOTHING into this repository, so 'discard both sides and re-run the generator' is not a question that arises. Recorded as NOT_DRIVER_MANAGED with `untracked: true` rather than omitted, because the refused alternative — a committed STATUS.md paired with a check — is exactly the routed-artifact shape a reader assumes. Owner is the ROOT manifest: the accounting is keyed per (owner, script). - `scripts/pm/dispatch-gates.mjs`: `selfTestPlannedStatus` is a genuine battery, so its fixtures SHOULD be masked away from watch-hint extraction. The docblock's TOTAL / GENUINE / distinct-spelling counts are pinned against the table and move with the row. Its two UNPINNED neighbours (the corpus totals) were already stale on origin/main at 253/223 against a measured 273/243; restated to the freshly measured 275/244 so the paragraph's own arithmetic (244 + 31 = 275) stays true rather than being broken by this row. Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude --- scripts/pm/dispatch-gates.mjs | 13 +++++++------ scripts/regen-artifacts.mjs | 27 +++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/scripts/pm/dispatch-gates.mjs b/scripts/pm/dispatch-gates.mjs index 714a556589..5d78406d67 100644 --- a/scripts/pm/dispatch-gates.mjs +++ b/scripts/pm/dispatch-gates.mjs @@ -5211,9 +5211,9 @@ const BARE_ENTRY_POINT_NAME = 'selfTest'; * * ## The census, re-derived on this tree * - * 253 code-position matches over the tracked JS/TS corpus. 223 are the bare - * `selfTest`; the remaining 30 carry compound names over 27 distinct spellings, - * and they are the rows below. Nineteen are genuine self-test batteries — the + * 275 code-position matches over the tracked JS/TS corpus. 244 are the bare + * `selfTest`; the remaining 31 carry compound names over 28 distinct spellings, + * and they are the rows below. Twenty are genuine self-test batteries — the * anchor firing on them is the anchor working. ELEVEN are production code: * * scripts/check-self-test-wired.mjs carriesSelfTest @@ -5240,7 +5240,7 @@ const BARE_ENTRY_POINT_NAME = 'selfTest'; * and it is exactly the kind of claim that stops being true without anything * going red, which is what the pin in this module's self-test exists to catch. * - * The same measurement, redone over the table's current nineteen genuine rows, + * The same measurement, redone over the table's current twenty genuine rows, * is still NOT zero, and that asymmetry is what makes the classification * load-bearing rather than decorative: `fixtureSelfTest` drops * `packages/spec/spec-changes.json` and `prePushIsArmedSelfTest` drops @@ -5269,8 +5269,8 @@ const BARE_ENTRY_POINT_NAME = 'selfTest'; * wider — and it would make the tool's self-scan differ from every other * scan, which is a hazard of its own. * - * ⇒ What ships is neither. The anchor keeps firing on all 30, the mask keeps - * blanking all 30, and the cost of the eleven accidental ones is MEASURED on + * ⇒ What ships is neither. The anchor keeps firing on all 31, the mask keeps + * blanking all 31, and the cost of the eleven accidental ones is MEASURED on * every run instead of asserted in prose. Silence was the defect; the remedy is * noise on the day it starts costing something. * @@ -5306,6 +5306,7 @@ const COMPOUND_ANCHOR_LEDGER = [ ['scripts/check-platform-checklist.mjs', 'selfTestMetaCallSpelling', false], ['scripts/check-platform-checklist.mjs', 'selfTestLineCitationBinding', false], ['scripts/check-platform-checklist.mjs', 'selfTestSymbolAnchors', false], + ['scripts/check-platform-checklist.mjs', 'selfTestPlannedStatus', false], ['scripts/check-regen-pending.mjs', 'fixtureSelfTest', false], ['scripts/check-regen-pending.mjs', 'prePushIsArmedSelfTest', false], ['scripts/check-regen-pending.mjs', 'decisionTableSelfTest', false], diff --git a/scripts/regen-artifacts.mjs b/scripts/regen-artifacts.mjs index 6bf2481617..fe69ba87ce 100644 --- a/scripts/regen-artifacts.mjs +++ b/scripts/regen-artifacts.mjs @@ -855,6 +855,33 @@ export const NOT_DRIVER_MANAGED = Object.freeze([ + 'ledger — recorded so that "no disposition" is not confused with "not yet decided". Same ' + 'expiry clause as the entry above: committing it turns this entry red.', }, + { + // ⚠️ NOT a directory of this repository, and by ruling never one. The + // generator's `--out ` is a runner tempdir whose contents are pushed + // to the repository WIKI — a separate git repo that this driver, this + // ledger and the merge queue all sit outside of. + path: 'wiki/**', + gen: 'gen:checklist-status', + // The ROOT manifest defines this script, not `packages/spec` — and the + // accounting is keyed per (owner, script), so leaving this to the default + // owner records the disposition against a manifest that has no such script + // and leaves the real one unaccounted. Both halves red at once, which is + // the two-way reconciliation working. + owner: ROOT_OWNER, + untracked: true, + why: + 'writes NOTHING into this repository. `gen:checklist-status` prints the per-area ' + + 'active/planned census to stdout and, with `--out `, renders the wiki page set into ' + + 'that directory; `.github/workflows/checklist-status.yml` publishes it to the repository ' + + 'wiki on a schedule. So "discard both sides and re-run the generator" is not a question ' + + 'that arises — git never merges these pages, and the wiki is regenerated wholesale every ' + + 'run. Recorded rather than omitted because the ALTERNATIVE was considered and REFUSED on ' + + 'the card: a `STATUS.md` committed to `docs/qa/platform-checklist/` paired with a ' + + '`check:checklist-status` gate, which is exactly the routed-artifact shape this ledger is ' + + 'full of, and which a reader may well assume happened here. Same expiry clause as the two ' + + 'entries above: the day any of these pages is committed to this tree, this entry turns red ' + + 'and a real disposition is owed.', + }, { path: 'docs/audits/**', why: From b835196bc1a32805df1974b4d8651f3e89d41bca Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 19 Sep 2026 02:48:43 +0000 Subject: [PATCH 4/5] fix(platform-checklist): drop the dangling ADR citation, pin both call sites, narrow the `since` claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four findings from the Tier S contract review, carried in one push. F1 (BLOCKING) — `docs/qa/platform-checklist/README.md` cited `ADR-0136`, which names no record under `docs/adr/` (the registry stops at 0135). A bare `ADR-NNNN` resolves against THIS repo, so `check:adr-anchors` exited 1 and took the required `Lint & Repo Gates` context with it, leaving 169 gates never run. The citation came from the card body and was copied; the fix is still owed here. ⛔ No number is invented and nothing is allowlisted: the lesson is stated self-containedly instead, which is what a rule in this tree owes anyway. F3 — the two rules ride on two call sites that nothing pinned. Both were severed and measured: `--self-test` stayed at 207/207 green, the live gate stayed green, and a fixture kind whose ONLY item is planned stayed green. A pure function's battery cannot see whether anything calls it, and the subject here IS a ratchet, so an unpinned binding is a ratchet that switches off without a number moving. `statusBindingProblems` reads this file's own source and is driven ON and OFF over one text, the way the line-citation limb below already pins its binding. The first draft of it shipped a decoy — its severing needles were plain string literals the predicate matched instead of the real call sites, so both OFF legs read as passes. The OFF legs caught it; the predicate now demands EXACTLY ONE occurrence and the needles arrive in halves. F2 — `since` is checked for SHAPE only. This ledger holds no release timeline, so a planned item targeting an already-shipped release passes. The README, the refusal message and the docblock now say that, and two rows pin the limit in the direction it deliberately does not go. F4 — the hand-typed `264` in the self-test prose is derived from the ledger. Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude --- docs/qa/platform-checklist/README.md | 10 +- scripts/check-platform-checklist.mjs | 194 ++++++++++++++++++++++++--- 2 files changed, 183 insertions(+), 21 deletions(-) diff --git a/docs/qa/platform-checklist/README.md b/docs/qa/platform-checklist/README.md index d5cfa4a248..7a3d77783d 100644 --- a/docs/qa/platform-checklist/README.md +++ b/docs/qa/platform-checklist/README.md @@ -304,9 +304,11 @@ listed capability had nowhere to live: the backlog sweep had nothing to point a at, and the platform's implementation status lived in nobody's head. The North Star's definition line (「做出来的是什么」…「缺任何一样就不是这个应用」) makes such a piece a **requirement**, so it belongs on the ledger — as a fourth status, not as a second -document. A separate feature list would drift against this one with no gate able to say -which is wrong (the ADR-0136 lesson), and the reading entry carries numbers only because -a command produces them. +document. A separate feature list drifts against this one the moment either is edited, +and nothing can then say which of the two is wrong: there is no ratchet between two +hand-maintained documents, only a pair of disagreeing readings. Hence one ledger, one id +space, one more status — and a reading entry that carries numbers only because a command +produces them. **`status: "planned"`** — the definition requires this capability and the platform does not yet implement or verify it. What a planned item carries, and what it deliberately @@ -316,7 +318,7 @@ does not: |:---|:---| | `id` · `title` · `priority` · `surface` · `revision` · `history` | as for any item — an id is picked once and is immutable, so it can be pointed at from the day the gap is found | | `personas` | **required** — who the capability is for is knowable the day the gap is found, and is what makes the gap readable to the next sweep | -| `since` | `null` (no target release chosen) **or the TARGET release** — never a release that already shipped without it | +| `since` | `null` (no target release chosen) **or a TARGET release**. ⛔ Only the SHAPE is checked: this ledger holds no release timeline, so a target naming a release that already shipped is an authoring error no gate here can see | | `steps` | **none.** There is nothing to drive. Steps arrive in the PR that implements the capability, in the same edit that promotes the item | | `acceptance` | not required — no oracle can be consulted yet. Clauses drafted early are still validated | diff --git a/scripts/check-platform-checklist.mjs b/scripts/check-platform-checklist.mjs index af7ab50717..e490e592f6 100644 --- a/scripts/check-platform-checklist.mjs +++ b/scripts/check-platform-checklist.mjs @@ -158,6 +158,14 @@ const RELEASE_RE = /^v\d+(\.\d+)?$/; * exchange it REQUIRES `personas`: who the capability is for is what makes a * gap readable to the next sweep, and it is knowable the day the gap is found. * + * ⚠️ On `since` the claim is kept as narrow as the enforcement. What is checked + * is the SHAPE — `null`, or a release-looking string. Whether the release named + * has already SHIPPED is not checkable here and is not checked: this ledger + * holds no release timeline and `RELEASE_RE` is a spelling rule. A planned item + * targeting a release that is already out is an authoring error, and it passes. + * Saying otherwise in the refusal would advertise a check that does not exist, + * which is the one thing a refusal must never do. + * * @param {{status?: string, since?: unknown, steps?: unknown, personas?: unknown}} item * @returns {string[]} */ @@ -168,7 +176,7 @@ function statusFieldProblems(item) { if (item.status === 'planned') { if (!(item.since === null || isRelease)) { - problems.push('"since" on a planned item must be null (no target release chosen yet) or the TARGET release, e.g. "v18" — never a release that already shipped without it'); + problems.push('"since" on a planned item must be null (no target release chosen yet) or a TARGET release, e.g. "v18". ⛔ Only the SHAPE is checked here: this ledger holds no release timeline, so a target naming a release that already shipped PASSES and is an authoring error no gate can see'); } if (hasSteps) { problems.push('a planned item carries NO "steps" — there is nothing to drive yet. Steps arrive in the PR that implements the capability, in the same edit that promotes it to "active"'); @@ -229,6 +237,82 @@ function coverageEntryProblems(ids, statusOf) { return { problems, bearing }; } +/** + * ⭐ The BINDING of the two predicates above into the walks that judge the + * ledger, read out of this file's own source so it can be driven ON and OFF. + * + * ## The hole this closes, measured rather than supposed + * + * `statusFieldProblems` and `coverageEntryProblems` are pure, which is what + * lets the battery drive them on fixtures — and a pure function's battery says + * NOTHING about whether anything calls it. Both severings were run on this + * file: + * + * - revert the coverage call site to the pre-`planned` loop so + * `coverageEntryProblems` is never called → `--self-test` exit 0 with all + * 207 assertions passing, the live gate green, and a fixture kind whose + * ONLY item is planned green too; + * - drop `statusFieldProblems(item)` from the item walk → `--self-test` + * exit 0 with 207, and an ACTIVE item carrying `since: null` and no + * `steps` green. + * + * ⇒ a severed call site left every instrument in this file reporting success. + * That is worse here than it would be almost anywhere else: the subject of + * these two functions IS a ratchet, so a ratchet whose binding nothing pins is + * one that can be switched off without a single number moving. + * + * The remedy is the one the line-citation limb already uses below — a source + * read driven BOTH ways over ONE text, so a green is the binding holding and + * not a read that matched everything, or nothing. + * + * ⚠️ This pin is SPELLING-SENSITIVE on purpose, and that is its whole cost: + * rewording a call site reds it. ⛔ The repair is to update the pinned spelling + * in the same edit — ⛔ never to delete the row, which is indistinguishable + * from severing the call it guards. + * + * @param {string} source this module's own text + * @returns {string[]} one message per binding that is not present + */ +function statusBindingProblems(source) { + const problems = []; + /** + * EXACTLY ONE occurrence, not "at least one" — and the second direction is + * the one this was rewritten for. The OFF legs below sever a call site over + * a copy of this text, so anything that leaves a SECOND literal copy of a + * pinned spelling anywhere in this file (a needle written out longhand, a + * commented-out draft, a doc example) keeps this check green after the real + * call is gone. That is a decoy, and the first draft of this very function + * shipped one: its severing needles were plain string literals, the predicate + * matched THOSE, and both OFF legs read as passes. The OFF legs caught it. + */ + const bound = (re, what) => { + const hits = source.match(re)?.length ?? 0; + if (hits === 1) return; + problems.push( + hits === 0 + ? what + : `${what} — and this spelling occurs ${hits} times in the file; a second literal copy of a pinned call site is a DECOY that holds this check green after the real one is severed`, + ); + }; + bound( + /for \(const msg of statusFieldProblems\(item\)\) where\(msg\);/g, + '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', + ); + bound( + /const \{ problems, bearing \} = coverageEntryProblems\(entry\.items, statusOf\);/g, + 'the capability-coverage limb does not call `coverageEntryProblems(entry.items, statusOf)` — the planned-items-are-not-coverage rule is then declared and never applied', + ); + bound( + /for \(const msg of problems\) err\('coverage\.json', kind, msg\);/g, + 'the capability-coverage limb computes `problems` and never reports them — an UNMAPPED kind is then found and swallowed', + ); + bound( + /if \(bearing > 0\) mappedCount\+\+;/g, + 'the capability-coverage limb does not gate `mappedCount` on `bearing` — a kind mapped only to planned items is then counted as covered on the OK line', + ); + return problems; +} + const errors = []; const err = (file, id, msg) => errors.push(`${file}${id ? ` · ${id}` : ''}: ${msg}`); @@ -932,7 +1016,13 @@ const SELF_TEST_BATTERIES = Object.freeze({ // nothing but these fixtures can tell a working ratchet rule from a deleted // one — the unreferenced-recipe argument, applied to a rule whose subject // population is empty on purpose rather than by luck. - [BATTERY_PLANNED_STATUS]: 28, + // + // 28 → 38: the fixtures above drive two PURE functions and so could say + // nothing about whether anything CALLS them. Both call sites were severed and + // measured green at 207/207, so the G-rows pin the BINDINGS by a source read + // driven ON and OFF, and two F-rows pin the `since` rule's own limit in the + // direction it deliberately does not go. + [BATTERY_PLANNED_STATUS]: 38, }); const SELF_TEST_BATTERY_FLOOR = 7; @@ -2135,6 +2225,21 @@ function selfTestPlannedStatus() { if (!ok) failures.push(`${what}${note ? ` — ${note}` : ''}`); }; + // ── the live ledger, read FIRST so every count below is derived ─────────── + // ⛔ Read here rather than from the item walk: this battery runs before that + // walk on every invocation, and behind `--self-test` the walk never runs. + // ⛔ And nothing below may TYPE a count of this ledger. A hand-typed 264 in an + // assertion label reads false on the 265th item and nothing moves — the same + // rot the census docblock in `scripts/pm/dispatch-gates.mjs` warns about. + const liveStatuses = new Set(); + let liveItems = 0; + for (const f of readdirSync(AREAS_DIR).filter((n) => n.endsWith('.json'))) { + for (const it of JSON.parse(readFileSync(join(AREAS_DIR, f), 'utf8')).items ?? []) { + liveItems += 1; + liveStatuses.add(it.status); + } + } + // ── the accept set ──────────────────────────────────────────────────────── t('S1 `planned` is an accepted status — the widening this rule is', STATUSES.has('planned')); t('S2 the statuses that were accepted before still are — a widening that narrowed something else is a different change', @@ -2160,11 +2265,21 @@ function selfTestPlannedStatus() { t('F10 an ACTIVE item is judged exactly as before — release `since`, non-empty steps', active().length === 0, active().join('; ')); t('F11 an active item may NOT use `since: null` — the relaxation is scoped to planned', active({ since: null }).length === 1); t('F12 an active item still owes steps', active({ steps: [] }).length === 1); - t('F13 an active item owes NO personas — this battery did not widen a requirement onto the 264 live items', + t(`F13 an active item owes NO personas — this battery did not widen a requirement onto the ${liveItems} live items`, active({ personas: undefined }).length === 0); t('F14 a planned item is never asked for steps AND a release at once — the two relaxations compose', planned({ since: null, steps: undefined }).length === 0); + // ── the `since` rule's own LIMIT, pinned in the direction it does NOT go ── + // The shape is enforced; the release TIMELINE is not, because this ledger + // holds none. Recorded as an assertion rather than left to prose, so the + // unenforced direction is a measured fact — and so that anyone who later adds + // a real floor finds a row that reds and tells them to move it. + t('F15 a planned item whose `since` names an already-shipped release PASSES — the shape is all this rule checks, and that is deliberate', + planned({ since: 'v1' }).length === 0); + t('F16 and the refusal says so, so an author is never told this gate checks a timeline it cannot read', + planned({ since: 'someday' })[0]?.includes('Only the SHAPE is checked')); + // ── the coverage ratchet, both directions ──────────────────────────────── // A miniature ledger: one kind's worth of ids, each with a status. const LEDGER = new Map([ @@ -2203,26 +2318,71 @@ function selfTestPlannedStatus() { t('C10 ⛔ the bearing set does not contain `planned` — folding it in is the ONE edit that turns this ratchet into a way to green an untested kind', !COVERAGE_BEARING_STATUSES.has('planned') && !COVERAGE_BEARING_STATUSES.has('retired')); + // ── ⭐ the BINDING, driven ON and OFF over ONE text ─────────────────────── + // + // Everything above drives two PURE functions, and a pure function's battery + // cannot see whether anything calls it. Both call sites were severed and + // measured: the self-test stayed at 207/207 green, the live gate stayed + // green, and even a fixture kind whose only item is planned stayed green. + // So these rows pin the CALL SITES, the way the line-citation limb below + // pins its own binding — the ON leg says the bindings are there, and each + // OFF leg severs exactly one of them over a COPY of this source and requires + // the predicate to notice. Without the OFF legs this would be a check that + // can never fail, which is the thing it exists to refuse. + const OWN_SOURCE = readFileSync(new URL(import.meta.url).pathname, 'utf8'); + /** + * Sever ONE spelling over a COPY; `changed` is what says the cut landed. + * + * ⛔ The needle arrives in TWO halves and is joined here, and that is not + * style: written out longhand it would be a second literal copy of the very + * call site being pinned, sitting in this file forever. `statusBindingProblems` + * counts occurrences precisely so such a decoy reds — and the split keeps this + * battery from being the thing that trips it. Each break falls INSIDE an + * identifier, so no contiguous copy exists in the source at rest. + */ + const sever = (head, tail) => { + const text = OWN_SOURCE.replace(head + tail, '/* severed for the OFF leg */'); + return { text, changed: text !== OWN_SOURCE }; + }; + + t('G1 ON — every binding these rules ride on is present in this file', + statusBindingProblems(OWN_SOURCE).length === 0, + statusBindingProblems(OWN_SOURCE).join(' | ')); + + const offWalk = sever('for (const msg of statusField', 'Problems(item)) where(msg);'); + t('G2 the item-walk severing really landed on a copy — an anchor that missed would make G3 a pass about nothing', offWalk.changed); + t('G3 OFF — with `statusFieldProblems(item)` gone from the walk the binding check FIRES. Measured before this row existed: that severing left `--self-test` at 207/207 and an ACTIVE item with `since: null` and no `steps` green', + statusBindingProblems(offWalk.text).some((p) => p.includes('statusFieldProblems(item)')), + statusBindingProblems(offWalk.text).join(' | ')); + + const offCov = sever('const { problems, bearing } = coverageEntry', 'Problems(entry.items, statusOf);'); + t('G4 the coverage-call severing really landed on a copy', offCov.changed); + t('G5 OFF — with `coverageEntryProblems` never called the binding check FIRES. Measured before this row existed: that severing left the live gate green on a kind whose ONLY item is planned', + statusBindingProblems(offCov.text).some((p) => p.includes('coverageEntryProblems')), + statusBindingProblems(offCov.text).join(' | ')); + + const offReport = sever("for (const msg of problems) err('cover", "age.json', kind, msg);"); + t('G6 OFF — a coverage limb that computes the problems and never reports them FIRES: found and swallowed is not found', + offReport.changed && statusBindingProblems(offReport.text).some((p) => p.includes('swallowed'))); + + const offCount = sever('if (bearing > 0) mapped', 'Count++;'); + t('G7 OFF — an ungated `mappedCount` FIRES: a kind mapped only to promises would otherwise be counted as covered on the OK line', + offCount.changed && statusBindingProblems(offCount.text).some((p) => p.includes('mappedCount'))); + + t('G8 CONTROL — the same read reaches this file and finds a landmark that is NOT one of the four pinned spellings, so G1 is the bindings holding rather than a read that matches anything it is handed', + OWN_SOURCE.length > 10000 && /const COVERAGE_BEARING_STATUSES = new Set/.test(OWN_SOURCE), + `${OWN_SOURCE.length} bytes read`); + // ── the live control ────────────────────────────────────────────────────── - // The fixtures above prove the rule; this reads the ledger the gate actually - // validates and proves the rule is pointed at IT. Every assertion above would + // The fixtures prove the rules; this reads the ledger the gate actually + // validates and proves they are pointed at IT. Every assertion above would // pass just as well against a `planned` no area file could ever carry. - // ⛔ Read here rather than from the item walk below: this battery runs before - // that walk on every invocation, and behind `--self-test` the walk never runs. - const liveStatuses = new Set(); - let liveItems = 0; - for (const f of readdirSync(AREAS_DIR).filter((n) => n.endsWith('.json'))) { - for (const it of JSON.parse(readFileSync(join(AREAS_DIR, f), 'utf8')).items ?? []) { - liveItems += 1; - liveStatuses.add(it.status); - } - } t('L1 every status on the live ledger is one this gate accepts — the control that says the assertions above are about THIS ledger', liveItems > 0 && [...liveStatuses].every((s) => STATUSES.has(s)), `${liveItems} items, statuses: ${[...liveStatuses].sort().join(', ')}`); plannedStatusReachedVerdict = true; - return { checked, failures }; + return { checked, failures, liveItems }; } if (process.argv.slice(2).includes('--self-test')) { @@ -2258,7 +2418,7 @@ if (process.argv.slice(2).includes('--self-test')) { ' 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;' + ' 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;' + ' 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;' + - ' and the `planned` status is driven on fixtures rather than on a ledger that carries none of it — the accept set widened without losing its closure, `since: null`/no-steps/personas relaxed for planned alone while the 264 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 \`planned\` status is driven on fixtures rather than on a ledger that carries none of it — 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 driven ON and OFF, because severing either one left this very self-test green.`, ); process.exit(0); } From 51792f85d2010dee76ba2327283a997ee517da5c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 19 Sep 2026 03:46:14 +0000 Subject: [PATCH 5/5] fix(platform-checklist): count the binding pin over comment-MASKED source, and disclose what it still cannot see MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F3, third pass. The pin demanded exactly one occurrence of each call site and counted RAW source, so the most ordinary severing gesture there is walked straight past it: commenting the call out IN PLACE left the commented line as the one occurrence, and `--self-test` stayed at 217/217 with the live gate at 264 active. The nastier variant did the same while inverting behaviour — comment the `mappedCount` gate out, add an ungated increment below it, and a kind mapped only to planned items is counted as covered on the OK line. The remedy is one line: `maskComments`, already imported in this file and already used twice in it, applied before counting. Masking also settles the decoy rule in the only consistent direction — a copy inside a comment is neither a call site nor a decoy, so it must neither satisfy the count nor inflate it. ⛔ And the claim is narrowed to the enforcement, which is the part that is not optional. This is a TEXT pin. It answers "is this call site still written, in live code, exactly once?" and it is NOT proof the call executes: shadowing, a call moved into a never-called helper, and a call left under a dead branch all keep the spelling intact and this pin reports nothing. The docblock says so and G12 asserts it, so nobody reads a green G1 as more than it is. Closing those needs the walk driven over a fixture ledger — a root knob or the walk factored into a callable — which is not built here by the seat's scope call. G9–G11 keep the mask: reverting it to a raw count reds those three rows and nothing else. The OFF legs' first-occurrence anchoring is noted where they are written. Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude --- scripts/check-platform-checklist.mjs | 140 +++++++++++++++++++++++---- 1 file changed, 123 insertions(+), 17 deletions(-) diff --git a/scripts/check-platform-checklist.mjs b/scripts/check-platform-checklist.mjs index e490e592f6..e1f4d4af10 100644 --- a/scripts/check-platform-checklist.mjs +++ b/scripts/check-platform-checklist.mjs @@ -265,33 +265,77 @@ function coverageEntryProblems(ids, statusOf) { * read driven BOTH ways over ONE text, so a green is the binding holding and * not a read that matched everything, or nothing. * + * ## ⛔ WHAT THIS PIN CANNOT SEE — read this before trusting it + * + * It is a TEXT pin over comment-masked source. It answers one question — "is + * this call site still written, in live code, exactly once?" — and ⛔ it is not + * a proof that the call EXECUTES. Three ordinary severings walk straight past + * it, and all three were measured leaving `--self-test` at exit 0 and the live + * gate at exit 0 on this file: + * + * - **shadowing** — `const statusFieldProblems = () => [];` above the call, + * which stays written and starts returning nothing; + * - **a dead helper** — the call moved into a function nobody invokes; + * - **a dead branch** — the call left under a condition that never holds. + * + * Those are SEMANTIC, and no text pin can reach them: the spelling is intact in + * every one. Closing them needs the walk driven over a fixture ledger, which + * needs a root knob (`AREAS_DIR` is fixed from `import.meta.url`) or the walk + * factored into a callable. That is deliberately NOT built here, and this + * paragraph is the disclosure that makes the omission a recorded trade rather + * than an implied guarantee. ⛔ Do not describe this function as proving the + * bindings execute. + * + * What masking DOES close is the form that defeated the first version of this + * pin: **commenting the call out in place**. The commented line was the one + * occurrence, the raw-source count read 1, and everything stayed green — the + * exact "commented-out draft" this function's own decoy note already named. + * `maskComments` is applied before counting for that reason, and three OFF legs + * below drive it. + * * ⚠️ This pin is SPELLING-SENSITIVE on purpose, and that is its whole cost: * rewording a call site reds it. ⛔ The repair is to update the pinned spelling * in the same edit — ⛔ never to delete the row, which is indistinguishable * from severing the call it guards. * - * @param {string} source this module's own text - * @returns {string[]} one message per binding that is not present + * Readings quoted above were taken on `claude/issue-19157-checklist-planned-status` + * at `b835196bc1` (this repo) — a count without the tree it came from is not a + * reading. + * + * @param {string} source this module's own text, raw; comments are masked here + * @returns {string[]} one message per binding that is not present in LIVE code */ function statusBindingProblems(source) { const problems = []; + // ⭐ Comments are masked BEFORE counting, and that one call is the whole + // difference between a pin that fires on a commented-out call site and one + // that does not. It also fixes the decoy rule's own blind spot in the right + // direction: a copy of a pinned spelling sitting in a comment is not a live + // call site, so it must neither satisfy the count nor inflate it. + // ⛔ Do not switch this back to raw `source` to make a reword green. + const live = maskComments(String(source)); /** - * EXACTLY ONE occurrence, not "at least one" — and the second direction is - * the one this was rewritten for. The OFF legs below sever a call site over - * a copy of this text, so anything that leaves a SECOND literal copy of a - * pinned spelling anywhere in this file (a needle written out longhand, a - * commented-out draft, a doc example) keeps this check green after the real - * call is gone. That is a decoy, and the first draft of this very function - * shipped one: its severing needles were plain string literals, the predicate - * matched THOSE, and both OFF legs read as passes. The OFF legs caught it. + * EXACTLY ONE occurrence in LIVE code, not "at least one" — and the second + * direction is the one this was rewritten for. The OFF legs below sever a + * call site over a copy of this text, so anything that leaves a SECOND + * literal copy of a pinned spelling in live code (a needle written out + * longhand, a doc example in a template string) keeps this check green after + * the real call is gone. That is a decoy, and the first draft of this very + * function shipped one: its severing needles were plain string literals, the + * predicate matched THOSE, and both OFF legs read as passes. The OFF legs + * caught it. + * + * A copy inside a COMMENT is neither a decoy nor a call site — masking removes + * it from both sides of the count, which is the only consistent reading: the + * same commented line must not satisfy the rule when the real call is gone. */ const bound = (re, what) => { - const hits = source.match(re)?.length ?? 0; + const hits = live.match(re)?.length ?? 0; if (hits === 1) return; problems.push( hits === 0 ? what - : `${what} — and this spelling occurs ${hits} times in the file; a second literal copy of a pinned call site is a DECOY that holds this check green after the real one is severed`, + : `${what} — and this spelling occurs ${hits} times in LIVE code; a second literal copy of a pinned call site is a DECOY that holds this check green after the real one is severed`, ); }; bound( @@ -1019,10 +1063,17 @@ const SELF_TEST_BATTERIES = Object.freeze({ // // 28 → 38: the fixtures above drive two PURE functions and so could say // nothing about whether anything CALLS them. Both call sites were severed and - // measured green at 207/207, so the G-rows pin the BINDINGS by a source read - // driven ON and OFF, and two F-rows pin the `since` rule's own limit in the - // direction it deliberately does not go. - [BATTERY_PLANNED_STATUS]: 38, + // measured green at 207/207 (this branch, `22453417e1`), so the G-rows pin the + // BINDINGS by a source read driven ON and OFF, and two F-rows pin the `since` + // rule's own limit in the direction it deliberately does not go. + // + // 38 → 42: that first pin counted RAW source, so commenting a pinned call out + // IN PLACE left it green — the commented line was the one occurrence. Counting + // over comment-MASKED source closes it; G9–G11 are what keep the mask, and + // G12 records in an assertion what the pin still cannot see. Measured on this + // branch at `b835196bc1`: reverting the mask reds G9, G10 and G11 and nothing + // else. + [BATTERY_PLANNED_STATUS]: 42, }); const SELF_TEST_BATTERY_FLOOR = 7; @@ -2339,11 +2390,27 @@ function selfTestPlannedStatus() { * counts occurrences precisely so such a decoy reds — and the split keeps this * battery from being the thing that trips it. Each break falls INSIDE an * identifier, so no contiguous copy exists in the source at rest. + * + * ⚠️ ORDER-SENSITIVE: `String.replace` with a string needle cuts the FIRST + * occurrence. Were a decoy copy ever to appear ABOVE the real call site, the + * cut would land on the decoy and the real call would survive — the leg still + * reds, but through the exactly-one row rather than the one it was written + * for. ⛔ Read the failure TEXT of a red leg, never just its exit code. */ const sever = (head, tail) => { const text = OWN_SOURCE.replace(head + tail, '/* severed for the OFF leg */'); return { text, changed: text !== OWN_SOURCE }; }; + /** + * Comment a line out IN PLACE — the severing gesture a RAW-text count misses + * entirely, because the commented line is still the one occurrence. Same + * two-half needle and the same first-occurrence caveat as `sever`. + */ + const commentOut = (head, tail) => { + const needle = head + tail; + const text = OWN_SOURCE.replace(needle, `// ${needle}`); + return { text, changed: text !== OWN_SOURCE }; + }; t('G1 ON — every binding these rules ride on is present in this file', statusBindingProblems(OWN_SOURCE).length === 0, @@ -2373,6 +2440,45 @@ function selfTestPlannedStatus() { OWN_SOURCE.length > 10000 && /const COVERAGE_BEARING_STATUSES = new Set/.test(OWN_SOURCE), `${OWN_SOURCE.length} bytes read`); + // ── the COMMENT-OUT forms, which a raw-text count misses entirely ───────── + // + // Measured on this file before masking landed: commenting a pinned call out + // IN PLACE left `--self-test` at exit 0 with all 217 assertions passing AND + // the live gate at exit 0 over 264 active items, because the commented line + // IS the one occurrence a raw count finds. `maskComments` is what closes it, + // and these rows are what keep it closed — reverting the mask reds G9–G11 + // instead of quietly restoring the hole. + const outWalk = commentOut('for (const msg of statusField', 'Problems(item)) where(msg);'); + t('G9 OFF — commenting the item-walk call out IN PLACE FIRES, because the count is taken over comment-MASKED source', + outWalk.changed && statusBindingProblems(outWalk.text).some((p) => p.includes('statusFieldProblems(item)')), + statusBindingProblems(outWalk.text).join(' | ')); + + // ⭐ The nastiest of the set: comment the GATE out and add an ungated + // increment below it. The spelling survives in the comment, the behaviour + // inverts, and a kind mapped only to planned items is counted as covered on + // the OK line — silently, in the one number a reader trusts. + const gateLine = `if (bearing > 0) mapped${'Count++;'}`; + const ungated = `mapped${'Count++;'}`; + const outGateThenAdd = OWN_SOURCE.replace(gateLine, `// ${gateLine}\n ${ungated}`); + t('G10 OFF — commenting the `mappedCount` gate out and adding an UNGATED increment in its place FIRES: the spelling survives in the comment while the behaviour inverts, and a kind mapped only to planned items would be counted as covered on the OK line', + outGateThenAdd !== OWN_SOURCE + && outGateThenAdd.includes(`// ${gateLine}`) + && maskComments(outGateThenAdd).includes(ungated) + && statusBindingProblems(outGateThenAdd).some((p) => p.includes('mappedCount')), + statusBindingProblems(outGateThenAdd).join(' | ')); + + t('G11 a copy of a pinned spelling inside a COMMENT neither satisfies the rule nor inflates it — the same masked line must not stand in for a call site that is gone', + statusBindingProblems(`${OWN_SOURCE}\n// for (const msg of statusField${'Problems(item)) where(msg);'}`).length === 0 + && statusBindingProblems(`${outWalk.text}\n// a second commented copy changes nothing`).some((p) => p.includes('statusFieldProblems(item)'))); + + // ⛔ And the disclosure, asserted rather than left to the docblock: the three + // SEMANTIC severings this pin cannot see. Each keeps the spelling intact, so + // the predicate reports no problem — that is the honest answer, and the row + // exists so nobody reads a green G1 as "the bindings execute". + const shadowed = `const statusFieldProblems = () => [];\n${OWN_SOURCE}`; + t('G12 DISCLOSED LIMIT — a shadowing redefinition leaves the spelling intact and this pin reports NOTHING. It is a TEXT pin; ⛔ never read it as proof the call executes', + statusBindingProblems(shadowed).length === 0); + // ── the live control ────────────────────────────────────────────────────── // The fixtures prove the rules; this reads the ledger the gate actually // validates and proves they are pointed at IT. Every assertion above would @@ -2418,7 +2524,7 @@ if (process.argv.slice(2).includes('--self-test')) { ' 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;' + ' 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;' + ' 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;' + - ` and the \`planned\` status is driven on fixtures rather than on a ledger that carries none of it — 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 driven ON and OFF, because severing either one left this very self-test green.`, + ` and the \`planned\` status is driven on fixtures rather than on a ledger that carries none of it — 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.`, ); process.exit(0); }