From e90b1f1185ed8ac18d305dbadc9f790bf8767c89 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 22:20:18 +0000 Subject: [PATCH 1/2] fix(pm): give the two reason-half markers the #18422 wholeness reading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `no-check-families` and `inherited-population` captured their reason with the same `(\S.*)$`-under-`m` shape #18422 repaired for the three population markers, so the capture ended at the first newline and a wrapped reason reached its reader as a sentence that stops. Both now come out of the shared grammar and are graded by the shared wholeness reading; a continued reason is refused by name (file, line, marker, the continuation it saw). The registration is by construction over the two grammar builders rather than hand-listed, so `self-test-reads` — the sixth reason-bearing key, same defect, same file — is closed in the same line instead of becoming a third card on this file. `no-check-families` is restricted to the `#` comment form: it is read out of workflow YAML, where `#` is the only comment syntax, so the block forms #18661 added cannot apply to it. Claude-Session: https://claude.ai/code/session_01Gqi43smmqjJ5sUrhfoPeKu Co-authored-by: Claude --- scripts/pm/dispatch-gates.mjs | 343 +++++++++++++++++++++++++++------- 1 file changed, 273 insertions(+), 70 deletions(-) diff --git a/scripts/pm/dispatch-gates.mjs b/scripts/pm/dispatch-gates.mjs index e72c4d48d33..4ecd21638e2 100644 --- a/scripts/pm/dispatch-gates.mjs +++ b/scripts/pm/dispatch-gates.mjs @@ -2700,12 +2700,43 @@ export function jobFilteredSteps(entries, paths) { * The reason is REQUIRED (not just the marker) — an opt-out with no reason * reads identically to a placeholder nobody will ever revisit, and is exactly * the shape a reviewer cannot tell apart from "forgot to name a family". + * + * ## The reason is WHOLE, or the declaration is REFUSED (#18662) + * + * This marker captured its reason with the same `(\S.*)$`-under-`m` shape the + * three population markers did, so the capture ended at the FIRST NEWLINE and + * a reason an author wrapped onto the comment line below reached its reader as + * a sentence that simply stops — the #18422 defect on a marker that repair did + * not reach. Measured on `origin/main` 034f5a3afd before this change: a + * two-line reason read back as line one, `checkFamilyCoverageGaps` accepted the + * workflow, and nothing sounded. So the grammar now comes out of the shared + * builder and the read is graded by the shared wholeness reading; a continued + * reason THROWS, naming the workflow, the line, the marker and the text that + * continues it. + * + * ⚠️ Nothing in this tool RENDERS this reason (measured, #18662): the only + * consumer is `checkFamilyCoverageGaps`, which reads the return value as a + * BOOLEAN. The refusal is still owed — wholeness is a property of the + * declaration, not of today's consumer, and the next reader of the reason is + * the seat that greps the workflow for it — but the cost of a cut here is that + * seat's, not a rendered row's, and this sentence is the price said out loud + * rather than left to be discovered. + * + * ## `#` is the ONLY form here, and that is a property of YAML (#18662) + * + * The key restriction in `MARKER_KEY_FORMS` is not a narrowing of the roster: + * this marker is read out of a workflow's YAML text, where `#` is the only + * comment syntax there is. `//`, and every BLOCK form #18661 added, are not + * comments in YAML at all — a line spelled that way is document content, so + * admitting one would read a declaration off text the workflow's own parser + * never treats as a remark. ⛔ The block forms therefore cannot apply to this + * marker, and widening it to them would be a defect rather than a courtesy. */ -const NO_CHECK_FAMILIES_MARKER = /^[ \t]*#[ \t]*dispatch-gates:[ \t]*no-check-families[ \t]*--[ \t]*(\S.*)$/m; - -export function declaredNoCheckFamiliesReason(workflowText) { - const m = NO_CHECK_FAMILIES_MARKER.exec(workflowText); - return m ? m[1].trim() : null; +export function declaredNoCheckFamiliesReason(workflowText, file = null) { + const read = readPopulationMarker(workflowText, 'no-check-families'); + if (!read) return null; + refuseCutMarkerReason(read, 'no-check-families', file); + return read.reason; } /** @@ -2843,8 +2874,55 @@ const MARKER_COMMENT_FORMS = Object.freeze([ Object.freeze({ label: '*', kind: 'block', open: '\\*' }), ]); -const MARKER_LINE_HEAD = - `^[ \\t]*(${MARKER_COMMENT_FORMS.map((f) => f.open).join('|')})[ \\t]*dispatch-gates:[ \\t]*`; +/** + * The marker keys whose files are NOT JavaScript, and the comment forms those + * files really have (#18662). + * + * The roster above is the set of idioms a `dispatch-gates:` declaration may be + * written in across this tree; it is not a claim that every one of them is a + * comment in every LANGUAGE a declaration is read out of. `no-check-families` + * is read out of workflow YAML, where `#` is the only comment there is: a + * `//` or slash-star line in a workflow is document content, and reading a + * declaration off one would be reading it off text the workflow's own parser + * never treats as a remark. So the alternation this key's pattern is built + * from is the roster FILTERED to the forms its language has — never a second + * roster, and never a second pattern. + * + * A key absent from this table gets the whole roster, which is the answer for + * every marker read out of a JavaScript or shell source. + * + * ⛔ This table may only ever NARROW: it names a subset of the labels in + * `MARKER_COMMENT_FORMS`, and a label that is not one of them throws below + * rather than silently contributing nothing to the alternation — a form set + * that quietly emptied would make every declaration of that key parse as + * nothing at all, which is precisely the #18661 failure one level up. + */ +const MARKER_KEY_FORMS = Object.freeze({ + 'no-check-families': Object.freeze(['#']), +}); + +function markerFormsFor(key) { + const labels = MARKER_KEY_FORMS[key]; + if (!labels) return MARKER_COMMENT_FORMS; + const forms = MARKER_COMMENT_FORMS.filter((f) => labels.includes(f.label)); + if (forms.length !== labels.length) { + throw new Error( + `dispatch-gates: MARKER_KEY_FORMS names ${labels.length} form(s) for '${key}' and only ${forms.length} of them ` + + `are in MARKER_COMMENT_FORMS (${MARKER_COMMENT_FORMS.map((f) => f.label).join(', ')}). The restriction may only ` + + 'ever NARROW the roster — a label the roster does not carry contributes nothing to the alternation, and a ' + + 'declaration of that key would then parse as nothing at all.', + ); + } + return forms; +} + +/** + * The HEAD every marker pattern is built out of — the indent, the comment form + * (group 1) and the key — for the forms THAT key's language has. + */ +function markerLineHead(key) { + return `^[ \\t]*(${markerFormsFor(key).map((f) => f.open).join('|')})[ \\t]*dispatch-gates:[ \\t]*`; +} /** * Which KIND a captured comment form is — `line` or `block`. Read off the @@ -2864,14 +2942,30 @@ function markerFormKind(form) { return known.kind; } +/** + * The REASON-TAIL markers — the keys whose grammar is head + `-- `, + * with nothing between the key and the separator (#18662). + * + * The three population keys were the whole roster until this card, and the + * builder is still spelled `populationMarkerPattern` because `population*` is + * what this machinery is CALLED everywhere it is exported + * (`populationReasonContinuation`, `populationReasonCutRefusal`) and a rename + * would move the names a reader greps for without moving a single behaviour. + * ⚠️ The ROSTER, not the name, is the authority on which keys it serves: + * `no-check-families` has exactly this grammar and is built here rather than + * out of the fourth hand-written copy of the pattern it used to be — which is + * what left its reason outside the #18422 wholeness reading for two cards. + */ +const REASON_TAIL_MARKER_KEYS = Object.freeze([...POPULATION_MARKER_KEYS, 'no-check-families']); + function populationMarkerPattern(key) { - if (!POPULATION_MARKER_KEYS.includes(key)) { + if (!REASON_TAIL_MARKER_KEYS.includes(key)) { throw new Error( - `dispatch-gates: unknown population marker key '${key}' — known keys: ${POPULATION_MARKER_KEYS.join(', ')}. ` + + `dispatch-gates: unknown population marker key '${key}' — known keys: ${REASON_TAIL_MARKER_KEYS.join(', ')}. ` + 'A marker is added by naming it here, never by writing a fourth copy of this pattern.', ); } - return new RegExp(`${MARKER_LINE_HEAD}${key}[ \\t]*--[ \\t]*(\\S.*)$`, 'm'); + return new RegExp(`${markerLineHead(key)}${key}[ \\t]*--[ \\t]*(\\S.*)$`, 'm'); } /** @@ -2896,23 +2990,57 @@ function pathListMarkerPattern(key) { 'A marker is added by naming it here, never by writing a third copy of this pattern.', ); } - return new RegExp(`${MARKER_LINE_HEAD}${key}[ \\t]+(\\S.*?)[ \\t]+--[ \\t]+(\\S.*)$`, 'm'); + return new RegExp(`${markerLineHead(key)}${key}[ \\t]+(\\S.*?)[ \\t]+--[ \\t]+(\\S.*)$`, 'm'); } /** - * A population declaration read WHOLE — its form, its reason, and the line that - * CUTS the reason short — off ONE match (#18422, widened to the block forms by - * #18661). Pure over the source text, so the refusals below, the three - * `declared*` readers and any future caller cannot disagree about what a cut - * reason is or about where a whole one ends. - * - * Returns `{ form, kind, line, reason, cut }`, or null when this source carries - * no usable declaration of that key — no match, or a match whose reason is - * empty, which is a declaration written and dropped rather than one made. + * Which grammar every reason-bearing `dispatch-gates:` key is read by, and + * which capture group of it holds the REASON (#18662) — built BY CONSTRUCTION + * out of the two rosters above rather than hand-listed. + * + * ## Why by construction, and not a fourth hand-written table + * + * Both builders end in a reason capture, so every key either builder serves + * carries a reason that can be CUT — the #18422 defect is a property of the + * grammar, not of the three keys it was found on. A hand-listed roster here + * would be a second copy of "which keys have a reason", and it would be wrong + * in exactly the silent direction the moment a sixth key was added to either + * builder: the key would parse, the reason would capture, and nothing would + * ask whether it ended where its author did — which is how `no-check-families` + * and both path-list markers sat outside the repair until this card. Derived, + * the class is closed: a key added to either roster gets the wholeness reading + * in the same line, and cannot be added without it. + * + * Group 2 is the reason for a reason-tail key (group 1 is the form); group 3 + * is the reason for a path-list key (group 2 is the path list). + */ +const MARKER_REASON_GRAMMARS = Object.freeze(Object.fromEntries([ + ...REASON_TAIL_MARKER_KEYS.map((k) => [k, Object.freeze({ build: populationMarkerPattern, reasonGroup: 2 })]), + ...PATH_LIST_MARKER_KEYS.map((k) => [k, Object.freeze({ build: pathListMarkerPattern, reasonGroup: 3 })]), +])); + +/** + * A `dispatch-gates:` declaration read WHOLE — its form, its reason, and the + * line that CUTS the reason short — off ONE match (#18422, widened to the block + * forms by #18661 and to every reason-bearing marker key by #18662). Pure over + * the source text, so the refusals below, the five `declared*` readers and any + * future caller cannot disagree about what a cut reason is or about where a + * whole one ends. + * + * ⚠️ Still spelled `readPopulationMarker` for the reason + * `REASON_TAIL_MARKER_KEYS` states: the exported half of this machinery is + * named `population*` and a reader greps for it. `MARKER_REASON_GRAMMARS` is + * the authority on which keys it reads — today all six, population or not. + * + * Returns `{ form, kind, line, reason, cut, match }`, or null when this source + * carries no usable declaration of that key — no match, or a match whose reason + * is empty, which is a declaration written and dropped rather than one made. * `line` is the 1-based line the declaration is written on; `cut` is * `{ line, text, kind }` — the 1-based line that truncates the reason and its * text, because a refusal a reader cannot navigate to is a refusal they cannot - * act on — or null when the reason is whole. + * act on — or null when the reason is whole. `match` is the raw match, so a + * path-list reader takes its path list off the SAME read its reason came from + * and the two can never describe different declarations. * * Reads the FIRST marker of that key, exactly as the capture does: a second * declaration of one key in one file is a different defect, and every reading @@ -2923,8 +3051,16 @@ function pathListMarkerPattern(key) { * functions under this one are where that answer is executed. */ function readPopulationMarker(scriptSource, markerKey) { + const grammar = MARKER_REASON_GRAMMARS[markerKey]; + if (!grammar) { + throw new Error( + `dispatch-gates: unknown marker key '${markerKey}' — known keys: ` + + `${Object.keys(MARKER_REASON_GRAMMARS).join(', ')}. A marker is added by naming it in one of the two grammar ` + + 'rosters, never by writing another copy of this pattern.', + ); + } const text = String(scriptSource); - const m = populationMarkerPattern(markerKey).exec(text); + const m = grammar.build(markerKey).exec(text); if (!m) return null; // Counted off the SAME text the capture read, and off `m.index` rather than // by re-matching: the pattern is anchored at the line start, so the newlines @@ -2932,11 +3068,12 @@ function readPopulationMarker(scriptSource, markerKey) { const declarationLine = text.slice(0, m.index).split('\n').length; const lines = text.split('\n'); const kind = markerFormKind(m[1]); + const tail = m[grammar.reasonGroup]; const read = kind === 'block' - ? blockFormReason(lines, declarationLine, m[2]) - : lineFormReason(lines, declarationLine, m[1], m[2]); + ? blockFormReason(lines, declarationLine, tail) + : lineFormReason(lines, declarationLine, m[1], tail); if (!read.reason) return null; - return { form: m[1], kind, line: declarationLine, reason: read.reason, cut: read.cut }; + return { form: m[1], kind, line: declarationLine, reason: read.reason, cut: read.cut, match: m }; } /** @@ -3047,6 +3184,83 @@ export function populationReasonContinuation(scriptSource, markerKey, file = nul return { file: file ?? null, line: read.cut.line, text: read.cut.text, kind: read.cut.kind }; } +/** + * WHY a cut reason must be refused — the text, for ANY reason-bearing marker + * key (#18662), and the one place both sentences live. + * + * `populationReasonCutRefusal` below is this function reached through a + * discovery ENTRY, and its output is byte-identical to what it was before this + * split: the three population channels carry their reason and their cut in + * entry fields, which the other three markers have no entry to carry. Those + * three reach it with the cut in hand instead — one text, five keys, so a + * reader who has seen this refusal once has seen all of them. + * + * Returns null when `cut` is null, i.e. when the reason is whole. + */ +export function markerReasonCutRefusal(markerKey, cut) { + if (!MARKER_REASON_GRAMMARS[markerKey]) { + throw new Error( + `dispatch-gates: unknown marker key '${markerKey}' — known keys: ` + + `${Object.keys(MARKER_REASON_GRAMMARS).join(', ')}.`, + ); + } + if (!cut) return null; + const where = `${cut.file ?? 'the declaring file'}:${cut.line}`; + // A BLOCK form is cut by a different shape and takes a different repair + // (#18661), so it gets its own text rather than the line forms' advice. In a + // block the star lines under a declaration ARE the reason — the walk joins + // them — so the only way to lose half of one is to write a line the walk + // cannot see as part of the comment. Telling that author to "put the whole + // reason on the marker line" would send them to the wrong half of their + // declaration, which is precisely what this family of refusals exists not to + // do. The line forms' text below is unchanged, byte for byte. + if (cut.kind === 'block') { + return `declares ${markerKey} inside a block comment and its reason is CUT at ${where} by a line the block walk ` + + `cannot read as part of the comment: "${cut.text}". Inside a block the star-prefixed lines under a ` + + 'declaration are the same comment and are joined into the reason; a line with text and no star prefix is ' + + 'neither a continuation nor one of the endings (the closing delimiter, a blank star line, the next star-@tag, ' + + 'another dispatch-gates: key), so everything from it on is dropped and the seat is handed the declaration cut ' + + 'off mid-sentence. Give that line the block\'s star prefix, or end the reason before it with a blank star ' + + 'line. Rewrite the declaration — never route around this refusal.'; + } + return `declares ${markerKey} and its reason does not END on the marker line: ${where} continues it with ` + + `"${cut.text}". The capture stops at the FIRST NEWLINE, so the seat is handed the declaration cut off ` + + 'mid-sentence — and the reason is the one thing a seat reads off this row when deciding whether the family ' + + 'belongs on its card. Put the WHOLE reason on the marker line, however long it runs (this tree already carries ' + + 'one-line reasons past 1200 characters), and separate any comment written under the declaration with a blank ' + + 'line. ⛔ Never widen the marker to swallow the next line instead: nothing in the text tells a wrapped reason ' + + 'from an unrelated comment, so that repair would make the next paragraph part of a seat-facing reason silently. ' + + 'Rewrite the declaration — never route around this refusal.'; +} + +/** + * The cut refusal DELIVERED, for the three markers whose reason no rendering + * ever prints (#18662) — `no-check-families`, `inherited-population` and + * `self-test-reads`. + * + * ## Why a throw here, where the population markers get a row + * + * The three population channels carry their declaration into a discovery entry + * that IS rendered, so their refusal is a printed row and a red self-test case. + * These three have no such row: measured on `origin/main` 034f5a3afd, every + * production call site of all three reads only the BOOLEAN or the PATH LIST, + * and not one renders the reason to a seat or to a log. A refusal returned as + * text would therefore be a refusal returned to nobody — the exact silence + * this whole marker family exists to end. + * + * So the read itself refuses, which is already how both path-list markers + * refuse an invented path: the tool's CLI catches it, prints + * `dispatch-gates: derivation failed — ` and exits 2, so a cut + * reason reds every run of this tool rather than reaching a reader as half a + * sentence. The message names the FILE, the LINE, the MARKER and the text that + * continues it — the four a reader needs to navigate to it. + */ +function refuseCutMarkerReason(read, markerKey, file) { + if (!read?.cut) return; + const why = markerReasonCutRefusal(markerKey, { ...read.cut, file: file ?? null }); + throw new Error(`dispatch-gates: ${file ?? 'the declaring file'} ${why}`); +} + /** * A GATE SCRIPT's own declaration that it deliberately has no path population — * a whole-line comment anywhere in the script's source: @@ -3449,34 +3663,11 @@ export function populationReasonCutRefusal(entry, markerKey) { ); } if (!entry?.[fields.reason]) return null; - const cut = entry?.[fields.cut] ?? null; - if (!cut) return null; - const where = `${cut.file ?? 'the declaring file'}:${cut.line}`; - // A BLOCK form is cut by a different shape and takes a different repair - // (#18661), so it gets its own text rather than the line forms' advice. In a - // block the star lines under a declaration ARE the reason — the walk joins - // them — so the only way to lose half of one is to write a line the walk - // cannot see as part of the comment. Telling that author to "put the whole - // reason on the marker line" would send them to the wrong half of their - // declaration, which is precisely what this family of refusals exists not to - // do. The line forms' text below is unchanged, byte for byte. - if (cut.kind === 'block') { - return `declares ${markerKey} inside a block comment and its reason is CUT at ${where} by a line the block walk ` - + `cannot read as part of the comment: "${cut.text}". Inside a block the star-prefixed lines under a ` - + 'declaration are the same comment and are joined into the reason; a line with text and no star prefix is ' - + 'neither a continuation nor one of the endings (the closing delimiter, a blank star line, the next star-@tag, ' - + 'another dispatch-gates: key), so everything from it on is dropped and the seat is handed the declaration cut ' - + 'off mid-sentence. Give that line the block\'s star prefix, or end the reason before it with a blank star ' - + 'line. Rewrite the declaration — never route around this refusal.'; - } - return `declares ${markerKey} and its reason does not END on the marker line: ${where} continues it with ` - + `"${cut.text}". The capture stops at the FIRST NEWLINE, so the seat is handed the declaration cut off ` - + 'mid-sentence — and the reason is the one thing a seat reads off this row when deciding whether the family ' - + 'belongs on its card. Put the WHOLE reason on the marker line, however long it runs (this tree already carries ' - + 'one-line reasons past 1200 characters), and separate any comment written under the declaration with a blank ' - + 'line. ⛔ Never widen the marker to swallow the next line instead: nothing in the text tells a wrapped reason ' - + 'from an unrelated comment, so that repair would make the next paragraph part of a seat-facing reason silently. ' - + 'Rewrite the declaration — never route around this refusal.'; + // The TEXT lives in `markerReasonCutRefusal` since #18662, so the three + // markers with no entry to carry a cut are refused in the same words as the + // three that have one. Byte-identical output either way: this reading is the + // entry-shaped half, nothing more. + return markerReasonCutRefusal(markerKey, entry?.[fields.cut] ?? null); } /** @@ -4142,16 +4333,22 @@ export function workflowEnvValues(entry) { */ const INHERITED_POPULATION_MARKER = pathListMarkerPattern('inherited-population'); -export function declaredInheritedPopulation(moduleSource, hints = null) { +export function declaredInheritedPopulation(moduleSource, hints = null, file = null) { const source = String(moduleSource); - const m = INHERITED_POPULATION_MARKER.exec(source); - if (!m) return null; + // ONE read, both halves (#18662): the path list AND the wholeness of the + // reason come off the same match, so a refusal can never grade the reason of + // one declaration against the path list of another. The reason half is the + // #18422 reading this marker sat outside of until that card. + const read = readPopulationMarker(source, 'inherited-population'); + if (!read) return null; + refuseCutMarkerReason(read, 'inherited-population', file); + const m = read.match; // The path list is non-empty by construction: the marker pattern requires a // non-space before the ` -- `, so a marker carrying only a reason does not // parse as a declaration at all — it reads as no marker, which is the safe // direction (inherit everything) rather than a silent blanket opt-out. const population = m[2].trim().split(/[ \t]+/).filter(Boolean); - const reason = m[3].trim(); + const reason = read.reason; const spelled = new Set(hints ?? extractWatchHints(source)); const invented = population.filter((h) => !spelled.has(h)); if (invented.length > 0) { @@ -4237,10 +4434,14 @@ export function declaredInheritedPopulation(moduleSource, hints = null) { */ const SELF_TEST_READS_MARKER = pathListMarkerPattern('self-test-reads'); -export function declaredSelfTestReads(scriptSource, readTargets) { +export function declaredSelfTestReads(scriptSource, readTargets, file = null) { const source = String(scriptSource); - const m = SELF_TEST_READS_MARKER.exec(source); - if (!m) return null; + // ONE read, both halves — see `declaredInheritedPopulation` (#18662). The + // wholeness reading reaches this marker by construction: both path-list keys + // come out of one grammar, so neither can carry a cut reason the other + // refuses. + const read = readPopulationMarker(source, 'self-test-reads'); + if (!read) return null; // ⛔ NOT an optional argument with a permissive default. The whole contract of // this marker is that it cannot invent, and a caller that supplies no read set // would be handed a declaration nothing can refuse — a silent opt-in, which is @@ -4252,8 +4453,9 @@ export function declaredSelfTestReads(scriptSource, readTargets) { + 'A declaration read with no read set is a declaration nothing can refuse.', ); } - const population = m[2].trim().split(/[ \t]+/).filter(Boolean); - const reason = m[3].trim(); + refuseCutMarkerReason(read, 'self-test-reads', file); + const population = read.match[2].trim().split(/[ \t]+/).filter(Boolean); + const reason = read.reason; const performed = new Set(readTargets); const invented = population.filter((p) => !performed.has(p)); if (invented.length > 0) { @@ -4295,7 +4497,7 @@ export function checkFamilyCoverageGaps(workflowEntries) { for (const { file, text } of workflowEntries) { if (extractTriggerPaths(text).length === 0) continue; if (extractCheckInvocations(text, file).length > 0) continue; - if (declaredNoCheckFamiliesReason(text)) continue; + if (declaredNoCheckFamiliesReason(text, file)) continue; out.push(file); } return out; @@ -8802,7 +9004,7 @@ export function governedReadCensus({ files = null, read = null } = {}) { // carry, and one naming a path only the wider scan reaches throws here // rather than passing as coverage the derivation does not have. const declared = new Set( - declaredSelfTestReads(source, anchoredReadTargets(rel, source, isTracked))?.population ?? [], + declaredSelfTestReads(source, anchoredReadTargets(rel, source, isTracked), rel)?.population ?? [], ); for (const file of [...governed].sort()) rows.push({ script: rel, file, declared: declared.has(file) }); } @@ -12058,7 +12260,7 @@ function discoverFamiliesPass(tree) { // module had before the marker existed. const source = sourceOfModule(rel); const spelled = extractWatchHints(source, rel, { tree }); - const declared = declaredInheritedPopulation(source, spelled); + const declared = declaredInheritedPopulation(source, spelled, rel); moduleHints.set(rel, declared ? declared.population : spelled); } return moduleHints.get(rel); @@ -12160,6 +12362,7 @@ function discoverFamiliesPass(tree) { const declaredReads = declaredSelfTestReads( source, anchoredReadTargets(f, source, (t) => trackedSet.has(t)), + f, ); if (declaredReads) { for (const target of declaredReads.population) { @@ -16148,7 +16351,7 @@ function selfTest() { const inheritableFromImports = importedByBareRoot.flatMap((m) => { const src = readFileSync(nodePath.join(ROOT, m), 'utf8'); const spelled = extractWatchHints(src, m); - return declaredInheritedPopulation(src, spelled)?.population ?? spelled; + return declaredInheritedPopulation(src, spelled, m)?.population ?? spelled; }); t( `a gate that IMPORTS the same modules inherits ${inheritableFromImports.length} of those ${wouldHaveInherited.length} literal(s)`, @@ -21292,7 +21495,7 @@ function selfTest() { // marker line and these cases redden instead of 2632 fabricated pairs coming // back silently for the next gate that imports the tool. const ownToolSource = readFileSync(nodePath.join(ROOT, 'scripts/pm/dispatch-gates.mjs'), 'utf8'); - const ownDeclared = declaredInheritedPopulation(ownToolSource); + const ownDeclared = declaredInheritedPopulation(ownToolSource, null, 'scripts/pm/dispatch-gates.mjs'); // Read through `?.` on purpose: deleting the marker line must render as a // NAMED failing case, not as a TypeError that aborts the run and takes every // case after this one with it — a self-test that crashes reports one defect @@ -21808,7 +22011,7 @@ function selfTest() { const liveModuleHints = (rel) => { const source = liveSource(rel); const spelled = extractWatchHints(source, rel, { tree: liveTree }); - return declaredInheritedPopulation(source, spelled)?.population ?? spelled; + return declaredInheritedPopulation(source, spelled, rel)?.population ?? spelled; }; const liveTargets = (rel) => firstPartyImportTargets(rel, liveSource(rel)); // The THIRD followed edge (#13518). Its population comes from the manifest's @@ -22235,7 +22438,7 @@ function selfTest() { for (const f of entry.files ?? []) { if (!existsSync(nodePath.join(ROOT, f))) continue; const src = liveSource(f); - const declared = declaredSelfTestReads(src, anchoredReadTargets(f, src, (x) => liveTree.files.has(x))); + const declared = declaredSelfTestReads(src, anchoredReadTargets(f, src, (x) => liveTree.files.has(x)), f); for (const r of declared?.population ?? []) if (!expected.includes(r)) expected.push(r); } if (expected.join(' · ') !== (entry.reads ?? []).join(' · ')) offReads.push(check); @@ -22962,7 +23165,7 @@ function selfTest() { const CLI_PREREQ = 'scripts/cli-build-prerequisite.mjs'; const cliPrereqSource = liveSource(CLI_PREREQ); const cliPrereqSpelled = extractWatchHints(cliPrereqSource, CLI_PREREQ, { tree: liveTree }); - const cliPrereqPopulation = declaredInheritedPopulation(cliPrereqSource, cliPrereqSpelled)?.population ?? []; + const cliPrereqPopulation = declaredInheritedPopulation(cliPrereqSource, cliPrereqSpelled, CLI_PREREQ)?.population ?? []; t( `the CLI build-prerequisite module declares what its callers inherit (${cliPrereqPopulation.join(' ') || 'nothing'})`, cliPrereqPopulation.length === 3, From 05389952eea490282bd54045b1d90f203110c891 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 22:24:20 +0000 Subject: [PATCH 2/2] test(pm): pin the marker reason wholeness reading, per marker and live Per marker: a wrapped reason REFUSED by name (file, line, marker, the continuation it saw) with the refusal text pinned, and a whole-reason control that still reads back. The `#`-only restriction on `no-check-families` is pinned in both directions, as a filter of the shared roster rather than a second grammar. The wholeness roster is pinned equal to the two grammar builders' own key rosters, so a seventh key cannot arrive without the reading. The live census reads the six declarations in the tree at the head and asserts each one whole, named rather than counted, with a non-vacuity control that puts a continuation under a live declaration and watches that file get refused. Claude-Session: https://claude.ai/code/session_01Gqi43smmqjJ5sUrhfoPeKu Co-authored-by: Claude --- scripts/pm/dispatch-gates.mjs | 361 +++++++++++++++++++++++++++++++++- 1 file changed, 357 insertions(+), 4 deletions(-) diff --git a/scripts/pm/dispatch-gates.mjs b/scripts/pm/dispatch-gates.mjs index 4ecd21638e2..1d4c95393c3 100644 --- a/scripts/pm/dispatch-gates.mjs +++ b/scripts/pm/dispatch-gates.mjs @@ -2901,8 +2901,8 @@ const MARKER_KEY_FORMS = Object.freeze({ 'no-check-families': Object.freeze(['#']), }); -function markerFormsFor(key) { - const labels = MARKER_KEY_FORMS[key]; +function markerFormsFor(key, table = MARKER_KEY_FORMS) { + const labels = table[key]; if (!labels) return MARKER_COMMENT_FORMS; const forms = MARKER_COMMENT_FORMS.filter((f) => labels.includes(f.label)); if (forms.length !== labels.length) { @@ -4329,6 +4329,23 @@ export function workflowEnvValues(entry) { * The reason is REQUIRED, and separated from the path list by a SPACE-delimited * `--`: a bare `--` would split a path that legitimately contains one. * + * ## The reason half is WHOLE, or the declaration is REFUSED (#18662) + * + * The path list above is this marker's own grammar and is unchanged. Its + * REASON captured with the same `(\S.*)$`-under-`m` shape #18422 repaired for + * the three population markers, so a reason wrapped onto the comment line + * below was read as line ONE and nothing sounded — measured on `origin/main` + * 034f5a3afd before this change. The read is now graded by the shared + * wholeness reading and a continued reason THROWS, naming the module, the + * line, the marker and the text that continues it, exactly as an invented path + * already did. + * + * ⚠️ Nothing in this tool RENDERS this reason (measured, #18662): every + * production call site reads `.population` alone. The refusal is owed anyway — + * wholeness is a property of the declaration, and the next reader of the + * reason is the seat that greps the module for it — and this sentence is that + * cost stated rather than left to be discovered. + * * Returns `{ population, reason }`, or null when the module declares nothing. */ const INHERITED_POPULATION_MARKER = pathListMarkerPattern('inherited-population'); @@ -4430,10 +4447,10 @@ export function declaredInheritedPopulation(moduleSource, hints = null, file = n * * @param {string} scriptSource the script's contents * @param {string[]} readTargets what `anchoredReadTargets` resolved from it + * @param {string|null} file what the caller knows the source by, named by the + * cut refusal (#18662) so a reader can navigate to the continuation * @returns {{ population: string[], reason: string } | null} */ -const SELF_TEST_READS_MARKER = pathListMarkerPattern('self-test-reads'); - export function declaredSelfTestReads(scriptSource, readTargets, file = null) { const source = String(scriptSource); // ONE read, both halves — see `declaredInheritedPopulation` (#18662). The @@ -20546,6 +20563,118 @@ function selfTest() { checkFamilyCoverageGaps([{ file: 'x.yml', text: exemptedWf }]).length === 0, ); + // ── This marker's reason is WHOLE, or the declaration is REFUSED (#18662) ── + // + // The capture was a fourth hand-written copy of the reason-tail grammar, so + // the #18422 wholeness reading never reached it: a reason wrapped onto the + // comment line below read back as line ONE and `checkFamilyCoverageGaps` + // accepted the workflow without a sound. Measured on `origin/main` + // 034f5a3afd before this change — the reading the cases below turn green. + const wrappedNoFamilyWf = [ + 'name: scaffold-e2e', + 'on:', + ' pull_request:', + " paths: ['packages/create-objectstack/**']", + '# dispatch-gates: no-check-families -- steps are an install/build/boot pipeline, and the verdict is', + '# whether the scaffolded app boots at all, which no named local check family covers', + '', + 'jobs:', + ' e2e:', + ' steps:', + ' - run: pnpm install', + ].join('\n'); + { + let refused = null; + try { + declaredNoCheckFamiliesReason(wrappedNoFamilyWf, '.github/workflows/scaffold-e2e.yml'); + } catch (error) { + refused = String(error.message); + } + t( + 'a no-check-families reason that does not END on the marker line is REFUSED, naming the workflow, the line, the marker and the continuation', + refused !== null + && refused.includes('.github/workflows/scaffold-e2e.yml declares no-check-families') + && refused.includes('.github/workflows/scaffold-e2e.yml:6 continues it with') + && refused.includes('"whether the scaffolded app boots at all, which no named local check family covers"') + && refused.includes('The capture stops at the FIRST NEWLINE'), + refused, + ); + } + t( + 'and the refusal reaches the ONE consumer this marker has — the boolean read in checkFamilyCoverageGaps refuses rather than accepting half a sentence', + (() => { + try { + checkFamilyCoverageGaps([{ file: '.github/workflows/scaffold-e2e.yml', text: wrappedNoFamilyWf }]); + return false; + } catch (error) { + return String(error.message).includes('declares no-check-families and its reason does not END on the marker line'); + } + })(), + ); + t( + 'the WHOLE-reason control still reads back and is still not a gap — the repair refuses a cut, it does not refuse the marker', + (() => { + const whole = wrappedNoFamilyWf.replace( + '# whether the scaffolded app boots at all, which no named local check family covers\n', + '', + ); + return declaredNoCheckFamiliesReason(whole, '.github/workflows/scaffold-e2e.yml') + === 'steps are an install/build/boot pipeline, and the verdict is' + && checkFamilyCoverageGaps([{ file: '.github/workflows/scaffold-e2e.yml', text: whole }]).length === 0; + })(), + ); + t( + 'nor is an unrelated comment separated by a blank line a continuation — the terminator the three live declarations already write', + declaredNoCheckFamiliesReason( + '# dispatch-gates: no-check-families -- an e2e pipeline, not named local checks\n\n# an unrelated remark\njobs:\n', + 'x.yml', + ) === 'an e2e pipeline, not named local checks', + ); + // ⛔ `#` is the ONLY comment form YAML has, so the forms #18661 added cannot + // apply to THIS marker: a `//` or slash-star line in a workflow is document + // content, not a remark. Both directions are pinned — the restriction holds, + // and it is a restriction of the shared roster rather than a second grammar. + t( + 'a // or block-form spelling in a workflow is NOT a declaration — those are not comments in YAML', + declaredNoCheckFamiliesReason('// dispatch-gates: no-check-families -- not a YAML comment\n') === null + && declaredNoCheckFamiliesReason('/* dispatch-gates: no-check-families -- not a YAML comment */\n') === null + && declaredNoCheckFamiliesReason('/** dispatch-gates: no-check-families -- not a YAML comment */\n') === null, + ); + t( + "and that restriction is the shared roster FILTERED, not a second pattern: the key's head lists exactly the `#` form", + populationMarkerPattern('no-check-families').source.startsWith('^[ \\t]*(#)[ \\t]*dispatch-gates:'), + populationMarkerPattern('no-check-families').source, + ); + t( + 'the restriction NARROWS and nothing else — a key the table does not name keeps the whole roster', + markerFormsFor('no-check-families').map((f) => f.label).join(' ') === '#' + && markerFormsFor('no-path-population').length === MARKER_COMMENT_FORMS.length, + ); + t( + 'every label MARKER_KEY_FORMS restricts a key to is one MARKER_COMMENT_FORMS really carries (the live half)', + Object.values(MARKER_KEY_FORMS) + .every((labels) => labels.every((l) => MARKER_COMMENT_FORMS.some((f) => f.label === l))), + ); + t( + 'and a restriction naming a label the roster does NOT carry REFUSES, rather than emptying the alternation silently — a form set that quietly emptied would make every declaration of that key parse as nothing', + (() => { + try { + markerFormsFor('no-check-families', { 'no-check-families': ['rem'] }); + return false; + } catch (error) { + return String(error.message).includes('may only ever NARROW the roster'); + } + })(), + ); + t( + 'the wholeness reading now REACHES this marker, in the same shape it reaches the population three', + (() => { + const cut = populationReasonContinuation(wrappedNoFamilyWf, 'no-check-families', 'w.yml'); + return cut?.line === 6 && cut?.kind === 'line' && cut?.file === 'w.yml' + && cut?.text === 'whether the scaffolded app boots at all, which no named local check family covers'; + })(), + ); + // ── The gate-level no-population declaration (#10542) ───────────────────── // // The workflow-level marker above says "this workflow names no gate"; this @@ -21045,6 +21174,43 @@ function selfTest() { 'the field roster and the marker roster name the SAME three channels — neither can grow one alone', Object.keys(POPULATION_DECLARATION_FIELDS).sort().join(' ') === [...POPULATION_MARKER_KEYS].sort().join(' '), ); + // And the WHOLENESS roster covers every reason-bearing key BY CONSTRUCTION + // (#18662). The three cases above are the population channels' half; this is + // the half that closed the class. `no-check-families` and both path-list + // markers carried the same first-newline capture and sat outside the #18422 + // reading for two cards, because the roster that decided who got the reading + // was hand-written. Derived from the two grammar builders' own key rosters, + // a key cannot be added to either without the reading arriving with it. + t( + 'every key either grammar builder serves has a wholeness reading — the roster is derived, so none can be added without one', + Object.keys(MARKER_REASON_GRAMMARS).sort().join(' ') + === [...REASON_TAIL_MARKER_KEYS, ...PATH_LIST_MARKER_KEYS].sort().join(' '), + Object.keys(MARKER_REASON_GRAMMARS).join(' '), + ); + t( + 'and it names all SIX live marker keys, not the three the repair was filed on', + Object.keys(MARKER_REASON_GRAMMARS).sort().join(' ') + === 'inherited-population no-check-families no-path-population self-test-reads whole-tree-population wide-population', + Object.keys(MARKER_REASON_GRAMMARS).sort().join(' '), + ); + t( + 'the reason GROUP is read off the roster rather than assumed — a path-list key carries its reason in group 3, a reason-tail key in group 2', + REASON_TAIL_MARKER_KEYS.every((k) => MARKER_REASON_GRAMMARS[k].reasonGroup === 2) + && PATH_LIST_MARKER_KEYS.every((k) => MARKER_REASON_GRAMMARS[k].reasonGroup === 3), + ); + t( + 'an unknown key is REFUSED by the shared refusal TEXT too, so the three markers with no entry cannot reach it by a back door', + (() => { try { markerReasonCutRefusal('made-up-marker', { line: 1, text: 'x', kind: 'line' }); return false; } catch { return true; } })() + && markerReasonCutRefusal('no-check-families', null) === null, + ); + t( + 'and the entry-shaped reading is that same text: one refusal, five keys, byte for byte', + (() => { + const cut = { file: 'scripts/probe.mjs', line: 9, text: 'and the rest of the sentence', kind: 'line' }; + return populationReasonCutRefusal({ widePopulationReason: 'r', widePopulationReasonCut: cut }, 'wide-population') + === markerReasonCutRefusal('wide-population', cut); + })(), + ); // ── The BLOCK comment forms, and where a reason written in one ENDS (#18661) ── // @@ -21488,6 +21654,73 @@ function selfTest() { return d.population.length === 1 && d.population[0] === 'packages/a--b/src' && d.reason === 'a real subtree'; })(), ); + + // ── This marker's reason half is WHOLE, or the declaration is REFUSED (#18662) ── + // + // The path list is its own grammar and is untouched; only the reason after + // the ` -- ` is in question. It captured with the same first-newline shape + // #18422 repaired for the population three, and — measured on `origin/main` + // 034f5a3afd — a wrapped reason read back as line one with no throw, no + // refusal and no row. The path-list half is pinned unchanged above; these + // are the reason half. + const wrappedInherited = [ + "const WORKFLOWS = '.github/workflows';", + '// dispatch-gates: inherited-population .github/workflows -- the workflow directory this module readdirs, and the verdict is', + '// that every other literal here is a join base no caller ever opens', + '', + 'export default WORKFLOWS;', + ].join('\n'); + { + let refused = null; + try { + declaredInheritedPopulation(wrappedInherited, null, 'scripts/x.mjs'); + } catch (error) { + refused = String(error.message); + } + t( + 'an inherited-population reason that does not END on the marker line is REFUSED, naming the module, the line, the marker and the continuation', + refused !== null + && refused.includes('scripts/x.mjs declares inherited-population') + && refused.includes('scripts/x.mjs:3 continues it with') + && refused.includes('"that every other literal here is a join base no caller ever opens"'), + refused, + ); + } + t( + 'the WHOLE-reason control still declares its population and its reason — the repair refuses a cut, it does not refuse the marker', + (() => { + const whole = wrappedInherited.replace( + '// that every other literal here is a join base no caller ever opens\n', + '', + ); + const d = declaredInheritedPopulation(whole, null, 'scripts/x.mjs'); + return d?.population.join(' ') === '.github/workflows' + && d?.reason === 'the workflow directory this module readdirs, and the verdict is'; + })(), + ); + t( + 'the wholeness reading reaches this marker through the SAME helper the population three use, and reads its reason out of group 3', + (() => { + const cut = populationReasonContinuation(wrappedInherited, 'inherited-population', 'scripts/x.mjs'); + return cut?.line === 3 && cut?.kind === 'line' + && cut?.text === 'that every other literal here is a join base no caller ever opens'; + })(), + ); + t( + 'and the two refusals this marker now carries are INDEPENDENT — an invented path is still refused for being invented, not for being cut', + (() => { + try { + declaredInheritedPopulation( + "const A = '.github/workflows';\n// dispatch-gates: inherited-population packages/spec/src/** -- invented\n", + null, + 'scripts/x.mjs', + ); + return false; + } catch (error) { + return String(error.message).includes('never invent it'); + } + })(), + ); // ── LIVE: this file's own declaration ───────────────────────────────────── // // Pinned against the real source, because the whole value of the marker is @@ -21550,6 +21783,78 @@ function selfTest() { `exactly the two priced modules in the scripts tree carry the declaration (${declaringModules.join(' · ') || 'none'})`, declaringModules.join(' · ') === 'scripts/cli-build-prerequisite.mjs · scripts/pm/dispatch-gates.mjs', ); + + // ── LIVE CENSUS: the reason half of every marker outside the population + // three, measured WHOLE over the real tree (#18662) ──────────────────── + // + // The population three are censused further down against the discovery's + // entries; these three have no entry to be censused through, so the census + // is taken off the files themselves. It is the half a fixture cannot give: + // a fixture shows the refusal works, and only the tree shows that no live + // declaration is being cut by it today. The reading this card was filed on + // said every live declaration is a one-liner followed by a blank line — this + // is that reading, re-taken on every run, so the day someone wraps one the + // refusal lands here rather than in a seat's half-read sentence. + // + // Each row is NAMED, never counted: a bare count reddens for a seventh + // declaration without saying which six were already read. + const liveMarkerCensus = []; + const censusRead = (file, key, read) => { + liveMarkerCensus.push({ file, key, line: read.line, whole: read.cut === null, reason: read.reason }); + }; + for (const wf of readdirSync(nodePath.join(ROOT, '.github/workflows')).filter((f) => /\.ya?ml$/.test(f))) { + const rel = `.github/workflows/${wf}`; + const read = readPopulationMarker(readFileSync(nodePath.join(ROOT, rel), 'utf8'), 'no-check-families'); + if (read) censusRead(rel, 'no-check-families', read); + } + for (const f of trackedFiles().filter((x) => x.startsWith('scripts/') && /\.(mjs|mts|js|sh)$/.test(x)).sort()) { + // Read from the MODULE BODY, so this self-test's own fixtures are not + // counted as live declarations — the discipline `declaringModules` above + // takes, and for the same reason. + const body = maskSelfTests(readFileSync(nodePath.join(ROOT, f), 'utf8')); + for (const key of ['inherited-population', 'self-test-reads']) { + const read = readPopulationMarker(body, key); + if (read) censusRead(f, key, read); + } + } + const censusRows = liveMarkerCensus.map((r) => `${r.file}:${r.line} ${r.key}`).sort(); + t( + `the live tree carries the six declarations this card measured, and no others (${censusRows.join(' · ') || 'none'})`, + censusRows.join(' · ') === [ + '.github/workflows/merged-branch-reaper.yml:212 no-check-families', + '.github/workflows/os-create-smoke.yml:48 no-check-families', + '.github/workflows/scaffold-e2e.yml:23 no-check-families', + 'scripts/cli-build-prerequisite.mjs:111 inherited-population', + 'scripts/pm/check-expected-skips.mjs:131 self-test-reads', + 'scripts/pm/dispatch-gates.mjs:702 inherited-population', + ].join(' · '), + censusRows.join(' · '), + ); + const censusCut = liveMarkerCensus.filter((r) => !r.whole).map((r) => `${r.file}:${r.line} ${r.key}`); + t( + `every live reason on those markers ENDS on its own marker line (cut: ${censusCut.join(', ') || 'none'})`, + censusCut.length === 0 && liveMarkerCensus.length === 6, + ); + t( + 'and every one of them carries a non-empty reason — whole is not the same claim as present, and both are owed', + liveMarkerCensus.every((r) => typeof r.reason === 'string' && r.reason.length > 0), + ); + t( + 'the census is not vacuous over this tree: put a continuation under a LIVE declaration and exactly that file is refused, by name', + (() => { + const specimen = liveMarkerCensus.find((r) => r.key === 'no-check-families'); + if (!specimen) return false; + const lines = readFileSync(nodePath.join(ROOT, specimen.file), 'utf8').split('\n'); + lines.splice(specimen.line, 0, '# and the rest of the sentence'); + try { + declaredNoCheckFamiliesReason(lines.join('\n'), specimen.file); + return false; + } catch (error) { + return String(error.message).includes(`${specimen.file}:${specimen.line + 1} continues it with`) + && String(error.message).includes('and the rest of the sentence'); + } + })(), + ); // The residue count that carries it refuses a missing or impossible value in // the same shape as every other count in that line: a subset that could go // absent quietly renders as `undefined` in the one line a reader needs. @@ -22597,6 +22902,54 @@ function selfTest() { pathListMarkerPattern('inherited-population').source.replace('inherited-population', '') === pathListMarkerPattern('self-test-reads').source.replace('self-test-reads', ''), ); + // One head means ONE wholeness reading too (#18662): this marker was never + // named on that card, and it did not need to be — the roster is derived from + // the two builders, so the sixth reason-bearing key got the reading in the + // same line the two named ones did rather than becoming a third card on this + // file. + const wrappedSelfTestReads = [ + "import { readFileSync } from 'node:fs';", + '// dispatch-gates: self-test-reads AGENTS.md -- the structural case asserts the bar still names this file, and the verdict is', + '// whether that bar moved', + '', + "function selfTest() { readFileSync(join(ROOT, 'AGENTS.md'), 'utf8'); }", + ].join('\n'); + { + let refused = null; + try { + declaredSelfTestReads(wrappedSelfTestReads, ['AGENTS.md'], 'scripts/y.mjs'); + } catch (error) { + refused = String(error.message); + } + t( + 'a self-test-reads reason that does not END on the marker line is REFUSED too, in the same words and naming the same four things', + refused !== null + && refused.includes('scripts/y.mjs declares self-test-reads') + && refused.includes('scripts/y.mjs:3 continues it with') + && refused.includes('"whether that bar moved"'), + refused, + ); + } + t( + 'and its WHOLE-reason control is unmoved', + (() => { + const whole = wrappedSelfTestReads.replace('// whether that bar moved\n', ''); + const d = declaredSelfTestReads(whole, ['AGENTS.md'], 'scripts/y.mjs'); + return d?.population.join(' ') === 'AGENTS.md' + && d?.reason === 'the structural case asserts the bar still names this file, and the verdict is'; + })(), + ); + t( + 'the missing-read-set refusal still fires FIRST — a declaration nothing can refuse is refused before its reason is graded', + (() => { + try { + declaredSelfTestReads(wrappedSelfTestReads, undefined, 'scripts/y.mjs'); + return false; + } catch (error) { + return String(error.message).includes('must supply them'); + } + })(), + ); // The CENSUS, live over the tree, against `GOVERNED_READ_FLOOR`. {