Skip to content

Commit 5e724a2

Browse files
claude[bot]claude
andauthored
tooling(pm): measure the scheduled-only routing question and pin its zero (#15236)
* tooling(pm): measure the scheduled-only routing question and pin its zero (#14899) The card reported that dispatch-gates derives the live half-state board sweep into the per-PR gate family for any diff carrying a changeset, on the reading that its only caller is a `schedule`-triggered workflow, and asked for a general "scheduled-only, not a PR gate" class before anything was built. Measured first, from the workflow text at 50d6c92: of 30 workflows, 15 declare `schedule:` and 8 of those contribute a discovered check family; of 251 discovered families, 21 reach a scheduled workflow and 10 are reached ONLY through scheduled workflows — and ZERO are scheduled-only, because every patrol here declares a `pull_request:` trigger with a `paths:` filter naming its own script, so changes to a patrol are exercised before they merge. The card's own specimen already carried that trigger on the day it was filed, and three of the ten are `validate-deps.yml`'s, including `check:override-consistency`. The zero does not depend on how wide PR-time is drawn: narrowing it to `pull_request` alone leaves the same zero. So the class is not shipped — an empty classification is a capability with nothing in it. What ships is the reading that makes the deferral honest: `declaredTriggerEvents` re-takes the measurement from the workflow text on every `--self-test`, so the day a family really is scheduled-only the pin reds on the PR that creates it, and its case names the two exits. No derivation consumes the reader, and `--commands` is byte-identical for every path: the cost the card measured is separately gone, because #15083 classified that invocation value-bearing off its `$PROVENANCE` argv. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019RfFHiRCSs3JXLK4cwcfox * tooling(pm): re-take the scheduled-only reading at the merged head (#14899) `main` moved five commits under the branch and added one check family, so the header's table is re-measured rather than left describing the pre-merge tree: 252 discovered families, not 251. Every other row is unchanged, and the answer is unchanged — ZERO scheduled-only families. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019RfFHiRCSs3JXLK4cwcfox --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5ab3507 commit 5e724a2

1 file changed

Lines changed: 315 additions & 0 deletions

File tree

‎scripts/pm/dispatch-gates.mjs‎

Lines changed: 315 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,63 @@
273273
* them is printed in the residue rather than left as an absence — a schedule
274274
* this tool cannot narrow is a fact the reader is owed, not one to keep quiet.
275275
*
276+
* ## The SCHEDULED-only routing question, measured and DEFERRED (#14899)
277+
*
278+
* The section above says what CI's trigger CAN answer. This one records what
279+
* a card asked it next, and why the answer was to ship a reading rather than a
280+
* rule. The card: the derivation names `node scripts/pm/check-half-states.mjs`
281+
* — a live board sweep — for any diff carrying a changeset, on the reading
282+
* that its only caller is a `schedule`-triggered workflow, so CI never runs it
283+
* on a PR while the dispatch protocol tells a dev to run every printed
284+
* command. The proposed general repair was a third withholding class beside
285+
* the CI-measured and value-bearing ones: "scheduled-only, not a PR gate".
286+
*
287+
* Measured first, from the workflow text at fa8c1963 (2026-09-04):
288+
*
289+
* .github/workflows/*.yml 30
290+
* declaring `schedule:` 15
291+
* …of those, contributing a discovered check family 8
292+
* discovered families 252
293+
* reaching a scheduled workflow at all 21
294+
* reached ONLY through scheduled workflows 10
295+
* SCHEDULED-ONLY — reached by no PR-time trigger 0
296+
*
297+
* All ten candidates dissolve on the same fact, and it is a deliberate repo
298+
* posture rather than an accident: every patrol here declares a
299+
* `pull_request:` trigger with a `paths:` filter naming its own script, so
300+
* "changes to the patrol itself get exercised before they merge"
301+
* (`half-state-patrol.yml`'s own comment, and the same words in
302+
* `required-set-patrol.yml`, `release-coverage-patrol.yml` and
303+
* `merged-branch-reaper.yml`). The card's own specimen is one of those ten:
304+
* `half-state-patrol.yml` already carried that trigger on the day the card was
305+
* filed. Three of the ten are `validate-deps.yml`'s, including
306+
* `check:override-consistency` — a gate every dependency card must run. So
307+
* "its only source workflow declares a schedule" is not close to "no PR runs
308+
* it", and a class keyed on that predicate would have withheld gates a dev
309+
* owes.
310+
*
311+
* The reading does not turn on where the PR-time line is drawn either:
312+
* narrowing `PR_TIME_TRIGGER_EVENTS` to `pull_request` alone leaves the same
313+
* zero, because every one of the ten is reached through a `pull_request`
314+
* trigger specifically.
315+
*
316+
* ⛔ So the class is NOT shipped: an empty classification is a capability with
317+
* nothing in it, the speculative-capability shape `extractTriggerPaths`'
318+
* `paths-ignore:` boundary already refuses two sections down — "when one does
319+
* and its families matter, model it then". The cost the card measured (3m09s
320+
* of a dev's wall clock, plus shared API quota) is separately gone: #15083
321+
* classified that invocation VALUE-BEARING off its `$PROVENANCE` argv, so it
322+
* left `--commands` without any scheduled-only rule existing.
323+
*
324+
* What ships is the reading, and that is the load-bearing half. A deferral is
325+
* only honest while its population stays empty, and nothing was watching that:
326+
* `declaredTriggerEvents` re-takes the measurement from the workflow text on
327+
* every `--self-test`, so the day a family really is scheduled-only the pin
328+
* reds on the PR that creates it. Two exits from there, and the pin's own case
329+
* names both: give the workflow the `pull_request` paths trigger every patrol
330+
* here already carries, or ship the class this section defers. ⛔ Neither exit
331+
* is "edit the pin's expectation" — the zero is a reading, not a roster.
332+
*
276333
* ## Why a declaration can only NARROW, and what that guarantee costs (#12842)
277334
*
278335
* `declaredInheritedPopulation` refuses any path its own module does not
@@ -1563,6 +1620,120 @@ export function declaresPullRequestTrigger(workflowText) {
15631620
return false;
15641621
}
15651622

1623+
/**
1624+
* Every event a workflow's `on:` block DECLARES, in declaration order.
1625+
*
1626+
* The third of the three narrow `on:` walkers, kept beside the two above so
1627+
* the trio cannot drift: `extractTriggerPaths` reads one event's `paths:`,
1628+
* `declaresPullRequestTrigger` answers one event's presence, and this one
1629+
* answers WHICH events there are. All three walk indentation rather than
1630+
* parse YAML, for the reason `extractTriggerPaths` states — this script is
1631+
* dependency-free by design.
1632+
*
1633+
* ## Why it exists when NO derivation consumes it (#14899)
1634+
*
1635+
* It is the instrument for the scheduled-only measurement in the header, and
1636+
* the self-test's live block is its only caller. That is deliberate and it is
1637+
* the whole shape of what shipped: the classification the card proposed has
1638+
* ZERO members on this tree, so shipping it would be a capability with nothing
1639+
* in it, and the header records why. What a deferral needs to be safe is a
1640+
* reading that goes loud the day the population stops being empty — and a
1641+
* reading needs an instrument. ⛔ Do not delete this as unused: its caller is
1642+
* the pin, and deleting it deletes the detection that makes the deferral
1643+
* honest rather than merely convenient.
1644+
*
1645+
* ## The three `on:` spellings, all read
1646+
*
1647+
* - the mapping (`on:` then ` pull_request:` on its own line) — every
1648+
* workflow in this tree today;
1649+
* - the flow sequence (`on: [push, pull_request]`);
1650+
* - the bare scalar (`on: push`), and the block sequence under it.
1651+
*
1652+
* The YAML 1.1 spelling is read too: an unquoted `on` is the BOOLEAN `true` to
1653+
* a 1.1 parser, so `'on':`, `"on":` and a literal `true:` are all the same key
1654+
* — the same three spellings the two walkers above already accept.
1655+
*
1656+
* ## The boundaries, each with the direction it fails in
1657+
*
1658+
* - Only keys at the FIRST indentation level inside `on:` are events. A
1659+
* `paths:`/`types:`/`branches:` under an event is not one, and neither is
1660+
* a `schedule:` under `jobs:` — a walk that scooped either would report
1661+
* events a workflow does not declare, and this reader's whole use is to
1662+
* decide that a workflow has NO PR-time trigger. A fabricated event fails
1663+
* in the safe direction (it can only ever REMOVE a family from the
1664+
* scheduled-only class); a missed one fails in the loud direction (a false
1665+
* member, caught by the reader of the pin). Both are pinned below.
1666+
* - `workflow_call` is reported as declared and is NOT a PR-time event: a
1667+
* reusable workflow runs with its caller's event, so the caller is where
1668+
* the question is answered. No family in this tree reaches one.
1669+
*/
1670+
export function declaredTriggerEvents(workflowText) {
1671+
const out = [];
1672+
const add = (name) => {
1673+
const clean = unquoteScalar(name);
1674+
if (clean !== '' && !out.includes(clean)) out.push(clean);
1675+
};
1676+
let inOn = false;
1677+
let eventIndent = -1;
1678+
for (const line of workflowText.split('\n')) {
1679+
const trimmed = line.trim();
1680+
if (trimmed === '' || trimmed.startsWith('#')) continue;
1681+
const indent = /^[ \t]*/.exec(line)[0].length;
1682+
if (indent === 0) {
1683+
// A new top-level key closes whatever we were inside — the same reset
1684+
// the two walkers above make, and what keeps a `jobs:` decoy out.
1685+
const on = /^(?:on|'on'|"on"|true):\s*(.*)$/.exec(trimmed);
1686+
inOn = Boolean(on);
1687+
eventIndent = -1;
1688+
if (on && on[1].trim() !== '') {
1689+
const flow = flowSequenceItems(on[1]);
1690+
if (flow.length > 0) for (const item of flow) add(item);
1691+
else add(on[1].trim());
1692+
// An inline value IS the whole declaration; nothing indented under it.
1693+
inOn = false;
1694+
}
1695+
continue;
1696+
}
1697+
if (!inOn) continue;
1698+
// The first indented line fixes the level events live at. Anything deeper
1699+
// belongs to an event, not to `on:`.
1700+
if (eventIndent === -1) eventIndent = indent;
1701+
if (indent !== eventIndent) continue;
1702+
const item = /^-\s*(.*)$/.exec(trimmed);
1703+
if (item) {
1704+
add(item[1]);
1705+
continue;
1706+
}
1707+
const key = /^([A-Za-z_][A-Za-z0-9_]*):/.exec(trimmed);
1708+
if (key) add(key[1]);
1709+
}
1710+
return out;
1711+
}
1712+
1713+
/**
1714+
* The events that put a workflow in front of a PULL REQUEST — the predicate
1715+
* the scheduled-only measurement subtracts by (#14899).
1716+
*
1717+
* `pull_request` and `pull_request_target` run on the PR itself; `merge_group`
1718+
* runs on the speculative merge the queue builds out of it; `push` runs on the
1719+
* result of landing it. All four judge a diff a dev wrote, which is the
1720+
* question — `schedule`, `workflow_dispatch`, `workflow_run` and
1721+
* `workflow_call` judge a board, a human's button, another run, or a caller.
1722+
*
1723+
* ⚠️ The set is deliberately WIDE, and the header records that the measured
1724+
* answer does not depend on it: narrowing it to `pull_request` alone leaves
1725+
* the scheduled-only count at zero, because every family this tree reaches
1726+
* through a scheduled workflow is also reached through a `pull_request` one.
1727+
* A wide set can only ever UNDER-report the class, which is the direction that
1728+
* fails quietly — so the pin below asserts the narrow reading too.
1729+
*/
1730+
export const PR_TIME_TRIGGER_EVENTS = Object.freeze([
1731+
'pull_request',
1732+
'pull_request_target',
1733+
'merge_group',
1734+
'push',
1735+
]);
1736+
15661737
/**
15671738
* A job's steps, each as `{ name, if: <text|null>, text }`, with the original
15681739
* indentation kept so every extractor above reads a step exactly as it reads a
@@ -14574,6 +14745,150 @@ function selfTest() {
1457414745
t('the flow-sequence spelling is read too', extractTriggerPaths("on:\n pull_request:\n paths: ['a/**', \"b/c\"]\n").join('|') === 'a/**|b/c');
1457514746
t('pull_request_target is not mistaken for pull_request', extractTriggerPaths("on:\n pull_request_target:\n paths:\n - 'x/**'\n").length === 0);
1457614747

14748+
// ── The SCHEDULED-ONLY routing question, measured and answered ZERO (#14899)
14749+
//
14750+
// The card: the derivation named `node scripts/pm/check-half-states.mjs` —
14751+
// a live board sweep — for any diff carrying a changeset, on the reading
14752+
// that its only caller is a `schedule`-triggered workflow. Two things were
14753+
// measured against the tree instead of accepted:
14754+
//
14755+
// 1. `half-state-patrol.yml` DOES declare a `pull_request:` trigger, with
14756+
// a `paths:` filter naming the sweeper and the workflow — it already
14757+
// did on the day the card was filed. So the specimen was never a
14758+
// workflow no PR runs; it is a patrol that exercises itself on the PRs
14759+
// that change it, the posture every patrol in this tree keeps.
14760+
// 2. Across the whole tree, the number of discovered families whose
14761+
// source workflows ALL lack a PR-time trigger is ZERO — and it stays
14762+
// zero under the narrowest reading of "PR-time" as well.
14763+
//
14764+
// So the classification the card proposed has no members, and shipping it
14765+
// would be a capability with nothing in it. What ships instead is this pin:
14766+
// the reading is re-taken from the workflow text on every run, so the
14767+
// deferral goes loud the day the population stops being empty. ⛔ The cases
14768+
// below are the whole remedy for that day — they are not a roster to edit
14769+
// when one reds. See the header section of the same name for the exits.
14770+
const wfDirLive = nodePath.join(ROOT, '.github/workflows');
14771+
const eventsWf = [
14772+
'name: Fixture',
14773+
'# a comment before the on: block',
14774+
'on:',
14775+
' schedule:',
14776+
" - cron: '37 1,7,13,19 * * *'",
14777+
' workflow_dispatch: {}',
14778+
' # a comment between events',
14779+
' pull_request:',
14780+
' types: [opened, synchronize]',
14781+
' paths:',
14782+
" - 'scripts/pm/check-half-states.mjs'",
14783+
// A decoy at an event's OWN depth-plus-one: a key under `pull_request:` is
14784+
// not an event, however event-shaped its name.
14785+
' push:',
14786+
' branches: [main]',
14787+
'jobs:',
14788+
' sweep:',
14789+
// A decoy under `jobs:`, the shape a walk without the top-level reset eats.
14790+
' merge_group:',
14791+
' never: read',
14792+
'',
14793+
].join('\n');
14794+
const fixtureEvents = declaredTriggerEvents(eventsWf);
14795+
t('the on: mapping\'s events are read in declaration order', fixtureEvents.join('|') === 'schedule|workflow_dispatch|pull_request');
14796+
t('a key nested UNDER an event is not an event, however event-shaped its name', !fixtureEvents.includes('push'));
14797+
t('a decoy event under jobs: is not read', !fixtureEvents.includes('merge_group'));
14798+
t('an event\'s own sub-keys never enter the list', !fixtureEvents.includes('types') && !fixtureEvents.includes('paths') && !fixtureEvents.includes('branches'));
14799+
t('the flow-sequence spelling is read', declaredTriggerEvents('on: [push, pull_request]\njobs: {}\n').join('|') === 'push|pull_request');
14800+
t('the bare-scalar spelling is read', declaredTriggerEvents('on: push\njobs: {}\n').join('|') === 'push');
14801+
t('the block-sequence spelling is read', declaredTriggerEvents('on:\n - push\n - schedule\njobs: {}\n').join('|') === 'push|schedule');
14802+
// The YAML 1.1 coercion the two walkers above already accept: unquoted `on`
14803+
// is the boolean `true`, so all three spellings name the same key.
14804+
t('the quoted and YAML-1.1 spellings of the key are all read', ["'on'", '"on"', 'true'].every((k) => declaredTriggerEvents(`${k}:\n schedule:\n - cron: '0 1 * * *'\n`).join('|') === 'schedule'));
14805+
t('a workflow declaring no on: block yields an empty list, not a fabricated event', declaredTriggerEvents('name: X\njobs: {}\n').length === 0);
14806+
14807+
// The same reader against REAL `on:` blocks, read from the tree rather than
14808+
// pasted: a quoted copy of a workflow is a second revision of it waiting to
14809+
// rot, which is what this whole file refuses.
14810+
const eventsOfWorkflow = new Map();
14811+
for (const f of readdirSync(wfDirLive).filter((x) => /\.ya?ml$/.test(x))) {
14812+
eventsOfWorkflow.set(f, declaredTriggerEvents(readFileSync(nodePath.join(wfDirLive, f), 'utf8')));
14813+
}
14814+
t(
14815+
'⭐ the card\'s own specimen declares a pull_request trigger beside its schedule — half-state-patrol.yml is not a workflow no PR runs',
14816+
['schedule', 'workflow_dispatch', 'pull_request'].every((e) => (eventsOfWorkflow.get('half-state-patrol.yml') ?? []).includes(e)),
14817+
);
14818+
t(
14819+
'and a genuinely scheduled-only workflow reads as one, so the predicate is not answering `pull_request` to everything (stale.yml)',
14820+
(eventsOfWorkflow.get('stale.yml') ?? []).join('|') === 'schedule|workflow_dispatch',
14821+
);
14822+
14823+
// The live half. Fixtures cannot prove the tree has no scheduled-only
14824+
// family; this reads it.
14825+
const reachesPRTime = (workflows, prTime = PR_TIME_TRIGGER_EVENTS) =>
14826+
[...workflows].some((wf) => (eventsOfWorkflow.get(wf) ?? []).some((e) => prTime.includes(e)));
14827+
const isScheduled = (wf) => (eventsOfWorkflow.get(wf) ?? []).includes('schedule');
14828+
const triggerFamilies = [...discoverFamilies().byCheck.values()];
14829+
const scheduledWorkflows = [...eventsOfWorkflow.keys()].filter(isScheduled);
14830+
const scheduledContributors = scheduledWorkflows.filter((wf) => triggerFamilies.some((e) => e.workflows.has(wf)));
14831+
const fromScheduled = triggerFamilies.filter((e) => [...e.workflows].some(isScheduled));
14832+
const scheduledOnly = triggerFamilies.filter((e) => !reachesPRTime(e.workflows) && [...e.workflows].every(isScheduled));
14833+
// Non-vacuity, both halves — a zero over an empty sweep is a broken
14834+
// instrument wearing a clean result's clothes, which is #4690's shape.
14835+
t(
14836+
`the live tree really declares ${scheduledWorkflows.length} schedule-triggered workflow(s), so the sweep below has a population`,
14837+
scheduledWorkflows.length > 0,
14838+
);
14839+
t(
14840+
`…and ${scheduledContributors.length} of them really contribute discovered families (${fromScheduled.length} famil(ies)), so the zero below is a reading`,
14841+
scheduledContributors.length > 0 && fromScheduled.length > 0,
14842+
);
14843+
t(
14844+
`⭐ ZERO of the ${triggerFamilies.length} discovered families is SCHEDULED-ONLY — every one reaches a workflow that declares a PR-time`
14845+
+ ' event, so no board sweep is routed into a per-PR gate list. If this reds, a scheduled-only family has ARRIVED: give its'
14846+
+ ' workflow the pull_request paths trigger every patrol here already carries, or ship the withheld class the header defers',
14847+
scheduledOnly.length === 0,
14848+
);
14849+
t(
14850+
'…and the reading does not depend on how wide PR-time is drawn: narrowing it to `pull_request` alone leaves the same zero',
14851+
triggerFamilies.filter((e) => !reachesPRTime(e.workflows, ['pull_request']) && [...e.workflows].every(isScheduled)).length === 0,
14852+
);
14853+
// The complement, so the zero above cannot be the union quietly hiding a
14854+
// member: a family reached by NO PR-time event at all must come from a
14855+
// workflow that declares no `schedule` either. Today that is `cut-rc.yml`,
14856+
// the human release lane, which is `workflow_dispatch`-only.
14857+
const noPRTime = triggerFamilies.filter((e) => !reachesPRTime(e.workflows));
14858+
t(
14859+
`the complement agrees: all ${noPRTime.length} famil(ies) reached by no PR-time event at all come from workflows that declare no schedule`,
14860+
noPRTime.every((e) => [...e.workflows].every((wf) => !isScheduled(wf))),
14861+
);
14862+
// The control the card's ruling names: a family from a scheduled workflow
14863+
// that ALSO declares a PR-time trigger keeps the class it already had. The
14864+
// card's own specimen is the subject — it is withheld from `--commands` by
14865+
// the value-bearing class (#15083) and by nothing else, which is why the
14866+
// 3m09s it measured is gone without any scheduled-only rule existing.
14867+
const sweepEntry = triggerFamilies.find((e) => e.check.startsWith('scripts/pm/check-half-states.mjs'));
14868+
t(
14869+
'the card\'s specimen is still discovered, still reached only through its patrol, and still classified VALUE-BEARING — not withheld for being scheduled',
14870+
Boolean(sweepEntry)
14871+
&& [...sweepEntry.workflows].join('|') === 'half-state-patrol.yml'
14872+
&& isScheduled('half-state-patrol.yml')
14873+
&& reachesPRTime(sweepEntry.workflows)
14874+
&& Boolean(sweepEntry.notRunnable)
14875+
&& !sweepEntry.ciOnly,
14876+
);
14877+
// And the half this card must NOT move: the OFFLINE self-test lint.yml runs
14878+
// on every PR stays a runnable command. It is the same script's other
14879+
// spelling, and a rule keyed on the sweeper's name rather than on the
14880+
// workflow text — the per-script exclusion the ruling refused — would have
14881+
// taken this one with it.
14882+
const offlineHalf = triggerFamilies.find((e) => e.check === 'check:pm-half-states');
14883+
t(
14884+
'and the offline half CI runs on every PR is untouched — check:pm-half-states reaches lint.yml, carries neither withholding class, and still renders a runnable command',
14885+
Boolean(offlineHalf)
14886+
&& offlineHalf.workflows.has('lint.yml')
14887+
&& reachesPRTime(offlineHalf.workflows)
14888+
&& !offlineHalf.ciOnly
14889+
&& !offlineHalf.notRunnable,
14890+
);
14891+
1457714892
// ── The population a job `if:` names one hop away (#12956) ────────────────
1457814893
//
1457914894
// The card: an `.objectui-sha` diff derived NO pin-critical gate, because

0 commit comments

Comments
 (0)