From 2c9c1a52826b72709c00b5da61a4c4081b65577f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 01:03:30 +0000 Subject: [PATCH] tooling(pm): derive the line ratchet's lane roster from the lanes/ directory The self-test's lane-roster case was an `.every` over eight literal lane names, so the ninth lane file was pinned by nothing: a ceiling row put there for it kept the case green without the case ever naming it, and deleting that row again was caught only by the map-wide cases, which say nothing about a row that is simply gone. The roster is now read from the directory (`readdirSync`, every `*.md`, sorted) and held against the ceiling map, so the tree and the map are two independent sources checked against one another. A new lane file is pinned by construction: no list to extend, and no count in the label to keep in step with the list. A derived roster has one failure mode of its own, and it is the same shape as the defect above -- `[].every(...)` is `true`, so an unreadable directory would report perfect coverage of nothing. The verdict refuses an empty roster outright, and each red path carries a fixture case beside the live one, because a red path that stopped working runs green forever. Moves named: the pinned case label and its expectation-table entry move together (duplicate-label refusal); one case becomes three, so the self-test runs 155 -> 157 cases and SELF_TEST_BATTERY_FLOOR moves 155 -> 157, with the two prose readings of that same number moved with it. No ceiling row changed; no other file. Claude-Session: https://claude.ai/code/session_019RfFHiRCSs3JXLK4cwcfox Co-authored-by: Claude --- scripts/pm/check-skill-line-ratchet.mjs | 81 +++++++++++++++++++++++-- 1 file changed, 75 insertions(+), 6 deletions(-) diff --git a/scripts/pm/check-skill-line-ratchet.mjs b/scripts/pm/check-skill-line-ratchet.mjs index 180e469559..b7db6099e4 100644 --- a/scripts/pm/check-skill-line-ratchet.mjs +++ b/scripts/pm/check-skill-line-ratchet.mjs @@ -253,7 +253,7 @@ * choose between preserving a defect and hiding it. */ -import { readFileSync } from 'node:fs'; +import { readdirSync, readFileSync } from 'node:fs'; import process from 'node:process'; import { isEntrypoint } from '../invoked-as.mjs'; @@ -1679,14 +1679,14 @@ function run() { // case is attributed to the row actually being run. There is no `battery()` // opener: for a table-driven self-test the ROW is the battery. // -// ⭐ ALL 155 rows are floored, the four `...(() => { ... })()` spreads included. +// ⭐ ALL 157 rows are floored, the four `...(() => { ... })()` spreads included. // Those spreads were flagged in the batch-8 census as an IIFE-produced block // whose rows could not take a literal roster key. Measured here, that premise // does not hold for this file: each IIFE is a SCOPING device that declares // local fixture consts and then `return [...]`s an array of LITERAL // `[label, actual, expected]` rows. No row label is a template string, none is // computed, and no row is produced by a `map`/`push`/loop. Three independent -// readings agree on 155 -- the source labels extracted by indentation, the +// readings agree on 157 -- the source labels extracted by indentation, the // literal row starts, and the `cases.length` the green line prints on a run -- // so nothing here is the `extra`-call residue of PR #15286, and leaving any row // outside the roster would have been the lossy reading. @@ -1711,7 +1711,9 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'SKILL.md is covered': 1, 'the dev-agent definition is covered': 1, 'all five compressed references are covered': 1, - 'all eight lane/seat job descriptions are covered': 1, + 'every lane/seat job description in the tree carries a ceiling row': 1, + '...and one carrying no row is RED, with the message naming that file': 1, + '...and an EMPTY roster is RED, not vacuously green: a derived roster checks nothing when the read fails': 1, 'the other four skills are covered (#9473)': 1, 'root AGENTS.md is covered (#9792)': 1, 'root CLAUDE.md is covered (#9965)': 1, @@ -1863,7 +1865,7 @@ const SELF_TEST_BATTERIES = Object.freeze({ // the literal above, so the roster falls below this number; the table // cross-check in the floor block is the other half, and names WHICH label // collided. -const SELF_TEST_BATTERY_FLOOR = 155; +const SELF_TEST_BATTERY_FLOOR = 157; // Returned by `selfTest()` only after its verdict is printed. The dispatch // refuses anything else: a `return` that leaves the function above that line @@ -1871,8 +1873,73 @@ const SELF_TEST_BATTERY_FLOOR = 155; // as one that passed (#13798). const SELF_TEST_VERDICT = 'check-skill-line-ratchet self-test reached its verdict'; +// -- The lane roster, DERIVED from the tree (#15965) ------------------------- +// +// The case this replaced was an `.every` over eight literal lane names, so the +// ninth lane file was pinned by nothing: a ceiling row put there for it kept +// that case green without the case ever naming it, and deleting that row again +// was caught only by the map-wide cases, which say nothing about a row that is +// simply GONE. The roster is now read from the DIRECTORY, so the tree and the +// map are two independent sources checked against one another: a new lane file +// is pinned by construction, there is no list to keep in step, and no count in +// the label to keep in step with the list. (This is NOT the derivation the +// battery-floor note above refuses. That one would read the roster from the +// very table it checks, so a deleted row would delete its own floor; this one +// reads a DIFFERENT source from the one it checks, which is the whole repair.) +// +// Deriving a roster introduces one failure mode of its own, and it is the same +// shape as the defect above: `[].every(...)` is `true`, so a lanes/ directory +// that cannot be read would report perfect coverage of nothing. The verdict +// therefore refuses an EMPTY roster outright -- #4690's cannot-read rule, on a +// directory rather than a file. Each red path carries a fixture case beside the +// live one, because enforcement holds neither: a red path that stopped working +// runs green forever, which is the same reason the case exists at all. +const LANES_DIR = '.claude/skills/pm-dispatch/references/lanes/'; + +/** + * The lane/seat job descriptions present in the tree, sorted. An unreadable + * directory yields `[]`, which the verdict below reads as RED. + * + * @returns {string[]} + */ +export function laneFilesOnDisk() { + try { + return readdirSync(new URL(LANES_DIR, REPO_ROOT)).filter((f) => f.endsWith('.md')).sort(); + } catch { + return []; + } +} + +/** + * Every lane file in the tree carries a ceiling row keyed by its repo-relative + * path -- the key spelling `CEILINGS` itself uses, forward slashes and all. + * + * @param {string[]} laneFiles lane file names, as read from the tree + * @param {Map} ceilings the ceiling map to hold them against + * @returns {{ok: boolean, msg: string}} + */ +export function laneRosterVerdict(laneFiles, ceilings) { + if (laneFiles.length === 0) { + return { + ok: false, + msg: `${LANES_DIR} yielded no *.md lane job description — an empty roster checks nothing (every() over nothing is true), so it is red, not a skip.`, + }; + } + const uncovered = laneFiles.filter((f) => !ceilings.has(`${LANES_DIR}${f}`)); + if (uncovered.length > 0) { + return { + ok: false, + msg: `${uncovered.map((f) => `${LANES_DIR}${f}`).join(', ')} — lane job description(s) in the tree carrying no ceiling row, so the ratchet does not read them at all. Give each one a row keyed by that path, or take the lane file out of the tree.`, + }; + } + return { ok: true, msg: `all ${laneFiles.length} lane/seat job descriptions in the tree carry a ceiling row.` }; +} + function selfTest() { const rel = '.claude/skills/pm-dispatch/SKILL.md'; + // Fixture for the derived roster's naming red path: a tree that carries + // triage.md, against a map holding a row for engine.md only. + const laneGap = laneRosterVerdict(['engine.md', 'triage.md'], new Map([[`${LANES_DIR}engine.md`, 32]])); const cases = [ ['under the ceiling -> green', verdict(rel, 2900, 3050).ok, true], ['at the ceiling -> green', verdict(rel, 3050, 3050).ok, true], @@ -1885,7 +1952,9 @@ function selfTest() { ['SKILL.md is covered', CEILINGS.has('.claude/skills/pm-dispatch/SKILL.md'), true], ['the dev-agent definition is covered', CEILINGS.has('.claude/agents/os-dev.md'), true], ['all five compressed references are covered', ['dispatch-runbook', 'platform-readings', 'review-checklist', 'landing-operations', 'seat-post-protocol'].every((n) => CEILINGS.has(`.claude/skills/pm-dispatch/references/${n}.md`)), true], - ['all eight lane/seat job descriptions are covered', ['engine', 'services', 'cli', 'devx', 'skills', 'spec', 'hotcrm', 'director'].every((n) => CEILINGS.has(`.claude/skills/pm-dispatch/references/lanes/${n}.md`)), true], + ['every lane/seat job description in the tree carries a ceiling row', laneRosterVerdict(laneFilesOnDisk(), CEILINGS).ok, true], + ['...and one carrying no row is RED, with the message naming that file', !laneGap.ok && laneGap.msg.includes('triage.md'), true], + ['...and an EMPTY roster is RED, not vacuously green: a derived roster checks nothing when the read fails', laneRosterVerdict([], CEILINGS).ok, false], ['the other four skills are covered (#9473)', ['checklist-test', 'checklist-author', 'dogfood-verification', 'spec-property-retirement'].every((n) => CEILINGS.has(`.claude/skills/${n}/SKILL.md`)), true], ['root AGENTS.md is covered (#9792)', CEILINGS.has('AGENTS.md'), true], ['root CLAUDE.md is covered (#9965)', CEILINGS.has('CLAUDE.md'), true],