@codyswann/lisa 4.37.0 → 4.37.2

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 (59) hide show
  1. package/all/copy-overwrite/scripts/check-third-party-action-pins.mjs +6 -2
  2. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  3. package/dist/core/lisa-owned-hash-ledger.js +12 -0
  4. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  5. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  6. package/dist/core/upstream-evidence-manifest.js +3 -3
  7. package/package.json +4 -4
  8. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  9. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  10. package/plugins/lisa-agy/plugin.json +1 -1
  11. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  12. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  13. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  14. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  15. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  16. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  17. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  18. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  19. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  20. package/plugins/lisa-expo-agy/plugin.json +1 -1
  21. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  22. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  23. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  24. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  25. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  26. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  27. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  28. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  29. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  30. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  31. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  32. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  33. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  34. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  35. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  36. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  37. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  38. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  39. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  40. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  41. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  45. package/plugins/lisa-rails-agy/plugin.json +1 -1
  46. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  50. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  51. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  55. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  56. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  58. package/scripts/check-third-party-action-pins.mjs +6 -2
  59. package/typescript/copy-overwrite/scripts/check-skipped-required-checks.mjs +754 -367
@@ -563,8 +563,10 @@ export function readFlagValue(argv, name) {
563
563
  }
564
564
  const at = argv.indexOf(name);
565
565
  if (at === -1) return undefined;
566
- const next = argv[at + 1];
567
- return next === undefined || next.startsWith("--") ? undefined : next;
566
+ const valueAt = at + 1;
567
+ if (valueAt >= argv.length) return undefined;
568
+ const next = argv[valueAt];
569
+ return next.startsWith("--") ? undefined : next;
568
570
  }
569
571
 
570
572
  /**
@@ -715,6 +717,21 @@ export function loadDeclaration(rootDir) {
715
717
  );
716
718
  }
717
719
  const declaration = JSON.parse(readFileSync(path, "utf8"));
720
+ assertRequiredDeclarationKeys(declaration);
721
+ assertDeclarationCollections(declaration);
722
+ assertEnforcementMode(declaration);
723
+ assertSkipJobDeclarations(declaration.skip_job_declarations);
724
+ assertEvidenceBearingChecks(declaration.evidence_bearing_checks ?? {});
725
+ return declaration;
726
+ }
727
+
728
+ /**
729
+ * Refuses a declaration that omits one of the guard's load-bearing snapshots.
730
+ *
731
+ * @param {object} declaration - Parsed declaration
732
+ * @returns {void}
733
+ */
734
+ function assertRequiredDeclarationKeys(declaration) {
718
735
  for (const key of [
719
736
  "required_contexts",
720
737
  "workflows",
@@ -726,6 +743,15 @@ export function loadDeclaration(rootDir) {
726
743
  );
727
744
  }
728
745
  }
746
+ }
747
+
748
+ /**
749
+ * Validates the two top-level collections the evaluator consumes.
750
+ *
751
+ * @param {object} declaration - Parsed declaration
752
+ * @returns {void}
753
+ */
754
+ function assertDeclarationCollections(declaration) {
729
755
  if (!Array.isArray(declaration.required_contexts)) {
730
756
  throw new Error(
731
757
  `check-skipped-required-checks: \`required_contexts\` must be an array of context strings, transcribed byte for byte from the ruleset (emoji and the \` / \` separator included).`
@@ -739,6 +765,15 @@ export function loadDeclaration(rootDir) {
739
765
  `check-skipped-required-checks: \`workflows\` must list at least one workflow file whose \`skip_jobs\` this guard reads.`
740
766
  );
741
767
  }
768
+ }
769
+
770
+ /**
771
+ * Validates the optional enforcement switch independently of declaration data.
772
+ *
773
+ * @param {object} declaration - Parsed declaration
774
+ * @returns {void}
775
+ */
776
+ function assertEnforcementMode(declaration) {
742
777
  if (
743
778
  declaration.enforcement !== undefined &&
744
779
  !ENFORCEMENT_MODES.includes(declaration.enforcement)
@@ -747,7 +782,41 @@ export function loadDeclaration(rootDir) {
747
782
  `check-skipped-required-checks: \`enforcement\` must be one of ${ENFORCEMENT_MODES.map(mode => `"${mode}"`).join(", ")}, not ${JSON.stringify(declaration.enforcement)}. Omit it to enforce — a typo silently downgrading this guard to advice is the fail-open shape it exists to refuse.`
748
783
  );
749
784
  }
785
+ }
750
786
 
787
+ /**
788
+ * Validates one skip-token declaration.
789
+ *
790
+ * @param {string} token - Declared skip token
791
+ * @param {unknown} entry - Declaration value
792
+ * @returns {void}
793
+ */
794
+ function assertSkipJobDeclaration(token, entry) {
795
+ if (
796
+ typeof entry !== "object" ||
797
+ entry === null ||
798
+ Array.isArray(entry) ||
799
+ !Array.isArray(entry.suppressed_contexts) ||
800
+ typeof entry.ruleset_required !== "boolean"
801
+ ) {
802
+ throw new Error(
803
+ `check-skipped-required-checks: the declaration for \`${token}\` in ${DECLARATION_PATH} is malformed. Each entry must be an object with an array \`suppressed_contexts\` and a boolean \`ruleset_required\`. A malformed entry still counts as "declared" where it is used, which turns this guard into a no-op for that exact token.`
804
+ );
805
+ }
806
+ if (entry.suppressed_contexts.some(name => typeof name !== "string")) {
807
+ throw new Error(
808
+ `check-skipped-required-checks: \`${token}.suppressed_contexts\` must contain only context strings — they are compared byte for byte against \`required_contexts\`.`
809
+ );
810
+ }
811
+ }
812
+
813
+ /**
814
+ * Validates every skip-token declaration.
815
+ *
816
+ * @param {Record<string, unknown>} declarations - Token declarations
817
+ * @returns {void}
818
+ */
819
+ function assertSkipJobDeclarations(declarations) {
751
820
  // Validate the SHAPE of every declaration, not just its presence. A truthy
752
821
  // non-object entry (`"test:e2e": true`, or a string) reads as "declared" at
753
822
  // the point of use, then yields `suppressed_contexts: undefined` — no hits,
@@ -755,53 +824,78 @@ export function loadDeclaration(rootDir) {
755
824
  // someone was trying to document, which is the fail-open class this file
756
825
  // exists to refuse. A bad snapshot fails loudly HERE instead, where it is
757
826
  // unambiguously a configuration error rather than a clean bill of health.
758
- for (const [token, entry] of Object.entries(
759
- declaration.skip_job_declarations
760
- )) {
761
- if (
762
- typeof entry !== "object" ||
763
- entry === null ||
764
- Array.isArray(entry) ||
765
- !Array.isArray(entry.suppressed_contexts) ||
766
- typeof entry.ruleset_required !== "boolean"
767
- ) {
768
- throw new Error(
769
- `check-skipped-required-checks: the declaration for \`${token}\` in ${DECLARATION_PATH} is malformed. Each entry must be an object with an array \`suppressed_contexts\` and a boolean \`ruleset_required\`. A malformed entry still counts as "declared" where it is used, which turns this guard into a no-op for that exact token.`
770
- );
771
- }
772
- if (entry.suppressed_contexts.some(name => typeof name !== "string")) {
773
- throw new Error(
774
- `check-skipped-required-checks: \`${token}.suppressed_contexts\` must contain only context strings — they are compared byte for byte against \`required_contexts\`.`
775
- );
776
- }
827
+ for (const [token, entry] of Object.entries(declarations)) {
828
+ assertSkipJobDeclaration(token, entry);
829
+ }
830
+ }
831
+
832
+ /**
833
+ * Validates one optional description-vocabulary list.
834
+ *
835
+ * @param {string} checkName - Evidence-bearing check name
836
+ * @param {object} entry - Vocabulary declaration
837
+ * @param {string} list - Vocabulary list name
838
+ * @returns {void}
839
+ */
840
+ function assertEvidenceVocabularyList(checkName, entry, list) {
841
+ if (entry[list] === undefined) return;
842
+ if (
843
+ !Array.isArray(entry[list]) ||
844
+ entry[list].some(phrase => typeof phrase !== "string")
845
+ ) {
846
+ throw new Error(
847
+ `check-skipped-required-checks: \`evidence_bearing_checks.${checkName}.${list}\` must be an array of description strings.`
848
+ );
849
+ }
850
+ if (entry[list].some(phrase => normalizeDescription(phrase) === "")) {
851
+ throw new Error(
852
+ `check-skipped-required-checks: \`evidence_bearing_checks.${checkName}.${list}\` must not contain an empty or whitespace-only description. An empty phrase can match missing evidence or every description, depending on how the vocabulary is consumed.`
853
+ );
777
854
  }
855
+ }
778
856
 
857
+ /**
858
+ * Refuses vocabulary that grants and denies credit for the same description.
859
+ *
860
+ * @param {string} checkName - Evidence-bearing check name
861
+ * @param {object} entry - Vocabulary declaration
862
+ * @returns {void}
863
+ */
864
+ function assertEvidenceVocabularyDisjoint(checkName, entry) {
865
+ const satisfactions = new Set(
866
+ [...REVIEW_SATISFACTIONS, ...(entry.satisfy ?? [])].map(
867
+ normalizeDescription
868
+ )
869
+ );
870
+ const waivers = new Set(
871
+ [...ENTITLEMENT_WAIVERS, ...(entry.waive ?? [])].map(normalizeDescription)
872
+ );
873
+ const overlap = [...satisfactions].filter(phrase => waivers.has(phrase));
874
+ if (overlap.length > 0) {
875
+ throw new Error(
876
+ `check-skipped-required-checks: \`evidence_bearing_checks.${checkName}.satisfy\` and \`.waive\` must be disjoint after shipped defaults are applied. These phrases appear in both: ${overlap.map(phrase => JSON.stringify(phrase)).join(", ")}. A phrase cannot mean both "review completed" and "review did not run".`
877
+ );
878
+ }
879
+ }
880
+
881
+ /**
882
+ * Validates every evidence-bearing check declaration.
883
+ *
884
+ * @param {Record<string, unknown>} checks - Evidence-bearing check declarations
885
+ * @returns {void}
886
+ */
887
+ function assertEvidenceBearingChecks(checks) {
779
888
  // Same reasoning for the vacuity declarations: a non-object entry would read
780
889
  // as "declared" and then yield an empty vocabulary, quietly examining the
781
890
  // check against defaults the author thought they had overridden.
782
- for (const [name, entry] of Object.entries(
783
- declaration.evidence_bearing_checks ?? {}
784
- )) {
891
+ for (const [name, entry] of Object.entries(checks)) {
785
892
  if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
786
893
  throw new Error(
787
894
  `check-skipped-required-checks: the declaration for \`${name}\` in \`evidence_bearing_checks\` must be an object — use \`{}\` to accept the shipped description vocabulary.`
788
895
  );
789
896
  }
790
897
  for (const list of ["proof", "no_work", "satisfy", "waive"]) {
791
- if (entry[list] === undefined) continue;
792
- if (
793
- !Array.isArray(entry[list]) ||
794
- entry[list].some(phrase => typeof phrase !== "string")
795
- ) {
796
- throw new Error(
797
- `check-skipped-required-checks: \`evidence_bearing_checks.${name}.${list}\` must be an array of description strings.`
798
- );
799
- }
800
- if (entry[list].some(phrase => normalizeDescription(phrase) === "")) {
801
- throw new Error(
802
- `check-skipped-required-checks: \`evidence_bearing_checks.${name}.${list}\` must not contain an empty or whitespace-only description. An empty phrase can match missing evidence or every description, depending on how the vocabulary is consumed.`
803
- );
804
- }
898
+ assertEvidenceVocabularyList(name, entry, list);
805
899
  }
806
900
 
807
901
  // Resolve the shipped defaults before checking disjointness. A custom
@@ -809,22 +903,8 @@ export function loadDeclaration(rootDir) {
809
903
  // ambiguous as overlap between two custom lists. Satisfaction is checked
810
904
  // first at runtime, so accepting overlap would silently upgrade an explicit
811
905
  // waiver into evidence that a review completed.
812
- const satisfactions = new Set(
813
- [...REVIEW_SATISFACTIONS, ...(entry.satisfy ?? [])].map(
814
- normalizeDescription
815
- )
816
- );
817
- const waivers = new Set(
818
- [...ENTITLEMENT_WAIVERS, ...(entry.waive ?? [])].map(normalizeDescription)
819
- );
820
- const overlap = [...satisfactions].filter(phrase => waivers.has(phrase));
821
- if (overlap.length > 0) {
822
- throw new Error(
823
- `check-skipped-required-checks: \`evidence_bearing_checks.${name}.satisfy\` and \`.waive\` must be disjoint after shipped defaults are applied. These phrases appear in both: ${overlap.map(phrase => JSON.stringify(phrase)).join(", ")}. A phrase cannot mean both "review completed" and "review did not run".`
824
- );
825
- }
906
+ assertEvidenceVocabularyDisjoint(name, entry);
826
907
  }
827
- return declaration;
828
908
  }
829
909
 
830
910
  /**
@@ -975,66 +1055,144 @@ export function evaluateSkippedRequiredChecks(
975
1055
  const violations = [];
976
1056
 
977
1057
  for (const token of skipped) {
978
- const entry = declarations[token];
979
- if (!entry) {
980
- violations.push({
1058
+ violations.push(
1059
+ ...evaluateSkippedToken(
1060
+ token,
1061
+ declarations[token],
1062
+ required,
1063
+ ticketPattern,
1064
+ trustRequired
1065
+ )
1066
+ );
1067
+ }
1068
+
1069
+ violations.push(...findOrphanedExemptions(declarations, skipped));
1070
+
1071
+ return { violations, checked: skipped.length };
1072
+ }
1073
+
1074
+ /**
1075
+ * Evaluates one token against its declaration and the trusted ruleset snapshot.
1076
+ *
1077
+ * @param {string} token - Skip token in a workflow
1078
+ * @param {object|undefined} entry - Its declaration
1079
+ * @param {ReadonlySet<string>} required - Required-context snapshot
1080
+ * @param {RegExp} ticketPattern - Accepted exemption ticket pattern
1081
+ * @param {boolean} trustRequired - Whether the snapshot is trusted
1082
+ * @returns {object[]} Violations for this token
1083
+ */
1084
+ function evaluateSkippedToken(
1085
+ token,
1086
+ entry,
1087
+ required,
1088
+ ticketPattern,
1089
+ trustRequired
1090
+ ) {
1091
+ if (!entry) {
1092
+ return [
1093
+ {
981
1094
  kind: VIOLATIONS.undeclared,
982
1095
  token,
983
1096
  message: `\`${token}\` is skipped but not declared in ${DECLARATION_PATH}. Declare what it silences and whether any of that is ruleset-required — an undeclared skip is a skip nobody reviewed.`,
984
- });
985
- continue;
986
- }
987
- if (!trustRequired) continue;
988
- const suppressed = entry.suppressed_contexts ?? [];
989
- const hits = suppressed.filter(context => required.has(context));
1097
+ },
1098
+ ];
1099
+ }
1100
+ if (!trustRequired) return [];
990
1101
 
991
- if (hits.length > 0 && entry.ruleset_required !== true) {
992
- violations.push({
1102
+ const suppressed = entry.suppressed_contexts ?? [];
1103
+ const hits = suppressed.filter(context => required.has(context));
1104
+ return [
1105
+ ...evaluateRequiredSnapshotCoherence(token, entry, suppressed, hits),
1106
+ ...evaluateRequiredSuppression(token, entry, hits, ticketPattern),
1107
+ ];
1108
+ }
1109
+
1110
+ /**
1111
+ * Checks whether one token's declaration agrees with the required snapshot.
1112
+ *
1113
+ * @param {string} token - Skip token
1114
+ * @param {object} entry - Token declaration
1115
+ * @param {ReadonlyArray<string>} suppressed - Contexts the token suppresses
1116
+ * @param {ReadonlyArray<string>} hits - Suppressed contexts that are required
1117
+ * @returns {object[]} Snapshot-coherence violations
1118
+ */
1119
+ function evaluateRequiredSnapshotCoherence(token, entry, suppressed, hits) {
1120
+ if (hits.length > 0 && entry.ruleset_required !== true) {
1121
+ return [
1122
+ {
993
1123
  kind: VIOLATIONS.incoherent,
994
1124
  token,
995
1125
  message: `\`${token}\` declares \`ruleset_required: false\`, but it suppresses ${hits.map(hit => `"${hit}"`).join(", ")}, which \`required_contexts\` says IS required. One of the two snapshots is out of date — fix the one that is wrong, do not delete the check.`,
996
- });
997
- }
998
- if (hits.length === 0 && entry.ruleset_required === true) {
999
- violations.push({
1126
+ },
1127
+ ];
1128
+ }
1129
+ if (hits.length === 0 && entry.ruleset_required === true) {
1130
+ return [
1131
+ {
1000
1132
  kind: VIOLATIONS.stale,
1001
1133
  token,
1002
1134
  message: `\`${token}\` declares \`ruleset_required: true\`, but none of ${suppressed.map(name => `"${name}"`).join(", ") || "(nothing)"} appears in \`required_contexts\`. The context was renamed or de-required and this declaration now describes a world that no longer exists.`,
1003
- });
1004
- }
1005
- if (hits.length > 0) {
1006
- const exemption = entry.exemption;
1007
- if (!exemption) {
1008
- violations.push({
1009
- kind: VIOLATIONS.suppressesRequired,
1010
- token,
1011
- contexts: hits,
1012
- message: `\`${token}\` silences the ruleset-required context(s) ${hits.map(hit => `"${hit}"`).join(", ")}. GitHub counts a SKIPPED required check as SATISFIED, so that context reports green having run zero steps — the gate is decorative. Fix the suite, de-require the context, or record an exemption with a tracker ticket.`,
1013
- });
1014
- } else if (
1015
- typeof exemption.ticket !== "string" ||
1016
- !ticketPattern.test(exemption.ticket)
1017
- ) {
1018
- violations.push({
1019
- kind: VIOLATIONS.badExemption,
1020
- token,
1021
- message: `\`${token}\` carries an exemption whose ticket ${JSON.stringify(exemption.ticket)} does not match ${ticketPattern}. An exemption is a decision someone owns; without a ticket it is just a way to silence this guard.`,
1022
- });
1023
- }
1024
- }
1135
+ },
1136
+ ];
1025
1137
  }
1138
+ return [];
1139
+ }
1026
1140
 
1027
- for (const [token, entry] of Object.entries(declarations)) {
1028
- if (entry.exemption && !skipped.includes(token)) {
1029
- violations.push({
1030
- kind: VIOLATIONS.orphaned,
1141
+ /**
1142
+ * Checks whether a real required-context suppression has an owned exemption.
1143
+ *
1144
+ * @param {string} token - Skip token
1145
+ * @param {object} entry - Token declaration
1146
+ * @param {ReadonlyArray<string>} hits - Required contexts suppressed
1147
+ * @param {RegExp} ticketPattern - Accepted exemption ticket pattern
1148
+ * @returns {object[]} Suppression or exemption violations
1149
+ */
1150
+ function evaluateRequiredSuppression(token, entry, hits, ticketPattern) {
1151
+ if (hits.length === 0) return [];
1152
+ const exemption = entry.exemption;
1153
+ if (!exemption) {
1154
+ return [
1155
+ {
1156
+ kind: VIOLATIONS.suppressesRequired,
1031
1157
  token,
1032
- message: `\`${token}\` carries an exemption but is no longer skipped anywhere. Delete the exemption — leaving it teaches readers that the exemption list is fiction.`,
1033
- });
1034
- }
1158
+ contexts: hits,
1159
+ message: `\`${token}\` silences the ruleset-required context(s) ${hits.map(hit => `"${hit}"`).join(", ")}. GitHub counts a SKIPPED required check as SATISFIED, so that context reports green having run zero steps — the gate is decorative. Fix the suite, de-require the context, or record an exemption with a tracker ticket.`,
1160
+ },
1161
+ ];
1035
1162
  }
1163
+ if (
1164
+ typeof exemption.ticket !== "string" ||
1165
+ !ticketPattern.test(exemption.ticket)
1166
+ ) {
1167
+ return [
1168
+ {
1169
+ kind: VIOLATIONS.badExemption,
1170
+ token,
1171
+ message: `\`${token}\` carries an exemption whose ticket ${JSON.stringify(exemption.ticket)} does not match ${ticketPattern}. An exemption is a decision someone owns; without a ticket it is just a way to silence this guard.`,
1172
+ },
1173
+ ];
1174
+ }
1175
+ return [];
1176
+ }
1036
1177
 
1037
- return { violations, checked: skipped.length };
1178
+ /**
1179
+ * Finds exemptions whose tokens are no longer skipped.
1180
+ *
1181
+ * @param {Record<string, object>} declarations - All token declarations
1182
+ * @param {ReadonlyArray<string>} skipped - Tokens currently skipped
1183
+ * @returns {object[]} Orphaned-exemption violations
1184
+ */
1185
+ function findOrphanedExemptions(declarations, skipped) {
1186
+ const violations = [];
1187
+ for (const [token, entry] of Object.entries(declarations)) {
1188
+ if (!entry.exemption || skipped.includes(token)) continue;
1189
+ violations.push({
1190
+ kind: VIOLATIONS.orphaned,
1191
+ token,
1192
+ message: `\`${token}\` carries an exemption but is no longer skipped anywhere. Delete the exemption — leaving it teaches readers that the exemption list is fiction.`,
1193
+ });
1194
+ }
1195
+ return violations;
1038
1196
  }
1039
1197
 
1040
1198
  /**
@@ -1120,53 +1278,83 @@ export function evaluateVacuousChecks(declaration, checks, options = {}) {
1120
1278
  const at = citeHeadSha(options.headSha);
1121
1279
  const required = new Set(declaration.required_contexts ?? []);
1122
1280
  const violations = [];
1123
- let checked = 0;
1281
+ const entries = Object.entries(declared);
1124
1282
 
1125
- for (const [name, entry] of Object.entries(declared)) {
1126
- checked += 1;
1127
- const vocabulary = typeof entry === "object" && entry !== null ? entry : {};
1128
- const found = checks.find(check => check.name === name);
1283
+ for (const [name, entry] of entries) {
1284
+ const violation = evaluateEvidenceBearingCheck(name, entry, checks, {
1285
+ at,
1286
+ required,
1287
+ trustRequired,
1288
+ });
1289
+ if (violation !== null) violations.push(violation);
1290
+ }
1129
1291
 
1130
- if (found === undefined) {
1131
- violations.push({
1132
- kind: VIOLATIONS.unproven,
1133
- token: name,
1134
- message: `\`${name}\` is declared evidence-bearing but did not report on this pull request at all.${at} A report of "no unresolved review threads" from this PR means NOBODY LOOKED, not that nothing was wrong — say which one you observed. (If the context was renamed, fix \`evidence_bearing_checks\`; names are compared byte for byte.)`,
1135
- });
1136
- continue;
1137
- }
1292
+ return { violations, checked: entries.length };
1293
+ }
1138
1294
 
1139
- const state = String(found.state ?? "").toUpperCase();
1140
- if (state === "FAILURE" || state === "ERROR") continue;
1295
+ /**
1296
+ * Explains whether the named evidence check is part of the trusted ruleset.
1297
+ *
1298
+ * @param {string} name - Check context name
1299
+ * @param {boolean} trustRequired - Whether the ruleset snapshot is trusted
1300
+ * @param {ReadonlySet<string>} required - Required-context snapshot
1301
+ * @returns {string} Sentence appended to a finding
1302
+ */
1303
+ function evidenceRequiredNote(name, trustRequired, required) {
1304
+ if (!trustRequired) {
1305
+ return " Whether it is ruleset-required is NOT KNOWN here — `required_contexts` has not been transcribed, so this cannot say what the merge gate recorded.";
1306
+ }
1307
+ return required.has(name)
1308
+ ? " This context IS ruleset-required, so branch protection recorded a satisfied review gate for a review that did not happen."
1309
+ : " This context is not in `required_contexts`, so no merge gate was falsified — but nothing reviewed this either.";
1310
+ }
1141
1311
 
1142
- const verdict = classifyCheckDescription(found.description, vocabulary);
1143
- if (verdict === DESCRIPTION_VERDICTS.proved && state === "SUCCESS") {
1144
- continue;
1145
- }
1312
+ /**
1313
+ * Evaluates one declared evidence-bearing check.
1314
+ *
1315
+ * @param {string} name - Declared check name
1316
+ * @param {object} entry - Per-check description vocabulary
1317
+ * @param {ReadonlyArray<object>} checks - Reported checks
1318
+ * @param {{at: string, required: ReadonlySet<string>, trustRequired: boolean}} context - Shared evaluation context
1319
+ * @returns {object|null} A finding, or null when this check proved work or is red
1320
+ */
1321
+ function evaluateEvidenceBearingCheck(name, entry, checks, context) {
1322
+ const vocabulary = entry && typeof entry === "object" ? entry : {};
1323
+ const found = checks.find(check => check.name === name);
1324
+ if (found === undefined) {
1325
+ return {
1326
+ kind: VIOLATIONS.unproven,
1327
+ token: name,
1328
+ message: `\`${name}\` is declared evidence-bearing but did not report on this pull request at all.${context.at} A report of "no unresolved review threads" from this PR means NOBODY LOOKED, not that nothing was wrong — say which one you observed. (If the context was renamed, fix \`evidence_bearing_checks\`; names are compared byte for byte.)`,
1329
+ };
1330
+ }
1146
1331
 
1147
- const requiredNote = !trustRequired
1148
- ? " Whether it is ruleset-required is NOT KNOWN here — `required_contexts` has not been transcribed, so this cannot say what the merge gate recorded."
1149
- : required.has(name)
1150
- ? " This context IS ruleset-required, so branch protection recorded a satisfied review gate for a review that did not happen."
1151
- : " This context is not in `required_contexts`, so no merge gate was falsified — but nothing reviewed this either.";
1332
+ const state = String(found.state ?? "").toUpperCase();
1333
+ if (state === "FAILURE" || state === "ERROR") return null;
1152
1334
 
1153
- violations.push(
1154
- verdict === DESCRIPTION_VERDICTS.noWork && state === "SUCCESS"
1155
- ? {
1156
- kind: VIOLATIONS.vacuous,
1157
- token: name,
1158
- contexts: [name],
1159
- message: `\`${name}\` reported ${state} with the description ${JSON.stringify(found.description ?? "")}, which says it DID NO WORK.${at}${requiredNote} \`gh pr checks\` prints \`pass\` for this exactly as it does for a real review — the description is the only thing that tells them apart. Treat this PR as UNREVIEWED.`,
1160
- }
1161
- : {
1162
- kind: VIOLATIONS.unproven,
1163
- token: name,
1164
- message: `\`${name}\` reported ${state} with the description ${JSON.stringify(found.description ?? "")}, which proves neither that it reviewed anything nor that it did not.${at}${requiredNote} Read the check itself before treating this PR as reviewed, or add the phrase to \`evidence_bearing_checks.${name}.proof\` once you have confirmed what it means.`,
1165
- }
1166
- );
1335
+ const verdict = classifyCheckDescription(found.description, vocabulary);
1336
+ if (verdict === DESCRIPTION_VERDICTS.proved && state === "SUCCESS") {
1337
+ return null;
1167
1338
  }
1168
1339
 
1169
- return { violations, checked };
1340
+ const requiredNote = evidenceRequiredNote(
1341
+ name,
1342
+ context.trustRequired,
1343
+ context.required
1344
+ );
1345
+ if (verdict === DESCRIPTION_VERDICTS.noWork && state === "SUCCESS") {
1346
+ return {
1347
+ kind: VIOLATIONS.vacuous,
1348
+ token: name,
1349
+ contexts: [name],
1350
+ message: `\`${name}\` reported ${state} with the description ${JSON.stringify(found.description ?? "")}, which says it DID NO WORK.${context.at}${requiredNote} \`gh pr checks\` prints \`pass\` for this exactly as it does for a real review — the description is the only thing that tells them apart. Treat this PR as UNREVIEWED.`,
1351
+ };
1352
+ }
1353
+ return {
1354
+ kind: VIOLATIONS.unproven,
1355
+ token: name,
1356
+ message: `\`${name}\` reported ${state} with the description ${JSON.stringify(found.description ?? "")}, which proves neither that it reviewed anything nor that it did not.${context.at}${requiredNote} Read the check itself before treating this PR as reviewed, or add the phrase to \`evidence_bearing_checks.${name}.proof\` once you have confirmed what it means.`,
1357
+ };
1170
1358
  }
1171
1359
 
1172
1360
  /**
@@ -1349,46 +1537,100 @@ function normalizeDescription(description) {
1349
1537
  * @returns {{state: string, condition: string, why: string}} Severity, the condition observed, and a one-line reason
1350
1538
  */
1351
1539
  export function reviewGateState(reading, vocabulary = {}) {
1352
- // Shared by both inconclusive shapes so they can never drift apart, and so
1353
- // the sentence always says what to do (re-run) before it says what was seen.
1354
- const undetermined = seen => ({
1355
- state: REVIEW_GATE_STATES.unsatisfied,
1356
- condition: REVIEW_GATE_CONDITIONS.undetermined,
1357
- why: `was ${seen} when the settle window EXPIRED. The gate stopped waiting; it did not observe that nobody reviewed. This is NOT a finding about the code and nothing about the change should be investigated on the strength of it — RE-RUN THIS JOB once the reviewer has settled. Blocking is deliberate: an expired wait has not established that a review happened, and passing on it would let a pull request merge unreviewed whenever a reviewer ran slow. Do NOT re-request the review: a re-request OVERWRITES the existing commit status rather than adding to it, so under throttle it destroys a real review one-way and replaces a substantive objection with a rate-limit string.`,
1358
- });
1359
- if (!reading.present) {
1360
- return reading.waitExpired === true
1361
- ? undetermined("still unreported")
1362
- : {
1363
- state: REVIEW_GATE_STATES.unsatisfied,
1364
- condition: REVIEW_GATE_CONDITIONS.absent,
1365
- why: "did not report on this pull request at all. ABSENT is not the same as waived: nothing said it could not review, so nothing accounts for the silence. A guard that read absence as permission would pass forever the first time it looked at the wrong commit.",
1366
- };
1367
- }
1540
+ if (!reading.present) return unreportedReviewGateState(reading.waitExpired);
1541
+
1368
1542
  const state = String(reading.state ?? "").toUpperCase();
1369
1543
  const text = normalizeDescription(reading.description);
1370
1544
  if (state === "FAILURE" || state === "ERROR") {
1371
- return {
1372
- state: REVIEW_GATE_STATES.unsatisfied,
1373
- condition: REVIEW_GATE_CONDITIONS.objected,
1374
- why: `reported ${state}${text === "" ? "" : ` — ${JSON.stringify(reading.description ?? "")}`}. A review that RAN AND OBJECTED is the case this gate exists to let through to a human, and it is the one thing no waiver covers. READ THE OBJECTION: this is the one condition here where a review did happen.`,
1375
- };
1545
+ return objectedReviewGateState(state, text, reading.description);
1376
1546
  }
1377
1547
  if (state !== "SUCCESS") {
1378
- return reading.waitExpired === true
1379
- ? undetermined(`still ${state === "" ? "unreported" : state}`)
1380
- : {
1381
- state: REVIEW_GATE_STATES.unsatisfied,
1382
- condition: REVIEW_GATE_CONDITIONS.pending,
1383
- why: `is still ${state === "" ? "unreported" : state} after the settle window. An unsettled check has not said anything yet, and the gate does not guess on its behalf. This is LATENESS, not a verdict about the code — wait for the reviewer to settle, then RE-RUN THIS JOB. Do NOT re-request the review: a re-request OVERWRITES the existing commit status rather than adding to it, so under throttle it destroys a real review one-way and replaces a substantive objection with a rate-limit string.`,
1384
- };
1548
+ return pendingReviewGateState(state, reading.waitExpired);
1549
+ }
1550
+ return successfulReviewGateState(text, reading.description, vocabulary);
1551
+ }
1552
+
1553
+ /**
1554
+ * Builds the common verdict for a settle window that expired inconclusively.
1555
+ *
1556
+ * @param {string} seen - What the final poll observed
1557
+ * @returns {{state: string, condition: string, why: string}} Review gate verdict
1558
+ */
1559
+ function undeterminedReviewGateState(seen) {
1560
+ return {
1561
+ state: REVIEW_GATE_STATES.unsatisfied,
1562
+ condition: REVIEW_GATE_CONDITIONS.undetermined,
1563
+ why: `was ${seen} when the settle window EXPIRED. The gate stopped waiting; it did not observe that nobody reviewed. This is NOT a finding about the code and nothing about the change should be investigated on the strength of it — RE-RUN THIS JOB once the reviewer has settled. Blocking is deliberate: an expired wait has not established that a review happened, and passing on it would let a pull request merge unreviewed whenever a reviewer ran slow. Do NOT re-request the review: a re-request OVERWRITES the existing commit status rather than adding to it, so under throttle it destroys a real review one-way and replaces a substantive objection with a rate-limit string.`,
1564
+ };
1565
+ }
1566
+
1567
+ /**
1568
+ * Classifies a check that did not report at all.
1569
+ *
1570
+ * @param {boolean|undefined} waitExpired - Whether the settle window expired
1571
+ * @returns {{state: string, condition: string, why: string}} Review gate verdict
1572
+ */
1573
+ function unreportedReviewGateState(waitExpired) {
1574
+ if (waitExpired === true) {
1575
+ return undeterminedReviewGateState("still unreported");
1385
1576
  }
1577
+ return {
1578
+ state: REVIEW_GATE_STATES.unsatisfied,
1579
+ condition: REVIEW_GATE_CONDITIONS.absent,
1580
+ why: "did not report on this pull request at all. ABSENT is not the same as waived: nothing said it could not review, so nothing accounts for the silence. A guard that read absence as permission would pass forever the first time it looked at the wrong commit.",
1581
+ };
1582
+ }
1583
+
1584
+ /**
1585
+ * Classifies a review check that ran and objected.
1586
+ *
1587
+ * @param {string} state - Normalized failure state
1588
+ * @param {string} text - Normalized description
1589
+ * @param {string|undefined} description - Original description
1590
+ * @returns {{state: string, condition: string, why: string}} Review gate verdict
1591
+ */
1592
+ function objectedReviewGateState(state, text, description) {
1593
+ return {
1594
+ state: REVIEW_GATE_STATES.unsatisfied,
1595
+ condition: REVIEW_GATE_CONDITIONS.objected,
1596
+ why: `reported ${state}${text === "" ? "" : ` — ${JSON.stringify(description ?? "")}`}. A review that RAN AND OBJECTED is the case this gate exists to let through to a human, and it is the one thing no waiver covers. READ THE OBJECTION: this is the one condition here where a review did happen.`,
1597
+ };
1598
+ }
1599
+
1600
+ /**
1601
+ * Classifies a check that has not reached success or failure.
1602
+ *
1603
+ * @param {string} state - Normalized in-flight state
1604
+ * @param {boolean|undefined} waitExpired - Whether the settle window expired
1605
+ * @returns {{state: string, condition: string, why: string}} Review gate verdict
1606
+ */
1607
+ function pendingReviewGateState(state, waitExpired) {
1608
+ const observed = state === "" ? "unreported" : state;
1609
+ if (waitExpired === true) {
1610
+ return undeterminedReviewGateState(`still ${observed}`);
1611
+ }
1612
+ return {
1613
+ state: REVIEW_GATE_STATES.unsatisfied,
1614
+ condition: REVIEW_GATE_CONDITIONS.pending,
1615
+ why: `is still ${observed} after the settle window. An unsettled check has not said anything yet, and the gate does not guess on its behalf. This is LATENESS, not a verdict about the code — wait for the reviewer to settle, then RE-RUN THIS JOB. Do NOT re-request the review: a re-request OVERWRITES the existing commit status rather than adding to it, so under throttle it destroys a real review one-way and replaces a substantive objection with a rate-limit string.`,
1616
+ };
1617
+ }
1618
+
1619
+ /**
1620
+ * Classifies a successful check from its exact description vocabulary.
1621
+ *
1622
+ * @param {string} text - Normalized description
1623
+ * @param {string|undefined} description - Original description
1624
+ * @param {{waive?: readonly string[], satisfy?: readonly string[]}} vocabulary - Per-check extensions
1625
+ * @returns {{state: string, condition: string, why: string}} Review gate verdict
1626
+ */
1627
+ function successfulReviewGateState(text, description, vocabulary) {
1386
1628
  const satisfies = [...REVIEW_SATISFACTIONS, ...(vocabulary.satisfy ?? [])];
1387
1629
  if (satisfies.some(phrase => text === normalizeDescription(phrase))) {
1388
1630
  return {
1389
1631
  state: REVIEW_GATE_STATES.satisfied,
1390
1632
  condition: REVIEW_GATE_CONDITIONS.satisfied,
1391
- why: `reported ${JSON.stringify(reading.description ?? "")}, which is a review that ran.`,
1633
+ why: `reported ${JSON.stringify(description ?? "")}, which is a review that ran.`,
1392
1634
  };
1393
1635
  }
1394
1636
  const waivers = [...ENTITLEMENT_WAIVERS, ...(vocabulary.waive ?? [])];
@@ -1396,13 +1638,13 @@ export function reviewGateState(reading, vocabulary = {}) {
1396
1638
  return {
1397
1639
  state: REVIEW_GATE_STATES.waived,
1398
1640
  condition: REVIEW_GATE_CONDITIONS.waived,
1399
- why: `reported ${JSON.stringify(reading.description ?? "")} — the check saying, in its own words, that it could not review. WAIVED, not satisfied: this pull request is UNREVIEWED and merging it is a decision taken on that basis. The waiver clears the moment the entitlement behind it is fixed, at which point this gate starts biting with no code change.`,
1641
+ why: `reported ${JSON.stringify(description ?? "")} — the check saying, in its own words, that it could not review. WAIVED, not satisfied: this pull request is UNREVIEWED and merging it is a decision taken on that basis. The waiver clears the moment the entitlement behind it is fixed, at which point this gate starts biting with no code change.`,
1400
1642
  };
1401
1643
  }
1402
1644
  return {
1403
1645
  state: REVIEW_GATE_STATES.unsatisfied,
1404
1646
  condition: REVIEW_GATE_CONDITIONS.unrecognised,
1405
- why: `reported SUCCESS with the description ${JSON.stringify(reading.description ?? "")}, which is neither a review that ran nor one of the named waivers. An UNRECOGNISED description is surfaced rather than waived — a gate that waived on "anything that is not a completed review" would waive a genuine failure and every phrase the vendor has not invented yet. If this string is legitimate, add it to \`evidence_bearing_checks\` under \`satisfy\` or \`waive\`, deliberately and by name.`,
1647
+ why: `reported SUCCESS with the description ${JSON.stringify(description ?? "")}, which is neither a review that ran nor one of the named waivers. An UNRECOGNISED description is surfaced rather than waived — a gate that waived on "anything that is not a completed review" would waive a genuine failure and every phrase the vendor has not invented yet. If this string is legitimate, add it to \`evidence_bearing_checks\` under \`satisfy\` or \`waive\`, deliberately and by name.`,
1406
1648
  };
1407
1649
  }
1408
1650
 
@@ -2628,235 +2870,380 @@ export function runGuard(argv, options = {}) {
2628
2870
  * @returns {void}
2629
2871
  */
2630
2872
  function main(argv) {
2631
- /** @type {{violations: object[], checked: number, tokens: string[], enforcement: string, trust: {trusted: boolean, reason: string}, recipe: string}} */
2632
- let result;
2873
+ const attempt = attemptGuardRun(argv);
2874
+ if (attempt.error !== undefined) {
2875
+ writeGuardError(argv, attempt.error);
2876
+ return;
2877
+ }
2878
+
2879
+ const result = attempt.result;
2880
+ const policy = cliPolicy(argv, result);
2881
+ const outcome = cliOutcome(result, policy);
2882
+
2883
+ // Published BEFORE the report and before the exit code, because it is the only
2884
+ // artefact of this run that a merge decision reads.
2885
+ if (result.verdict !== undefined) writeVerdictOutputs(result.verdict);
2886
+
2887
+ if (argv.includes("--json")) {
2888
+ writeJsonResult(result, outcome.failed);
2889
+ return;
2890
+ }
2891
+ writeTextResult(result, policy, outcome);
2892
+ }
2893
+
2894
+ /**
2895
+ * Runs the guard without making error presentation part of the entry point.
2896
+ *
2897
+ * @param {ReadonlyArray<string>} argv - Arguments
2898
+ * @returns {{result?: object, error?: unknown}} One guard result or error
2899
+ */
2900
+ function attemptGuardRun(argv) {
2633
2901
  try {
2634
- result = runGuard(argv);
2902
+ return { result: runGuard(argv) };
2635
2903
  } catch (error) {
2636
- const message = error instanceof Error ? error.message : String(error);
2637
- if (argv.includes("--json")) {
2638
- process.stdout.write(
2639
- `${JSON.stringify({ ok: false, error: message }, null, 2)}\n`
2640
- );
2641
- } else {
2642
- process.stderr.write(
2643
- `::error title=Skipped-required-check guard::${message}\n`
2644
- );
2645
- process.stdout.write(`❌ ${message}\n`);
2646
- }
2647
- process.exitCode = 1;
2648
- return;
2904
+ return { error };
2649
2905
  }
2906
+ }
2650
2907
 
2651
- const warnOnly = result.enforcement === "warn";
2652
- // The supported alternative to a per-consumer `--json` wrapper. Opt-in, so
2653
- // the shipped default is unchanged for everyone who does not pass it.
2654
- const failOnVacuous = argv.includes("--fail-on-vacuous");
2655
- // The review gate's own switch, deliberately NOT `--fail-on-vacuous`. That
2656
- // flag means "fail on a check that did no work", which under the owner's
2657
- // ruling (CodySwannGT/lisa#3221) is precisely the case that must NOT fail:
2658
- // the two named entitlement descriptions are waived. Sharing one flag between
2659
- // opposite policies is how a gate ends up doing the thing its name forbids.
2660
- const requireReviewEvidence = argv.includes(REQUIRE_REVIEW_EVIDENCE_FLAG);
2661
- /**
2662
- * True when a violation still fails the build under the active mode.
2663
- *
2664
- * @param {{kind: string}} violation - One violation
2665
- * @returns {boolean} True when it blocks
2666
- */
2667
- const blocks = violation => {
2668
- if (REVIEW_GATE_BLOCKING.includes(violation.kind)) {
2669
- return requireReviewEvidence;
2670
- }
2671
- // A named waiver is report-only under every flag. `--fail-on-vacuous`
2672
- // governs checks that claimed success without proving work; it must not
2673
- // turn a vendor entitlement waiver into a failure through a shared array.
2674
- if (violation.kind === VIOLATIONS.reviewWaived) return false;
2675
- return NEVER_BLOCKING.includes(violation.kind)
2676
- ? failOnVacuous
2677
- : !warnOnly || ALWAYS_BLOCKING.includes(violation.kind);
2908
+ /**
2909
+ * Prints an exception in the selected output format.
2910
+ *
2911
+ * @param {ReadonlyArray<string>} argv - Arguments
2912
+ * @param {unknown} error - Guard exception
2913
+ * @returns {void}
2914
+ */
2915
+ function writeGuardError(argv, error) {
2916
+ const message = error instanceof Error ? error.message : String(error);
2917
+ if (argv.includes("--json")) {
2918
+ process.stdout.write(
2919
+ `${JSON.stringify({ ok: false, error: message }, null, 2)}\n`
2920
+ );
2921
+ } else {
2922
+ process.stderr.write(
2923
+ `::error title=Skipped-required-check guard::${message}\n`
2924
+ );
2925
+ process.stdout.write(`❌ ${message}\n`);
2926
+ }
2927
+ process.exitCode = 1;
2928
+ }
2929
+
2930
+ /**
2931
+ * Reads the CLI switches that change which findings block.
2932
+ *
2933
+ * @param {ReadonlyArray<string>} argv - Arguments
2934
+ * @param {object} result - Guard result
2935
+ * @returns {{warnOnly: boolean, failOnVacuous: boolean, requireReviewEvidence: boolean}} Active policy
2936
+ */
2937
+ function cliPolicy(argv, result) {
2938
+ return {
2939
+ warnOnly: result.enforcement === "warn",
2940
+ // The supported alternative to a per-consumer `--json` wrapper.
2941
+ failOnVacuous: argv.includes("--fail-on-vacuous"),
2942
+ // Deliberately distinct from `--fail-on-vacuous`; entitlement waivers do
2943
+ // not become failures through a shared switch.
2944
+ requireReviewEvidence: argv.includes(REQUIRE_REVIEW_EVIDENCE_FLAG),
2678
2945
  };
2679
- const blocking = result.violations.filter(blocks);
2680
- // Published BEFORE the report and before the exit code, because it is the only
2681
- // artefact of this run that a merge decision reads. A verdict computed after a
2682
- // `return` on some branch is a verdict that exists on the happy path only.
2683
- const verdict = result.verdict;
2684
- if (verdict !== undefined) writeVerdictOutputs(verdict);
2946
+ }
2947
+
2948
+ /**
2949
+ * True when one violation blocks under the active CLI policy.
2950
+ *
2951
+ * @param {{kind: string}} violation - One violation
2952
+ * @param {{warnOnly: boolean, failOnVacuous: boolean, requireReviewEvidence: boolean}} policy - Active policy
2953
+ * @returns {boolean} True when the finding blocks
2954
+ */
2955
+ function violationBlocks(violation, policy) {
2956
+ if (REVIEW_GATE_BLOCKING.includes(violation.kind)) {
2957
+ return policy.requireReviewEvidence;
2958
+ }
2959
+ if (violation.kind === VIOLATIONS.reviewWaived) return false;
2960
+ if (NEVER_BLOCKING.includes(violation.kind)) return policy.failOnVacuous;
2961
+ return !policy.warnOnly || ALWAYS_BLOCKING.includes(violation.kind);
2962
+ }
2963
+
2964
+ /**
2965
+ * Computes the blocking set and refusal state once for both renderers.
2966
+ *
2967
+ * @param {object} result - Guard result
2968
+ * @param {object} policy - Active CLI policy
2969
+ * @returns {{blocking: object[], refusal: object|null, refusalBlocks: boolean, failed: boolean}} CLI outcome
2970
+ */
2971
+ function cliOutcome(result, policy) {
2972
+ const blocking = result.violations.filter(violation =>
2973
+ violationBlocks(violation, policy)
2974
+ );
2685
2975
  const refusal = result.vacuity?.refusal ?? null;
2686
2976
  const refusalBlocks =
2687
- refusal !== null && (!warnOnly || requireReviewEvidence);
2977
+ refusal !== null && (!policy.warnOnly || policy.requireReviewEvidence);
2688
2978
  const failed =
2689
2979
  blocking.length > 0 ||
2690
- (!result.trust.trusted && !warnOnly) ||
2980
+ (!result.trust.trusted && !policy.warnOnly) ||
2691
2981
  refusalBlocks;
2982
+ return { blocking, refusal, refusalBlocks, failed };
2983
+ }
2692
2984
 
2693
- if (argv.includes("--json")) {
2694
- process.stdout.write(
2695
- `${JSON.stringify(
2696
- {
2697
- ok: !failed,
2698
- answered: result.trust.trusted,
2699
- inspected:
2700
- result.vacuity !== undefined && result.vacuity.refusal === null,
2701
- ...result,
2702
- },
2703
- null,
2704
- 2
2705
- )}\n`
2706
- );
2707
- if (failed) process.exitCode = 1;
2708
- return;
2709
- }
2985
+ /**
2986
+ * Writes the machine-readable report.
2987
+ *
2988
+ * @param {object} result - Guard result
2989
+ * @param {boolean} failed - Whether the run blocks
2990
+ * @returns {void}
2991
+ */
2992
+ function writeJsonResult(result, failed) {
2993
+ process.stdout.write(
2994
+ `${JSON.stringify(
2995
+ {
2996
+ ok: !failed,
2997
+ answered: result.trust.trusted,
2998
+ inspected:
2999
+ result.vacuity !== undefined && result.vacuity.refusal === null,
3000
+ ...result,
3001
+ },
3002
+ null,
3003
+ 2
3004
+ )}\n`
3005
+ );
3006
+ if (failed) process.exitCode = 1;
3007
+ }
2710
3008
 
2711
- const lines = ["## 🔒 Required checks that prove nothing", ""];
3009
+ /**
3010
+ * Appends the published review verdict to the human report.
3011
+ *
3012
+ * @param {string[]} lines - Report lines
3013
+ * @param {object} result - Guard result
3014
+ * @returns {void}
3015
+ */
3016
+ function appendVerdictReport(lines, result) {
3017
+ if (result.verdict === undefined) return;
3018
+ lines.push(
3019
+ `**${result.verdict.title}**`,
3020
+ "",
3021
+ `Published as check-run conclusion \`${result.verdict.conclusion}\`. A \`neutral\` conclusion is how a WAIVED pull request is kept out of the shape a satisfied one prints — the waiver still blocks nothing, and it no longer looks like a review.`,
3022
+ "",
3023
+ result.waiveRate === undefined
3024
+ ? "The waive rate over recently merged pull requests was NOT sampled on this run, so no rate is claimed here."
3025
+ : `Recently merged pull requests sampled: ${result.waiveRate.sampled} — ${result.waiveRate.satisfied} reviewed, ${result.waiveRate.waived} waived, ${result.waiveRate.unsatisfied} unsatisfied.`,
3026
+ ""
3027
+ );
3028
+ }
2712
3029
 
2713
- // The verdict is the FIRST line of the report and not a footnote, and it is
2714
- // the same string published to the check run, so the two surfaces cannot
2715
- // drift into disagreeing about what this run concluded.
2716
- if (verdict !== undefined) {
2717
- lines.push(
2718
- `**${verdict.title}**`,
2719
- "",
2720
- `Published as check-run conclusion \`${verdict.conclusion}\`. A \`neutral\` conclusion is how a WAIVED pull request is kept out of the shape a satisfied one prints — the waiver still blocks nothing, and it no longer looks like a review.`,
2721
- "",
2722
- result.waiveRate === undefined
2723
- ? "The waive rate over recently merged pull requests was NOT sampled on this run, so no rate is claimed here."
2724
- : `Recently merged pull requests sampled: ${result.waiveRate.sampled} — ${result.waiveRate.satisfied} reviewed, ${result.waiveRate.waived} waived, ${result.waiveRate.unsatisfied} unsatisfied.`,
2725
- ""
2726
- );
2727
- }
3030
+ /**
3031
+ * Appends and annotates an untrusted ruleset snapshot.
3032
+ *
3033
+ * @param {string[]} lines - Report lines
3034
+ * @param {object} result - Guard result
3035
+ * @param {object} policy - Active CLI policy
3036
+ * @returns {void}
3037
+ */
3038
+ function appendSnapshotRefusal(lines, result, policy) {
3039
+ if (result.trust.trusted) return;
3040
+ lines.push(
3041
+ "⛔ **NOT CHECKED** — this guard cannot say whether any skip silences a required status check, and will not pretend to.",
3042
+ "",
3043
+ result.trust.reason,
3044
+ "",
3045
+ "```",
3046
+ result.recipe,
3047
+ "```",
3048
+ ""
3049
+ );
3050
+ process.stderr.write(
3051
+ `::${policy.warnOnly ? "warning" : "error"} title=Skipped-required checks NOT CHECKED::${result.trust.reason.split("\n")[0]}\n`
3052
+ );
3053
+ }
2728
3054
 
2729
- // The refusal comes FIRST and replaces the verdict. Printing "✅ none
2730
- // silences a required check" from a snapshot nobody transcribed is the one
2731
- // outcome that is worse than never running: it is a confident wrong answer,
2732
- // and it teaches people to trust it (#2476).
3055
+ /**
3056
+ * Appends and annotates a vacuity inspection that could not run.
3057
+ *
3058
+ * @param {string[]} lines - Report lines
3059
+ * @param {{refusal: object|null, refusalBlocks: boolean}} outcome - CLI outcome
3060
+ * @returns {void}
3061
+ */
3062
+ function appendInspectionRefusal(lines, outcome) {
3063
+ if (outcome.refusal === null) return;
3064
+ lines.push(
3065
+ `⛔ **NOT INSPECTED** (\`${outcome.refusal.kind}\`) — the vacuity arm did not examine a single check, and will not report that nothing was vacuous.`,
3066
+ "",
3067
+ outcome.refusal.reason,
3068
+ ""
3069
+ );
3070
+ process.stderr.write(
3071
+ `::${outcome.refusalBlocks ? "error" : "warning"} title=${outcome.refusal.kind}::${outcome.refusal.reason.split("\n")[0]}\n`
3072
+ );
3073
+ }
3074
+
3075
+ /**
3076
+ * Appends the clean result, respecting a refused inspection.
3077
+ *
3078
+ * @param {string[]} lines - Report lines
3079
+ * @param {object} result - Guard result
3080
+ * @param {object|null} refusal - Vacuity refusal
3081
+ * @returns {void}
3082
+ */
3083
+ function appendCleanResult(lines, result, refusal) {
3084
+ if (refusal !== null) return;
2733
3085
  if (!result.trust.trusted) {
2734
3086
  lines.push(
2735
- `⛔ **NOT CHECKED** — this guard cannot say whether any skip silences a required status check, and will not pretend to.`,
2736
- "",
2737
- result.trust.reason,
2738
- "",
2739
- "```",
2740
- result.recipe,
2741
- "```",
2742
- ""
3087
+ `The rules that do NOT read \`required_contexts\` were still applied to ${result.checked} token(s) and found nothing.`
2743
3088
  );
3089
+ return;
3090
+ }
3091
+
3092
+ lines.push(
3093
+ `✅ ${result.checked} \`skip_jobs\` token(s) examined; none silences a ruleset-required status check.`
3094
+ );
3095
+ if (result.pr === undefined) return;
3096
+ const settleNote =
3097
+ result.vacuity?.settled === false
3098
+ ? " (One or more had not settled when the wait expired, so this is what was true at that moment.)"
3099
+ : "";
3100
+ lines.push(
3101
+ `✅ ${result.evidenceChecked} evidence-bearing check(s) examined on PR #${result.pr}; each proved it did work.${settleNote}`
3102
+ );
3103
+ }
3104
+
3105
+ /**
3106
+ * Appends annotations and prose for every finding.
3107
+ *
3108
+ * @param {string[]} lines - Report lines
3109
+ * @param {object} result - Guard result
3110
+ * @param {object} policy - Active CLI policy
3111
+ * @param {object[]} blocking - Blocking subset
3112
+ * @returns {void}
3113
+ */
3114
+ function appendFindingReport(lines, result, policy, blocking) {
3115
+ lines.push(
3116
+ `${blocking.length > 0 ? "❌" : "⚠️"} ${result.violations.length} violation(s) across ${result.checked} \`skip_jobs\` token(s):`,
3117
+ ""
3118
+ );
3119
+ for (const violation of result.violations) {
3120
+ lines.push(`- **${violation.kind}** — ${violation.message}`);
2744
3121
  process.stderr.write(
2745
- `::${warnOnly ? "warning" : "error"} title=Skipped-required checks NOT CHECKED::${result.trust.reason.split("\n")[0]}\n`
3122
+ `::${violationBlocks(violation, policy) ? "error" : "warning"} title=${violation.kind}::${violation.message.split("\n")[0]}\n`
2746
3123
  );
2747
3124
  }
3125
+ appendFindingContext(lines, result, policy);
3126
+ }
2748
3127
 
2749
- // Second refusal, same shape and the same reason as the first: an inspection
2750
- // that never happened must not print the sentence a clean one prints.
2751
- if (refusal !== null) {
3128
+ /**
3129
+ * Appends contextual guidance that applies to groups of findings.
3130
+ *
3131
+ * @param {string[]} lines - Report lines
3132
+ * @param {object} result - Guard result
3133
+ * @param {object} policy - Active CLI policy
3134
+ * @returns {void}
3135
+ */
3136
+ function appendFindingContext(lines, result, policy) {
3137
+ if (result.vacuity?.settled === false) {
2752
3138
  lines.push(
2753
- `⛔ **NOT INSPECTED** (\`${refusal.kind}\`) — the vacuity arm did not examine a single check, and will not report that nothing was vacuous.`,
2754
3139
  "",
2755
- refusal.reason,
2756
- ""
2757
- );
2758
- process.stderr.write(
2759
- `::${refusalBlocks ? "error" : "warning"} title=${refusal.kind}::${refusal.reason.split("\n")[0]}\n`
3140
+ "⏳ The settle wait EXPIRED before every declared review check reached a terminal state, so the readings above are where the wait stopped rather than where the reviewer finished. RE-RUN this job before treating any of it as a finding about the change."
2760
3141
  );
2761
3142
  }
3143
+ if (policy.warnOnly) appendWarnModeGuidance(lines, policy);
3144
+ if (hasVacuityFinding(result.violations)) {
3145
+ appendVacuityGuidance(lines, policy.failOnVacuous);
3146
+ }
3147
+ if (hasReviewWaiver(result.violations)) appendReviewWaiverGuidance(lines);
3148
+ }
2762
3149
 
2763
- if (refusal !== null && result.violations.length === 0) {
2764
- // NOT INSPECTED is the whole verdict for this arm. Neither a clean summary
2765
- // nor a vacuity-violation summary may contradict a result that examined
2766
- // nothing. Independent offline violations still render below: a refused
2767
- // evidence read cannot erase what the skip declaration already proved.
2768
- } else if (result.violations.length === 0) {
2769
- if (result.trust.trusted) {
2770
- lines.push(
2771
- `✅ ${result.checked} \`skip_jobs\` token(s) examined; none silences a ruleset-required status check.`,
2772
- ...(result.pr === undefined || refusal !== null
2773
- ? []
2774
- : [
2775
- `✅ ${result.evidenceChecked} evidence-bearing check(s) examined on PR #${result.pr}; each proved it did work.${
2776
- result.vacuity?.settled === false
2777
- ? " (One or more had not settled when the wait expired, so this is what was true at that moment.)"
2778
- : ""
2779
- }`,
2780
- ])
2781
- );
2782
- } else {
2783
- lines.push(
2784
- `The rules that do NOT read \`required_contexts\` were still applied to ${result.checked} token(s) and found nothing.`
2785
- );
2786
- }
3150
+ /** @param {ReadonlyArray<object>} violations @returns {boolean} */
3151
+ function hasVacuityFinding(violations) {
3152
+ return violations.some(violation =>
3153
+ [VIOLATIONS.vacuous, VIOLATIONS.unproven].includes(violation.kind)
3154
+ );
3155
+ }
3156
+
3157
+ /** @param {ReadonlyArray<object>} violations @returns {boolean} */
3158
+ function hasReviewWaiver(violations) {
3159
+ return violations.some(
3160
+ violation => violation.kind === VIOLATIONS.reviewWaived
3161
+ );
3162
+ }
3163
+
3164
+ /** @param {string[]} lines @param {object} policy @returns {void} */
3165
+ function appendWarnModeGuidance(lines, policy) {
3166
+ const reviewNote = policy.requireReviewEvidence
3167
+ ? ` This run also passed \`--require-review-evidence\`, so \`${VIOLATIONS.reviewUnsatisfied}\` blocks.`
3168
+ : "";
3169
+ const vacuityNote = policy.failOnVacuous
3170
+ ? ` This run also passed \`--fail-on-vacuous\`, so \`${VIOLATIONS.vacuous}\` and \`${VIOLATIONS.unproven}\` block.`
3171
+ : "";
3172
+ lines.push(
3173
+ "",
3174
+ `This declaration sets \`"enforcement": "warn"\`, so ordinary findings are report-only. A proven false green (\`${VIOLATIONS.suppressesRequired}\`) still blocks.${reviewNote}${vacuityNote} Review each finding, fix or declare it, then delete the \`enforcement\` key when the adoption ramp is complete.`
3175
+ );
3176
+ }
3177
+
3178
+ /** @param {string[]} lines @param {boolean} failOnVacuous @returns {void} */
3179
+ function appendVacuityGuidance(lines, failOnVacuous) {
3180
+ lines.push(
3181
+ "",
3182
+ failOnVacuous
3183
+ ? `\`${VIOLATIONS.vacuous}\` and \`${VIOLATIONS.unproven}\` are REPORT-ONLY by default; this run passed \`--fail-on-vacuous\`, which is the supported way to ask for an exit code once the governance call has been made. What they mean is unchanged: a PR carrying either finding has not been shown to be reviewed, so do not record it as reviewed.`
3184
+ : `\`${VIOLATIONS.vacuous}\` and \`${VIOLATIONS.unproven}\` are REPORT-ONLY in every enforcement mode — they never fail a build. A required check can go hollow because a vendor hit an org-wide spending cap, and reddening every PR on a billing state would be a worse gate than the one being criticised. What they change is what you may CLAIM: a PR carrying either finding has not been shown to be reviewed, so do not record it as reviewed. Pass \`--fail-on-vacuous\` to make them block.`
3185
+ );
3186
+ }
3187
+
3188
+ /** @param {string[]} lines @returns {void} */
3189
+ function appendReviewWaiverGuidance(lines) {
3190
+ lines.push(
3191
+ "",
3192
+ `\`${VIOLATIONS.reviewWaived}\` is REPORT-ONLY under every enforcement mode and command-line flag. It records that the named review could not run; it is neither evidence that a review completed nor a failure the pull-request author can fix.`
3193
+ );
3194
+ }
3195
+
3196
+ /**
3197
+ * Builds the complete human-readable report.
3198
+ *
3199
+ * @param {object} result - Guard result
3200
+ * @param {object} policy - Active CLI policy
3201
+ * @param {object} outcome - CLI outcome
3202
+ * @returns {string} Markdown report
3203
+ */
3204
+ function buildTextReport(result, policy, outcome) {
3205
+ const lines = ["## 🔒 Required checks that prove nothing", ""];
3206
+ appendVerdictReport(lines, result);
3207
+ appendSnapshotRefusal(lines, result, policy);
3208
+ appendInspectionRefusal(lines, outcome);
3209
+ if (result.violations.length === 0) {
3210
+ appendCleanResult(lines, result, outcome.refusal);
2787
3211
  } else {
2788
- lines.push(
2789
- `${blocking.length > 0 ? "❌" : "⚠️"} ${result.violations.length} violation(s) across ${result.checked} \`skip_jobs\` token(s):`,
2790
- ""
2791
- );
2792
- for (const violation of result.violations) {
2793
- lines.push(`- **${violation.kind}** — ${violation.message}`);
2794
- process.stderr.write(
2795
- `::${blocks(violation) ? "error" : "warning"} title=${violation.kind}::${violation.message.split("\n")[0]}\n`
2796
- );
2797
- }
2798
- // The clean branch above has always disclosed an expired wait. This branch
2799
- // — a RED run, the only one where the disclosure changes what an operator
2800
- // does next — did not, so the fact was published exactly where it was not
2801
- // needed and withheld where it was (#3716).
2802
- if (result.vacuity?.settled === false) {
2803
- lines.push(
2804
- "",
2805
- "⏳ The settle wait EXPIRED before every declared review check reached a terminal state, so the readings above are where the wait stopped rather than where the reviewer finished. RE-RUN this job before treating any of it as a finding about the change."
2806
- );
2807
- }
2808
- if (warnOnly) {
2809
- lines.push(
2810
- "",
2811
- `This declaration sets \`"enforcement": "warn"\`, so ordinary findings are report-only. A proven false green (\`${VIOLATIONS.suppressesRequired}\`) still blocks.${requireReviewEvidence ? ` This run also passed \`--require-review-evidence\`, so \`${VIOLATIONS.reviewUnsatisfied}\` blocks.` : ""}${failOnVacuous ? ` This run also passed \`--fail-on-vacuous\`, so \`${VIOLATIONS.vacuous}\` and \`${VIOLATIONS.unproven}\` block.` : ""} Review each finding, fix or declare it, then delete the \`enforcement\` key when the adoption ramp is complete.`
2812
- );
2813
- }
2814
- if (
2815
- result.violations.some(violation =>
2816
- [VIOLATIONS.vacuous, VIOLATIONS.unproven].includes(violation.kind)
2817
- )
2818
- ) {
2819
- lines.push(
2820
- "",
2821
- failOnVacuous
2822
- ? `\`${VIOLATIONS.vacuous}\` and \`${VIOLATIONS.unproven}\` are REPORT-ONLY by default; this run passed \`--fail-on-vacuous\`, which is the supported way to ask for an exit code once the governance call has been made. What they mean is unchanged: a PR carrying either finding has not been shown to be reviewed, so do not record it as reviewed.`
2823
- : `\`${VIOLATIONS.vacuous}\` and \`${VIOLATIONS.unproven}\` are REPORT-ONLY in every enforcement mode — they never fail a build. A required check can go hollow because a vendor hit an org-wide spending cap, and reddening every PR on a billing state would be a worse gate than the one being criticised. What they change is what you may CLAIM: a PR carrying either finding has not been shown to be reviewed, so do not record it as reviewed. Pass \`--fail-on-vacuous\` to make them block.`
2824
- );
2825
- }
2826
- if (
2827
- result.violations.some(
2828
- violation => violation.kind === VIOLATIONS.reviewWaived
2829
- )
2830
- ) {
2831
- lines.push(
2832
- "",
2833
- `\`${VIOLATIONS.reviewWaived}\` is REPORT-ONLY under every enforcement mode and command-line flag. It records that the named review could not run; it is neither evidence that a review completed nor a failure the pull-request author can fix.`
2834
- );
2835
- }
3212
+ appendFindingReport(lines, result, policy, outcome.blocking);
2836
3213
  }
2837
- const report = `${lines.join("\n")}\n`;
3214
+ return `${lines.join("\n")}\n`;
3215
+ }
3216
+
3217
+ /**
3218
+ * Writes the human report, best-effort step summary, and exit status.
3219
+ *
3220
+ * @param {object} result - Guard result
3221
+ * @param {object} policy - Active CLI policy
3222
+ * @param {object} outcome - CLI outcome
3223
+ * @returns {void}
3224
+ */
3225
+ function writeTextResult(result, policy, outcome) {
3226
+ const report = buildTextReport(result, policy, outcome);
2838
3227
  process.stdout.write(report);
2839
- if (process.env.GITHUB_STEP_SUMMARY) {
2840
- // Synchronous, and failure-tolerant, because this is reporting rather than
2841
- // verdict. The dynamic import returned a promise `main` did not await, so an
2842
- // unwritable path, a removed directory or a full disk produced an unhandled
2843
- // rejection — which Node exits non-zero on. A clean run then reported a CI
2844
- // failure while the printed report said the guard passed, inverting the exit
2845
- // code this file works hard to make meaningful. Losing a summary line is the
2846
- // acceptable failure here; losing the verdict is not.
2847
- try {
2848
- appendFileSync(process.env.GITHUB_STEP_SUMMARY, report);
2849
- } catch (error) {
2850
- console.error(
2851
- `[skipped-required-checks] step summary not written: ${error.message}`
2852
- );
2853
- }
3228
+ writeStepSummary(report);
3229
+ if (outcome.failed) process.exitCode = 1;
3230
+ }
3231
+
3232
+ /**
3233
+ * Appends the report to the Actions summary without changing the verdict.
3234
+ *
3235
+ * @param {string} report - Rendered report
3236
+ * @returns {void}
3237
+ */
3238
+ function writeStepSummary(report) {
3239
+ if (!process.env.GITHUB_STEP_SUMMARY) return;
3240
+ try {
3241
+ appendFileSync(process.env.GITHUB_STEP_SUMMARY, report);
3242
+ } catch (error) {
3243
+ console.error(
3244
+ `[skipped-required-checks] step summary not written: ${error.message}`
3245
+ );
2854
3246
  }
2855
- // Refusal is a failure, not a pass: an explicit run must never be mistaken
2856
- // for a clean bill of health. `warn` — which only Lisa's untranscribed seeds
2857
- // ship — downgrades it, because reddening a whole fleet the day a seed
2858
- // arrives is how a gate gets deleted instead of transcribed.
2859
- if (failed) process.exitCode = 1;
2860
3247
  }
2861
3248
 
2862
3249
  if (invokedAsScript(import.meta.url)) {