@codyswann/lisa 4.42.2 → 4.42.4

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 (112) hide show
  1. package/all/copy-overwrite/scripts/check-conflict-markers.mjs +24 -1
  2. package/all/copy-overwrite/scripts/check-orphaned-branches.mjs +458 -29
  3. package/dist/cli/doctor-reusable-workflow-refs.d.ts +3 -3
  4. package/dist/cli/doctor-reusable-workflow-refs.d.ts.map +1 -1
  5. package/dist/cli/doctor-reusable-workflow-refs.js +46 -74
  6. package/dist/cli/doctor-reusable-workflow-refs.js.map +1 -1
  7. package/dist/cli/version.d.ts +17 -0
  8. package/dist/cli/version.d.ts.map +1 -1
  9. package/dist/cli/version.js +34 -0
  10. package/dist/cli/version.js.map +1 -1
  11. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  12. package/dist/core/lisa-owned-hash-ledger.js +22 -0
  13. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  14. package/dist/core/lisa-release-pin.d.ts +63 -0
  15. package/dist/core/lisa-release-pin.d.ts.map +1 -0
  16. package/dist/core/lisa-release-pin.js +147 -0
  17. package/dist/core/lisa-release-pin.js.map +1 -0
  18. package/dist/core/lisa.d.ts.map +1 -1
  19. package/dist/core/lisa.js +4 -1
  20. package/dist/core/lisa.js.map +1 -1
  21. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  22. package/dist/core/reusable-workflow-pin.d.ts +113 -0
  23. package/dist/core/reusable-workflow-pin.d.ts.map +1 -0
  24. package/dist/core/reusable-workflow-pin.js +165 -0
  25. package/dist/core/reusable-workflow-pin.js.map +1 -0
  26. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  27. package/dist/core/upstream-evidence-manifest.js +19 -9
  28. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  29. package/dist/migrations/ensure-nightly-e2e-workflow-pins.d.ts +16 -36
  30. package/dist/migrations/ensure-nightly-e2e-workflow-pins.d.ts.map +1 -1
  31. package/dist/migrations/ensure-nightly-e2e-workflow-pins.js +33 -76
  32. package/dist/migrations/ensure-nightly-e2e-workflow-pins.js.map +1 -1
  33. package/dist/migrations/ensure-pinned-reusable-workflow-refs.d.ts +143 -0
  34. package/dist/migrations/ensure-pinned-reusable-workflow-refs.d.ts.map +1 -0
  35. package/dist/migrations/ensure-pinned-reusable-workflow-refs.js +293 -0
  36. package/dist/migrations/ensure-pinned-reusable-workflow-refs.js.map +1 -0
  37. package/dist/migrations/index.d.ts +1 -0
  38. package/dist/migrations/index.d.ts.map +1 -1
  39. package/dist/migrations/index.js +5 -0
  40. package/dist/migrations/index.js.map +1 -1
  41. package/package.json +6 -5
  42. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  44. package/plugins/lisa/.codex-plugin/skills/lisa-drive-pr-to-merge/SKILL.md +27 -0
  45. package/plugins/lisa/.codex-plugin/skills/lisa-git-submit-pr/SKILL.md +16 -0
  46. package/plugins/lisa/rules/reference/falsifiable-checks.md +20 -0
  47. package/plugins/lisa/skills/lisa-drive-pr-to-merge/SKILL.md +27 -0
  48. package/plugins/lisa/skills/lisa-git-submit-pr/SKILL.md +16 -0
  49. package/plugins/lisa-agy/plugin.json +1 -1
  50. package/plugins/lisa-agy/skills/lisa-drive-pr-to-merge/SKILL.md +27 -0
  51. package/plugins/lisa-agy/skills/lisa-git-submit-pr/SKILL.md +16 -0
  52. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  54. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  55. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-copilot/rules/reference/falsifiable-checks.md +20 -0
  59. package/plugins/lisa-copilot/skills/lisa-drive-pr-to-merge/SKILL.md +27 -0
  60. package/plugins/lisa-copilot/skills/lisa-git-submit-pr/SKILL.md +16 -0
  61. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  62. package/plugins/lisa-cursor/rules/falsifiable-checks-reference.mdc +20 -0
  63. package/plugins/lisa-cursor/skills/lisa-drive-pr-to-merge/SKILL.md +27 -0
  64. package/plugins/lisa-cursor/skills/lisa-git-submit-pr/SKILL.md +16 -0
  65. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  66. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  67. package/plugins/lisa-expo-agy/plugin.json +1 -1
  68. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  69. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  70. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  71. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  72. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  73. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  74. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  75. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  76. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  77. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  78. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  79. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  80. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  81. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  82. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  83. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  84. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  85. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  86. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  87. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  88. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  89. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  90. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  91. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  92. package/plugins/lisa-rails-agy/plugin.json +1 -1
  93. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  94. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  95. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  96. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  97. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  98. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  99. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  100. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  101. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  102. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  103. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  104. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  105. package/plugins/src/base/rules/reference/falsifiable-checks.md +20 -0
  106. package/plugins/src/base/skills/lisa-drive-pr-to-merge/SKILL.md +27 -0
  107. package/plugins/src/base/skills/lisa-git-submit-pr/SKILL.md +16 -0
  108. package/scripts/check-empty-subject-guards.mjs +366 -0
  109. package/scripts/lisa-update-local.sh +1 -0
  110. package/typescript/copy-overwrite/.github/GITHUB_ACTIONS.md +6 -1
  111. package/typescript/copy-overwrite/scripts/check-skipped-required-checks.mjs +370 -4
  112. package/typescript/create-only/.github/workflows/review-evidence.yml +20 -0
@@ -344,6 +344,7 @@ export const VIOLATIONS = Object.freeze({
344
344
  absent: "absent_required_check",
345
345
  reviewWaived: "review_evidence_waived",
346
346
  reviewUnsatisfied: "review_evidence_unsatisfied",
347
+ reviewCarried: "review_evidence_carried_unreviewed",
347
348
  });
348
349
 
349
350
  /**
@@ -475,6 +476,12 @@ export const NEVER_BLOCKING = Object.freeze([
475
476
  // operator must see that — and it never fails the build, which is the whole
476
477
  // content of the owner's ruling on CodySwannGT/lisa#3221.
477
478
  VIOLATIONS.reviewWaived,
479
+ // A carried finding is about ANOTHER pull request. Reddening this one for a
480
+ // condition its own author cannot reach would punish the batch for the
481
+ // constituent, and #3658 is a VISIBILITY defect, not an enforcement one: the
482
+ // constituents' own gates already went red, on branches no ruleset watches.
483
+ // What was missing was that the batch rendered green anyway.
484
+ VIOLATIONS.reviewCarried,
478
485
  ]);
479
486
 
480
487
  /**
@@ -1863,6 +1870,103 @@ export function evaluateReviewGate(declaration, checks, options = {}) {
1863
1870
  return { violations, states, conditions, descriptions, checked };
1864
1871
  }
1865
1872
 
1873
+ /**
1874
+ * How many carried pull requests this will account for before it gives up.
1875
+ *
1876
+ * A cap that SILENTLY truncates is the fail-open this file exists to refuse, so
1877
+ * exceeding it is reported as "not read" rather than as the part that was read.
1878
+ * Fifty is well above any batch observed here — the drain that produced #3658
1879
+ * carried seven — and it exists so a pathological history cannot turn one job
1880
+ * into hundreds of API reads.
1881
+ */
1882
+ export const CARRIED_PULL_REQUEST_LIMIT = 50;
1883
+
1884
+ /** Why a carried finding matters, appended to every one of them. */
1885
+ const CARRIED_WHY =
1886
+ "A batch integration pull request is the only place this gap is enforceable. The constituent's own review gate ran on its own pull request, whose base branch no ruleset watches, so its red changed nothing; merging this one carries that unreviewed diff into the default branch. Read the constituent before recording this batch as reviewed.";
1887
+
1888
+ /**
1889
+ * Runs the review gate over the pull requests THIS pull request carries.
1890
+ *
1891
+ * ## Why a batch needs its own arm (#3658)
1892
+ *
1893
+ * {@link evaluateReviewGate} reads one commit: this pull request's head. That is
1894
+ * the right commit for the pull request's own diff and the wrong one for a
1895
+ * batch, because a stack integration pull request's head carries the merges of
1896
+ * every constituent and none of their review statuses. MEASURED 2026-09-03 on
1897
+ * one queue drain: four constituents based on a batching branch each reported
1898
+ * `Review skipped: reviews are disabled for this base branch`, each one's own
1899
+ * review-evidence run went red, and every one of those reds landed on a branch
1900
+ * outside the rulesets' ref conditions — so nothing was watching. The
1901
+ * integration pull request that carried all of them into the default branch was
1902
+ * the one place a ruleset applied, and it read only itself.
1903
+ *
1904
+ * Pure, and injected with already-read rows, so the property that matters — a
1905
+ * batch carrying an unreviewed constituent never renders as a reviewed one — is
1906
+ * testable without a GitHub API.
1907
+ *
1908
+ * @param {object} declaration - The per-repo declaration
1909
+ * @param {ReadonlyArray<{number: number|string, headSha?: string, checks?: ReadonlyArray<object>, unreadable?: string}>} carried -
1910
+ * One entry per carried pull request; `unreadable` marks one whose evidence
1911
+ * could not be fetched, which counts AGAINST the batch rather than for it
1912
+ * @returns {{violations: object[], unreviewed: string[], reviewed: number}} Findings and the tally
1913
+ */
1914
+ export function evaluateCarriedReview(declaration, carried) {
1915
+ const declared = Object.entries(declaration.evidence_bearing_checks ?? {});
1916
+ const violations = [];
1917
+ const unreviewed = [];
1918
+ let reviewed = 0;
1919
+
1920
+ for (const constituent of carried) {
1921
+ const label = `#${constituent.number}`;
1922
+ if (constituent.unreadable) {
1923
+ unreviewed.push(label);
1924
+ violations.push({
1925
+ kind: VIOLATIONS.reviewCarried,
1926
+ token: label,
1927
+ contexts: [],
1928
+ message: `This pull request CARRIES ${label}, whose review evidence could NOT be read (${constituent.unreadable}). An unread constituent is not a reviewed one. ${CARRIED_WHY}`,
1929
+ });
1930
+ continue;
1931
+ }
1932
+ const failing = [];
1933
+ for (const [name, entry] of declared) {
1934
+ const vocabulary =
1935
+ typeof entry === "object" && entry !== null ? entry : {};
1936
+ const found = (constituent.checks ?? []).find(
1937
+ check => check.name === name
1938
+ );
1939
+ const verdict = reviewGateState(
1940
+ found === undefined
1941
+ ? { present: false }
1942
+ : {
1943
+ present: true,
1944
+ state: found.state,
1945
+ description: found.description,
1946
+ },
1947
+ vocabulary
1948
+ );
1949
+ if (verdict.state !== REVIEW_GATE_STATES.satisfied)
1950
+ failing.push({ name, verdict });
1951
+ }
1952
+ if (failing.length === 0) {
1953
+ reviewed += 1;
1954
+ continue;
1955
+ }
1956
+ unreviewed.push(label);
1957
+ for (const { name, verdict } of failing) {
1958
+ violations.push({
1959
+ kind: VIOLATIONS.reviewCarried,
1960
+ token: label,
1961
+ contexts: [name],
1962
+ message: `This pull request CARRIES ${label}, whose \`${name}\` ${verdict.why}${citeHeadSha(constituent.headSha)} ${CARRIED_WHY}`,
1963
+ });
1964
+ }
1965
+ }
1966
+
1967
+ return { violations, unreviewed, reviewed };
1968
+ }
1969
+
1866
1970
  /**
1867
1971
  * The check-run conclusion each review-gate verdict is published under.
1868
1972
  *
@@ -1928,6 +2032,27 @@ function waiveRateSuffix(rate) {
1928
2032
  : ` · waived on ${rate.waived} of the last ${rate.sampled} merged pull requests`;
1929
2033
  }
1930
2034
 
2035
+ /**
2036
+ * The carried-batch sentence appended to a verdict title, or nothing.
2037
+ *
2038
+ * NUMBERS ONLY, no vendor descriptions. The title is capped at
2039
+ * {@link REVIEW_VERDICT_TITLE_LIMIT}, and one quoted skip string is long enough
2040
+ * to push the constituent list out of it — which would lose exactly the fact
2041
+ * this exists to render. The quotes live in the findings, where nothing
2042
+ * truncates them.
2043
+ *
2044
+ * @param {{unreviewed?: readonly string[], unread?: string}|undefined} carried - The carried tally
2045
+ * @returns {string} The sentence, or an empty string when the batch is accounted for
2046
+ */
2047
+ function carriedSuffix(carried) {
2048
+ if (carried === undefined) return "";
2049
+ if (carried.unread !== undefined && carried.unread !== "")
2050
+ return " · the pull requests this one CARRIES could NOT be enumerated, so nothing accounts for their review";
2051
+ const unreviewed = carried.unreviewed ?? [];
2052
+ if (unreviewed.length === 0) return "";
2053
+ return ` · carries ${unreviewed.length} UNREVIEWED pull request${unreviewed.length === 1 ? "" : "s"}: ${unreviewed.join(", ")}`;
2054
+ }
2055
+
1931
2056
  /**
1932
2057
  * Renders one merge-time verdict for every declared evidence-bearing check.
1933
2058
  *
@@ -1948,9 +2073,14 @@ function waiveRateSuffix(rate) {
1948
2073
  * the one path where it mattered, a red check blocking a merge, was the one
1949
2074
  * path that never said it. The conditions are carried here for that reason.
1950
2075
  *
1951
- * @param {{states?: Record<string, string>, conditions?: Record<string, string>, descriptions?: Record<string, string>, refusal?: {kind: string}|null, waiveRate?: {waived: number, sampled: number}}} reading -
2076
+ * AND SO IS WHAT THE PULL REQUEST CARRIES (#3658), for the same reason one step
2077
+ * out: every state above is read from THIS pull request's head, which is the
2078
+ * one commit a batch integration pull request does not speak for.
2079
+ *
2080
+ * @param {{states?: Record<string, string>, conditions?: Record<string, string>, descriptions?: Record<string, string>, refusal?: {kind: string}|null, waiveRate?: {waived: number, sampled: number}, carried?: {unreviewed?: readonly string[], reviewed?: number, unread?: string}}} reading -
1952
2081
  * The gate's per-check states and conditions, the descriptions they were read
1953
- * from, any refusal, and an optional sampled waive rate
2082
+ * from, any refusal, an optional sampled waive rate, and the tally for the
2083
+ * pull requests this one CARRIES
1954
2084
  * @returns {{verdict: string, conclusion: string, title: string}} What to publish
1955
2085
  */
1956
2086
  export function reviewGateVerdict(reading = {}) {
@@ -1958,7 +2088,8 @@ export function reviewGateVerdict(reading = {}) {
1958
2088
  const conditions = reading.conditions ?? {};
1959
2089
  const descriptions = reading.descriptions ?? {};
1960
2090
  const names = Object.keys(states);
1961
- const suffix = waiveRateSuffix(reading.waiveRate);
2091
+ const carried = carriedSuffix(reading.carried);
2092
+ const suffix = `${carried}${waiveRateSuffix(reading.waiveRate)}`;
1962
2093
 
1963
2094
  if (reading.refusal || names.length === 0) {
1964
2095
  return {
@@ -2033,6 +2164,28 @@ export function reviewGateVerdict(reading = {}) {
2033
2164
  };
2034
2165
  }
2035
2166
 
2167
+ // #3658. Everything above judged THIS pull request's own head commit, which
2168
+ // is the only commit a batch integration pull request does not speak for. A
2169
+ // batch whose own review completed while it carries constituents nothing read
2170
+ // is the exact indistinguishability #3639 removed one level down: green here
2171
+ // and green for a fully reviewed pull request, character for character.
2172
+ //
2173
+ // It renders `waived`, never `unsatisfied`, and the cap is deliberate. The
2174
+ // constituents' own gates already reached the failing verdict on their own
2175
+ // pull requests; re-failing the batch would redden every integration pull
2176
+ // request for a vendor condition its author cannot reach, which is the
2177
+ // "gate that gets deleted" failure this file names twice already. The batch
2178
+ // stops looking reviewed without acquiring the power to block.
2179
+ if (carried !== "") {
2180
+ return {
2181
+ verdict: REVIEW_GATE_STATES.waived,
2182
+ conclusion: REVIEW_VERDICT_CONCLUSIONS.waived,
2183
+ title: fitTitle(
2184
+ `CARRIED UNREVIEWED — this pull request's own review ran, but the work it carries was not reviewed with it${suffix}`
2185
+ ),
2186
+ };
2187
+ }
2188
+
2036
2189
  return {
2037
2190
  verdict: REVIEW_GATE_STATES.satisfied,
2038
2191
  conclusion: REVIEW_VERDICT_CONCLUSIONS.satisfied,
@@ -2153,6 +2306,193 @@ export function sampleWaiveRate(declaration, limit, repo, fetch) {
2153
2306
  }
2154
2307
  }
2155
2308
 
2309
+ /**
2310
+ * How many merge commits this will look behind before giving up on the batch.
2311
+ *
2312
+ * Each one costs a REST read, and an ordinary pull request has none, so the
2313
+ * ceiling only ever binds on a history nobody could review by hand either.
2314
+ */
2315
+ export const CARRIED_MERGE_COMMIT_LIMIT = 200;
2316
+
2317
+ /**
2318
+ * Refuses a commit list that the pull-request endpoint silently truncated.
2319
+ *
2320
+ * `GET /repos/{owner}/{repo}/pulls/{pr}/commits` stops at **250 commits** and
2321
+ * does not paginate past it — `--paginate` gets the same 250. A caller that
2322
+ * reads the short list and carries on has enumerated a PARTIAL batch and cannot
2323
+ * tell that it did, which is precisely the fail-open this whole file exists to
2324
+ * refuse: the missing merge commits are constituents nobody accounted for, and
2325
+ * the batch would render as though they had been.
2326
+ *
2327
+ * So the count is checked against the pull request's own `commits` figure,
2328
+ * which comes from a different endpoint and is not subject to the ceiling. A
2329
+ * mismatch is reported as UNREAD, never as the part that was read. An
2330
+ * unreadable figure is also unread — without it there is no way to tell a
2331
+ * complete list from a truncated one, and guessing in the permissive direction
2332
+ * is the whole defect.
2333
+ *
2334
+ * Pure and exported so the refusal can be exercised without a 251-commit pull
2335
+ * request, which is not a fixture anybody can reasonably build.
2336
+ *
2337
+ * @param {string|number} pr - The pull request, named in the message
2338
+ * @param {number} enumerated - How many commits the list endpoint returned
2339
+ * @param {unknown} declared - The pull request's own commit count
2340
+ * @returns {void}
2341
+ * @throws {Error} When the counts disagree or the declared count is unreadable
2342
+ */
2343
+ export function assertCompleteCommitList(pr, enumerated, declared) {
2344
+ // `Number("")` is 0, not NaN — an empty answer from `gh` would otherwise read
2345
+ // as "this pull request has zero commits" and produce the TRUNCATED message
2346
+ // for what is really an unreadable count. Two different facts, two messages.
2347
+ const raw = String(declared ?? "").trim();
2348
+ const total = raw === "" ? Number.NaN : Number(raw);
2349
+ if (!Number.isInteger(total) || total < 0) {
2350
+ throw new Error(
2351
+ `could not read how many commits PR ${pr} has (${JSON.stringify(declared)}), so nothing can tell a complete commit list from a truncated one`
2352
+ );
2353
+ }
2354
+ if (enumerated !== total) {
2355
+ throw new Error(
2356
+ `enumerated ${enumerated} of the ${total} commits on PR ${pr} — \`/pulls/{pr}/commits\` stops at 250 even under --paginate, so this batch is TRUNCATED and cannot be accounted for`
2357
+ );
2358
+ }
2359
+ }
2360
+
2361
+ /**
2362
+ * Enumerates the pull requests this pull request CARRIES.
2363
+ *
2364
+ * The merge commits in a pull request's own commit list are what a batch is
2365
+ * made of, and `/commits/{sha}/pulls` resolves each one to the pull request it
2366
+ * brought in, head SHA included. MEASURED 2026-09-03 against the integration
2367
+ * pull request that surfaced #3658: every constituent resolved, including one
2368
+ * reached through a back-merge of the batching branch into a constituent
2369
+ * branch, which deduplicates against the constituent it already found.
2370
+ *
2371
+ * NO BRANCH-NAME MATCHING ANYWHERE, deliberately. Matching a batching-branch
2372
+ * pattern would be an allowlist of bases, which is the shape the convention can
2373
+ * rename its way out of without anybody noticing. Merge commits are what
2374
+ * carrying actually IS, so this generalises to any batched pull request and
2375
+ * cannot be dodged by naming the branch something else.
2376
+ *
2377
+ * A pull request that merged its own base back in yields merge commits that
2378
+ * resolve to ITSELF, which is filtered out — so an ordinary pull request
2379
+ * reports an empty batch rather than a false one.
2380
+ *
2381
+ * @param {string|number} pr - The pull request whose carried work to enumerate
2382
+ * @param {string} [repo] - `OWNER/NAME`; defaults to the current repository
2383
+ * @returns {Array<{number: number, headSha: string}>} One entry per carried pull request
2384
+ * @throws {Error} When the slug cannot be resolved, `gh` cannot answer, the commit list was truncated (see {@link assertCompleteCommitList}), or the batch exceeds {@link CARRIED_MERGE_COMMIT_LIMIT}
2385
+ */
2386
+ export function fetchCarriedPullRequests(pr, repo) {
2387
+ const slug = resolveRepoSlug(repo);
2388
+ if (slug === undefined) {
2389
+ throw new Error(
2390
+ `check-skipped-required-checks: cannot enumerate what PR ${pr} carries without an OWNER/NAME. Pass \`--repo=OWNER/NAME\`, or set GITHUB_REPOSITORY.`
2391
+ );
2392
+ }
2393
+ // Read BEFORE the list, so a pull request that grows mid-read is caught by
2394
+ // the comparison rather than hidden by it.
2395
+ const declaredCommits = boundedExecFileSync(
2396
+ "gh",
2397
+ ["api", `repos/${slug}/pulls/${pr}`, "--jq", ".commits"],
2398
+ { encoding: "utf8" }
2399
+ ).trim();
2400
+ const commits = ghApiPaginatedArray(
2401
+ `repos/${slug}/pulls/${pr}/commits?per_page=100`,
2402
+ "[.[] | {sha: .sha, parents: (.parents | length)}]"
2403
+ );
2404
+ assertCompleteCommitList(pr, commits.length, declaredCommits);
2405
+ const merges = commits
2406
+ .filter(row => Number(row?.parents) > 1)
2407
+ .map(row => String(row?.sha));
2408
+ // Applied only to a list already proven COMPLETE. A ceiling checked against a
2409
+ // truncated list is a ceiling that never fires.
2410
+ if (merges.length > CARRIED_MERGE_COMMIT_LIMIT) {
2411
+ throw new Error(
2412
+ `PR ${pr} contains ${merges.length} merge commits, past the ${CARRIED_MERGE_COMMIT_LIMIT} this will look behind`
2413
+ );
2414
+ }
2415
+ const carried = new Map();
2416
+ for (const sha of merges) {
2417
+ const rows = ghApiPaginatedArray(
2418
+ `repos/${slug}/commits/${sha}/pulls?per_page=100`,
2419
+ "[.[] | {number: .number, headSha: .head.sha}]"
2420
+ );
2421
+ for (const row of rows) {
2422
+ const number = row?.number;
2423
+ const headSha = row?.headSha;
2424
+ if (number === undefined || headSha === undefined) continue;
2425
+ if (String(number) === String(pr)) continue;
2426
+ if (!carried.has(number)) carried.set(number, { number, headSha });
2427
+ }
2428
+ }
2429
+ return [...carried.values()];
2430
+ }
2431
+
2432
+ /**
2433
+ * Reads and judges the review evidence for everything this pull request carries.
2434
+ *
2435
+ * FAILING TO READ IS NOT PASSING. Every error path here — an unresolvable
2436
+ * repository, an unreadable commit, a batch past the cap — reports the batch as
2437
+ * UNREAD, which renders the same way an unreviewed one does. That is the
2438
+ * "refusing to report a clean scan that never ran" shape already in this tree,
2439
+ * applied to the one arm whose silence would otherwise read as approval.
2440
+ *
2441
+ * @param {object} declaration - The per-repo declaration
2442
+ * @param {string|number} pr - The pull request under inspection
2443
+ * @param {string} [repo] - `OWNER/NAME`; defaults to the current repository
2444
+ * @param {{fetchCarried?: Function, fetchCarriedChecks?: Function}} [options] - Injection seams
2445
+ * @returns {{violations: object[], unreviewed: string[], reviewed: number, unread?: string}} Findings and the tally
2446
+ */
2447
+ export function readCarriedReview(declaration, pr, repo, options = {}) {
2448
+ const enumerate = options.fetchCarried ?? fetchCarriedPullRequests;
2449
+ const readChecks = options.fetchCarriedChecks ?? fetchChecksForCommit;
2450
+ /**
2451
+ * Renders "the batch was not read" as a finding, never as an empty one.
2452
+ *
2453
+ * @param {string} why - What stopped the read
2454
+ * @returns {{violations: object[], unreviewed: string[], reviewed: number, unread: string}} The unread result
2455
+ */
2456
+ const unread = why => ({
2457
+ violations: [
2458
+ {
2459
+ kind: VIOLATIONS.reviewCarried,
2460
+ token: "carried",
2461
+ contexts: [],
2462
+ message: `The pull requests this one CARRIES could not be enumerated (${why}), so this run cannot say whether the work it brings was reviewed. Refusing to report a clean batch nothing read. ${CARRIED_WHY}`,
2463
+ },
2464
+ ],
2465
+ unreviewed: [],
2466
+ reviewed: 0,
2467
+ unread: why,
2468
+ });
2469
+
2470
+ let listed;
2471
+ try {
2472
+ listed = enumerate(pr, repo);
2473
+ } catch (error) {
2474
+ return unread(error instanceof Error ? error.message : String(error));
2475
+ }
2476
+ if (listed.length > CARRIED_PULL_REQUEST_LIMIT) {
2477
+ return unread(
2478
+ `PR ${pr} carries ${listed.length} pull requests, past the ${CARRIED_PULL_REQUEST_LIMIT} this will account for`
2479
+ );
2480
+ }
2481
+ return evaluateCarriedReview(
2482
+ declaration,
2483
+ listed.map(entry => {
2484
+ try {
2485
+ return { ...entry, checks: readChecks(entry.headSha, repo) };
2486
+ } catch (error) {
2487
+ return {
2488
+ ...entry,
2489
+ unreadable: error instanceof Error ? error.message : String(error),
2490
+ };
2491
+ }
2492
+ })
2493
+ );
2494
+ }
2495
+
2156
2496
  /**
2157
2497
  * Publishes the verdict where a workflow step can read it.
2158
2498
  *
@@ -2917,6 +3257,15 @@ export function inspectVacuity(argv, declaration, options = {}) {
2917
3257
  // downstream sees a check row that looks identical either way (#3716).
2918
3258
  waitExpired: read.settled === false,
2919
3259
  });
3260
+ // #3658. Everything above judged ONE commit — this pull request's head — and
3261
+ // that is the one commit a batch integration pull request does not speak for.
3262
+ // Armed by the same flag as the gate itself, because this IS the gate,
3263
+ // applied to what the pull request CARRIES rather than to what it changes. A
3264
+ // pull request with no merge commits enumerates an empty batch and pays one
3265
+ // read for the privilege, so the ordinary case is untouched.
3266
+ const carried = requireReviewEvidence
3267
+ ? readCarriedReview(declaration, pr, repo, options)
3268
+ : undefined;
2920
3269
  // Sampled ONLY when this pull request is itself waived. The rate answers
2921
3270
  // "is this the exception or the rule?", a question that only arises once a
2922
3271
  // waiver is on the table, and every other run keeps the sampler's network
@@ -2940,17 +3289,20 @@ export function inspectVacuity(argv, declaration, options = {}) {
2940
3289
  ...evaluated.violations,
2941
3290
  ...absent.violations,
2942
3291
  ...gate.violations,
3292
+ ...(carried?.violations ?? []),
2943
3293
  ],
2944
3294
  absent: absent.absent,
2945
3295
  gateStates: gate.states,
2946
3296
  gateConditions: gate.conditions,
2947
3297
  gateDescriptions: gate.descriptions,
2948
3298
  waiveRate,
3299
+ carried,
2949
3300
  verdict: reviewGateVerdict({
2950
3301
  states: gate.states,
2951
3302
  conditions: gate.conditions,
2952
3303
  descriptions: gate.descriptions,
2953
3304
  waiveRate,
3305
+ carried,
2954
3306
  }),
2955
3307
  settled: read.settled,
2956
3308
  refusal: null,
@@ -3110,7 +3462,21 @@ function violationBlocks(violation, policy) {
3110
3462
  if (REVIEW_GATE_BLOCKING.includes(violation.kind)) {
3111
3463
  return policy.requireReviewEvidence;
3112
3464
  }
3113
- if (violation.kind === VIOLATIONS.reviewWaived) return false;
3465
+ // A named waiver is report-only under every flag. `--fail-on-vacuous`
3466
+ // governs checks that claimed success without proving work; it must not
3467
+ // turn a vendor entitlement waiver into a failure through a shared array.
3468
+ //
3469
+ // A carried finding (#3658) is report-only for a second reason on top of
3470
+ // that one: it is about ANOTHER pull request, whose own gate already
3471
+ // reached its own verdict on its own diff. `--fail-on-vacuous` asks "did
3472
+ // the checks on THIS pull request prove their work?", and answering it with
3473
+ // a constituent's condition would fail an author for a diff they did not
3474
+ // write and cannot change.
3475
+ if (
3476
+ violation.kind === VIOLATIONS.reviewWaived ||
3477
+ violation.kind === VIOLATIONS.reviewCarried
3478
+ )
3479
+ return false;
3114
3480
  if (NEVER_BLOCKING.includes(violation.kind)) return policy.failOnVacuous;
3115
3481
  return !policy.warnOnly || ALWAYS_BLOCKING.includes(violation.kind);
3116
3482
  }
@@ -43,6 +43,26 @@
43
43
  # a gate that reddens every pull request on a vendor's billing state gets
44
44
  # deleted, and then nothing is watching at all.
45
45
  #
46
+ # AND A PULL REQUEST SPEAKS FOR ONE COMMIT ONLY (#3658). Everything above judges
47
+ # this pull request's HEAD, which is the one commit a batch integration pull
48
+ # request does not speak for: its head carries the MERGES of every constituent
49
+ # and none of their review statuses. MEASURED 2026-09-03 on one queue drain —
50
+ # four constituents based on a batching branch each reported `Review skipped:
51
+ # reviews are disabled for this base branch`, each one's own run of this workflow
52
+ # went red, and every one of those reds landed on a branch outside the rulesets'
53
+ # ref conditions, so nothing was watching. The integration pull request that
54
+ # carried all four into the default branch was the one place a ruleset applied,
55
+ # and it read only itself. `--require-review-evidence` now also enumerates what
56
+ # this pull request CARRIES — through its merge commits, never through a branch
57
+ # NAME, because a name is an allowlist the convention can rename its way out of
58
+ # — reads each constituent at its OWN head, and renders a batch carrying an
59
+ # unreviewed constituent `neutral` instead of `success`. Capped at `neutral`
60
+ # deliberately: the constituents' own runs already reached the failing verdict
61
+ # where their diffs live, and reddening the batch too would fail an author for a
62
+ # diff they did not write. Failing to READ the batch renders `neutral` as well —
63
+ # an unread batch is not a reviewed one. An ordinary pull request has no merge
64
+ # commits, enumerates an empty batch, and is untouched.
65
+ #
46
66
  # `--waive-rate-sample=<N>` samples recently merged pull requests and reports
47
67
  # how many of them waived, in the job summary and in the verdict title. It runs
48
68
  # ONLY when this pull request is itself waived, is one GraphQL call, and is