Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 75 additions & 6 deletions scripts/pm/check-skill-line-ratchet.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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.
Expand All @@ -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,
Expand Down Expand Up @@ -1863,16 +1865,81 @@ 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
// prints nothing and still exits 0 — a self-test that never finished, reported
// 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<string, number>} 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],
Expand All @@ -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],
Expand Down
Loading