@codyswann/lisa 4.87.5 → 4.87.7

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 (106) hide show
  1. package/all/copy-overwrite/scripts/check-third-party-review-evidence.mjs +568 -80
  2. package/dist/cli/doctor-seeded-artifacts.d.ts.map +1 -1
  3. package/dist/cli/doctor-seeded-artifacts.js +10 -3
  4. package/dist/cli/doctor-seeded-artifacts.js.map +1 -1
  5. package/dist/cli/doctor-third-party-evidence-seed.d.ts +32 -0
  6. package/dist/cli/doctor-third-party-evidence-seed.d.ts.map +1 -0
  7. package/dist/cli/doctor-third-party-evidence-seed.js +239 -0
  8. package/dist/cli/doctor-third-party-evidence-seed.js.map +1 -0
  9. package/dist/cli/doctor.js +2 -2
  10. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  11. package/dist/core/lisa-owned-hash-ledger.js +6 -0
  12. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  13. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  14. package/dist/core/third-party-evidence-seed.d.ts +42 -0
  15. package/dist/core/third-party-evidence-seed.d.ts.map +1 -0
  16. package/dist/core/third-party-evidence-seed.js +233 -0
  17. package/dist/core/third-party-evidence-seed.js.map +1 -0
  18. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  19. package/dist/core/upstream-evidence-manifest.js +23 -4
  20. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  21. package/package.json +4 -4
  22. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  23. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  24. package/plugins/lisa/.codex-plugin/skills/lisa-drive-pr-to-merge/SKILL.md +59 -31
  25. package/plugins/lisa/.codex-plugin/skills/lisa-pull-request-review/SKILL.md +11 -5
  26. package/plugins/lisa/skills/lisa-drive-pr-to-merge/SKILL.md +59 -31
  27. package/plugins/lisa/skills/lisa-pull-request-review/SKILL.md +11 -5
  28. package/plugins/lisa-agy/plugin.json +1 -1
  29. package/plugins/lisa-agy/skills/lisa-drive-pr-to-merge/SKILL.md +59 -31
  30. package/plugins/lisa-agy/skills/lisa-pull-request-review/SKILL.md +11 -5
  31. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  32. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  33. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  34. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  35. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  36. package/plugins/lisa-cdk-devin/.claude-plugin/plugin.json +1 -1
  37. package/plugins/lisa-cdk-devin/.devin-plugin/plugin.json +1 -1
  38. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  39. package/plugins/lisa-copilot/skills/lisa-drive-pr-to-merge/SKILL.md +59 -31
  40. package/plugins/lisa-copilot/skills/lisa-pull-request-review/SKILL.md +11 -5
  41. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-cursor/skills/lisa-drive-pr-to-merge/SKILL.md +59 -31
  43. package/plugins/lisa-cursor/skills/lisa-pull-request-review/SKILL.md +11 -5
  44. package/plugins/lisa-devin/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-devin/.devin-plugin/plugin.json +1 -1
  46. package/plugins/lisa-devin/skills/lisa-drive-pr-to-merge/SKILL.md +59 -31
  47. package/plugins/lisa-devin/skills/lisa-pull-request-review/SKILL.md +11 -5
  48. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  50. package/plugins/lisa-expo-agy/plugin.json +1 -1
  51. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-expo-devin/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-expo-devin/.devin-plugin/plugin.json +1 -1
  55. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  57. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  58. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-harper-fabric-devin/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-harper-fabric-devin/.devin-plugin/plugin.json +1 -1
  62. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  63. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  64. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  65. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  66. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  67. package/plugins/lisa-nestjs-devin/.claude-plugin/plugin.json +1 -1
  68. package/plugins/lisa-nestjs-devin/.devin-plugin/plugin.json +1 -1
  69. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  70. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  71. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  72. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  73. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  74. package/plugins/lisa-openclaw-devin/.claude-plugin/plugin.json +1 -1
  75. package/plugins/lisa-openclaw-devin/.devin-plugin/plugin.json +1 -1
  76. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  77. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  78. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  79. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  80. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  81. package/plugins/lisa-phaser-devin/.claude-plugin/plugin.json +1 -1
  82. package/plugins/lisa-phaser-devin/.devin-plugin/plugin.json +1 -1
  83. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  84. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  85. package/plugins/lisa-rails-agy/plugin.json +1 -1
  86. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  87. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  88. package/plugins/lisa-rails-devin/.claude-plugin/plugin.json +1 -1
  89. package/plugins/lisa-rails-devin/.devin-plugin/plugin.json +1 -1
  90. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  91. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  92. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  93. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  94. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  95. package/plugins/lisa-typescript-devin/.claude-plugin/plugin.json +1 -1
  96. package/plugins/lisa-typescript-devin/.devin-plugin/plugin.json +1 -1
  97. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  98. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  99. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  100. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  101. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  102. package/plugins/lisa-wiki-devin/.claude-plugin/plugin.json +1 -1
  103. package/plugins/lisa-wiki-devin/.devin-plugin/plugin.json +1 -1
  104. package/plugins/src/base/skills/lisa-drive-pr-to-merge/SKILL.md +59 -31
  105. package/plugins/src/base/skills/lisa-pull-request-review/SKILL.md +11 -5
  106. package/typescript/create-only/.github/workflows/third-party-review-evidence.yml +62 -30
@@ -8,10 +8,12 @@
8
8
  * @remarks
9
9
  * ## The measurement this exists for
10
10
  *
11
- * A third-party review app posts a **commit status** on the pull request head.
12
- * When it is throttled, or declines to review at all, it still reports
13
- * `state: success` — it never blocks. Only the status `description`
14
- * distinguishes a review that ran from one that never started.
11
+ * A third-party review app reports on the pull request head through a **commit
12
+ * status** or a **check run**. When it is throttled, or declines to review at
13
+ * all, it never blocks — a status still reports `state: success`, and a check
14
+ * run concludes `neutral`, which GitHub counts as passing a required check. Only
15
+ * the reviewer's own words distinguish a review that ran from one that never
16
+ * started.
15
17
  *
16
18
  * Re-measured over the last 30 merged pull requests in this repository, reading
17
19
  * the reviewer's status on each pull request's head SHA:
@@ -50,6 +52,52 @@
50
52
  * Hollow green is invisible there. The evidence read MUST come from
51
53
  * `repos/{owner}/{repo}/commits/{sha}/status` and use `.statuses[].description`.
52
54
  *
55
+ * ## Two representations, one question (#4553)
56
+ *
57
+ * The same reviewer can move from statuses to check runs without notice.
58
+ * Measured on this repository's last 30 merged pull requests: the 18 up to
59
+ * 2026-10-26 carry a `CodeRabbit` commit status and no check run, and the 12
60
+ * from 2026-10-27 carry a `CodeRabbit` check run and no status. A reader of
61
+ * statuses alone reported `absent` at every one of those 12 heads — including
62
+ * one the reviewer had approved — so every pull request needed a hand-written
63
+ * substitute review.
64
+ *
65
+ * Both are read now, and statuses exactly as before. A check run is the
66
+ * reviewer's evidence only when it carries the declared context as its name,
67
+ * was reported for this exact head, and — when the gate pins `posted_by` — was
68
+ * posted by that app; an unpinned gate accepts any app, which is GitHub's own
69
+ * meaning of an unpinned required check. When such a run exists it is
70
+ * authoritative over a status of the same name, and the newest run wins.
71
+ *
72
+ * Those two rules and the decoding below are the ones
73
+ * `check-skipped-required-checks.mjs` applies (#4547). Four differences are
74
+ * deliberate. The first three only keep a reviewer's real evidence from being
75
+ * hidden; the fourth follows from the two provers reading different
76
+ * declarations:
77
+ *
78
+ * - a run from an app other than the pinned `posted_by` is set aside BEFORE
79
+ * the newest run is chosen, so another app's later run under the
80
+ * reviewer's name cannot shadow the reviewer's own (that prover reads no
81
+ * app at all);
82
+ * - when the check-run read fails, the reviewer's own status still counts,
83
+ * so a host whose token cannot read check runs keeps reading statuses
84
+ * exactly as before (that prover refuses the whole read);
85
+ * - a phrase is compared under this prover's existing normalisation — whole
86
+ * string, case-insensitive, whitespace-collapsed — as statuses always were;
87
+ * - the words that are denied first are Lisa's shipped no-work phrases, while
88
+ * that prover also denies phrases its own declaration configures
89
+ * (`no_work`, `waive`). A phrase only configured there is never one
90
+ * this prover's allowlist declares as proof unless a project declares the
91
+ * same words as both, which no shipped configuration does.
92
+ *
93
+ * A check run's title is often generic (`Finished`), so its result is read from
94
+ * the FIRST line of its summary, exactly as #4547 reads it. Only
95
+ * `Result: Success - Review completed`, on a run that completed with `success`,
96
+ * stands for the proof phrase `Review completed`; every other result-shaped
97
+ * first line stays authoritative, so a negative result can never fall back to a
98
+ * positive title. Later lines, a generic title, and `success` alone grant
99
+ * nothing — and a run that did not succeed grants nothing whatever it says.
100
+ *
53
101
  * ## A review OBJECT is not a status CONTEXT
54
102
  *
55
103
  * An empty-bodied `APPROVED` review is an ordinary human approval, not hollow.
@@ -74,10 +122,11 @@
74
122
  * node scripts/check-third-party-review-evidence.mjs --sha <sha> [--json]
75
123
  * [--context <context>] [--root <dir>] [--repo <owner/repo>]
76
124
  *
77
- * `--context` names the status context that woke the run. When it is given and
78
- * belongs to no declared reviewer, the run is an explicit no-op: a status from
79
- * some other app cannot change this answer, and evaluating on one would report
80
- * a reviewer as absent merely because it had not posted yet.
125
+ * `--context` names the status context or check-run name that woke the run.
126
+ * When it is given and belongs to no declared reviewer, the run is an explicit
127
+ * no-op: a status or check run from some other app cannot change this answer,
128
+ * and evaluating on one would report a reviewer as absent merely because it had
129
+ * not posted yet.
81
130
  *
82
131
  * @module scripts/check-third-party-review-evidence
83
132
  */
@@ -85,11 +134,11 @@ import { boundedExecFileSync } from "./lib/bounded-spawn.mjs";
85
134
  import { invokedAsScript } from "./lib/invoked-as-script.mjs";
86
135
  import { EVIDENCE_DEFAULTS, PULL_REQUEST, readGates } from "./lisa-gates.mjs";
87
136
 
88
- /** How a reviewer's status at one commit reads. */
137
+ /** How a reviewer's evidence at one commit reads. */
89
138
  export const EVIDENCE_READINGS = Object.freeze({
90
139
  /** The description matched a declared reviewed-when phrase. */
91
140
  reviewed: "reviewed",
92
- /** No status for this context on the commit at all. */
141
+ /** No status and no check run for this context on the commit at all. */
93
142
  absent: "absent",
94
143
  /** A status with no description, or only whitespace. */
95
144
  empty: "empty",
@@ -99,6 +148,18 @@ export const EVIDENCE_READINGS = Object.freeze({
99
148
  unrecognised: "unrecognised",
100
149
  /** The reviewer has started and has not answered yet. Not an answer either way. */
101
150
  pending: "pending",
151
+ /** The reviewer's check run finished with a conclusion other than success. */
152
+ unsuccessful: "unsuccessful",
153
+ /** A read this answer depends on failed, so nothing could be seen. */
154
+ unreadable: "unreadable",
155
+ });
156
+
157
+ /** Which representation a reading's evidence came from. */
158
+ export const EVIDENCE_SOURCES = Object.freeze({
159
+ /** `repos/{repo}/commits/{sha}/status`. */
160
+ status: "status",
161
+ /** `repos/{repo}/commits/{sha}/check-runs`. */
162
+ checkRun: "check-run",
102
163
  });
103
164
 
104
165
  /** What the whole evaluation concluded. */
@@ -146,7 +207,7 @@ export const READING_SENTENCES = Object.freeze({
146
207
  [EVIDENCE_READINGS.reviewed]:
147
208
  "the reviewer reported a description this project declares as proof it read the code",
148
209
  [EVIDENCE_READINGS.absent]:
149
- "no third-party status was present at this head at all — nothing from that reviewer, of any state",
210
+ "no third-party status was present at this head at all — nothing from that reviewer, of any state, as a commit status or as a check run",
150
211
  [EVIDENCE_READINGS.empty]:
151
212
  "the reviewer posted a status carrying no description, so nothing says whether it read anything",
152
213
  [EVIDENCE_READINGS.noWork]:
@@ -155,6 +216,10 @@ export const READING_SENTENCES = Object.freeze({
155
216
  "the reviewer reported a description this project does not declare as proof, and which nothing here recognises",
156
217
  [EVIDENCE_READINGS.pending]:
157
218
  "the reviewer has started and has not answered yet",
219
+ [EVIDENCE_READINGS.unsuccessful]:
220
+ "the reviewer's check run finished without success, so nothing it reported counts as proof it read the code",
221
+ [EVIDENCE_READINGS.unreadable]:
222
+ "the reviewer's statuses or check runs at this head could not be read, so nothing here can say whether it reviewed",
158
223
  });
159
224
 
160
225
  /**
@@ -193,10 +258,16 @@ export function normalizeDescription(description) {
193
258
  * The reviewed-when allowlist is the gate's merged `evidence.proof`: Lisa's
194
259
  * shipped phrases plus whatever the project adds. Extension, never replacement,
195
260
  * for the reason `mergeEvidence` records.
261
+ *
262
+ * `postedBy` is the gate's `posted_by` — the app a check run must come from to
263
+ * speak for this reviewer — exactly as declared, and absent when the gate omits
264
+ * it, which is unpinned. Any declared value is carried as written, so a
265
+ * malformed pin such as `null` or a string id matches no app rather than
266
+ * widening to every app; `validateGates` refuses it separately.
196
267
  * @param {object} options - Resolution inputs.
197
268
  * @param {object} options.gates - The `gates` block of `.lisa.config.json`.
198
269
  * @param {string} [options.moment] - Which moment to read. Defaults to the pull-request moment.
199
- * @returns {Array<{gateId: string, context: string, proof: string[]}>} Declared reviewers.
270
+ * @returns {Array<{gateId: string, context: string, proof: string[], postedBy?: unknown}>} Declared reviewers.
200
271
  */
201
272
  export function configuredReviewers({ gates, moment = PULL_REQUEST }) {
202
273
  const reviewers = [];
@@ -212,6 +283,7 @@ export function configuredReviewers({ gates, moment = PULL_REQUEST }) {
212
283
  proof: [...EVIDENCE_DEFAULTS.proof, ...(entry.evidence.proof ?? [])].map(
213
284
  normalizeDescription
214
285
  ),
286
+ ...(entry.posted_by === undefined ? {} : { postedBy: entry.posted_by }),
215
287
  });
216
288
  }
217
289
  return reviewers.sort((left, right) =>
@@ -222,11 +294,14 @@ export function configuredReviewers({ gates, moment = PULL_REQUEST }) {
222
294
  /**
223
295
  * Whether a description reads as the vendor saying it did no work.
224
296
  *
225
- * REPORTING ONLY. This never grants nor denies credit — the verdict is settled
226
- * by the allowlist above, and this only chooses between two ways of saying
227
- * "not reviewed" so an operator can tell a known vendor state from a string
228
- * nobody has seen. Wiring it into the verdict would turn the allowlist back
229
- * into a denylist, which is the fail-open shape this module refuses.
297
+ * It NEVER GRANTS credit — the verdict is settled by the allowlist above. For
298
+ * a status it only chooses between two ways of saying "not reviewed", so an
299
+ * operator can tell a known vendor state from a string nobody has seen. For a
300
+ * check run it also decides which of the run's words are read, denial first,
301
+ * exactly as #4547 decodes a run: a no-work title or first line is read in
302
+ * place of the structured result. That can only withhold credit. Wiring it in
303
+ * anywhere it could grant credit would turn the allowlist back into a
304
+ * denylist, which is the fail-open shape this module refuses.
230
305
  * @param {string} normalised - An already-normalised description.
231
306
  * @returns {boolean} Whether a shipped no-work phrase appears in it.
232
307
  */
@@ -236,6 +311,66 @@ function readsAsNoWork(normalised) {
236
311
  );
237
312
  }
238
313
 
314
+ /**
315
+ * @typedef {object} ReviewerShape
316
+ * @property {string} context - The status context or check-run name it reports under.
317
+ * @property {string[]} proof - The normalised reviewed-when allowlist.
318
+ * @property {unknown} [postedBy] - The app id a check run must come from, exactly as declared; absent for any app.
319
+ */
320
+
321
+ /**
322
+ * One row of `.check_runs` from `repos/{repo}/commits/{sha}/check-runs`, as the
323
+ * API returns it. Every field is untrusted until read here.
324
+ * @typedef {object} RawCheckRun
325
+ * @property {unknown} [id] - The run id, which GitHub assigns in creation order.
326
+ * @property {unknown} [name] - The check-run name; a reviewer's is its declared context.
327
+ * @property {unknown} [head_sha] - The commit the run reports on.
328
+ * @property {unknown} [status] - `queued`, `in_progress`, `completed`, ...
329
+ * @property {unknown} [conclusion] - Set once completed: `success`, `neutral`, ...
330
+ * @property {unknown} [created_at] - Absent from the API object; honoured if a source supplies it.
331
+ * @property {{id?: unknown}|null} [app] - The app that posted the run.
332
+ * @property {{title?: unknown, summary?: unknown}|null} [output] - What the run reported.
333
+ */
334
+
335
+ /**
336
+ * @typedef {object} EvidenceReading
337
+ * @property {string} context - The reviewer's context.
338
+ * @property {boolean} reviewed - Whether this is evidence the reviewer read the code.
339
+ * @property {string} reading - One of {@link EVIDENCE_READINGS}.
340
+ * @property {string|null} observed - The words that decided, verbatim.
341
+ * @property {string|null} state - A status's state, or a check run's conclusion or lifecycle.
342
+ * @property {string|null} source - One of {@link EVIDENCE_SOURCES}, or null when nothing was found.
343
+ * @property {unknown} [checkRunId] - The run that decided, for a check-run reading.
344
+ * @property {Array<{id: unknown, reason: string}>} ignoredCheckRuns - Runs under the reviewer's name that could not speak for it, and why.
345
+ */
346
+
347
+ /**
348
+ * The one structured first line that says a review completed.
349
+ *
350
+ * Byte-identical to the line `check-skipped-required-checks.mjs` recognises
351
+ * (#4547), and matched the same way: exactly, as the first physical line, on a
352
+ * run that completed with `success`. Duplicated rather than imported because
353
+ * the two scripts ship from different template trees and a host that has this
354
+ * one need not have that one; `tests/integration/third-party-review-check-run-cli`
355
+ * holds both provers to the same answers.
356
+ */
357
+ export const COMPLETED_REVIEW_RESULT = "Result: Success - Review completed";
358
+
359
+ /** The proof phrase that line stands for. */
360
+ const COMPLETED_REVIEW_PROOF = "Review completed";
361
+
362
+ /** What a summary that is present but not text is reported as. */
363
+ const MALFORMED_SUMMARY = "Malformed check-run summary";
364
+
365
+ /** Check-run lifecycle values that mean the reviewer has not answered yet. */
366
+ const UNSETTLED_RUN_STATUSES = new Set([
367
+ "queued",
368
+ "in_progress",
369
+ "requested",
370
+ "waiting",
371
+ "pending",
372
+ ]);
373
+
239
374
  /**
240
375
  * Read one reviewer's evidence off the statuses on a commit.
241
376
  *
@@ -244,15 +379,13 @@ function readsAsNoWork(normalised) {
244
379
  * no-work phrase, or an unrecognised string — is NOT REVIEWED, and the observed
245
380
  * string is carried back verbatim so the report can say what was actually seen.
246
381
  * @param {object} options - Reading inputs.
247
- * @param {{context: string, proof: string[]}} options.reviewer - One declared reviewer.
382
+ * @param {ReviewerShape} options.reviewer - One declared reviewer.
248
383
  * @param {ReadonlyArray<{context?: string, state?: string, description?: string}>} options.statuses -
249
384
  * `.statuses` from `repos/{owner}/{repo}/commits/{sha}/status`, newest first.
250
- * @returns {{context: string, reviewed: boolean, reading: string, observed: string|null, state: string|null}} The reading.
385
+ * @returns {Omit<EvidenceReading, "ignoredCheckRuns">} The reading.
251
386
  */
252
- export function readReviewerEvidence({ reviewer, statuses }) {
253
- const found = (statuses ?? []).find(
254
- status => status?.context === reviewer.context
255
- );
387
+ function readStatusEvidence({ reviewer, statuses }) {
388
+ const found = statuses.find(status => status?.context === reviewer.context);
256
389
  if (found === undefined) {
257
390
  return {
258
391
  context: reviewer.context,
@@ -260,50 +393,267 @@ export function readReviewerEvidence({ reviewer, statuses }) {
260
393
  reading: EVIDENCE_READINGS.absent,
261
394
  observed: null,
262
395
  state: null,
396
+ source: null,
263
397
  };
264
398
  }
265
399
  const observed = found.description ?? null;
266
400
  const normalised = normalizeDescription(observed);
267
401
  const state = found.state ?? null;
402
+ const answer = (
403
+ /** @type {string} */ kind,
404
+ /** @type {boolean} */ reviewed = false
405
+ ) => ({
406
+ context: reviewer.context,
407
+ reviewed,
408
+ reading: kind,
409
+ observed,
410
+ state,
411
+ source: EVIDENCE_SOURCES.status,
412
+ });
268
413
  // A reviewer posts several statuses on one head before the settled one —
269
414
  // measured `Review queued` then `Review in progress` then the verdict. Judging
270
415
  // an unsettled one would report a finding on every pull request, and a guard
271
416
  // that fires every time gets deleted rather than read.
272
417
  if (String(state).toLowerCase() === "pending") {
273
- return {
274
- context: reviewer.context,
275
- reviewed: false,
276
- reading: EVIDENCE_READINGS.pending,
277
- observed,
278
- state,
279
- };
280
- }
281
- if (normalised === "") {
282
- return {
283
- context: reviewer.context,
284
- reviewed: false,
285
- reading: EVIDENCE_READINGS.empty,
286
- observed,
287
- state,
288
- };
418
+ return answer(EVIDENCE_READINGS.pending);
289
419
  }
420
+ if (normalised === "") return answer(EVIDENCE_READINGS.empty);
290
421
  if (reviewer.proof.includes(normalised)) {
422
+ return answer(EVIDENCE_READINGS.reviewed, true);
423
+ }
424
+ return answer(
425
+ readsAsNoWork(normalised)
426
+ ? EVIDENCE_READINGS.noWork
427
+ : EVIDENCE_READINGS.unrecognised
428
+ );
429
+ }
430
+
431
+ /**
432
+ * Whether a line is shaped like a structured result, however it is decorated.
433
+ *
434
+ * The same two tests `check-skipped-required-checks.mjs` applies. Such a line is
435
+ * authoritative even when it is not the one positive form, which is what stops
436
+ * a negative or malformed result from falling back to a positive title.
437
+ * @param {string} line - A first summary line.
438
+ * @returns {boolean} True for anything that reads as a result line.
439
+ */
440
+ function looksLikeResult(line) {
441
+ return /^[\s>#|]*Result\b/iu.test(line) || /\bResult\s*[:=-]/iu.test(line);
442
+ }
443
+
444
+ /**
445
+ * What a check run says, in the place a status carries its description.
446
+ *
447
+ * The decoding #4547 shipped for the registration prover, applied unchanged:
448
+ *
449
+ * 1. A title that reads as no-work decides first — a denial is never
450
+ * overridden by later text.
451
+ * 2. No summary keeps the title, the representation's own description slot.
452
+ * 3. A summary that is not text is never read: it stands for no phrase, and
453
+ * the report carries the value it actually was.
454
+ * 4. A first summary line that reads as no-work decides next.
455
+ * 5. `Result: Success - Review completed`, as the whole first line of a run
456
+ * that completed with `success`, stands for the proof phrase
457
+ * `Review completed`. This is the ONLY route from a summary to credit.
458
+ * 6. Any other result-shaped first line is authoritative as it stands, so a
459
+ * failed or unknown result cannot inherit a positive title.
460
+ * 7. A plain first line keeps the title, and later lines are never read.
461
+ *
462
+ * `text` is what decided, verbatim, for the report; `canonical` is what the
463
+ * allowlist compares. They differ only in rules 3 and 5.
464
+ * @param {RawCheckRun} run - One raw check run.
465
+ * @returns {{text: string, canonical: string}} The run's words.
466
+ */
467
+ export function checkRunResult(run) {
468
+ const output = run?.output ?? {};
469
+ const title = typeof output.title === "string" ? output.title : "";
470
+ const said = (/** @type {string} */ text) => ({ text, canonical: text });
471
+ if (readsAsNoWork(normalizeDescription(title))) return said(title);
472
+ const summary = output.summary;
473
+ if (summary === null || summary === undefined) return said(title);
474
+ if (typeof summary !== "string") {
475
+ return { text: JSON.stringify(summary), canonical: MALFORMED_SUMMARY };
476
+ }
477
+ const firstLine = summary.split(/\r?\n/u, 1)[0] ?? "";
478
+ if (readsAsNoWork(normalizeDescription(firstLine))) return said(firstLine);
479
+ if (
480
+ run?.status === "completed" &&
481
+ run?.conclusion === "success" &&
482
+ firstLine === COMPLETED_REVIEW_RESULT
483
+ ) {
484
+ return { text: firstLine, canonical: COMPLETED_REVIEW_PROOF };
485
+ }
486
+ if (looksLikeResult(firstLine)) return said(firstLine);
487
+ return said(title);
488
+ }
489
+
490
+ /**
491
+ * Order two runs by creation, newest last.
492
+ *
493
+ * Creation time first and id second, as `newestCheckRuns` in
494
+ * `check-skipped-required-checks.mjs` orders them. The API object carries no
495
+ * `created_at` — measured — so in practice the id decides, and GitHub assigns
496
+ * ids in creation order.
497
+ * @param {RawCheckRun} run - One run.
498
+ * @returns {[number, number]} Its creation time and id, zero when unknown.
499
+ */
500
+ function runRank(run) {
501
+ const created =
502
+ typeof run.created_at === "string" ? Date.parse(run.created_at) : NaN;
503
+ return [Number.isFinite(created) ? created : 0, Number(run.id) || 0];
504
+ }
505
+
506
+ /**
507
+ * Why a run under the reviewer's name cannot speak for it, or null if it can.
508
+ * @param {object} options - The run and what it must match.
509
+ * @param {ReviewerShape} options.reviewer - One declared reviewer.
510
+ * @param {RawCheckRun} options.run - A run carrying the reviewer's name.
511
+ * @param {string|undefined} options.sha - The head commit.
512
+ * @returns {string|null} The reason, or null when the run is the reviewer's.
513
+ */
514
+ function whyNotTheReviewers({ reviewer, run, sha }) {
515
+ if (typeof sha !== "string" || run.head_sha !== sha) {
516
+ return `reported for head ${JSON.stringify(run.head_sha ?? null)}, not ${JSON.stringify(sha ?? null)}`;
517
+ }
518
+ // Absent means unpinned. Anything declared — a malformed `null` included —
519
+ // must match exactly, so a broken pin narrows to no app instead of widening
520
+ // to every app.
521
+ const pin = reviewer.postedBy;
522
+ if (pin !== undefined && run.app?.id !== pin) {
523
+ return `posted by app ${JSON.stringify(run.app?.id ?? null)}, not the declared ${JSON.stringify(pin)}`;
524
+ }
525
+ return null;
526
+ }
527
+
528
+ /**
529
+ * The check run that speaks for one reviewer at one head, if any.
530
+ *
531
+ * A run counts only when it carries the reviewer's context as its name, was
532
+ * reported for this exact head, and — when the gate pins `posted_by` — came
533
+ * from that app. Anything else under the reviewer's name is set aside WITH its
534
+ * reason rather than dropped in silence: a run another app posted under the
535
+ * reviewer's name is the case an operator most needs explained. Of the runs
536
+ * that count, the newest decides.
537
+ * @param {object} options - Selection inputs.
538
+ * @param {ReviewerShape} options.reviewer - One declared reviewer.
539
+ * @param {ReadonlyArray<RawCheckRun>|null|undefined} options.runs - Runs read for its name; null when the read failed.
540
+ * @param {string|undefined} options.sha - The head commit.
541
+ * @returns {{run: RawCheckRun|null, ignored: Array<{id: unknown, reason: string}>}} The run, and those set aside.
542
+ */
543
+ export function selectReviewerCheckRun({ reviewer, runs, sha }) {
544
+ /** @type {Array<{id: unknown, reason: string}>} */
545
+ const ignored = [];
546
+ /** @type {RawCheckRun|null} */
547
+ let selected = null;
548
+ for (const run of Array.isArray(runs) ? runs : []) {
549
+ if (run?.name !== reviewer.context) continue;
550
+ const reason = whyNotTheReviewers({ reviewer, run, sha });
551
+ if (reason !== null) {
552
+ ignored.push({ id: run.id ?? null, reason });
553
+ continue;
554
+ }
555
+ if (selected === null) {
556
+ selected = run;
557
+ continue;
558
+ }
559
+ const [time, id] = runRank(run);
560
+ const [selectedTime, selectedId] = runRank(selected);
561
+ if (time > selectedTime || (time === selectedTime && id > selectedId)) {
562
+ selected = run;
563
+ }
564
+ }
565
+ return { run: selected, ignored };
566
+ }
567
+
568
+ /**
569
+ * Read one reviewer's evidence off the check run that speaks for it.
570
+ *
571
+ * Credit needs all three: the run completed, it concluded `success`, and what
572
+ * it said is a declared reviewed-when phrase. A run that has not finished is
573
+ * pending — not an answer either way — and one that finished any other way
574
+ * grants nothing, whatever its words.
575
+ *
576
+ * The lifecycle and conclusion are compared exactly as GitHub sends them,
577
+ * lowercase, the way the registration prover compares them. A value in any
578
+ * other spelling is not one GitHub defines, so it is read as unknown rather
579
+ * than folded into one that grants credit.
580
+ * @param {object} options - Reading inputs.
581
+ * @param {ReviewerShape} options.reviewer - One declared reviewer.
582
+ * @param {RawCheckRun} options.run - The run selected for it.
583
+ * @returns {Omit<EvidenceReading, "ignoredCheckRuns">} The reading.
584
+ */
585
+ function readCheckRunEvidence({ reviewer, run }) {
586
+ const status = typeof run.status === "string" ? run.status : "";
587
+ const conclusion = typeof run.conclusion === "string" ? run.conclusion : null;
588
+ const { text, canonical } = checkRunResult(run);
589
+ const settled = status === "completed";
590
+ const normalised = normalizeDescription(text);
591
+ let reading = EVIDENCE_READINGS.unrecognised;
592
+ if (UNSETTLED_RUN_STATUSES.has(status)) {
593
+ reading = EVIDENCE_READINGS.pending;
594
+ } else if (!settled) {
595
+ reading = EVIDENCE_READINGS.unrecognised;
596
+ } else if (
597
+ conclusion === "success" &&
598
+ reviewer.proof.includes(normalizeDescription(canonical))
599
+ ) {
600
+ reading = EVIDENCE_READINGS.reviewed;
601
+ } else if (readsAsNoWork(normalised)) {
602
+ reading = EVIDENCE_READINGS.noWork;
603
+ } else if (conclusion !== "success") {
604
+ reading = EVIDENCE_READINGS.unsuccessful;
605
+ } else if (normalised === "") {
606
+ reading = EVIDENCE_READINGS.empty;
607
+ }
608
+ return {
609
+ context: reviewer.context,
610
+ reviewed: reading === EVIDENCE_READINGS.reviewed,
611
+ reading,
612
+ observed: text,
613
+ state: settled ? (conclusion ?? status) : status || null,
614
+ source: EVIDENCE_SOURCES.checkRun,
615
+ checkRunId: run.id ?? null,
616
+ };
617
+ }
618
+
619
+ /**
620
+ * Read one reviewer's evidence at one commit, from both representations.
621
+ *
622
+ * A check run that speaks for the reviewer is authoritative; otherwise its
623
+ * status is read exactly as before. A read that failed is never reported as
624
+ * an absence: when nothing stands and a read this answer depended on failed,
625
+ * the reading is `unreadable`, because "nothing was there" and "nothing could
626
+ * be seen" call for different operator actions. A failed check-run read never
627
+ * takes away a status's credit — reviewers that still post statuses keep
628
+ * reading exactly as they did.
629
+ * @param {object} options - Reading inputs.
630
+ * @param {ReviewerShape} options.reviewer - One declared reviewer.
631
+ * @param {ReadonlyArray<{context?: string, state?: string, description?: string}>|null} [options.statuses] -
632
+ * `.statuses` from `repos/{owner}/{repo}/commits/{sha}/status`, newest first; null when the read failed.
633
+ * @param {ReadonlyArray<RawCheckRun>|null} [options.checkRuns] - Runs read for the reviewer's name; null when the read failed, absent when none were read.
634
+ * @param {string} [options.sha] - The head commit, which every check run must report on.
635
+ * @returns {EvidenceReading} The reading.
636
+ */
637
+ export function readReviewerEvidence({ reviewer, statuses, checkRuns, sha }) {
638
+ const { run, ignored } = selectReviewerCheckRun({
639
+ reviewer,
640
+ runs: checkRuns,
641
+ sha,
642
+ });
643
+ if (run !== null) {
291
644
  return {
292
- context: reviewer.context,
293
- reviewed: true,
294
- reading: EVIDENCE_READINGS.reviewed,
295
- observed,
296
- state,
645
+ ...readCheckRunEvidence({ reviewer, run }),
646
+ ignoredCheckRuns: ignored,
297
647
  };
298
648
  }
649
+ const fromStatus = readStatusEvidence({ reviewer, statuses: statuses ?? [] });
650
+ const unreadable =
651
+ fromStatus.reading === EVIDENCE_READINGS.absent &&
652
+ (checkRuns === null || statuses === null);
299
653
  return {
300
- context: reviewer.context,
301
- reviewed: false,
302
- reading: readsAsNoWork(normalised)
303
- ? EVIDENCE_READINGS.noWork
304
- : EVIDENCE_READINGS.unrecognised,
305
- observed,
306
- state,
654
+ ...fromStatus,
655
+ ...(unreadable ? { reading: EVIDENCE_READINGS.unreadable } : {}),
656
+ ignoredCheckRuns: ignored,
307
657
  };
308
658
  }
309
659
 
@@ -416,16 +766,21 @@ export function reviewStanding({ reviews, sha }) {
416
766
  * this project declared no third-party reviewer, which is a legitimate state
417
767
  * that must be SAID rather than passed through in silence.
418
768
  * @param {object} options - Decision inputs.
419
- * @param {Array<{gateId: string, context: string, proof: string[]}>} options.reviewers - Declared reviewers.
420
- * @param {ReadonlyArray<object>} options.statuses - Statuses on the head commit.
769
+ * @param {Array<{gateId: string, context: string, proof: string[], postedBy?: unknown}>} options.reviewers - Declared reviewers.
770
+ * @param {ReadonlyArray<object>|null} options.statuses - Statuses on the head commit; null when the read failed.
771
+ * @param {Record<string, ReadonlyArray<RawCheckRun>|null>} [options.checkRuns] -
772
+ * Check runs read for each reviewer's context, keyed by that context; a null
773
+ * entry is a read that failed, and a missing key is a reviewer whose runs
774
+ * were not read.
421
775
  * @param {ReadonlyArray<{body?: string}>} options.comments - Issue comments on the pull request.
422
776
  * @param {string} options.sha - The head commit.
423
777
  * @param {boolean} [options.isPullRequestHead] - Whether an open pull request has this commit as its head.
424
- * @returns {{outcome: string, sha: string, readings: Array<object>}} The verdict.
778
+ * @returns {{outcome: string, sha: string, readings: Array<EvidenceReading & {gateId: string, substituted: boolean}>}} The verdict.
425
779
  */
426
780
  export function reviewEvidenceVerdict({
427
781
  reviewers,
428
782
  statuses,
783
+ checkRuns,
429
784
  comments,
430
785
  sha,
431
786
  isPullRequestHead = true,
@@ -445,7 +800,15 @@ export function reviewEvidenceVerdict({
445
800
  };
446
801
  }
447
802
  const readings = reviewers.map(reviewer => {
448
- const reading = readReviewerEvidence({ reviewer, statuses });
803
+ const reading = readReviewerEvidence({
804
+ reviewer,
805
+ statuses,
806
+ checkRuns:
807
+ checkRuns && Object.hasOwn(checkRuns, reviewer.context)
808
+ ? checkRuns[reviewer.context]
809
+ : undefined,
810
+ sha,
811
+ });
449
812
  return {
450
813
  ...reading,
451
814
  gateId: reviewer.gateId,
@@ -474,9 +837,22 @@ export function reviewEvidenceVerdict({
474
837
  return { outcome: EVIDENCE_OUTCOMES.unsatisfied, sha, readings };
475
838
  }
476
839
 
840
+ /**
841
+ * A reading as the report prints it.
842
+ * @typedef {object} ReportedReading
843
+ * @property {string} context - The reviewer's context.
844
+ * @property {boolean} reviewed - Whether it is evidence of a review.
845
+ * @property {string} reading - One of {@link EVIDENCE_READINGS}.
846
+ * @property {string|null} observed - The words that decided, verbatim.
847
+ * @property {string|null} state - The state, conclusion or lifecycle seen.
848
+ * @property {string|null} [source] - One of {@link EVIDENCE_SOURCES}.
849
+ * @property {boolean} [substituted] - Whether a substitute stands in.
850
+ * @property {Array<{id: unknown, reason: string}>} [ignoredCheckRuns] - Runs set aside, and why.
851
+ */
852
+
477
853
  /**
478
854
  * One operator-readable line per reviewer, plus the headline.
479
- * @param {{outcome: string, sha: string, readings: Array<object>}} verdict - A verdict.
855
+ * @param {{outcome: string, sha: string, readings: ReadonlyArray<ReportedReading>}} verdict - A verdict.
480
856
  * @returns {string} The report.
481
857
  */
482
858
  export function humanReport(verdict) {
@@ -491,8 +867,8 @@ export function humanReport(verdict) {
491
867
  }
492
868
  if (verdict.outcome === EVIDENCE_OUTCOMES.notAConfiguredReviewer) {
493
869
  return (
494
- `NO-OP — the triggering status belongs to no declared reviewer.\n` +
495
- `Reviewers ARE configured; this status is not one of theirs, so it ` +
870
+ `NO-OP — the triggering status or check run belongs to no declared reviewer.\n` +
871
+ `Reviewers ARE configured; this one is not one of theirs, so it ` +
496
872
  `cannot change the answer. This is a no-op, NOT a pass.`
497
873
  );
498
874
  }
@@ -504,23 +880,49 @@ export function humanReport(verdict) {
504
880
  );
505
881
  }
506
882
  const lines = verdict.readings.map(reading => {
507
- const seen =
508
- reading.observed === null
509
- ? "no status posted"
510
- : `${reading.state ?? "?"} — ${JSON.stringify(reading.observed)}`;
511
- if (reading.reviewed) return ` reviewed ${reading.context}: ${seen}`;
883
+ const seen = describeSeen(reading);
884
+ const ignored = (reading.ignoredCheckRuns ?? []).map(
885
+ entry => ` ignored check run ${String(entry.id)}: ${entry.reason}`
886
+ );
887
+ if (reading.reviewed) {
888
+ return [` reviewed ${reading.context}: ${seen}`, ...ignored].join(
889
+ "\n"
890
+ );
891
+ }
512
892
  const stood = reading.substituted
513
893
  ? "a local adversarial review was posted in its place"
514
894
  : "NOTHING stands in its place";
515
- return (
516
- ` NOT REVIEWED ${reading.context} (${reading.reading}): ${seen}\n` +
517
- ` observed: ${READING_SENTENCES[reading.reading]}\n` +
518
- ` ${stood}`
519
- );
895
+ return [
896
+ ` NOT REVIEWED ${reading.context} (${reading.reading}): ${seen}`,
897
+ ` observed: ${READING_SENTENCES[reading.reading]}`,
898
+ ` ${stood}`,
899
+ ...ignored,
900
+ ].join("\n");
520
901
  });
521
902
  return `${verdict.outcome.toUpperCase()} at ${verdict.sha}\n${lines.join("\n")}`;
522
903
  }
523
904
 
905
+ /**
906
+ * What was seen for one reviewer, in the words the report prints.
907
+ *
908
+ * A reading without a `source` — one built before check runs were read — is
909
+ * placed by whether it observed anything, which is how it was always printed.
910
+ * @param {{reading: string, observed: string|null, state: string|null, source?: string|null}} reading - One reading.
911
+ * @returns {string} The observation.
912
+ */
913
+ function describeSeen(reading) {
914
+ if (reading.reading === EVIDENCE_READINGS.unreadable) {
915
+ return "its statuses or check runs could not be read";
916
+ }
917
+ const source =
918
+ reading.source ??
919
+ (reading.observed === null ? null : EVIDENCE_SOURCES.status);
920
+ const words = `${reading.state ?? "?"} — ${JSON.stringify(reading.observed)}`;
921
+ if (source === EVIDENCE_SOURCES.checkRun) return `check run ${words}`;
922
+ if (source === EVIDENCE_SOURCES.status) return words;
923
+ return "no status or check run posted";
924
+ }
925
+
524
926
  /**
525
927
  * Parse the CLI arguments.
526
928
  * @param {string[]} argv - Arguments after the script path.
@@ -570,8 +972,11 @@ function splitFlag(arg) {
570
972
  *
571
973
  * `gh` is asked for a path rather than handed a query, and the child is bounded
572
974
  * — a guard whose child never returned would otherwise report a verdict it
573
- * never reached. A failed read returns `null`, which the callers turn into
574
- * "absent", so a broken read can only ever make this stricter.
975
+ * never reached. A failed read returns `null`. A failed pull-request read
976
+ * reads as "not a pull request head", and a failed status or check-run read is
977
+ * reported as unreadable rather than as evidence of anything — except that a
978
+ * failed check-run read leaves the reviewer's own status standing, so a host
979
+ * whose token cannot read check runs keeps reading statuses as before.
575
980
  * @param {string} path - The API path.
576
981
  * @returns {unknown} The parsed body, or null.
577
982
  */
@@ -591,13 +996,43 @@ function ghJson(path) {
591
996
  * `repos/{repo}/commits/{sha}/status` and NOT `gh pr view --json
592
997
  * statusCheckRollup` — verified, the rollup returns a `StatusContext` with no
593
998
  * `description` key at all, so hollow green is invisible through it.
999
+ * A read that failed returns null rather than an empty list, so the reading
1000
+ * can say the statuses could not be read instead of claiming there were none.
594
1001
  * @param {string} repo - `owner/repo`.
595
1002
  * @param {string} sha - The commit.
596
- * @returns {ReadonlyArray<object>} The statuses, newest first.
1003
+ * @returns {ReadonlyArray<object>|null} The statuses, newest first, or null.
597
1004
  */
598
1005
  function readStatuses(repo, sha) {
599
- const body = ghJson(`repos/${repo}/commits/${sha}/status`);
600
- return Array.isArray(body?.statuses) ? body.statuses : [];
1006
+ const body = /** @type {{statuses?: unknown}|null} */ (
1007
+ ghJson(`repos/${repo}/commits/${sha}/status`)
1008
+ );
1009
+ return Array.isArray(body?.statuses) ? body.statuses : null;
1010
+ }
1011
+
1012
+ /**
1013
+ * The check runs on one commit that carry one reviewer's name.
1014
+ *
1015
+ * Filtered by name at the source: a head here carries around sixty check runs,
1016
+ * past the endpoint's default page of thirty, so an unfiltered first page can
1017
+ * simply not contain the reviewer's run. A response that admits to more rows
1018
+ * than it returned is incomplete, and an incomplete read is reported as a
1019
+ * failed one — the newest run could be on the page that was not read.
1020
+ * @param {string} repo - `owner/repo`.
1021
+ * @param {string} sha - The commit.
1022
+ * @param {string} context - The reviewer's declared context, which is its run name.
1023
+ * @returns {ReadonlyArray<RawCheckRun>|null} The runs, or null when they could not all be read.
1024
+ */
1025
+ function readCheckRuns(repo, sha, context) {
1026
+ const body =
1027
+ /** @type {{total_count?: unknown, check_runs?: unknown}|null} */ (
1028
+ ghJson(
1029
+ `repos/${repo}/commits/${sha}/check-runs?check_name=${encodeURIComponent(context)}&per_page=100`
1030
+ )
1031
+ );
1032
+ if (!Array.isArray(body?.check_runs)) return null;
1033
+ const total = body?.total_count;
1034
+ if (typeof total === "number" && total > body.check_runs.length) return null;
1035
+ return body.check_runs;
601
1036
  }
602
1037
 
603
1038
  /**
@@ -638,13 +1073,64 @@ function readComments(repo, numbers) {
638
1073
  });
639
1074
  }
640
1075
 
1076
+ /**
1077
+ * Which check-run reader a run uses.
1078
+ *
1079
+ * A caller that injected the status reader supplies the evidence itself, so it
1080
+ * gets no check runs rather than a live read mixed into its fixture.
1081
+ * @param {{statuses?: unknown, checkRuns?: (sha: string, context: string) => ReadonlyArray<RawCheckRun>|null}} io - Injected side effects.
1082
+ * @param {string} repo - `owner/repo`.
1083
+ * @returns {(sha: string, context: string) => ReadonlyArray<RawCheckRun>|null} The reader.
1084
+ */
1085
+ function checkRunReader(io, repo) {
1086
+ if (io.checkRuns) return io.checkRuns;
1087
+ if (io.statuses) return () => [];
1088
+ return (sha, context) => readCheckRuns(repo, sha, context);
1089
+ }
1090
+
1091
+ /**
1092
+ * Which status reader a run uses — the same rule, the other way round.
1093
+ * @param {{statuses?: (sha: string) => ReadonlyArray<object>|null, checkRuns?: unknown}} io - Injected side effects.
1094
+ * @param {string} repo - `owner/repo`.
1095
+ * @returns {(sha: string) => ReadonlyArray<object>|null} The reader.
1096
+ */
1097
+ function statusReader(io, repo) {
1098
+ if (io.statuses) return io.statuses;
1099
+ if (io.checkRuns) return () => [];
1100
+ return sha => readStatuses(repo, sha);
1101
+ }
1102
+
1103
+ /**
1104
+ * Read each declared reviewer's check runs once, keyed by its context.
1105
+ *
1106
+ * Two gates may await one context; that is one read, not two.
1107
+ * @param {ReadonlyArray<{context: string}>} reviewers - Declared reviewers.
1108
+ * @param {string} sha - The head commit.
1109
+ * @param {(sha: string, context: string) => ReadonlyArray<RawCheckRun>|null} read - The reader.
1110
+ * @returns {Record<string, ReadonlyArray<RawCheckRun>|null>} Runs per context; null for a failed read.
1111
+ */
1112
+ function checkRunsByContext(reviewers, sha, read) {
1113
+ /** @type {Record<string, ReadonlyArray<RawCheckRun>|null>} */
1114
+ const runs = Object.create(null);
1115
+ for (const { context } of reviewers) {
1116
+ if (Object.hasOwn(runs, context)) continue;
1117
+ runs[context] = read(sha, context);
1118
+ }
1119
+ return runs;
1120
+ }
1121
+
641
1122
  /**
642
1123
  * The CLI body.
643
1124
  * @param {string[]} argv - Arguments after the script path.
644
1125
  * @param {object} [io] - Injected side effects, for tests.
645
1126
  * @param {object} [io.gates] - The gates block, read from disk when absent.
646
1127
  * @param {(sha: string) => number[]} [io.pulls] - Head-pull-request reader.
647
- * @param {(sha: string) => ReadonlyArray<object>} [io.statuses] - Status reader.
1128
+ * @param {(sha: string) => ReadonlyArray<object>|null} [io.statuses] - Status reader.
1129
+ * @param {(sha: string, context: string) => ReadonlyArray<RawCheckRun>|null} [io.checkRuns] -
1130
+ * Check-run reader for one reviewer's name. When a caller injects either
1131
+ * reader and not the other, the other reads nothing: an injected reader
1132
+ * means the caller supplies the evidence, and mixing a live read into it
1133
+ * would judge a fixture against the network.
648
1134
  * @param {(numbers: number[]) => ReadonlyArray<object>} [io.comments] - Comment reader.
649
1135
  * @param {{write: (text: string) => void}} [io.out] - Stdout.
650
1136
  * @param {{write: (text: string) => void}} [io.err] - Stderr.
@@ -661,9 +1147,10 @@ export function main(argv, io = {}) {
661
1147
  const gates = io.gates ?? readGates(opts.root).gates;
662
1148
  const reviewers = configuredReviewers({ gates });
663
1149
  const repo = opts.repo ?? process.env.GITHUB_REPOSITORY ?? "";
664
- // A status from some other app cannot change this answer, and evaluating on
665
- // one would read a reviewer as absent merely because it had not posted yet —
666
- // a finding on every pull request, which is how a guard gets deleted.
1150
+ // A status or check run from some other app cannot change this answer, and
1151
+ // evaluating on one would read a reviewer as absent merely because it had
1152
+ // not posted yet — a finding on every pull request, which is how a guard
1153
+ // gets deleted.
667
1154
  const woken =
668
1155
  reviewers.length === 0 ||
669
1156
  opts.context === null ||
@@ -684,9 +1171,10 @@ export function main(argv, io = {}) {
684
1171
  }
685
1172
  : reviewEvidenceVerdict({
686
1173
  reviewers,
687
- statuses: evaluated
688
- ? (io.statuses ?? (sha => readStatuses(repo, sha)))(opts.sha)
689
- : [],
1174
+ statuses: evaluated ? statusReader(io, repo)(opts.sha) : [],
1175
+ checkRuns: evaluated
1176
+ ? checkRunsByContext(reviewers, opts.sha, checkRunReader(io, repo))
1177
+ : {},
690
1178
  comments: evaluated
691
1179
  ? (io.comments ?? (numbers => readComments(repo, numbers)))(pulls)
692
1180
  : [],