@cleocode/core 2026.10.22 → 2026.10.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/build-provenance.json +2 -2
  2. package/dist/orchestrate/spawn-ops.d.ts +7 -1
  3. package/dist/orchestrate/spawn-ops.d.ts.map +1 -1
  4. package/dist/orchestrate/spawn-ops.js +13 -1
  5. package/dist/orchestrate/spawn-ops.js.map +1 -1
  6. package/dist/release/ci-evidence.d.ts +114 -1
  7. package/dist/release/ci-evidence.d.ts.map +1 -1
  8. package/dist/release/ci-evidence.js +265 -45
  9. package/dist/release/ci-evidence.js.map +1 -1
  10. package/dist/resources/admission-ledger.d.ts +8 -2
  11. package/dist/resources/admission-ledger.d.ts.map +1 -1
  12. package/dist/resources/admission-ledger.js +14 -4
  13. package/dist/resources/admission-ledger.js.map +1 -1
  14. package/dist/tasks/complete-ci.d.ts.map +1 -1
  15. package/dist/tasks/complete-ci.js +12 -4
  16. package/dist/tasks/complete-ci.js.map +1 -1
  17. package/dist/tasks/component-pr.d.ts +35 -0
  18. package/dist/tasks/component-pr.d.ts.map +1 -1
  19. package/dist/tasks/component-pr.js +64 -0
  20. package/dist/tasks/component-pr.js.map +1 -1
  21. package/dist/tasks/evidence.d.ts.map +1 -1
  22. package/dist/tasks/evidence.js +119 -7
  23. package/dist/tasks/evidence.js.map +1 -1
  24. package/dist/tasks/gate-runner.d.ts.map +1 -1
  25. package/dist/tasks/gate-runner.js +48 -13
  26. package/dist/tasks/gate-runner.js.map +1 -1
  27. package/dist/tasks/heavy-tool-env.d.ts +42 -4
  28. package/dist/tasks/heavy-tool-env.d.ts.map +1 -1
  29. package/dist/tasks/heavy-tool-env.js +132 -4
  30. package/dist/tasks/heavy-tool-env.js.map +1 -1
  31. package/dist/tasks/test-run-binding.js +3 -1
  32. package/dist/tasks/test-run-binding.js.map +1 -1
  33. package/dist/tasks/tool-cache.d.ts.map +1 -1
  34. package/dist/tasks/tool-cache.js +6 -2
  35. package/dist/tasks/tool-cache.js.map +1 -1
  36. package/package.json +13 -13
  37. package/templates/CLEO-INJECTION.md +1 -1
  38. package/templates/CLEO-REFERENCE.md +7 -5
@@ -39,6 +39,14 @@
39
39
  * {@link nonTestFailuresOn}. The stand-in search also looks past later runs red
40
40
  * for that reason alone, up to {@link CI_DESCENDANT_MAX_CANDIDATES} commits.
41
41
  *
42
+ * A fourth (T14032, owner decision D11192), for a WORKSPACE-WIDE change only
43
+ * (one a targeted test-run cannot speak for: a lockfile, a root config): when
44
+ * the merge commit is not green and no T12742 descendant stands in, a green
45
+ * push run of every required check on the default branch's CURRENT head (or
46
+ * on a commit differing from it only in documentation) that descends from the
47
+ * merge stands in, whatever the merge's own run concluded and whatever later
48
+ * commits touched. See {@link findGreenCurrentHead}.
49
+ *
42
50
  * @task T12634
43
51
  */
44
52
  import { execFileSync } from 'node:child_process';
@@ -384,6 +392,7 @@ export function evaluateMergeCommitChecks(required, checks, mergeCommitSha, opts
384
392
  if (descendantSha !== undefined &&
385
393
  descendantSha !== mergeCommitSha &&
386
394
  (SUPERSEDED_VERDICTS.has(onMerge.verdict) ||
395
+ opts.currentHead === true ||
387
396
  (opts.inheritedFailures === true &&
388
397
  onMerge.verdict !== 'missing' &&
389
398
  !onMerge.verdict.startsWith('pending')))) {
@@ -544,6 +553,18 @@ function latestRuns(checks, sha, scope) {
544
553
  }
545
554
  return [...latest.values()];
546
555
  }
556
+ /**
557
+ * Why a merge-commit run did not test the merge as it was when it landed
558
+ * (T14026): a re-run, or a start after a later default-branch commit landed.
559
+ * Null when neither is known.
560
+ */
561
+ function lateRun(run, nextMainCommitAt) {
562
+ if ((run.runAttempt ?? 1) > 1)
563
+ return `is re-run attempt ${run.runAttempt}`;
564
+ if (run.startedAt && nextMainCommitAt && Date.parse(run.startedAt) > Date.parse(nextMainCommitAt))
565
+ return `started at ${run.startedAt}, after a later default-branch commit landed (${nextMainCommitAt})`;
566
+ return null;
567
+ }
547
568
  /**
548
569
  * Whether the merge commit's failures were all inherited from main (T13453).
549
570
  *
@@ -555,15 +576,23 @@ function latestRuns(checks, sha, scope) {
555
576
  * the PR may have introduced and refuses. A pending merge-commit run refuses.
556
577
  * Pure: the ancestors' checks are passed in, nearest first.
557
578
  *
579
+ * The refusal blames the PR only when the failing run could have seen nothing
580
+ * but the PR's change (T14026): a re-run, or a run that started after a later
581
+ * default-branch commit landed, ran in a world that moved on since the merge
582
+ * (rotated secrets, external services), so it still refuses but says the
583
+ * failure may not be the PR's.
584
+ *
558
585
  * @param required - Required check names.
559
586
  * @param checks - Checks fetched for the merge commit.
560
587
  * @param mergeCommitSha - The merge commit.
561
588
  * @param ancestors - First-parent ancestors of the merge, nearest first, with their checks.
562
589
  * @param pins - Required-name to app/workflow pin.
590
+ * @param nextMainCommitAt - When the next default-branch commit after the merge landed, if known.
563
591
  * @returns The required names to stand in for and the inherited jobs, or why not.
564
592
  * @task T13453
593
+ * @task T14026
565
594
  */
566
- export function inheritedOnMerge(required, checks, mergeCommitSha, ancestors, pins) {
595
+ export function inheritedOnMerge(required, checks, mergeCommitSha, ancestors, pins, nextMainCommitAt = null) {
567
596
  const scope = pinnedScope(required, pins);
568
597
  const onMerge = latestRuns(checks, mergeCommitSha, scope);
569
598
  const pending = onMerge.find((c) => c.status !== 'completed');
@@ -597,9 +626,13 @@ export function inheritedOnMerge(required, checks, mergeCommitSha, ancestors, pi
597
626
  };
598
627
  }
599
628
  if (base.run.conclusion === 'success') {
629
+ const at = `${job.name}: ${job.conclusion} on merge commit ${mergeCommitSha.slice(0, 12)} but success on its base ${base.sha.slice(0, 12)}`;
630
+ const late = lateRun(job, nextMainCommitAt);
600
631
  return {
601
632
  ok: false,
602
- reason: `${job.name}: ${job.conclusion} on merge commit ${mergeCommitSha.slice(0, 12)} but success on its base ${base.sha.slice(0, 12)} — the PR introduced this failure`,
633
+ reason: late
634
+ ? `${at}; the failing run ${late}, so it may have failed on something that changed after the merge (secrets, external services) rather than on the PR`
635
+ : `${at} — the PR introduced this failure`,
603
636
  };
604
637
  }
605
638
  inherited.push({
@@ -958,7 +991,7 @@ export async function findGreenDescendant(input) {
958
991
  break;
959
992
  ancestors.push({ sha: at, checks: fetched.checks });
960
993
  }
961
- const result = inheritedOnMerge(required, input.checks, merge, ancestors, pins);
994
+ const result = inheritedOnMerge(required, input.checks, merge, ancestors, pins, input.nextMainCommitAt?.(merge, input.ref, cwd) ?? null);
962
995
  if (result.ok) {
963
996
  superseded = { ok: true, names: result.names };
964
997
  inherited = result.inherited;
@@ -1050,59 +1083,140 @@ export async function findGreenDescendant(input) {
1050
1083
  reason: `${failedJob.name}: ${failedJob.conclusion} on later ${input.ref} commit ${sha.slice(0, 12)}`,
1051
1084
  };
1052
1085
  }
1053
- let decided = true;
1054
- for (const name of supersededNames) {
1055
- const pinned = fetched.checks.filter((c) => c.name === name && pinMatches(c, pins[name]));
1056
- const verdict = judgeOnSha(pinned, sha, 'push');
1057
- if (verdict.ok)
1058
- continue;
1059
- if (verdict.verdict.startsWith('pending')) {
1060
- return {
1061
- ok: false,
1062
- reason: `${name}: ${verdict.verdict} on later ${input.ref} commit ${sha.slice(0, 12)} — wait for ${sha.slice(0, 12)} to finish, then verify again`,
1063
- };
1064
- }
1065
- if (verdict.verdict === 'missing' || SUPERSEDED_VERDICTS.has(verdict.verdict)) {
1066
- decided = false;
1067
- break;
1068
- }
1086
+ const verdict = judgeStandIn(fetched.checks, sha, supersededNames, pins, input.jobs, input.ref);
1087
+ if (verdict === 'undecided')
1088
+ continue;
1089
+ if (!verdict.ok)
1090
+ return verdict;
1091
+ return {
1092
+ ok: true,
1093
+ sha,
1094
+ checks: [...fetched.checks, ...headChecks],
1095
+ range: `${merge}..${sha}`,
1096
+ ...(head ? { prHeadSha: head } : {}),
1097
+ ...(inherited ? { inherited } : {}),
1098
+ ...(nonTest ? { nonTest } : {}),
1099
+ };
1100
+ }
1101
+ return {
1102
+ ok: false,
1103
+ reason: `no later ${input.ref} commit (first ${CI_DESCENDANT_MAX_CANDIDATES} within 7 days) has a completed push run of ${supersededNames.join(', ')}`,
1104
+ };
1105
+ }
1106
+ /**
1107
+ * Judge one later default-branch commit's own `push` runs as a stand-in
1108
+ * (T12742, T14032): every named check green, and the gates' jobs run and
1109
+ * green (T13846). A pending run refuses with "wait"; a failed one refuses —
1110
+ * a green run is never shopped for past one that is red or may still go red.
1111
+ * Cancelled, skipped and missing (never-started) runs leave it undecided, so
1112
+ * the caller looks at the next candidate.
1113
+ */
1114
+ function judgeStandIn(checks, sha, names, pins, jobs, ref) {
1115
+ for (const name of names) {
1116
+ const pinned = checks.filter((c) => c.name === name && pinMatches(c, pins[name]));
1117
+ const verdict = judgeOnSha(pinned, sha, 'push');
1118
+ if (verdict.ok)
1119
+ continue;
1120
+ if (verdict.verdict.startsWith('pending')) {
1069
1121
  return {
1070
1122
  ok: false,
1071
- reason: `${name}: ${verdict.verdict} on later ${input.ref} commit ${sha.slice(0, 12)}`,
1123
+ reason: `${name}: ${verdict.verdict} on later ${ref} commit ${sha.slice(0, 12)} — wait for ${sha.slice(0, 12)} to finish, then verify again`,
1072
1124
  };
1073
1125
  }
1074
- // T13846: the gates' own jobs must have run on the stand-in too.
1075
- for (const job of decided ? (input.jobs ?? []) : []) {
1076
- const onSha = evaluateJobs(job.globs, fetched.checks, sha, job.scope, 'push');
1077
- if (onSha.ok)
1078
- continue;
1079
- const pending = onSha.reasons.find((r) => r.includes(': pending ('));
1080
- if (pending) {
1126
+ if (verdict.verdict === 'missing' || SUPERSEDED_VERDICTS.has(verdict.verdict)) {
1127
+ return 'undecided';
1128
+ }
1129
+ return {
1130
+ ok: false,
1131
+ reason: `${name}: ${verdict.verdict} on later ${ref} commit ${sha.slice(0, 12)}`,
1132
+ };
1133
+ }
1134
+ // T13846: the gates' own jobs must have run on the stand-in too.
1135
+ let decided = true;
1136
+ for (const job of jobs ?? []) {
1137
+ const onSha = evaluateJobs(job.globs, checks, sha, job.scope, 'push');
1138
+ if (onSha.ok)
1139
+ continue;
1140
+ const pending = onSha.reasons.find((r) => r.includes(': pending ('));
1141
+ if (pending) {
1142
+ return {
1143
+ ok: false,
1144
+ reason: `${pending} — wait for ${sha.slice(0, 12)} to finish, then verify again`,
1145
+ };
1146
+ }
1147
+ const decisive = onSha.reasons.find((r) => !JOB_NOT_RUN.test(r));
1148
+ if (decisive)
1149
+ return { ok: false, reason: `${decisive} (later ${ref} commit)` };
1150
+ decided = false;
1151
+ }
1152
+ return decided ? { ok: true } : 'undecided';
1153
+ }
1154
+ /**
1155
+ * Find the green required-CI push run on the default branch's CURRENT head
1156
+ * that stands in for a workspace-wide change's merge-commit CI (T14032, owner
1157
+ * decision D11192). A workspace-wide change (a lockfile, a root config, a CI
1158
+ * definition) only a full run speaks for; when its merge-commit run was red
1159
+ * for a reason that is not the change (a secrets job failing on a re-run) and
1160
+ * later commits touched its files, {@link findGreenDescendant} refuses, yet a
1161
+ * full green CI on today's HEAD tests the merged change as it is now — the
1162
+ * same reasoning as the targeted test-run stand-in (T13673), which also
1163
+ * allows later edits.
1164
+ *
1165
+ * Candidates are HEAD, then its first parents while they differ from HEAD only
1166
+ * in documentation ({@link isCiDocumentPath}, the T13784 rule: nothing a test
1167
+ * reads moved), up to {@link CI_DESCENDANT_MAX_CANDIDATES}. Each must descend
1168
+ * from the merge commit. The first decisive verdict decides, as in
1169
+ * {@link findGreenDescendant}: every required check green on its own `push`
1170
+ * runs (and the gates' jobs run, T13846) accepts; a failed or pending one
1171
+ * refuses; cancelled, skipped or missing runs move to the parent.
1172
+ *
1173
+ * @param input - Required checks, pins, merge commit and injected I/O.
1174
+ * @returns The stand-in commit, its checks and the head, or the refusal reason.
1175
+ * @task T14032
1176
+ */
1177
+ export async function findGreenCurrentHead(input) {
1178
+ const { mergeCommitSha: merge, ref, cwd } = input;
1179
+ const head = input.defaultBranchHead(ref, cwd);
1180
+ if (!head)
1181
+ return { ok: false, reason: `cannot read the current head of ${ref}` };
1182
+ let sha = head;
1183
+ for (let n = 0; n < CI_DESCENDANT_MAX_CANDIDATES && sha; n++) {
1184
+ const at = sha === head ? `${ref} HEAD ${head.slice(0, 12)}` : `${sha.slice(0, 12)}`;
1185
+ if (sha === merge || !input.isAncestor(merge, sha, cwd)) {
1186
+ return {
1187
+ ok: false,
1188
+ reason: sha === head
1189
+ ? `${at} does not descend from merge commit ${merge.slice(0, 12)} here (git fetch origin, then verify again)`
1190
+ : `no push run between merge commit ${merge.slice(0, 12)} and ${ref} HEAD ${head.slice(0, 12)} decided the required checks`,
1191
+ };
1192
+ }
1193
+ if (sha !== head) {
1194
+ const moved = input.changedBetween(sha, head, cwd);
1195
+ if (moved === null) {
1196
+ return { ok: false, reason: `cannot diff ${sha.slice(0, 12)}..${head.slice(0, 12)}` };
1197
+ }
1198
+ const testInput = moved.find((p) => !isCiDocumentPath(p));
1199
+ if (testInput) {
1081
1200
  return {
1082
1201
  ok: false,
1083
- reason: `${pending} — wait for ${sha.slice(0, 12)} to finish, then verify again`,
1202
+ reason: `${ref} HEAD ${head.slice(0, 12)} has no completed push run of the required checks, and ${sha.slice(0, 12)}..${head.slice(0, 12)} changes ${testInput}, so an earlier run does not test HEAD — wait for HEAD's push CI to finish, then verify again`,
1084
1203
  };
1085
1204
  }
1086
- const decisive = onSha.reasons.find((r) => !JOB_NOT_RUN.test(r));
1087
- if (decisive)
1088
- return { ok: false, reason: `${decisive} (later ${input.ref} commit)` };
1089
- decided = false;
1090
1205
  }
1091
- if (decided) {
1092
- return {
1093
- ok: true,
1094
- sha,
1095
- checks: [...fetched.checks, ...headChecks],
1096
- range: `${merge}..${sha}`,
1097
- ...(head ? { prHeadSha: head } : {}),
1098
- ...(inherited ? { inherited } : {}),
1099
- ...(nonTest ? { nonTest } : {}),
1100
- };
1206
+ const fetched = await input.fetchChecks(sha, cwd);
1207
+ if (!fetched.ok)
1208
+ return { ok: false, reason: fetched.reason };
1209
+ const verdict = judgeStandIn(fetched.checks, sha, input.required, input.pins, input.jobs, ref);
1210
+ if (verdict !== 'undecided') {
1211
+ if (!verdict.ok)
1212
+ return verdict;
1213
+ return { ok: true, sha, checks: fetched.checks, range: `${merge}..${sha}`, head };
1101
1214
  }
1215
+ sha = input.firstParentOf(sha, cwd);
1102
1216
  }
1103
1217
  return {
1104
1218
  ok: false,
1105
- reason: `no later ${input.ref} commit (first ${CI_DESCENDANT_MAX_CANDIDATES} within 7 days) has a completed push run of ${supersededNames.join(', ')}`,
1219
+ reason: `no commit within ${CI_DESCENDANT_MAX_CANDIDATES} first parents of ${ref} HEAD ${head.slice(0, 12)} has a completed push run of ${input.required.join(', ')}`,
1106
1220
  };
1107
1221
  }
1108
1222
  /**
@@ -1222,6 +1336,48 @@ export function ghDefaultBranch(cwd) {
1222
1336
  return null;
1223
1337
  }
1224
1338
  }
1339
+ /** Default {@link ResolveCiEvidenceOptions.nextMainCommitAt} (T14026). */
1340
+ function defaultNextMainCommitAt(mergeSha, ref, cwd) {
1341
+ const out = gitRead(cwd, [
1342
+ 'log',
1343
+ '--first-parent',
1344
+ '--ancestry-path',
1345
+ '--reverse',
1346
+ '--format=%cI',
1347
+ `${mergeSha}..${ref}`,
1348
+ ]);
1349
+ return out?.split('\n')[0] || null;
1350
+ }
1351
+ /**
1352
+ * Default {@link ResolveCiEvidenceOptions.defaultBranchHead} (T14032): the
1353
+ * branch head GitHub reports, so a stale local ref never passes for the
1354
+ * current one; `git rev-parse <ref>` only when `gh` cannot answer.
1355
+ */
1356
+ function defaultBranchHeadOf(ref, cwd) {
1357
+ const branch = ref.replace(/^origin\//, '');
1358
+ if (isGitWorkTree(cwd) && isGhCliAvailable()) {
1359
+ const commit = ghApi(`repos/{owner}/{repo}/commits/${encodeURIComponent(branch)}`, cwd);
1360
+ if (typeof commit === 'object' &&
1361
+ commit !== null &&
1362
+ 'sha' in commit &&
1363
+ typeof commit.sha === 'string' &&
1364
+ /^[0-9a-f]{40}$/.test(commit.sha)) {
1365
+ return commit.sha;
1366
+ }
1367
+ }
1368
+ return gitRead(cwd, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]);
1369
+ }
1370
+ /** Default {@link ResolveCiEvidenceOptions.changedBetween} (T14032). */
1371
+ function defaultChangedBetween(from, to, cwd) {
1372
+ const out = gitRead(cwd, ['diff', '--name-only', '--no-renames', from, to]);
1373
+ return out === null ? null : out.split('\n').filter(Boolean);
1374
+ }
1375
+ /** Default {@link ResolveCiEvidenceOptions.workspaceWideReason}: the `test-run:` rule (T14032). */
1376
+ async function defaultWorkspaceWideReason(root, paths) {
1377
+ const { testRunRequirement } = await import('../tasks/test-run-binding.js');
1378
+ const need = testRunRequirement(root, paths);
1379
+ return need.kind === 'full' ? need.reason : null;
1380
+ }
1225
1381
  function defaultFirstParentOf(sha, cwd) {
1226
1382
  return gitRead(cwd, ['rev-parse', '--verify', '--quiet', `${sha}^1`]);
1227
1383
  }
@@ -1310,8 +1466,10 @@ async function defaultFetchChecks(sha, cwd) {
1310
1466
  for (const r of wf.workflow_runs ?? []) {
1311
1467
  const event = typeof r.event === 'string' ? r.event : undefined;
1312
1468
  const path = typeof r.path === 'string' ? r.path.replace(/@.*$/, '') : undefined;
1469
+ const attempt = typeof r.run_attempt === 'number' ? r.run_attempt : undefined;
1470
+ const startedAt = typeof r.run_started_at === 'string' ? r.run_started_at : undefined;
1313
1471
  if (typeof r.check_suite_id === 'number')
1314
- bySuite.set(r.check_suite_id, { event, path });
1472
+ bySuite.set(r.check_suite_id, { event, path, attempt, startedAt });
1315
1473
  checks.push({
1316
1474
  name: String(r.name ?? ''),
1317
1475
  source: 'workflow-run',
@@ -1322,6 +1480,8 @@ async function defaultFetchChecks(sha, cwd) {
1322
1480
  appSlug: 'github-actions',
1323
1481
  ...(path ? { workflowPath: path } : {}),
1324
1482
  ...(event ? { event } : {}),
1483
+ ...(attempt !== undefined ? { runAttempt: attempt } : {}),
1484
+ ...(startedAt ? { startedAt } : {}),
1325
1485
  });
1326
1486
  }
1327
1487
  for (let page = 1; page <= 10; page++) {
@@ -1333,6 +1493,14 @@ async function defaultFetchChecks(sha, cwd) {
1333
1493
  const app = r.app;
1334
1494
  const suite = r.check_suite?.id;
1335
1495
  const origin = typeof suite === 'number' ? bySuite.get(suite) : undefined;
1496
+ const startedAt = typeof r.started_at === 'string' ? r.started_at : undefined;
1497
+ // A job started before its workflow's latest attempt belongs to an earlier one.
1498
+ const attempt = origin?.attempt !== undefined &&
1499
+ startedAt &&
1500
+ origin.startedAt &&
1501
+ Date.parse(startedAt) >= Date.parse(origin.startedAt)
1502
+ ? origin.attempt
1503
+ : undefined;
1336
1504
  checks.push({
1337
1505
  name: String(r.name ?? ''),
1338
1506
  source: 'check-run',
@@ -1344,6 +1512,8 @@ async function defaultFetchChecks(sha, cwd) {
1344
1512
  ...(typeof app?.id === 'number' ? { appId: app.id } : {}),
1345
1513
  ...(origin?.path ? { workflowPath: origin.path } : {}),
1346
1514
  ...(origin?.event ? { event: origin.event } : {}),
1515
+ ...(attempt !== undefined ? { runAttempt: attempt } : {}),
1516
+ ...(startedAt ? { startedAt } : {}),
1347
1517
  });
1348
1518
  }
1349
1519
  if (runs.length < 100)
@@ -1371,7 +1541,8 @@ export async function recheckCiDescendantAtom(atom, cwd, fetchChecks = defaultFe
1371
1541
  if (!descendant)
1372
1542
  return { ok: true };
1373
1543
  const head = atom.descendantPrHeadSha;
1374
- if (!head && !atom.mainOnly) {
1544
+ // T14032: a workspace-wide current-HEAD stand-in consulted main's push runs only.
1545
+ if (!head && !atom.mainOnly && !atom.workspaceWide) {
1375
1546
  return {
1376
1547
  ok: false,
1377
1548
  reason: `ci:${atom.prNumber} leans on ${descendant.slice(0, 12)} but records no PR head; verify again`,
@@ -1650,6 +1821,7 @@ export async function resolveCiEvidenceAtom(prNumber, roots, opts = {}) {
1650
1821
  isAncestor: opts.isAncestor ?? defaultIsAncestor,
1651
1822
  firstParentOf: opts.firstParentOf ?? defaultFirstParentOf,
1652
1823
  fetchJobLog: opts.fetchJobLog ?? defaultFetchJobLog,
1824
+ nextMainCommitAt: opts.nextMainCommitAt ?? defaultNextMainCommitAt,
1653
1825
  });
1654
1826
  if (found.ok) {
1655
1827
  const withDescendant = [...checks, ...found.checks];
@@ -1678,6 +1850,52 @@ export async function resolveCiEvidenceAtom(prNumber, roots, opts = {}) {
1678
1850
  descendantReason = found.reason;
1679
1851
  }
1680
1852
  }
1853
+ // T14032 (owner decision D11192): a workspace-wide change, which only a full
1854
+ // run speaks for, is stood in for by a green push run of every required
1855
+ // check on the default branch's current head, descending from the merge,
1856
+ // whatever the merge's own run concluded and whatever later commits touched:
1857
+ // that run tested the merged change as it is now (as the T13673 test-run
1858
+ // stand-in does). A scoped change keeps the T12742 rule alone.
1859
+ let workspaceWide;
1860
+ let currentHeadReason;
1861
+ if (!judgedChecks.ok && onDefault.ref !== null && !docsOnlyDiff) {
1862
+ const wide = await (opts.workspaceWideReason ?? defaultWorkspaceWideReason)(cwd, pr.changedPaths);
1863
+ if (wide !== null) {
1864
+ const found = await findGreenCurrentHead({
1865
+ ...(jobSpecs.length > 0 ? { jobs: jobSpecs } : {}),
1866
+ required: required.workflows,
1867
+ mergeCommitSha: pr.mergeCommitSha,
1868
+ pins,
1869
+ ref: onDefault.ref,
1870
+ cwd,
1871
+ fetchChecks,
1872
+ defaultBranchHead: opts.defaultBranchHead ?? defaultBranchHeadOf,
1873
+ changedBetween: opts.changedBetween ?? defaultChangedBetween,
1874
+ firstParentOf: opts.firstParentOf ?? defaultFirstParentOf,
1875
+ isAncestor: opts.isAncestor ?? defaultIsAncestor,
1876
+ });
1877
+ const rejudged = found.ok
1878
+ ? evaluateMergeCommitChecks(required.workflows, [...checks, ...found.checks], pr.mergeCommitSha, {
1879
+ ...(treeEqualHead ? { treeEquivalentSha: treeEqualHead } : {}),
1880
+ pins,
1881
+ descendantSha: found.sha,
1882
+ currentHead: true,
1883
+ })
1884
+ : null;
1885
+ if (found.ok && rejudged?.ok) {
1886
+ checks = [...checks, ...found.checks];
1887
+ judgedChecks = rejudged;
1888
+ descendant = { sha: found.sha, range: found.range };
1889
+ workspaceWide = wide;
1890
+ }
1891
+ else {
1892
+ const why = found.ok
1893
+ ? `${found.sha.slice(0, 12)} was green, but re-judging with it still fails: ${rejudged && !rejudged.ok ? rejudged.reasons.join('; ') : ''}`
1894
+ : found.reason;
1895
+ currentHeadReason = `the change is workspace-wide (${wide}), so a green push run of the required checks on the current ${onDefault.ref} HEAD would stand in (T14032), but ${why}`;
1896
+ }
1897
+ }
1898
+ }
1681
1899
  if (!judgedChecks.ok) {
1682
1900
  const waiting = judgedChecks.reasons.some((r) => /pending|not found|missing/.test(r));
1683
1901
  return {
@@ -1686,6 +1904,7 @@ export async function resolveCiEvidenceAtom(prNumber, roots, opts = {}) {
1686
1904
  reason: `Required CI on PR #${prNumber}'s merge commit ${pr.mergeCommitSha.slice(0, 12)} is not green ` +
1687
1905
  `(source: ${describeRequiredWorkflowsSource(required.source)}):\n - ${judgedChecks.reasons.join('\n - ')}` +
1688
1906
  (descendantReason ? `\n No later main run stands in: ${descendantReason}` : '') +
1907
+ (currentHeadReason ? `\n No current-HEAD run stands in: ${currentHeadReason}` : '') +
1689
1908
  (mainOnly
1690
1909
  ? `\n PR #${prNumber} edits the pinned workflow ${editedWorkflows.join(', ')}, so only main's push CI attests it (T13174)` +
1691
1910
  (waiting
@@ -1806,6 +2025,7 @@ export async function resolveCiEvidenceAtom(prNumber, roots, opts = {}) {
1806
2025
  ...(descendant.nonTest ? { nonTestFailures: descendant.nonTest } : {}),
1807
2026
  }
1808
2027
  : {}),
2028
+ ...(workspaceWide ? { workspaceWide } : {}),
1809
2029
  ...(mainOnly ? { mainOnly: true } : {}),
1810
2030
  requiredSource: required.source.tier,
1811
2031
  taskId: context.task.id,