@@ -1187,6 +1187,15 @@ export function extractTriggerPaths(workflowText) {
11871187 * of it before the red lands on `main`. Refusing it would drop the console and
11881188 * dogfood leads this function exists to give. Any other event term, a reordered
11891189 * clause or one event alone is still refused whole.
1190+ *
1191+ * ⛔ Stripped is not FORGOTTEN (#24073). The clause decides nothing about paths,
1192+ * but it decides WHEN: such a job never runs on the PR or in the merge queue.
1193+ * `jobIfReading` below returns that fact beside the refs, and every row a
1194+ * path-scheduled job renders carries it to the line the reader reads. Measured
1195+ * before it did: a `packages/runtime/src/route-ledger.ts` change derived the
1196+ * Dogfood Regression Gate as "scheduled BY YOUR PATHS" with nothing saying the
1197+ * PR would never run it, and the first run of its pins was `main`'s push
1198+ * battery, red.
11901199 */
11911200const JOB_IF_STATUS_TERM = /(?:^|\s)!\s*cancelled\(\)\s*&&\s*/g;
11921201const JOB_IF_FULL_BATTERY_TERM =
@@ -1201,12 +1210,27 @@ const JOB_FILTER_OUTPUT_TERM =
12011210 * family keeps whatever verdict it had before.
12021211 */
12031212export function jobFilterOutputRefs(ifExpression) {
1213+ return jobIfReading(ifExpression)?.refs ?? null;
1214+ }
1215+
1216+ /**
1217+ * The whole reading of a job `if:` — `{ refs, fullBatteryOnly }`, or null on
1218+ * exactly the refusals `jobFilterOutputRefs` makes, because that function IS
1219+ * this one with the second half dropped. `fullBatteryOnly` is true when the
1220+ * full-battery clause was stripped as the last conjunct: the job runs on
1221+ * `main`'s push and hourly runs and NEVER on a `pull_request` or `merge_group`
1222+ * event. It is read in the same pass as the refs, from the same text, so the
1223+ * population and the event fact cannot describe two different `if:`s.
1224+ */
1225+ export function jobIfReading(ifExpression) {
12041226 if (typeof ifExpression !== 'string') return null;
12051227 let expr = ifExpression.trim();
12061228 const wrapped = /^\$\{\{([\s\S]*)\}\}$/.exec(expr);
12071229 if (wrapped) expr = wrapped[1].trim();
12081230 expr = expr.replace(JOB_IF_STATUS_TERM, ' ').trim();
1209- expr = expr.replace(JOB_IF_FULL_BATTERY_TERM, '').trim();
1231+ const eventFree = expr.replace(JOB_IF_FULL_BATTERY_TERM, '').trim();
1232+ const fullBatteryOnly = eventFree !== expr;
1233+ expr = eventFree;
12101234 // One balanced outer paren pair at a time — `(A || B)` is the live spelling.
12111235 for (;;) {
12121236 if (!expr.startsWith('(') || !expr.endsWith(')')) break;
@@ -1227,7 +1251,7 @@ export function jobFilterOutputRefs(ifExpression) {
12271251 if (!m) return null;
12281252 refs.push({ job: m[1], output: m[2] });
12291253 }
1230- return refs.length ? refs : null;
1254+ return refs.length ? { refs, fullBatteryOnly } : null;
12311255}
12321256
12331257/**
@@ -1426,12 +1450,15 @@ const EXTGLOB_CONSTRUCT = /[!?*+@]\(/;
14261450 * The path population a job's `if:` resolves to, or null — the whole of
14271451 * direction 1, assembled from the three readings above.
14281452 *
1429- * Returns `{ paths, outputs, dropped }`. `paths` is a UNION: the `if:` is an OR
1430- * of filter outputs, so CI schedules the job when ANY of them matched.
1453+ * Returns `{ paths, outputs, dropped, fullBatteryOnly }`. `paths` is a UNION:
1454+ * the `if:` is an OR of filter outputs, so CI schedules the job when ANY of them
1455+ * matched. `fullBatteryOnly` is `jobIfReading`'s event fact, carried so a row
1456+ * built from this population can say WHEN CI schedules it (#24073).
14311457 */
14321458export function jobFilterPopulation(job, filterStepsByOutputSource) {
1433- const refs = jobFilterOutputRefs(job.if);
1434- if (!refs) return null;
1459+ const reading = jobIfReading(job.if);
1460+ if (!reading) return null;
1461+ const { refs, fullBatteryOnly } = reading;
14351462 const paths = [];
14361463 const outputs = [];
14371464 let dropped = 0;
@@ -1450,12 +1477,12 @@ export function jobFilterPopulation(job, filterStepsByOutputSource) {
14501477 }
14511478 outputs.push(`${ref.job}.${ref.output}`);
14521479 }
1453- return paths.length ? { paths, outputs, dropped } : null;
1480+ return paths.length ? { paths, outputs, dropped, fullBatteryOnly } : null;
14541481}
14551482
14561483/**
14571484 * Every job in one workflow whose `if:` resolves to a path population, WITH the
1458- * job's own text: `[{ job, name, text, outputs, paths, dropped }]`.
1485+ * job's own text: `[{ job, name, text, outputs, paths, dropped, fullBatteryOnly }]`.
14591486 *
14601487 * ⛔ No check filter here, and that is the whole reason this is its own
14611488 * function. The caller below drops a job that invokes no discoverable check
@@ -2811,6 +2838,13 @@ export function alwaysRunSteps(entries) {
28112838 * of it runs a command no discovered family accounts for. Both halves are
28122839 * read off the workflow text on every run; nothing here is listed.
28132840 *
2841+ * ⭐ And WHEN it is scheduled (#24073). A row whose job `if:` ended in the
2842+ * full-battery clause carries `fullBatteryOnly: true`, read by `jobIfReading`
2843+ * in the same pass as the population: CI schedules that job for your path on
2844+ * `main`'s push and hourly runs ONLY — never on this PR, never in the merge
2845+ * queue. Both renderings print it on the row's own line, so "scheduled BY YOUR
2846+ * PATHS" cannot be read as "this PR runs it".
2847+ *
28142848 * ⛔ NOT claimed: that a row is a test, a build or a setup step.
28152849 * `alwaysRunLines` refuses that classification for its own rows and the reason
28162850 * carries unchanged — every rule that separates them is a guess about a step's
@@ -2896,7 +2930,10 @@ export function jobFilteredSteps(entries, paths) {
28962930 }
28972931 if (steps.length === 0) continue;
28982932 counts.named += 1;
2899- rows.push({ workflow: file, job: job.name, outputs: job.outputs, hits, dropped: job.dropped, steps });
2933+ rows.push({
2934+ workflow: file, job: job.name, outputs: job.outputs, hits, dropped: job.dropped,
2935+ fullBatteryOnly: job.fullBatteryOnly === true, steps,
2936+ });
29002937 }
29012938 }
29022939 return { rows, counts };
@@ -11460,6 +11497,37 @@ export function alwaysRunLines(rows, counts) {
1146011497 return lines;
1146111498}
1146211499
11500+ /**
11501+ * The WHEN of a path-scheduled job row, as the one clause both renderings print
11502+ * on that row's own line (#24073) — or the empty string when the row's job runs
11503+ * on the PR like any other.
11504+ *
11505+ * Read off `row.fullBatteryOnly`, the fact `jobIfReading` took from the job's
11506+ * own `if:`; ⛔ never keyed on a job name, so a job that gains or loses the
11507+ * full-battery clause gains or loses the clause here with nothing to update.
11508+ * The reader it exists for: a dev whose path schedules the Dogfood Regression
11509+ * Gate, told only "scheduled BY YOUR PATHS", inferred that the PR would run its
11510+ * pins — and the first run was `main`'s push battery, red.
11511+ */
11512+ export function fullBatteryOnlyClause(row) {
11513+ if (row?.fullBatteryOnly !== true) return '';
11514+ return '⛔ does NOT run on this PR or in the merge queue: its `if:` excludes the pull_request and merge_group events,' +
11515+ " so its first run on this change is `main`'s push battery, AFTER the merge";
11516+ }
11517+
11518+ /**
11519+ * One path-scheduled job row as `--commands` / `--json` print it on stderr — a
11520+ * named reader rather than an inline template so the line a dispatch is built
11521+ * from is the line the self-test pins. The WHEN clause sits directly after the
11522+ * job name, ahead of the step list, because a long step list is where a
11523+ * trailing clause goes unread.
11524+ */
11525+ export function jobFilteredNotMeasuredLine(row) {
11526+ const when = fullBatteryOnlyClause(row);
11527+ return ` ⊘ NOT MEASURED — [${row.workflow} · ${row.job}]${when ? ` ${when} —` : ''} ${row.steps.length} step(s):` +
11528+ ` ${row.steps.map((s) => s.step).join(' · ')}`;
11529+ }
11530+
1146311531/**
1146411532 * The path-scheduled CI jobs, rendered (#16285) — printed BELOW the
1146511533 * reconciliation, because everything under that total names something OUTSIDE
@@ -11502,8 +11570,9 @@ export function jobFilteredStepLines(rows, counts) {
1150211570 ' ⛔ NOT runnable and ⛔ NOT in --commands: every row is CI\'s own shell in CI\'s environment. They sit OUTSIDE the runnable',
1150311571 ' total above — running every family named there does NOT cover them, which is the gap this block exists to close.',
1150411572 ' ⛔ NOT classified into tests, builds and setup — the same refusal the always-runs tail makes, for the same reason: every rule',
11505- ' that tells them apart is a guess about a step\'s intent. What is claimed for a row is only what was measured — YOUR path puts',
11506- ' this job on the PR, and nothing above names a family for this step. The reader judges the rest.',
11573+ ' that tells them apart is a guess about a step\'s intent. What is claimed for a row is only what was measured — YOUR path',
11574+ ' schedules this job (on this PR unless its row says it does NOT), and nothing above names a family for this step.',
11575+ ' The reader judges the rest.',
1150711576 ' ⇒ The always-runs tail below is the OTHER half of this partition: it reads the jobs CI cannot narrow by path, this one reads',
1150811577 ' the jobs your paths narrow it TO — and a job carrying an `if:` is excluded there, which is every job on this list.',
1150911578 ];
@@ -11516,8 +11585,10 @@ export function jobFilteredStepLines(rows, counts) {
1151611585 for (const row of rows) {
1151711586 const via = row.hits.map((h) => `${h.path} ⇢ '${h.pattern}'`).join('; ');
1151811587 const dropped = row.dropped ? `, ${row.dropped} glob(s) of it untranslatable and dropped` : '';
11588+ const when = fullBatteryOnlyClause(row);
1151911589 lines.push(
11520- ` - [${row.workflow} · ${row.job}] scheduled by ${via} (job \`if:\` reads ${row.outputs.join(', ')}${dropped})`,
11590+ ` - [${row.workflow} · ${row.job}] scheduled by ${via} (job \`if:\` reads ${row.outputs.join(', ')}${dropped})` +
11591+ `${when ? ` ${when}` : ''}`,
1152111592 );
1152211593 for (const { step, commands } of row.steps) {
1152311594 lines.push(` · ${step}`);
@@ -14346,12 +14417,7 @@ export function machineReadableOutput(mode, { paths, size = null, matchedRows, k
1434614417 ` — ${mode === 'json' ? 'under jobFilteredSteps, not in commands' : 'NOT above'}. They are CI's own shell in CI's environment,` +
1434714418 ' so there is no local invocation to hand you, and running every command on stdout does ⛔ NOT cover them.',
1434814419 );
14349- for (const row of jobFiltered.rows) {
14350- console.error(
14351- ` ⊘ NOT MEASURED — [${row.workflow} · ${row.job}] ${row.steps.length} step(s):` +
14352- ` ${row.steps.map((s) => s.step).join(' · ')}`,
14353- );
14354- }
14420+ for (const row of jobFiltered.rows) console.error(jobFilteredNotMeasuredLine(row));
1435514421 console.error(' ⇒ Run without --commands/--json to see each step printed as CI spells it.');
1435614422 }
1435714423 // ⭐ The SEVENTH thing stdout deliberately omits (#19172) — and the lane this
0 commit comments