@codyswann/lisa 4.81.8 → 4.82.0

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 (94) hide show
  1. package/dist/cli/lisa-test-run-jest-merge.d.ts +11 -0
  2. package/dist/cli/lisa-test-run-jest-merge.d.ts.map +1 -0
  3. package/dist/cli/lisa-test-run-jest-merge.js +152 -0
  4. package/dist/cli/lisa-test-run-jest-merge.js.map +1 -0
  5. package/dist/cli/lisa-test-run-shard.d.ts +123 -0
  6. package/dist/cli/lisa-test-run-shard.d.ts.map +1 -0
  7. package/dist/cli/lisa-test-run-shard.js +333 -0
  8. package/dist/cli/lisa-test-run-shard.js.map +1 -0
  9. package/dist/cli/lisa-test-run.d.ts.map +1 -1
  10. package/dist/cli/lisa-test-run.js +38 -1
  11. package/dist/cli/lisa-test-run.js.map +1 -1
  12. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  13. package/dist/core/lisa-owned-hash-ledger.js +6 -0
  14. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  15. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  16. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  17. package/dist/core/upstream-evidence-manifest.js +16 -5
  18. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  19. package/expo/create-only/.github/required-checks.json +2 -1
  20. package/nestjs/create-only/.github/required-checks.json +2 -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-agy/plugin.json +1 -1
  25. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  26. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  27. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  28. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  29. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  30. package/plugins/lisa-cdk-devin/.claude-plugin/plugin.json +1 -1
  31. package/plugins/lisa-cdk-devin/.devin-plugin/plugin.json +1 -1
  32. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  33. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  34. package/plugins/lisa-devin/.claude-plugin/plugin.json +1 -1
  35. package/plugins/lisa-devin/.devin-plugin/plugin.json +1 -1
  36. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  37. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  38. package/plugins/lisa-expo-agy/plugin.json +1 -1
  39. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  40. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-expo-devin/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-expo-devin/.devin-plugin/plugin.json +1 -1
  43. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  45. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  46. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-harper-fabric-devin/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-harper-fabric-devin/.devin-plugin/plugin.json +1 -1
  50. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  52. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  53. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-nestjs-devin/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-nestjs-devin/.devin-plugin/plugin.json +1 -1
  57. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  59. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  60. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  62. package/plugins/lisa-openclaw-devin/.claude-plugin/plugin.json +1 -1
  63. package/plugins/lisa-openclaw-devin/.devin-plugin/plugin.json +1 -1
  64. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  66. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  67. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  68. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  69. package/plugins/lisa-phaser-devin/.claude-plugin/plugin.json +1 -1
  70. package/plugins/lisa-phaser-devin/.devin-plugin/plugin.json +1 -1
  71. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  72. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  73. package/plugins/lisa-rails-agy/plugin.json +1 -1
  74. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  75. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  76. package/plugins/lisa-rails-devin/.claude-plugin/plugin.json +1 -1
  77. package/plugins/lisa-rails-devin/.devin-plugin/plugin.json +1 -1
  78. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  79. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  80. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  81. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  82. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  83. package/plugins/lisa-typescript-devin/.claude-plugin/plugin.json +1 -1
  84. package/plugins/lisa-typescript-devin/.devin-plugin/plugin.json +1 -1
  85. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  86. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  87. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  88. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  89. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  90. package/plugins/lisa-wiki-devin/.claude-plugin/plugin.json +1 -1
  91. package/plugins/lisa-wiki-devin/.devin-plugin/plugin.json +1 -1
  92. package/scripts/two-channel-couplings.json +1 -1
  93. package/typescript/copy-overwrite/scripts/check-skipped-required-checks.mjs +299 -3
  94. package/typescript/create-only/.github/required-checks.json +2 -1
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "4.81.8",
3
+ "version": "4.82.0",
4
4
  "description": "TypeScript-specific hooks — Prettier formatting, ESLint linting, ast-grep scanning, and error-suppression blocking on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "4.81.8",
3
+ "version": "4.82.0",
4
4
  "description": "TypeScript-specific hooks — Prettier formatting, ESLint linting, ast-grep scanning, and error-suppression blocking on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "4.81.8",
3
+ "version": "4.82.0",
4
4
  "description": "TypeScript-specific hooks — Prettier formatting, ESLint linting, ast-grep scanning, and error-suppression blocking on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "4.81.8",
3
+ "version": "4.82.0",
4
4
  "description": "LLM Wiki — a distributable, git-native markdown knowledge base for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "4.81.8",
3
+ "version": "4.82.0",
4
4
  "description": "Distributable LLM Wiki kernel — ingest, query, lint, and maintain a git-native markdown knowledge base across Claude and Codex.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "4.81.8",
3
+ "version": "4.82.0",
4
4
  "description": "LLM Wiki — a distributable, git-native markdown knowledge base for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "4.81.8",
3
+ "version": "4.82.0",
4
4
  "description": "LLM Wiki — a distributable, git-native markdown knowledge base for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "4.81.8",
3
+ "version": "4.82.0",
4
4
  "description": "LLM Wiki — a distributable, git-native markdown knowledge base for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "4.81.8",
3
+ "version": "4.82.0",
4
4
  "description": "LLM Wiki — a distributable, git-native markdown knowledge base for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "4.81.8",
3
+ "version": "4.82.0",
4
4
  "description": "LLM Wiki — a distributable, git-native markdown knowledge base for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -103,7 +103,7 @@
103
103
  },
104
104
  "inspected": {
105
105
  "workflows": 26,
106
- "steps": 765,
106
+ "steps": 791,
107
107
  "couplings": 36,
108
108
  "inventory": 186
109
109
  },
@@ -215,6 +215,29 @@
215
215
  * credential. It is stated here rather than hidden, and it is not an invitation
216
216
  * to build a lighter-weight version of the same thing.
217
217
  *
218
+ * ## `required_contexts_by_branch` — absence is judged per BASE branch (#4484)
219
+ *
220
+ * `required_contexts` is the UNION across every protected branch. That is the
221
+ * right set for the skip and outcome arms, which ask "could this context gate
222
+ * a merge anywhere?", and the WRONG one for the absent arm, which asks "did a
223
+ * context THIS pull request's merge depends on fail to report?". Measured in a
224
+ * caller repository: two contexts required on `dev` only, so every
225
+ * staging-base pull request — green on everything staging requires — failed
226
+ * the evidence job with `absent_required_check` for a dev-only gate, and
227
+ * operators learned to ignore the job.
228
+ *
229
+ * So the declaration MAY carry `required_contexts_by_branch: { <branch>: [...] }`
230
+ * — transcribed from `rules/branches/<branch>`, which needs only read access,
231
+ * by hand or by a host refresh where a repository has one. Lisa ships no
232
+ * refresh and reads nothing live (#3599 stands). The absent arm selects the
233
+ * set for the base ref (`--base`, else `GITHUB_BASE_REF`, else the payload's
234
+ * `pull_request.base.ref`, the last two only for the event's own pull
235
+ * request). With no set
236
+ * for that base it falls back to the union — findings and exit code exactly as
237
+ * before — and REPORTS it as `required_contexts_branch_fallback`, never
238
+ * silently. Every run that reaches the arm prints which set it used. Trust is
239
+ * unchanged: the one `ruleset.baseline_fetched_at` stamp covers both lists.
240
+ *
218
241
  * ## `--pr` — the VACUOUS arm, and why it only ever reports
219
242
  *
220
243
  * Measured (CodySwannGT/lisa#2497): `CodeRabbit` was in this repository's
@@ -473,6 +496,22 @@ export const VIOLATIONS = Object.freeze({
473
496
  requiredAmbiguous: "required_context_ambiguous",
474
497
  });
475
498
 
499
+ /**
500
+ * The named notice a run carries when it judged absence against the UNION.
501
+ *
502
+ * `required_contexts` is the union of what every protected branch requires,
503
+ * which is the wrong set for any one pull request whose base requires less
504
+ * (CodySwannGT/lisa#4484): a staging-base pull request was flagged for a
505
+ * dev-only context on every run. `required_contexts_by_branch` carries the
506
+ * per-branch sets; when the base has none, the absent arm still falls back to
507
+ * the union — the pre-#4484 behaviour, findings and exit code unchanged — and
508
+ * this token is what makes that fallback impossible to miss. It is a notice
509
+ * about the DECLARATION'S coverage, not a finding about the pull request, so it
510
+ * travels beside the violations rather than among them and never changes a
511
+ * verdict on its own.
512
+ */
513
+ export const REQUIRED_SET_FALLBACK = "required_contexts_branch_fallback";
514
+
476
515
  /**
477
516
  * The shipped description vocabulary for review-bot style checks.
478
517
  *
@@ -990,6 +1029,7 @@ export function loadDeclaration(rootDir) {
990
1029
  const declaration = JSON.parse(readFileSync(path, "utf8"));
991
1030
  assertRequiredDeclarationKeys(declaration);
992
1031
  assertDeclarationCollections(declaration);
1032
+ assertRequiredContextsByBranch(declaration);
993
1033
  assertEnforcementMode(declaration);
994
1034
  assertSkipJobDeclarations(declaration.skip_job_declarations);
995
1035
  assertEvidenceBearingChecks(declaration.evidence_bearing_checks ?? {});
@@ -1038,6 +1078,49 @@ function assertDeclarationCollections(declaration) {
1038
1078
  }
1039
1079
  }
1040
1080
 
1081
+ /**
1082
+ * Validates the optional per-branch required sets (CodySwannGT/lisa#4484).
1083
+ *
1084
+ * Optional so a declaration written before #4484 keeps loading. When present it
1085
+ * must map each branch to an array of context strings, and every one of those
1086
+ * contexts must also be in `required_contexts`: the union is DEFINED as the
1087
+ * union of these sets, so a member missing from it means the two were written
1088
+ * at different times and one is stale. Naming it beats picking one silently.
1089
+ *
1090
+ * @param {object} declaration - Parsed declaration
1091
+ * @returns {void}
1092
+ */
1093
+ function assertRequiredContextsByBranch(declaration) {
1094
+ const byBranch = declaration.required_contexts_by_branch;
1095
+ if (byBranch === undefined) return;
1096
+ if (
1097
+ byBranch === null ||
1098
+ typeof byBranch !== "object" ||
1099
+ Array.isArray(byBranch)
1100
+ ) {
1101
+ throw new Error(
1102
+ `check-skipped-required-checks: \`required_contexts_by_branch\` must be an object mapping each branch name to the array of contexts its rules require, e.g. \`{ "staging": ["CodeRabbit"] }\`.`
1103
+ );
1104
+ }
1105
+ const union = new Set(declaration.required_contexts);
1106
+ for (const [branch, contexts] of Object.entries(byBranch)) {
1107
+ if (
1108
+ !Array.isArray(contexts) ||
1109
+ !contexts.every(context => typeof context === "string")
1110
+ ) {
1111
+ throw new Error(
1112
+ `check-skipped-required-checks: \`required_contexts_by_branch.${branch}\` must be an array of context strings, transcribed byte for byte from \`rules/branches/${branch}\`.`
1113
+ );
1114
+ }
1115
+ const strays = contexts.filter(context => !union.has(context));
1116
+ if (strays.length > 0) {
1117
+ throw new Error(
1118
+ `check-skipped-required-checks: \`required_contexts_by_branch.${branch}\` names ${strays.map(name => JSON.stringify(name)).join(", ")}, which \`required_contexts\` (the cross-branch union) does not carry. The two were written at different times and one of them is stale — re-run the host refresh so both come from the same read.`
1119
+ );
1120
+ }
1121
+ }
1122
+ }
1123
+
1041
1124
  /**
1042
1125
  * Validates the optional enforcement switch independently of declaration data.
1043
1126
  *
@@ -1688,9 +1771,19 @@ export function evaluateAbsentRequiredChecks(
1688
1771
  checks,
1689
1772
  options = {}
1690
1773
  ) {
1691
- const required = declaration.required_contexts;
1692
- if (!Array.isArray(required))
1774
+ if (!Array.isArray(declaration.required_contexts))
1693
1775
  return { violations: [], checked: 0, absent: [] };
1776
+ // #4484: judged against the BASE branch's own set when the declaration has
1777
+ // one, and against the union — reported, never silently — when it does not.
1778
+ const requiredSet = selectRequiredContexts(
1779
+ declaration,
1780
+ options.base ?? { base: null, source: null }
1781
+ );
1782
+ const required = requiredSet.contexts;
1783
+ const judgedFor =
1784
+ requiredSet.source === "branch"
1785
+ ? ` Judged against the contexts required on base \`${requiredSet.base}\`.`
1786
+ : "";
1694
1787
 
1695
1788
  const at = citeHeadSha(options.headSha);
1696
1789
  const parked = (options.parkedRuns ?? []).filter(
@@ -1718,15 +1811,186 @@ export function evaluateAbsentRequiredChecks(
1718
1811
  return {
1719
1812
  checked: required.length,
1720
1813
  absent,
1814
+ requiredSet,
1721
1815
  violations: absent.map(name => ({
1722
1816
  kind: VIOLATIONS.absent,
1723
1817
  token: name,
1724
1818
  contexts: [name],
1725
- message: `\`${name}\` is ruleset-required but did not report on this pull request at all.${at} It posted NO check-run — not pending, not failing, ABSENT. \`gh pr checks\` prints no line for it and the pull request page renders it exactly as one still in flight, so every surface says "wait" and none says "broken".${approval} (If the context was renamed, fix \`required_contexts\`; names are compared byte for byte.)`,
1819
+ message: `\`${name}\` is ruleset-required but did not report on this pull request at all.${at}${judgedFor} It posted NO check-run — not pending, not failing, ABSENT. \`gh pr checks\` prints no line for it and the pull request page renders it exactly as one still in flight, so every surface says "wait" and none says "broken".${approval} (If the context was renamed, fix \`required_contexts\`; names are compared byte for byte.)`,
1726
1820
  })),
1727
1821
  };
1728
1822
  }
1729
1823
 
1824
+ /** The prefix a full ref carries and a declaration's branch keys do not. */
1825
+ const HEADS_PREFIX = "refs/heads/";
1826
+
1827
+ /**
1828
+ * Normalises a base ref to the bare branch name the declaration is keyed by.
1829
+ *
1830
+ * @param {unknown} ref - A ref as some source reported it
1831
+ * @returns {string|null} The branch name, or null when there is none
1832
+ */
1833
+ function normaliseBaseRef(ref) {
1834
+ if (typeof ref !== "string") return null;
1835
+ const trimmed = ref.trim();
1836
+ const bare = trimmed.startsWith(HEADS_PREFIX)
1837
+ ? trimmed.slice(HEADS_PREFIX.length)
1838
+ : trimmed;
1839
+ return bare === "" ? null : bare;
1840
+ }
1841
+
1842
+ /**
1843
+ * Resolves the branch the pull request under inspection merges INTO.
1844
+ *
1845
+ * Most-explicit-first, the same order {@link resolvePullRequestNumber} uses:
1846
+ * an operator's `--base`, then `GITHUB_BASE_REF` (set by Actions on every
1847
+ * `pull_request` and `pull_request_target` run), then the event payload's
1848
+ * `pull_request.base.ref`. Offline on purpose — every source is a fact this run
1849
+ * already holds, so selecting a set costs no API call, no token scope and no
1850
+ * rate limit, and works the same on a fork pull request. Returning null is a
1851
+ * real answer: the caller falls back to the union and SAYS so.
1852
+ *
1853
+ * The run's own base describes the run's own pull request, and nothing else.
1854
+ * When `options.pr` names the pull request being inspected, GITHUB_BASE_REF
1855
+ * and the payload are used only if the payload's `pull_request.number` IS that
1856
+ * pull request, in the same repository (`options.repo`, from `--repo`).
1857
+ * Otherwise a `--pr=8` inspected from PR #7's staging-base run, or another
1858
+ * repository's PR #7, would be judged against staging, and a dev-base PR could
1859
+ * pass while missing a dev-only context. The ignored base is returned as
1860
+ * `ignoredEventBase` so the fallback can say why it fell back; `--base` still
1861
+ * names it explicitly.
1862
+ *
1863
+ * @param {ReadonlyArray<string>} argv - CLI arguments
1864
+ * @param {NodeJS.ProcessEnv} [env] - Environment, injectable for tests
1865
+ * @param {{pr?: string, repo?: string}} [options] - The pull request and repository being inspected, when known
1866
+ * @returns {{base: string|null, source: string|null, ignoredEventBase?: string}} The base and where it came from
1867
+ */
1868
+ export function resolveBaseRef(argv, env = process.env, options = {}) {
1869
+ const explicit = normaliseBaseRef(readFlagValue(argv, "--base"));
1870
+ if (explicit !== null) return { base: explicit, source: "--base" };
1871
+
1872
+ const eventPath = env.GITHUB_EVENT_PATH;
1873
+ const payload =
1874
+ eventPath !== undefined && eventPath !== "" && existsSync(eventPath)
1875
+ ? readJsonOrNull(eventPath)
1876
+ : null;
1877
+ const fromEnv = normaliseBaseRef(env.GITHUB_BASE_REF);
1878
+ const fromEvent = normaliseBaseRef(payload?.pull_request?.base?.ref);
1879
+ const found =
1880
+ fromEnv !== null
1881
+ ? { base: fromEnv, source: "GITHUB_BASE_REF" }
1882
+ : fromEvent !== null
1883
+ ? { base: fromEvent, source: "GITHUB_EVENT_PATH" }
1884
+ : null;
1885
+ if (found === null) return { base: null, source: null };
1886
+ if (options.pr === undefined) return found;
1887
+
1888
+ const eventPr = payload?.pull_request?.number ?? payload?.number;
1889
+ return String(eventPr) === String(options.pr) &&
1890
+ isEventRepository(options.repo, payload, env)
1891
+ ? found
1892
+ : { base: null, source: null, ignoredEventBase: found.base };
1893
+ }
1894
+
1895
+ /**
1896
+ * True when the inspected repository is the one this run's event belongs to.
1897
+ *
1898
+ * PR numbers are per repository, so `--repo=other/name --pr=7` from
1899
+ * owner/name's PR #7 run is a different pull request with the same number. No
1900
+ * `--repo` means the run's own repository. An event repository that cannot be
1901
+ * read is not a match: the base would then be a guess.
1902
+ *
1903
+ * @param {string|undefined} repo - The inspected `owner/name`, if given
1904
+ * @param {object|null} payload - The parsed event payload
1905
+ * @param {NodeJS.ProcessEnv} env - Environment
1906
+ * @returns {boolean} True when the event's base describes the inspected repository
1907
+ */
1908
+ function isEventRepository(repo, payload, env) {
1909
+ if (repo === undefined || repo === "") return true;
1910
+ const eventRepo = payload?.repository?.full_name ?? env.GITHUB_REPOSITORY;
1911
+ return (
1912
+ typeof eventRepo === "string" &&
1913
+ eventRepo.toLowerCase() === repo.toLowerCase()
1914
+ );
1915
+ }
1916
+
1917
+ /**
1918
+ * Picks the required set the absent arm judges a pull request against.
1919
+ *
1920
+ * The base branch's own set when `required_contexts_by_branch` carries one —
1921
+ * including an EMPTY one, which is a branch that requires nothing, not a branch
1922
+ * nobody recorded. Otherwise the union in `required_contexts`, with a
1923
+ * {@link REQUIRED_SET_FALLBACK} notice naming why. `line` is printed on every
1924
+ * run that reaches the absent arm, so a downstream run proves which path it
1925
+ * took from its own log.
1926
+ *
1927
+ * @param {object} declaration - The per-repo declaration
1928
+ * @param {{base: string|null, source: string|null}} base - The resolved base ref
1929
+ * @returns {{source: "branch"|"union", base: string|null, baseSource: string|null, contexts: string[], fallback: {kind: string, reason: string}|null, line: string}} The selection
1930
+ */
1931
+ export function selectRequiredContexts(declaration, base) {
1932
+ const byBranch = declaration.required_contexts_by_branch;
1933
+ const branchSet =
1934
+ base.base !== null &&
1935
+ byBranch !== undefined &&
1936
+ Object.hasOwn(byBranch, base.base)
1937
+ ? byBranch[base.base]
1938
+ : undefined;
1939
+ if (Array.isArray(branchSet)) {
1940
+ return {
1941
+ source: "branch",
1942
+ base: base.base,
1943
+ baseSource: base.source,
1944
+ contexts: [...branchSet],
1945
+ fallback: null,
1946
+ line: `required contexts: per-branch set for base '${base.base}' (${branchSet.length} contexts)`,
1947
+ };
1948
+ }
1949
+ return {
1950
+ source: "union",
1951
+ base: base.base,
1952
+ baseSource: base.source,
1953
+ contexts: [...(declaration.required_contexts ?? [])],
1954
+ fallback: {
1955
+ kind: REQUIRED_SET_FALLBACK,
1956
+ reason: unionFallbackReason(declaration, base),
1957
+ },
1958
+ line:
1959
+ base.base === null
1960
+ ? "required contexts: union fallback (no base ref resolved)"
1961
+ : `required contexts: union fallback (no set for base '${base.base}')`,
1962
+ };
1963
+ }
1964
+
1965
+ /**
1966
+ * Says why a run judged against the union, and what would end the fallback.
1967
+ *
1968
+ * The remedy is one a consumer of Lisa's shipped seed can always carry out:
1969
+ * transcribe the branch's rules by hand. Lisa ships no refresh, so a host
1970
+ * refresh is named only as an option for a repository that has one.
1971
+ *
1972
+ * @param {object} declaration - The per-repo declaration
1973
+ * @param {{base: string|null, ignoredEventBase?: string}} resolved - The resolved base
1974
+ * @returns {string} The reason
1975
+ */
1976
+ function unionFallbackReason(declaration, resolved) {
1977
+ const consequence =
1978
+ "so absent required checks were judged against `required_contexts`, the UNION across every protected branch. A context only some other branch requires is reported absent here even though this base may not require it (CodySwannGT/lisa#4484).";
1979
+ const base = resolved.base;
1980
+ if (base === null) {
1981
+ const ignored =
1982
+ resolved.ignoredEventBase === undefined
1983
+ ? ""
1984
+ : ` This run's own base, \`${resolved.ignoredEventBase}\`, was NOT used: it belongs to the run's own pull request, and \`--pr\`/\`--repo\` named a different one.`;
1985
+ return `No base ref could be resolved for the inspected pull request (looked at \`--base\`, GITHUB_BASE_REF and the event payload's \`pull_request.base.ref\`), ${consequence}${ignored} Pass \`--base=<branch>\` to name it.`;
1986
+ }
1987
+ const remedy = `Record it by hand: transcribe \`gh api repos/OWNER/NAME/rules/branches/${base} --jq '.[] | select(.type=="required_status_checks") | .parameters.required_status_checks[].context'\` (read access only) into \`required_contexts_by_branch.${base}\`, byte for byte, keeping every context in \`required_contexts\` too. A host refresh that writes per-branch sets does the same, if this repository has one.`;
1988
+ if (declaration.required_contexts_by_branch === undefined) {
1989
+ return `The declaration has no \`required_contexts_by_branch\`, ${consequence} ${remedy}`;
1990
+ }
1991
+ return `\`required_contexts_by_branch\` has no set for base \`${base}\`, ${consequence} ${remedy}`;
1992
+ }
1993
+
1730
1994
  /**
1731
1995
  * Descriptions that WAIVE the review gate, matched whole and case-insensitively.
1732
1996
  *
@@ -3984,6 +4248,9 @@ export function inspectVacuity(argv, declaration, options = {}) {
3984
4248
  const absent = evaluateAbsentRequiredChecks(declaration, read.checks, {
3985
4249
  headSha: read.headSha,
3986
4250
  parkedRuns: options.parkedRuns,
4251
+ // Tied to the pull request actually inspected, so a `--pr` naming another
4252
+ // pull request is never judged against this run's own base.
4253
+ base: resolveBaseRef(argv, options.env ?? process.env, { pr, repo }),
3987
4254
  });
3988
4255
  // The gate is evaluated on EVERY run, not only when a caller asked it to
3989
4256
  // block. Its findings are what make a waiver visible, and a waiver that is
@@ -4057,6 +4324,7 @@ export function inspectVacuity(argv, declaration, options = {}) {
4057
4324
  ...(objection?.violations ?? []),
4058
4325
  ],
4059
4326
  absent: absent.absent,
4327
+ requiredSet: absent.requiredSet,
4060
4328
  gateStates: gate.states,
4061
4329
  gateConditions: gate.conditions,
4062
4330
  gateDescriptions: gate.descriptions,
@@ -4436,6 +4704,7 @@ export function runGuard(argv, options = {}) {
4436
4704
  "require-review-evidence": { type: "boolean" },
4437
4705
  outcomes: { type: "boolean" },
4438
4706
  pr: { type: "string" },
4707
+ base: { type: "string" },
4439
4708
  repo: { type: "string" },
4440
4709
  "settle-timeout": { type: "string" },
4441
4710
  "settle-interval": { type: "string" },
@@ -4676,6 +4945,32 @@ function appendVerdictReport(lines, result) {
4676
4945
  );
4677
4946
  }
4678
4947
 
4948
+ /**
4949
+ * Prints which required set the absent arm judged against, and annotates a
4950
+ * union fallback (CodySwannGT/lisa#4484).
4951
+ *
4952
+ * The line is printed on EVERY run that reached the absent arm, both paths, so
4953
+ * a downstream run proves from its own log whether the per-branch set was used.
4954
+ * A fallback is additionally a named `::warning` annotation — loud on the
4955
+ * checks page — while the verdict stays whatever the union's findings make it.
4956
+ *
4957
+ * @param {string[]} lines - Report lines
4958
+ * @param {{line: string, fallback: {kind: string, reason: string}|null}|undefined} requiredSet - The selection
4959
+ * @returns {void}
4960
+ */
4961
+ function appendRequiredSetReport(lines, requiredSet) {
4962
+ if (requiredSet === undefined) return;
4963
+ lines.push(requiredSet.line, "");
4964
+ if (requiredSet.fallback === null) return;
4965
+ lines.push(
4966
+ `⚠️ **${requiredSet.fallback.kind}** — ${requiredSet.fallback.reason}`,
4967
+ ""
4968
+ );
4969
+ process.stderr.write(
4970
+ `::warning title=${requiredSet.fallback.kind}::${requiredSet.fallback.reason}\n`
4971
+ );
4972
+ }
4973
+
4679
4974
  /**
4680
4975
  * Appends and annotates an untrusted ruleset snapshot.
4681
4976
  *
@@ -4956,6 +5251,7 @@ function appendUnexaminedContexts(lines, notExamined) {
4956
5251
  function buildTextReport(result, policy, outcome) {
4957
5252
  const lines = ["## 🔒 Required checks that prove nothing", ""];
4958
5253
  appendVerdictReport(lines, result);
5254
+ appendRequiredSetReport(lines, result.vacuity?.requiredSet);
4959
5255
  appendSnapshotRefusal(lines, result, policy);
4960
5256
  appendInspectionRefusal(lines, outcome);
4961
5257
  appendOutcomeReport(lines, result.outcomes);
@@ -17,7 +17,8 @@
17
17
  "It reads `gh pr checks --json name,state,bucket,description`, the only route that carries the description for a legacy commit status like CodeRabbit's, and falls back to the commit-status API when the token lacks `actions: read` (`gh pr checks` resolves the rollup through `checkSuite.workflowRun`, and without that scope it exits non-zero with EMPTY STDOUT — a failure shaped like a content problem that never says the word \"permission\").",
18
18
  "IT REFUSES RATHER THAN REPORTING AN EMPTY INSPECTION. An unresolvable pull request, a `gh` it could not read, a roster with no checks in it, or an empty `evidence_bearing_checks` all print the same \"nothing vacuous here\" that a genuinely clean run prints — so the guard fails instead and names which of the four it hit (`vacuity_pr_unresolved`, `vacuity_checks_unreadable`, `vacuity_no_checks_reported`, `vacuity_none_declared`). A refusal is NOT a finding: NOBODY LOOKED and THE REVIEW WAS FAKE are opposite facts, and a single red job would otherwise report them identically.",
19
19
  "`evidence_bearing_checks` names the checks whose GREEN is supposed to mean something reviewed the code. Use `{}` to accept the shipped description vocabulary, or add `proof` / `no_work` arrays to extend it — extensions ADD to the defaults, they do not replace them. Undeclared checks are never examined, because most CI jobs ship an empty description and flagging them all would bury the one finding that matters.",
20
- "This arm REPORTS AND NEVER BLOCKS, in every enforcement mode. A review bot can go hollow because an org-wide SPENDING CAP was hit, and a gate that reddens every PR on a billing state is worse than the one it criticises. What it changes is what you may CLAIM: a PR carrying a `vacuous_required_check` finding has not been shown to be reviewed, so do not record it as reviewed. Once you have made the governance call, `--fail-on-vacuous` is the supported way to ask for an exit code — add it to the command in `review-evidence.yml`, instead of writing a wrapper that reads `--json`."
20
+ "This arm REPORTS AND NEVER BLOCKS, in every enforcement mode. A review bot can go hollow because an org-wide SPENDING CAP was hit, and a gate that reddens every PR on a billing state is worse than the one it criticises. What it changes is what you may CLAIM: a PR carrying a `vacuous_required_check` finding has not been shown to be reviewed, so do not record it as reviewed. Once you have made the governance call, `--fail-on-vacuous` is the supported way to ask for an exit code — add it to the command in `review-evidence.yml`, instead of writing a wrapper that reads `--json`.",
21
+ "PER-BRANCH SETS (CodySwannGT/lisa#4484). `required_contexts` is the UNION across every protected branch, which is the wrong set for judging which required contexts did NOT REPORT on one pull request: a context only `dev` requires was reported absent on every staging-base pull request. Optionally add `required_contexts_by_branch: { \"<branch>\": [ ...contexts ] }`, each set transcribed byte for byte from `gh api repos/OWNER/NAME/rules/branches/<branch> --jq '.[] | select(.type==\"required_status_checks\") | .parameters.required_status_checks[].context'` (read access only). The absent arm then judges a pull request against its BASE branch's set (`--base`, else `GITHUB_BASE_REF`, else the event payload's `pull_request.base.ref`). Without a set for that base it falls back to the union — same findings as before — and says so as `required_contexts_branch_fallback`; every run prints which set it used. Every per-branch context must also appear in `required_contexts`, and the one `baseline_fetched_at` stamp covers both."
21
22
  ],
22
23
  "enforcement": "warn",
23
24
  "ruleset": {