@cleocode/core 2026.10.25 → 2026.10.26-rc.ci.697

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 (198) hide show
  1. package/dist/build-provenance.json +2 -2
  2. package/dist/dispatch/contracts/output-contracts.d.ts +13 -0
  3. package/dist/dispatch/contracts/output-contracts.d.ts.map +1 -1
  4. package/dist/dispatch/contracts/output-contracts.js +29 -0
  5. package/dist/dispatch/contracts/output-contracts.js.map +1 -1
  6. package/dist/docs/docs-ops.d.ts +8 -0
  7. package/dist/docs/docs-ops.d.ts.map +1 -1
  8. package/dist/docs/docs-ops.js +24 -1
  9. package/dist/docs/docs-ops.js.map +1 -1
  10. package/dist/docs/docs-update.d.ts +8 -5
  11. package/dist/docs/docs-update.d.ts.map +1 -1
  12. package/dist/docs/docs-update.js +16 -5
  13. package/dist/docs/docs-update.js.map +1 -1
  14. package/dist/gateway-client/generated/types.gen.d.ts +27 -0
  15. package/dist/gateway-client/generated/types.gen.d.ts.map +1 -1
  16. package/dist/hooks/handlers/watchdog-hooks.d.ts.map +1 -1
  17. package/dist/hooks/handlers/watchdog-hooks.js +5 -0
  18. package/dist/hooks/handlers/watchdog-hooks.js.map +1 -1
  19. package/dist/internal.d.ts +3 -2
  20. package/dist/internal.d.ts.map +1 -1
  21. package/dist/internal.js +5 -2
  22. package/dist/internal.js.map +1 -1
  23. package/dist/lifecycle/verification/satisfies-validator.d.ts.map +1 -1
  24. package/dist/lifecycle/verification/satisfies-validator.js +22 -10
  25. package/dist/lifecycle/verification/satisfies-validator.js.map +1 -1
  26. package/dist/memory/brain-writer-thread.d.ts +30 -0
  27. package/dist/memory/brain-writer-thread.d.ts.map +1 -1
  28. package/dist/memory/brain-writer-thread.js +46 -1
  29. package/dist/memory/brain-writer-thread.js.map +1 -1
  30. package/dist/memory/brain-writer-worker.js +13 -2
  31. package/dist/memory/brain-writer-worker.js.map +1 -1
  32. package/dist/memory/embedding-backfill.d.ts +12 -0
  33. package/dist/memory/embedding-backfill.d.ts.map +1 -1
  34. package/dist/memory/embedding-backfill.js +51 -31
  35. package/dist/memory/embedding-backfill.js.map +1 -1
  36. package/dist/orchestrate/include-deps.d.ts +58 -0
  37. package/dist/orchestrate/include-deps.d.ts.map +1 -0
  38. package/dist/orchestrate/include-deps.js +138 -0
  39. package/dist/orchestrate/include-deps.js.map +1 -0
  40. package/dist/orchestrate/spawn-ops.d.ts +14 -33
  41. package/dist/orchestrate/spawn-ops.d.ts.map +1 -1
  42. package/dist/orchestrate/spawn-ops.js +49 -61
  43. package/dist/orchestrate/spawn-ops.js.map +1 -1
  44. package/dist/orchestrate/worktree-complete.d.ts +15 -1
  45. package/dist/orchestrate/worktree-complete.d.ts.map +1 -1
  46. package/dist/orchestrate/worktree-complete.js +36 -3
  47. package/dist/orchestrate/worktree-complete.js.map +1 -1
  48. package/dist/release/ci-evidence.d.ts +142 -4
  49. package/dist/release/ci-evidence.d.ts.map +1 -1
  50. package/dist/release/ci-evidence.js +298 -48
  51. package/dist/release/ci-evidence.js.map +1 -1
  52. package/dist/release/open.d.ts +25 -1
  53. package/dist/release/open.d.ts.map +1 -1
  54. package/dist/release/open.js +63 -11
  55. package/dist/release/open.js.map +1 -1
  56. package/dist/resources/admission-ledger.d.ts +83 -11
  57. package/dist/resources/admission-ledger.d.ts.map +1 -1
  58. package/dist/resources/admission-ledger.js +138 -39
  59. package/dist/resources/admission-ledger.js.map +1 -1
  60. package/dist/resources/governor.js +1 -1
  61. package/dist/resources/governor.js.map +1 -1
  62. package/dist/resources/hook-footprint.d.ts +24 -0
  63. package/dist/resources/hook-footprint.d.ts.map +1 -1
  64. package/dist/resources/hook-footprint.js +38 -1
  65. package/dist/resources/hook-footprint.js.map +1 -1
  66. package/dist/resources/run-class.d.ts +38 -5
  67. package/dist/resources/run-class.d.ts.map +1 -1
  68. package/dist/resources/run-class.js +60 -11
  69. package/dist/resources/run-class.js.map +1 -1
  70. package/dist/resources/run-governed.d.ts +13 -3
  71. package/dist/resources/run-governed.d.ts.map +1 -1
  72. package/dist/resources/run-governed.js +42 -9
  73. package/dist/resources/run-governed.js.map +1 -1
  74. package/dist/resources/slot-holder.d.ts +3 -3
  75. package/dist/resources/slot-holder.js +3 -3
  76. package/dist/resources/tool-groups.d.ts +11 -7
  77. package/dist/resources/tool-groups.d.ts.map +1 -1
  78. package/dist/resources/tool-groups.js +24 -13
  79. package/dist/resources/tool-groups.js.map +1 -1
  80. package/dist/spawn/branch-integration.d.ts +281 -0
  81. package/dist/spawn/branch-integration.d.ts.map +1 -0
  82. package/dist/spawn/branch-integration.js +715 -0
  83. package/dist/spawn/branch-integration.js.map +1 -0
  84. package/dist/spawn/branch-lock.d.ts +32 -270
  85. package/dist/spawn/branch-lock.d.ts.map +1 -1
  86. package/dist/spawn/branch-lock.js +59 -770
  87. package/dist/spawn/branch-lock.js.map +1 -1
  88. package/dist/spawn/index.d.ts +3 -1
  89. package/dist/spawn/index.d.ts.map +1 -1
  90. package/dist/spawn/index.js +5 -1
  91. package/dist/spawn/index.js.map +1 -1
  92. package/dist/store/exodus/recovery.d.ts.map +1 -1
  93. package/dist/store/exodus/recovery.js +9 -4
  94. package/dist/store/exodus/recovery.js.map +1 -1
  95. package/dist/system/health.d.ts.map +1 -1
  96. package/dist/system/health.js +3 -1
  97. package/dist/system/health.js.map +1 -1
  98. package/dist/tasks/ac-coverage-gate.d.ts +18 -2
  99. package/dist/tasks/ac-coverage-gate.d.ts.map +1 -1
  100. package/dist/tasks/ac-coverage-gate.js +40 -6
  101. package/dist/tasks/ac-coverage-gate.js.map +1 -1
  102. package/dist/tasks/ac-table.d.ts.map +1 -1
  103. package/dist/tasks/ac-table.js +3 -10
  104. package/dist/tasks/ac-table.js.map +1 -1
  105. package/dist/tasks/affected-packages.d.ts.map +1 -1
  106. package/dist/tasks/affected-packages.js +54 -10
  107. package/dist/tasks/affected-packages.js.map +1 -1
  108. package/dist/tasks/affected-scope.d.ts +10 -1
  109. package/dist/tasks/affected-scope.d.ts.map +1 -1
  110. package/dist/tasks/affected-scope.js +13 -3
  111. package/dist/tasks/affected-scope.js.map +1 -1
  112. package/dist/tasks/change-set.d.ts +9 -6
  113. package/dist/tasks/change-set.d.ts.map +1 -1
  114. package/dist/tasks/change-set.js +75 -97
  115. package/dist/tasks/change-set.js.map +1 -1
  116. package/dist/tasks/command-line.d.ts.map +1 -1
  117. package/dist/tasks/command-line.js +6 -2
  118. package/dist/tasks/command-line.js.map +1 -1
  119. package/dist/tasks/complete.d.ts.map +1 -1
  120. package/dist/tasks/complete.js +7 -2
  121. package/dist/tasks/complete.js.map +1 -1
  122. package/dist/tasks/done-plan.d.ts.map +1 -1
  123. package/dist/tasks/done-plan.js +111 -29
  124. package/dist/tasks/done-plan.js.map +1 -1
  125. package/dist/tasks/done-record.d.ts +14 -0
  126. package/dist/tasks/done-record.d.ts.map +1 -1
  127. package/dist/tasks/done-record.js +17 -10
  128. package/dist/tasks/done-record.js.map +1 -1
  129. package/dist/tasks/evidence-tree.d.ts +33 -0
  130. package/dist/tasks/evidence-tree.d.ts.map +1 -0
  131. package/dist/tasks/evidence-tree.js +132 -0
  132. package/dist/tasks/evidence-tree.js.map +1 -0
  133. package/dist/tasks/evidence.d.ts +42 -6
  134. package/dist/tasks/evidence.d.ts.map +1 -1
  135. package/dist/tasks/evidence.js +175 -79
  136. package/dist/tasks/evidence.js.map +1 -1
  137. package/dist/tasks/gate-preview.d.ts.map +1 -1
  138. package/dist/tasks/gate-preview.js +3 -2
  139. package/dist/tasks/gate-preview.js.map +1 -1
  140. package/dist/tasks/gate-runner.d.ts +11 -3
  141. package/dist/tasks/gate-runner.d.ts.map +1 -1
  142. package/dist/tasks/gate-runner.js +156 -37
  143. package/dist/tasks/gate-runner.js.map +1 -1
  144. package/dist/tasks/gate-staleness.d.ts +58 -0
  145. package/dist/tasks/gate-staleness.d.ts.map +1 -0
  146. package/dist/tasks/gate-staleness.js +78 -0
  147. package/dist/tasks/gate-staleness.js.map +1 -0
  148. package/dist/tasks/heavy-tool-env.d.ts +26 -1
  149. package/dist/tasks/heavy-tool-env.d.ts.map +1 -1
  150. package/dist/tasks/heavy-tool-env.js +92 -5
  151. package/dist/tasks/heavy-tool-env.js.map +1 -1
  152. package/dist/tasks/heavy-tool-limit.d.ts +24 -0
  153. package/dist/tasks/heavy-tool-limit.d.ts.map +1 -1
  154. package/dist/tasks/heavy-tool-limit.js +44 -1
  155. package/dist/tasks/heavy-tool-limit.js.map +1 -1
  156. package/dist/tasks/show.d.ts.map +1 -1
  157. package/dist/tasks/show.js +43 -0
  158. package/dist/tasks/show.js.map +1 -1
  159. package/dist/tasks/test-run-binding.d.ts +48 -3
  160. package/dist/tasks/test-run-binding.d.ts.map +1 -1
  161. package/dist/tasks/test-run-binding.js +111 -3
  162. package/dist/tasks/test-run-binding.js.map +1 -1
  163. package/dist/tasks/tool-cache.d.ts +58 -7
  164. package/dist/tasks/tool-cache.d.ts.map +1 -1
  165. package/dist/tasks/tool-cache.js +216 -16
  166. package/dist/tasks/tool-cache.js.map +1 -1
  167. package/dist/tasks/tool-resolver.d.ts +13 -0
  168. package/dist/tasks/tool-resolver.d.ts.map +1 -1
  169. package/dist/tasks/tool-resolver.js +34 -0
  170. package/dist/tasks/tool-resolver.js.map +1 -1
  171. package/dist/tasks/tool-runner-guard.d.ts +6 -0
  172. package/dist/tasks/tool-runner-guard.d.ts.map +1 -1
  173. package/dist/tasks/tool-runner-guard.js.map +1 -1
  174. package/dist/tasks/tool-semaphore.js +1 -1
  175. package/dist/tasks/tool-semaphore.js.map +1 -1
  176. package/dist/tasks/verification-policy.d.ts +26 -3
  177. package/dist/tasks/verification-policy.d.ts.map +1 -1
  178. package/dist/tasks/verification-policy.js +76 -11
  179. package/dist/tasks/verification-policy.js.map +1 -1
  180. package/dist/validation/doctor/checks.d.ts +12 -0
  181. package/dist/validation/doctor/checks.d.ts.map +1 -1
  182. package/dist/validation/doctor/checks.js +33 -0
  183. package/dist/validation/doctor/checks.js.map +1 -1
  184. package/dist/validation/engine-ops.d.ts +6 -0
  185. package/dist/validation/engine-ops.d.ts.map +1 -1
  186. package/dist/validation/engine-ops.js +51 -1
  187. package/dist/validation/engine-ops.js.map +1 -1
  188. package/dist/worktree/force-unlock.js +2 -2
  189. package/dist/worktree/force-unlock.js.map +1 -1
  190. package/dist/worktree/list.d.ts +7 -7
  191. package/dist/worktree/list.d.ts.map +1 -1
  192. package/dist/worktree/list.js +11 -56
  193. package/dist/worktree/list.js.map +1 -1
  194. package/dist/worktree/prune.d.ts +2 -2
  195. package/dist/worktree/prune.d.ts.map +1 -1
  196. package/dist/worktree/prune.js +4 -4
  197. package/dist/worktree/prune.js.map +1 -1
  198. package/package.json +13 -13
@@ -50,7 +50,7 @@
50
50
  * @task T12634
51
51
  */
52
52
  import { execFileSync } from 'node:child_process';
53
- import { readFileSync } from 'node:fs';
53
+ import { readdirSync, readFileSync } from 'node:fs';
54
54
  import { join } from 'node:path';
55
55
  import { isGitWorkTree } from '../git/work-tree.js';
56
56
  import { checkPrTaskLinkage, isDocumentArtifact, linkedPrChange, } from '../tasks/evidence.js';
@@ -96,6 +96,51 @@ export function readCiSatisfies(projectRoot) {
96
96
  const evidence = readProjectContextFile(projectRoot)?.evidence;
97
97
  return evidence?.ciSatisfies === true;
98
98
  }
99
+ /**
100
+ * The `evidence.ciSatisfies` remedy for a project that has GitHub workflows
101
+ * but has not opted in (T13096), or null when it has, or has no workflows.
102
+ *
103
+ * Without the setting, `cleo complete` cannot stand a merged PR's CI in for
104
+ * `testsPassed`/`qaPassed` (`ci:<pr>`), so a scoped run recorded before the
105
+ * merge leaves only a full local suite. The remedy is a ready-to-paste
106
+ * `evidence` block whose `ciChecks` are the default branch's required checks
107
+ * ({@link resolveRequiredWorkflowsDetailed}, read-only: nothing is cached).
108
+ *
109
+ * @param root - The project checkout (its `.cleo/project-context.json` and `.github/workflows`).
110
+ * @param opts - Branch-protection fetcher override (tests).
111
+ * @returns The remedy text, or null.
112
+ * @task T13096
113
+ */
114
+ export async function ciSatisfiesRemedy(root, opts = {}) {
115
+ if (readCiSatisfies(root))
116
+ return null;
117
+ let workflows;
118
+ try {
119
+ workflows = readdirSync(join(root, '.github', 'workflows')).filter((f) => /\.ya?ml$/.test(f));
120
+ }
121
+ catch {
122
+ return null;
123
+ }
124
+ if (workflows.length === 0)
125
+ return null;
126
+ const required = await resolveRequiredWorkflowsDetailed({ storeRoot: root, executionRoot: root }, {
127
+ projectContext: readProjectContextFile(root),
128
+ readOnly: true,
129
+ ...(opts.fetchGhBranchProtection
130
+ ? { fetchGhBranchProtection: opts.fetchGhBranchProtection }
131
+ : {}),
132
+ });
133
+ const checks = required.source.tier === 'unknown' ? [] : required.workflows;
134
+ // ci:<pr> attests a gate only through its declared ciChecks list (T12634).
135
+ const listed = checks.length > 0 ? checks : ['<required check>'];
136
+ const block = { ciSatisfies: true, ciChecks: { tests: listed, qa: listed } };
137
+ return (`This project has GitHub workflows (.github/workflows) but does not set evidence.ciSatisfies, ` +
138
+ "so a merged PR's CI cannot stand in for testsPassed/qaPassed (ci:<pr>). Add to " +
139
+ `.cleo/project-context.json: "evidence": ${JSON.stringify(block)}` +
140
+ (checks.length > 0
141
+ ? ` (required checks from ${describeRequiredWorkflowsSource(required.source)}; narrow ciChecks.tests/qa to the checks that run tests and QA).`
142
+ : `, replacing <required check> with the required checks that run tests and QA (none were found: ${describeRequiredWorkflowsSource(required.source)}).`));
143
+ }
99
144
  /**
100
145
  * The checks that attest each gate (`evidence.ciChecks`, T12634).
101
146
  *
@@ -330,17 +375,46 @@ export function evaluateJobs(globs, checks, sha, scope, onlyEvent) {
330
375
  }
331
376
  return reasons.length > 0 ? { ok: false, reasons } : { ok: true, jobs };
332
377
  }
333
- function pinMatches(check, pin) {
378
+ /**
379
+ * Whether a run comes from a check's pinned app and workflow (T13017):
380
+ * `unknown` when the response left out an identity field the pin names (a
381
+ * check run whose workflow run was not listed yet), so the run is neither
382
+ * counted nor reported as posted by another app.
383
+ */
384
+ function pinMatch(check, pin) {
334
385
  if (!pin)
335
- return true;
386
+ return 'match';
387
+ let unknown = false;
336
388
  if (pin.app !== undefined) {
337
- const ok = typeof pin.app === 'number' ? check.appId === pin.app : check.appSlug === pin.app;
338
- if (!ok)
339
- return false;
340
- }
341
- if (pin.workflow !== undefined && check.workflowPath !== pin.workflow)
342
- return false;
343
- return true;
389
+ const actual = typeof pin.app === 'number' ? check.appId : check.appSlug;
390
+ if (actual === undefined)
391
+ unknown = true;
392
+ else if (actual !== pin.app)
393
+ return 'mismatch';
394
+ }
395
+ if (pin.workflow !== undefined) {
396
+ if (check.workflowPath === undefined)
397
+ unknown = true;
398
+ else if (check.workflowPath !== pin.workflow)
399
+ return 'mismatch';
400
+ }
401
+ return unknown ? 'unknown' : 'match';
402
+ }
403
+ function pinMatches(check, pin) {
404
+ return pinMatch(check, pin) === 'match';
405
+ }
406
+ /**
407
+ * Whether a fetch left a required check's run without the identity its pin
408
+ * names (T13017), so the fetch is retried before anything is judged.
409
+ *
410
+ * @param checks - Checks fetched for one commit.
411
+ * @param required - Required check names.
412
+ * @param pins - Required-name to app/workflow pin.
413
+ * @returns True when a required run's identity is incomplete.
414
+ * @task T13017
415
+ */
416
+ export function hasIncompleteIdentity(checks, required, pins) {
417
+ return checks.some((c) => required.includes(c.name) && pinMatch(c, pins[c.name]) === 'unknown');
344
418
  }
345
419
  function describeApp(check) {
346
420
  return check.appSlug ?? (check.appId !== undefined ? `app ${check.appId}` : 'an unknown app');
@@ -402,17 +476,20 @@ export function evaluateMergeCommitChecks(required, checks, mergeCommitSha, opts
402
476
  continue;
403
477
  }
404
478
  }
405
- const impostor = named.find((c) => !pinMatches(c, pins[name]));
479
+ const impostor = named.find((c) => pinMatch(c, pins[name]) === 'mismatch');
480
+ const unidentified = named.some((c) => pinMatch(c, pins[name]) === 'unknown');
406
481
  const elsewhere = pinned.find((c) => c.headSha !== mergeCommitSha &&
407
482
  c.headSha !== treeEquivalentSha &&
408
483
  c.headSha !== descendantSha);
409
- reasons.push(onMerge.verdict === 'missing' && impostor && pinned.length === 0
410
- ? `${name}: posted by ${describeApp(impostor)}${impostor.workflowPath ? ` (${impostor.workflowPath})` : ''}, not the pinned ${describePin(pins[name])}`
411
- : onMerge.verdict === 'missing' && elsewhere
412
- ? `${name}: ran on ${elsewhere.headSha.slice(0, 12)}, not the merge commit ${mergeCommitSha.slice(0, 12)}`
413
- : onMerge.verdict === 'missing'
414
- ? `${name}: not found on merge commit ${mergeCommitSha.slice(0, 12)}`
415
- : `${name}: ${onMerge.verdict} on merge commit ${mergeCommitSha.slice(0, 12)}`);
484
+ reasons.push(onMerge.verdict === 'missing' && unidentified && pinned.length === 0
485
+ ? `${name}: pending (GitHub has not reported its run's app or workflow yet) on merge commit ${mergeCommitSha.slice(0, 12)}`
486
+ : onMerge.verdict === 'missing' && impostor && pinned.length === 0
487
+ ? `${name}: posted by ${describeApp(impostor)}${impostor.workflowPath ? ` (${impostor.workflowPath})` : ''}, not the pinned ${describePin(pins[name])}`
488
+ : onMerge.verdict === 'missing' && elsewhere
489
+ ? `${name}: ran on ${elsewhere.headSha.slice(0, 12)}, not the merge commit ${mergeCommitSha.slice(0, 12)}`
490
+ : onMerge.verdict === 'missing'
491
+ ? `${name}: not found on merge commit ${mergeCommitSha.slice(0, 12)}`
492
+ : `${name}: ${onMerge.verdict} on merge commit ${mergeCommitSha.slice(0, 12)}`);
416
493
  }
417
494
  return reasons.length > 0 ? { ok: false, reasons } : { ok: true, checks: matched };
418
495
  }
@@ -588,11 +665,13 @@ function lateRun(run, nextMainCommitAt) {
588
665
  * @param ancestors - First-parent ancestors of the merge, nearest first, with their checks.
589
666
  * @param pins - Required-name to app/workflow pin.
590
667
  * @param nextMainCommitAt - When the next default-branch commit after the merge landed, if known.
668
+ * @param untested - Failing jobs with no failed test, a runner cancellation or a worker crash (T14049, T14074): never the PR's, so never blamed.
591
669
  * @returns The required names to stand in for and the inherited jobs, or why not.
592
670
  * @task T13453
593
671
  * @task T14026
672
+ * @task T14049
594
673
  */
595
- export function inheritedOnMerge(required, checks, mergeCommitSha, ancestors, pins, nextMainCommitAt = null) {
674
+ export function inheritedOnMerge(required, checks, mergeCommitSha, ancestors, pins, nextMainCommitAt = null, untested = new Set()) {
596
675
  const scope = pinnedScope(required, pins);
597
676
  const onMerge = latestRuns(checks, mergeCommitSha, scope);
598
677
  const pending = onMerge.find((c) => c.status !== 'completed');
@@ -602,7 +681,7 @@ export function inheritedOnMerge(required, checks, mergeCommitSha, ancestors, pi
602
681
  reason: `${pending.name}: pending on merge commit ${mergeCommitSha.slice(0, 12)}`,
603
682
  };
604
683
  }
605
- const failing = onMerge.filter((c) => !HARMLESS_CONCLUSIONS.has(c.conclusion ?? ''));
684
+ const failing = onMerge.filter((c) => !HARMLESS_CONCLUSIONS.has(c.conclusion ?? '') && !untested.has(c.id));
606
685
  if (failing.length === 0)
607
686
  return { ok: false, reason: 'no job failed on the merge commit' };
608
687
  const inherited = [];
@@ -713,11 +792,18 @@ const RUN_DIED = /Worker exited unexpectedly|Worker terminated due to reaching m
713
792
  * quarantine, a file that failed to load, or counts that do not add up
714
793
  * (per-file `(N tests | M failed)` lines included).
715
794
  *
795
+ * With `unretriedTimeouts` (T14086, later runs only), a failing test whose
796
+ * error is vitest's own "Test timed out in N ms" also qualifies: the budget
797
+ * stopped the run before its rerun-once flake rule (T13145) could judge it,
798
+ * so the timeout decided nothing. An assertion failure never qualifies.
799
+ *
716
800
  * @param log - The job's plain-text log (ANSI colour codes allowed).
801
+ * @param opts - `unretriedTimeouts`: accept unretried timeouts too.
717
802
  * @returns Whether the job failed only on the quarantine budget.
718
803
  * @task T13809
804
+ * @task T14086
719
805
  */
720
- export function isQuarantineBudgetOnlyLog(log) {
806
+ export function isQuarantineBudgetOnlyLog(log, opts = {}) {
721
807
  const text = log.replace(/\u001b\[[0-9;]*m/g, '');
722
808
  // The script's own verdict is the last heading, with its count line under it
723
809
  // (a test's fixture output may carry the heading too).
@@ -748,9 +834,19 @@ export function isQuarantineBudgetOnlyLog(log) {
748
834
  ...text.slice(heading).matchAll(new RegExp(`- \`(${testPath})\` > (.+)$`, 'gm')),
749
835
  ].map((m) => ({ file: m[1] ?? '', title: normTitle(m[2] ?? '') }));
750
836
  const failing = new Map();
837
+ const timedOut = new Set();
751
838
  for (const m of run.matchAll(new RegExp(`\\bFAIL\\s{2,}(?:\\S+\\s+)?(${testPath}) > (.+)$`, 'gm'))) {
752
839
  const entry = { file: m[1] ?? '', title: normTitle(m[2] ?? '') };
753
- failing.set(`${entry.file}\u0000${entry.title}`, entry);
840
+ const key = `${entry.file}\u0000${entry.title}`;
841
+ failing.set(key, entry);
842
+ // The failure's own error is the first line after its FAIL header.
843
+ const next = run
844
+ .slice((m.index ?? 0) + m[0].length)
845
+ .split('\n')
846
+ .map((l) => l.replace(/^\S*Z\s/, '').trim())
847
+ .find((l) => l !== '');
848
+ if (next && /^Error: Test timed out in \d+ms\b/.test(next))
849
+ timedOut.add(key);
754
850
  }
755
851
  if (failing.size !== failedTests)
756
852
  return false;
@@ -758,7 +854,8 @@ export function isQuarantineBudgetOnlyLog(log) {
758
854
  return false;
759
855
  if (!perFile.every((p) => [...failing.values()].some((f) => f.file === p.file)))
760
856
  return false;
761
- return [...failing.values()].every((f) => quarantined.some((q) => q.title === f.title && q.file.endsWith(f.file)));
857
+ return [...failing.entries()].every(([key, f]) => quarantined.some((q) => q.title === f.title && q.file.endsWith(f.file)) ||
858
+ (opts.unretriedTimeouts === true && timedOut.has(key)));
762
859
  }
763
860
  /**
764
861
  * Whether every failure on a commit's CI is a non-test failure (T13809): each
@@ -774,10 +871,11 @@ export function isQuarantineBudgetOnlyLog(log) {
774
871
  * @param pins - Required-name to app/workflow pin.
775
872
  * @param fetchJobLog - Plain-text log of one job.
776
873
  * @param cwd - Repository work tree for `gh`.
874
+ * @param opts - `unretriedTimeouts` for a later run (T14086, {@link isQuarantineBudgetOnlyLog}).
777
875
  * @returns The quarantine-only jobs, or why the commit failed for real.
778
876
  * @task T13809
779
877
  */
780
- export async function nonTestFailuresOn(checks, sha, required, pins, fetchJobLog, cwd) {
878
+ export async function nonTestFailuresOn(checks, sha, required, pins, fetchJobLog, cwd, opts = {}) {
781
879
  const latest = latestRuns(checks, sha, pinnedScope(required, pins));
782
880
  const jobs = [];
783
881
  for (const c of latest) {
@@ -790,7 +888,7 @@ export async function nonTestFailuresOn(checks, sha, required, pins, fetchJobLog
790
888
  if (c.source === 'workflow-run' || required.includes(c.name))
791
889
  continue;
792
890
  const log = c.conclusion === 'failure' ? await fetchJobLog(c.id, cwd) : null;
793
- if (log === null || !isQuarantineBudgetOnlyLog(log)) {
891
+ if (log === null || !isQuarantineBudgetOnlyLog(log, opts)) {
794
892
  return {
795
893
  ok: false,
796
894
  reason: `${c.name}: ${c.conclusion} on ${sha.slice(0, 12)}${log === null && c.conclusion === 'failure' ? ' (its log could not be read)' : ''} — not the flaky-quarantine budget alone`,
@@ -846,11 +944,111 @@ export async function cancelledWithoutTestFailure(checks, sha, required, pins, f
846
944
  return false;
847
945
  if (runs.some((r) => r.conclusion !== 'cancelled' && r.conclusion !== 'success'))
848
946
  return false;
849
- const noTestFailed = (log) => {
850
- const text = log.replace(/\u001b\[[0-9;]*m/g, '');
851
- return !/\bFAIL\s{2,}|\d+ failed/.test(text) && !RUN_DIED.test(text);
852
- };
853
- return ((await everyFailedJobLog(checks, sha, required, pins, fetchJobLog, cwd, noTestFailed)) !== null);
947
+ return ((await everyFailedJobLog(checks, sha, required, pins, fetchJobLog, cwd, noTestFailedLog)) !==
948
+ null);
949
+ }
950
+ /** Whether a job log names no failed test and no dead vitest run (T13809). */
951
+ function noTestFailedLog(log) {
952
+ const text = log.replace(/\u001b\[[0-9;]*m/g, '');
953
+ return !/\bFAIL\s{2,}|\d+ failed/.test(text) && !RUN_DIED.test(text);
954
+ }
955
+ /** The errors GitHub prints when the runner, not the job, stopped a step (T14049). */
956
+ const RUNNER_CANCEL_ERROR = /##\[error\](?:The operation was canceled\.|The runner has received a shutdown signal\b.*)\s*$/;
957
+ /**
958
+ * Whether a job log shows only a runner cancellation (T14049): every
959
+ * `##[error]` line is GitHub's own "The operation was canceled." (or the
960
+ * runner shutdown notice before it), and no test failed. GitHub then reports
961
+ * the job, and the aggregate check that needs it, as `failure`, though
962
+ * nothing the PR changed failed.
963
+ *
964
+ * @param log - The job's plain-text log (ANSI colour codes allowed).
965
+ * @returns Whether the job failed only because its runner was cancelled.
966
+ * @task T14049
967
+ */
968
+ export function isRunnerCancelOnlyLog(log) {
969
+ const text = log.replace(/\u001b\[[0-9;]*m/g, '');
970
+ const errors = text.split('\n').filter((line) => line.includes('##[error]'));
971
+ return (errors.some((line) => /##\[error\]The operation was canceled\./.test(line)) &&
972
+ errors.every((line) => RUNNER_CANCEL_ERROR.test(line)) &&
973
+ noTestFailedLog(text));
974
+ }
975
+ /** A vitest summary or failure line naming a failed test or file. */
976
+ const FAILED_TEST = /\bFAIL\s{2,}|\d+ failed/;
977
+ /**
978
+ * Whether a job log shows only an unattributable vitest worker crash
979
+ * (T14074): no test or file failed, every `Unhandled Error` vitest reports is
980
+ * its own pool's "Worker forks emitted error" (a fork that exited
981
+ * unexpectedly, e.g. a native addon abort), and the only `##[error]` is the
982
+ * step's non-zero exit. Such a run never showed a test of the change failing.
983
+ *
984
+ * @param log - The job's plain-text log (ANSI colour codes allowed).
985
+ * @returns Whether the job failed only on a worker crash.
986
+ * @task T14074
987
+ */
988
+ export function isWorkerCrashOnlyLog(log) {
989
+ const text = log.replace(/\u001b\[[0-9;]*m/g, '');
990
+ const crashes = text.match(/Error: \[vitest-pool\]: Worker forks emitted error\./g)?.length ?? 0;
991
+ const unhandled = text.match(/⎯+ Unhandled Error ⎯+/g)?.length ?? 0;
992
+ const errors = text.split('\n').filter((line) => line.includes('##[error]'));
993
+ return (crashes > 0 &&
994
+ unhandled === crashes &&
995
+ /Caused by: Error: Worker exited unexpectedly/.test(text) &&
996
+ !FAILED_TEST.test(text) &&
997
+ errors.every((line) => /##\[error\]Process completed with exit code \d+\.\s*$/.test(line)));
998
+ }
999
+ /**
1000
+ * Why a failing job's log shows no failed test, or `null` when it may have
1001
+ * failed on the change: a runner cancellation ({@link isRunnerCancelOnlyLog})
1002
+ * or a worker crash ({@link isWorkerCrashOnlyLog}).
1003
+ *
1004
+ * @param log - The job's plain-text log.
1005
+ * @returns The cause, or `null`.
1006
+ * @task T14074
1007
+ */
1008
+ export function untestedFailureCause(log) {
1009
+ if (isRunnerCancelOnlyLog(log))
1010
+ return 'runner-cancel';
1011
+ if (isWorkerCrashOnlyLog(log))
1012
+ return 'worker-crash';
1013
+ return null;
1014
+ }
1015
+ /**
1016
+ * The failing jobs of a pinned workflow on `sha` (the required aggregate and
1017
+ * workflow runs excluded) that failed without a failed test: a runner
1018
+ * cancellation (T14049) or a worker crash (T14074), by
1019
+ * {@link untestedFailureCause}. A pending job or an unreadable log is never
1020
+ * one, so `all` is then false.
1021
+ *
1022
+ * @param checks - Checks fetched for `sha`.
1023
+ * @param sha - The commit judged.
1024
+ * @param required - Required check names.
1025
+ * @param pins - Required-name to app/workflow pin.
1026
+ * @param fetchJobLog - Plain-text log of one job.
1027
+ * @param cwd - Repository work tree for `gh`.
1028
+ * @returns Those jobs, and whether they are every failure.
1029
+ * @task T14049
1030
+ * @task T14074
1031
+ */
1032
+ export async function untestedJobFailures(checks, sha, required, pins, fetchJobLog, cwd) {
1033
+ const jobs = [];
1034
+ let all = true;
1035
+ for (const c of latestRuns(checks, sha, pinnedScope(required, pins))) {
1036
+ if (c.source === 'workflow-run' || required.includes(c.name))
1037
+ continue;
1038
+ if (c.status !== 'completed') {
1039
+ all = false;
1040
+ continue;
1041
+ }
1042
+ if (HARMLESS_CONCLUSIONS.has(c.conclusion ?? ''))
1043
+ continue;
1044
+ const log = c.conclusion === 'failure' ? await fetchJobLog(c.id, cwd) : null;
1045
+ const cause = log === null ? null : untestedFailureCause(log);
1046
+ if (cause)
1047
+ jobs.push({ name: c.name, mergeRunId: c.id, cause });
1048
+ else
1049
+ all = false;
1050
+ }
1051
+ return { jobs, ids: new Set(jobs.map((j) => j.mergeRunId)), all: all && jobs.length > 0 };
854
1052
  }
855
1053
  /**
856
1054
  * Whether a job log shows a failure in a setup step before any test ran
@@ -964,16 +1162,28 @@ export async function findGreenDescendant(input) {
964
1162
  });
965
1163
  let inherited;
966
1164
  let nonTest;
1165
+ let untested;
1166
+ let untestedIds = new Set();
1167
+ const unproven = () => required.filter((name) => !judgeOnSha(input.checks.filter((c) => c.name === name && pinMatches(c, pins[name])), merge).ok);
1168
+ if (!superseded.ok && input.fetchJobLog) {
1169
+ // T14049, T14074: a job that failed with no failed test (a runner
1170
+ // cancellation, or a vitest worker crash) decided nothing, though GitHub
1171
+ // reports it and the aggregate as `failure`: it is superseded, as a
1172
+ // cancelled run is. A job with any failed test never is.
1173
+ const found = await untestedJobFailures(input.checks, merge, required, pins, input.fetchJobLog, cwd);
1174
+ untestedIds = found.ids;
1175
+ if (found.all) {
1176
+ untested = found.jobs;
1177
+ superseded = { ok: true, names: unproven() };
1178
+ }
1179
+ }
967
1180
  if (!superseded.ok && input.fetchJobLog) {
968
1181
  // T13809: a merge-commit run red only because main's flaky quarantine was
969
1182
  // over budget (every test passed) failed no test of the PR's.
970
1183
  const result = await nonTestFailuresOn(input.checks, merge, required, pins, input.fetchJobLog, cwd);
971
1184
  if (result.ok) {
972
1185
  nonTest = result.jobs;
973
- superseded = {
974
- ok: true,
975
- names: required.filter((name) => !judgeOnSha(input.checks.filter((c) => c.name === name && pinMatches(c, pins[name])), merge).ok),
976
- };
1186
+ superseded = { ok: true, names: unproven() };
977
1187
  }
978
1188
  }
979
1189
  if (!superseded.ok && input.firstParentOf) {
@@ -991,7 +1201,7 @@ export async function findGreenDescendant(input) {
991
1201
  break;
992
1202
  ancestors.push({ sha: at, checks: fetched.checks });
993
1203
  }
994
- const result = inheritedOnMerge(required, input.checks, merge, ancestors, pins, input.nextMainCommitAt?.(merge, input.ref, cwd) ?? null);
1204
+ const result = inheritedOnMerge(required, input.checks, merge, ancestors, pins, input.nextMainCommitAt?.(merge, input.ref, cwd) ?? null, untestedIds);
995
1205
  if (result.ok) {
996
1206
  superseded = { ok: true, names: result.names };
997
1207
  inherited = result.inherited;
@@ -1043,6 +1253,7 @@ export async function findGreenDescendant(input) {
1043
1253
  ["the PR's files", input.changedPaths],
1044
1254
  [`a CI definition (${ciPaths.join(', ')})`, ciPaths],
1045
1255
  ];
1256
+ const passedOver = [];
1046
1257
  for (const sha of candidates.slice(0, CI_DESCENDANT_MAX_CANDIDATES)) {
1047
1258
  if (sha === merge || !input.isAncestor(merge, sha, cwd))
1048
1259
  continue;
@@ -1067,9 +1278,17 @@ export async function findGreenDescendant(input) {
1067
1278
  // T13809: a later run red only for the quarantine budget, cancelled with
1068
1279
  // no test failing, or failed before any test ran decides nothing; look on.
1069
1280
  // Any other failure still refuses: a green run is never shopped for past a real one.
1070
- const quarantineOnly = await nonTestFailuresOn(fetched.checks, sha, required, pins, input.fetchJobLog, cwd);
1071
- if (quarantineOnly.ok)
1281
+ // T14086: in such a run, a test that only timed out was never retried, so
1282
+ // it decided nothing either; an assertion failure still refuses.
1283
+ const quarantineOnly = await nonTestFailuresOn(fetched.checks, sha, required, pins, input.fetchJobLog, cwd, { unretriedTimeouts: true });
1284
+ if (quarantineOnly.ok) {
1285
+ passedOver.push({
1286
+ sha,
1287
+ jobs: quarantineOnly.jobs.map((j) => j.name),
1288
+ reason: 'the flaky-quarantine budget stopped the run before its flake rerun',
1289
+ });
1072
1290
  continue;
1291
+ }
1073
1292
  if (await cancelledWithoutTestFailure(fetched.checks, sha, required, pins, input.fetchJobLog, cwd)) {
1074
1293
  continue;
1075
1294
  }
@@ -1096,6 +1315,8 @@ export async function findGreenDescendant(input) {
1096
1315
  ...(head ? { prHeadSha: head } : {}),
1097
1316
  ...(inherited ? { inherited } : {}),
1098
1317
  ...(nonTest ? { nonTest } : {}),
1318
+ ...(untested ? { untested } : {}),
1319
+ ...(passedOver.length > 0 ? { passedOver } : {}),
1099
1320
  };
1100
1321
  }
1101
1322
  return {
@@ -1431,6 +1652,26 @@ async function defaultFetchJobLog(jobId, cwd) {
1431
1652
  return null;
1432
1653
  }
1433
1654
  }
1655
+ /**
1656
+ * Fetch once more when a required check's run came back without the identity
1657
+ * its pin names (T13017): GitHub lists a job before its workflow run, and a
1658
+ * run judged from that response would read as another app's. A run still
1659
+ * incomplete after the retry is judged pending, never as a mismatch.
1660
+ *
1661
+ * @param fetchChecks - The underlying check fetch.
1662
+ * @param required - Required check names.
1663
+ * @param pins - Required-name to app/workflow pin.
1664
+ * @returns The same fetch, retried once on incomplete identity.
1665
+ * @task T13017
1666
+ */
1667
+ export function refetchIncompleteIdentity(fetchChecks, required, pins) {
1668
+ return async (sha, cwd) => {
1669
+ const first = await fetchChecks(sha, cwd);
1670
+ if (!first.ok || !hasIncompleteIdentity(first.checks, required, pins))
1671
+ return first;
1672
+ return fetchChecks(sha, cwd);
1673
+ };
1674
+ }
1434
1675
  /**
1435
1676
  * Drop runs a schedule started from every fetch (T13623). A nightly run that
1436
1677
  * happens to land on a merge commit tests main at that hour, not the merge:
@@ -1755,7 +1996,7 @@ export async function resolveCiEvidenceAtom(prNumber, roots, opts = {}) {
1755
1996
  ];
1756
1997
  const editedWorkflows = pinnedWorkflows.filter((w) => pr.changedPaths.includes(w));
1757
1998
  const mainOnly = editedWorkflows.length > 0;
1758
- const fetchChecks = withoutScheduledRuns(opts.fetchChecks ?? defaultFetchChecks);
1999
+ const fetchChecks = refetchIncompleteIdentity(withoutScheduledRuns(opts.fetchChecks ?? defaultFetchChecks), required.workflows, pins);
1759
2000
  const fetched = await fetchChecks(pr.mergeCommitSha, roots.executionRoot);
1760
2001
  if (!fetched.ok)
1761
2002
  return { ok: false, reason: fetched.reason, codeName: 'E_EVIDENCE_TOOL_FAILED' };
@@ -1829,7 +2070,9 @@ export async function resolveCiEvidenceAtom(prNumber, roots, opts = {}) {
1829
2070
  ...(treeEqualHead ? { treeEquivalentSha: treeEqualHead } : {}),
1830
2071
  pins,
1831
2072
  descendantSha: found.sha,
1832
- ...(found.inherited || found.nonTest ? { inheritedFailures: true } : {}),
2073
+ ...(found.inherited || found.nonTest || found.untested
2074
+ ? { inheritedFailures: true }
2075
+ : {}),
1833
2076
  });
1834
2077
  if (rejudged.ok) {
1835
2078
  checks = withDescendant;
@@ -1840,6 +2083,8 @@ export async function resolveCiEvidenceAtom(prNumber, roots, opts = {}) {
1840
2083
  ...(found.prHeadSha ? { prHeadSha: found.prHeadSha } : {}),
1841
2084
  ...(found.inherited ? { inherited: found.inherited } : {}),
1842
2085
  ...(found.nonTest ? { nonTest: found.nonTest } : {}),
2086
+ ...(found.untested ? { untested: found.untested } : {}),
2087
+ ...(found.passedOver ? { passedOver: found.passedOver } : {}),
1843
2088
  };
1844
2089
  }
1845
2090
  else {
@@ -1898,19 +2143,22 @@ export async function resolveCiEvidenceAtom(prNumber, roots, opts = {}) {
1898
2143
  }
1899
2144
  if (!judgedChecks.ok) {
1900
2145
  const waiting = judgedChecks.reasons.some((r) => /pending|not found|missing/.test(r));
2146
+ const merge12 = pr.mergeCommitSha.slice(0, 12);
2147
+ // T14042: the decisive reason and the next step lead; the detail follows,
2148
+ // so a truncated refusal still says what to do.
2149
+ const lead = mainOnly
2150
+ ? `PR #${prNumber} edits the pinned workflow ${editedWorkflows.join(', ')}, so only main's push CI attests it (T13174)` +
2151
+ (waiting
2152
+ ? `: wait for the push run on ${merge12} (or a later main commit) to finish, then verify again. No local run is needed.`
2153
+ : `, and it is not green: ${judgedChecks.reasons[0]}.`)
2154
+ : `Required CI on PR #${prNumber}'s merge commit ${merge12} is not green: ${judgedChecks.reasons[0]}` +
2155
+ (waiting ? '; wait for it to finish, then verify again.' : '.');
1901
2156
  return {
1902
2157
  ok: false,
1903
2158
  codeName: 'E_EVIDENCE_TESTS_FAILED',
1904
- reason: `Required CI on PR #${prNumber}'s merge commit ${pr.mergeCommitSha.slice(0, 12)} is not green ` +
1905
- `(source: ${describeRequiredWorkflowsSource(required.source)}):\n - ${judgedChecks.reasons.join('\n - ')}` +
2159
+ reason: `${lead}\n Required checks (source: ${describeRequiredWorkflowsSource(required.source)}):\n - ${judgedChecks.reasons.join('\n - ')}` +
1906
2160
  (descendantReason ? `\n No later main run stands in: ${descendantReason}` : '') +
1907
- (currentHeadReason ? `\n No current-HEAD run stands in: ${currentHeadReason}` : '') +
1908
- (mainOnly
1909
- ? `\n PR #${prNumber} edits the pinned workflow ${editedWorkflows.join(', ')}, so only main's push CI attests it (T13174)` +
1910
- (waiting
1911
- ? `: wait for the push run on ${pr.mergeCommitSha.slice(0, 12)} (or a later main commit) to finish, then verify again. No local run is needed.`
1912
- : '.')
1913
- : ''),
2161
+ (currentHeadReason ? `\n No current-HEAD run stands in: ${currentHeadReason}` : ''),
1914
2162
  };
1915
2163
  }
1916
2164
  // Round 2 (skipped tests): the aggregate counts skipped jobs as a pass. For a
@@ -2023,6 +2271,8 @@ export async function resolveCiEvidenceAtom(prNumber, roots, opts = {}) {
2023
2271
  ...(descendant.prHeadSha ? { descendantPrHeadSha: descendant.prHeadSha } : {}),
2024
2272
  ...(descendant.inherited ? { inheritedFailures: descendant.inherited } : {}),
2025
2273
  ...(descendant.nonTest ? { nonTestFailures: descendant.nonTest } : {}),
2274
+ ...(descendant.untested ? { untestedFailures: descendant.untested } : {}),
2275
+ ...(descendant.passedOver ? { passedOverRuns: descendant.passedOver } : {}),
2026
2276
  }
2027
2277
  : {}),
2028
2278
  ...(workspaceWide ? { workspaceWide } : {}),