@codyswann/lisa 4.37.0 → 4.37.1

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 (57) hide show
  1. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  2. package/dist/core/lisa-owned-hash-ledger.js +2 -0
  3. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  4. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  5. package/dist/core/upstream-evidence-manifest.js +1 -1
  6. package/package.json +4 -4
  7. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  8. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  9. package/plugins/lisa-agy/plugin.json +1 -1
  10. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  11. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  12. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  13. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  14. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  15. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  16. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  17. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  18. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  19. package/plugins/lisa-expo-agy/plugin.json +1 -1
  20. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  21. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  22. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  23. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  24. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  25. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  26. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  27. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  28. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  29. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  30. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  31. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  32. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  33. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  34. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  35. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  36. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  37. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  38. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  39. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  40. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  44. package/plugins/lisa-rails-agy/plugin.json +1 -1
  45. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  49. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  50. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  54. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  55. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  57. package/typescript/copy-overwrite/scripts/check-skipped-required-checks.mjs +750 -365
@@ -715,6 +715,21 @@ export function loadDeclaration(rootDir) {
715
715
  );
716
716
  }
717
717
  const declaration = JSON.parse(readFileSync(path, "utf8"));
718
+ assertRequiredDeclarationKeys(declaration);
719
+ assertDeclarationCollections(declaration);
720
+ assertEnforcementMode(declaration);
721
+ assertSkipJobDeclarations(declaration.skip_job_declarations);
722
+ assertEvidenceBearingChecks(declaration.evidence_bearing_checks ?? {});
723
+ return declaration;
724
+ }
725
+
726
+ /**
727
+ * Refuses a declaration that omits one of the guard's load-bearing snapshots.
728
+ *
729
+ * @param {object} declaration - Parsed declaration
730
+ * @returns {void}
731
+ */
732
+ function assertRequiredDeclarationKeys(declaration) {
718
733
  for (const key of [
719
734
  "required_contexts",
720
735
  "workflows",
@@ -726,6 +741,15 @@ export function loadDeclaration(rootDir) {
726
741
  );
727
742
  }
728
743
  }
744
+ }
745
+
746
+ /**
747
+ * Validates the two top-level collections the evaluator consumes.
748
+ *
749
+ * @param {object} declaration - Parsed declaration
750
+ * @returns {void}
751
+ */
752
+ function assertDeclarationCollections(declaration) {
729
753
  if (!Array.isArray(declaration.required_contexts)) {
730
754
  throw new Error(
731
755
  `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 +763,15 @@ export function loadDeclaration(rootDir) {
739
763
  `check-skipped-required-checks: \`workflows\` must list at least one workflow file whose \`skip_jobs\` this guard reads.`
740
764
  );
741
765
  }
766
+ }
767
+
768
+ /**
769
+ * Validates the optional enforcement switch independently of declaration data.
770
+ *
771
+ * @param {object} declaration - Parsed declaration
772
+ * @returns {void}
773
+ */
774
+ function assertEnforcementMode(declaration) {
742
775
  if (
743
776
  declaration.enforcement !== undefined &&
744
777
  !ENFORCEMENT_MODES.includes(declaration.enforcement)
@@ -747,7 +780,41 @@ export function loadDeclaration(rootDir) {
747
780
  `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
781
  );
749
782
  }
783
+ }
750
784
 
785
+ /**
786
+ * Validates one skip-token declaration.
787
+ *
788
+ * @param {string} token - Declared skip token
789
+ * @param {unknown} entry - Declaration value
790
+ * @returns {void}
791
+ */
792
+ function assertSkipJobDeclaration(token, entry) {
793
+ if (
794
+ typeof entry !== "object" ||
795
+ entry === null ||
796
+ Array.isArray(entry) ||
797
+ !Array.isArray(entry.suppressed_contexts) ||
798
+ typeof entry.ruleset_required !== "boolean"
799
+ ) {
800
+ throw new Error(
801
+ `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.`
802
+ );
803
+ }
804
+ if (entry.suppressed_contexts.some(name => typeof name !== "string")) {
805
+ throw new Error(
806
+ `check-skipped-required-checks: \`${token}.suppressed_contexts\` must contain only context strings — they are compared byte for byte against \`required_contexts\`.`
807
+ );
808
+ }
809
+ }
810
+
811
+ /**
812
+ * Validates every skip-token declaration.
813
+ *
814
+ * @param {Record<string, unknown>} declarations - Token declarations
815
+ * @returns {void}
816
+ */
817
+ function assertSkipJobDeclarations(declarations) {
751
818
  // Validate the SHAPE of every declaration, not just its presence. A truthy
752
819
  // non-object entry (`"test:e2e": true`, or a string) reads as "declared" at
753
820
  // the point of use, then yields `suppressed_contexts: undefined` — no hits,
@@ -755,53 +822,78 @@ export function loadDeclaration(rootDir) {
755
822
  // someone was trying to document, which is the fail-open class this file
756
823
  // exists to refuse. A bad snapshot fails loudly HERE instead, where it is
757
824
  // 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
- }
825
+ for (const [token, entry] of Object.entries(declarations)) {
826
+ assertSkipJobDeclaration(token, entry);
827
+ }
828
+ }
829
+
830
+ /**
831
+ * Validates one optional description-vocabulary list.
832
+ *
833
+ * @param {string} checkName - Evidence-bearing check name
834
+ * @param {object} entry - Vocabulary declaration
835
+ * @param {string} list - Vocabulary list name
836
+ * @returns {void}
837
+ */
838
+ function assertEvidenceVocabularyList(checkName, entry, list) {
839
+ if (entry[list] === undefined) return;
840
+ if (
841
+ !Array.isArray(entry[list]) ||
842
+ entry[list].some(phrase => typeof phrase !== "string")
843
+ ) {
844
+ throw new Error(
845
+ `check-skipped-required-checks: \`evidence_bearing_checks.${checkName}.${list}\` must be an array of description strings.`
846
+ );
847
+ }
848
+ if (entry[list].some(phrase => normalizeDescription(phrase) === "")) {
849
+ throw new Error(
850
+ `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.`
851
+ );
777
852
  }
853
+ }
778
854
 
855
+ /**
856
+ * Refuses vocabulary that grants and denies credit for the same description.
857
+ *
858
+ * @param {string} checkName - Evidence-bearing check name
859
+ * @param {object} entry - Vocabulary declaration
860
+ * @returns {void}
861
+ */
862
+ function assertEvidenceVocabularyDisjoint(checkName, entry) {
863
+ const satisfactions = new Set(
864
+ [...REVIEW_SATISFACTIONS, ...(entry.satisfy ?? [])].map(
865
+ normalizeDescription
866
+ )
867
+ );
868
+ const waivers = new Set(
869
+ [...ENTITLEMENT_WAIVERS, ...(entry.waive ?? [])].map(normalizeDescription)
870
+ );
871
+ const overlap = [...satisfactions].filter(phrase => waivers.has(phrase));
872
+ if (overlap.length > 0) {
873
+ throw new Error(
874
+ `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".`
875
+ );
876
+ }
877
+ }
878
+
879
+ /**
880
+ * Validates every evidence-bearing check declaration.
881
+ *
882
+ * @param {Record<string, unknown>} checks - Evidence-bearing check declarations
883
+ * @returns {void}
884
+ */
885
+ function assertEvidenceBearingChecks(checks) {
779
886
  // Same reasoning for the vacuity declarations: a non-object entry would read
780
887
  // as "declared" and then yield an empty vocabulary, quietly examining the
781
888
  // check against defaults the author thought they had overridden.
782
- for (const [name, entry] of Object.entries(
783
- declaration.evidence_bearing_checks ?? {}
784
- )) {
889
+ for (const [name, entry] of Object.entries(checks)) {
785
890
  if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
786
891
  throw new Error(
787
892
  `check-skipped-required-checks: the declaration for \`${name}\` in \`evidence_bearing_checks\` must be an object — use \`{}\` to accept the shipped description vocabulary.`
788
893
  );
789
894
  }
790
895
  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
- }
896
+ assertEvidenceVocabularyList(name, entry, list);
805
897
  }
806
898
 
807
899
  // Resolve the shipped defaults before checking disjointness. A custom
@@ -809,22 +901,8 @@ export function loadDeclaration(rootDir) {
809
901
  // ambiguous as overlap between two custom lists. Satisfaction is checked
810
902
  // first at runtime, so accepting overlap would silently upgrade an explicit
811
903
  // 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
- }
904
+ assertEvidenceVocabularyDisjoint(name, entry);
826
905
  }
827
- return declaration;
828
906
  }
829
907
 
830
908
  /**
@@ -975,66 +1053,144 @@ export function evaluateSkippedRequiredChecks(
975
1053
  const violations = [];
976
1054
 
977
1055
  for (const token of skipped) {
978
- const entry = declarations[token];
979
- if (!entry) {
980
- violations.push({
1056
+ violations.push(
1057
+ ...evaluateSkippedToken(
1058
+ token,
1059
+ declarations[token],
1060
+ required,
1061
+ ticketPattern,
1062
+ trustRequired
1063
+ )
1064
+ );
1065
+ }
1066
+
1067
+ violations.push(...findOrphanedExemptions(declarations, skipped));
1068
+
1069
+ return { violations, checked: skipped.length };
1070
+ }
1071
+
1072
+ /**
1073
+ * Evaluates one token against its declaration and the trusted ruleset snapshot.
1074
+ *
1075
+ * @param {string} token - Skip token in a workflow
1076
+ * @param {object|undefined} entry - Its declaration
1077
+ * @param {ReadonlySet<string>} required - Required-context snapshot
1078
+ * @param {RegExp} ticketPattern - Accepted exemption ticket pattern
1079
+ * @param {boolean} trustRequired - Whether the snapshot is trusted
1080
+ * @returns {object[]} Violations for this token
1081
+ */
1082
+ function evaluateSkippedToken(
1083
+ token,
1084
+ entry,
1085
+ required,
1086
+ ticketPattern,
1087
+ trustRequired
1088
+ ) {
1089
+ if (!entry) {
1090
+ return [
1091
+ {
981
1092
  kind: VIOLATIONS.undeclared,
982
1093
  token,
983
1094
  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));
1095
+ },
1096
+ ];
1097
+ }
1098
+ if (!trustRequired) return [];
990
1099
 
991
- if (hits.length > 0 && entry.ruleset_required !== true) {
992
- violations.push({
1100
+ const suppressed = entry.suppressed_contexts ?? [];
1101
+ const hits = suppressed.filter(context => required.has(context));
1102
+ return [
1103
+ ...evaluateRequiredSnapshotCoherence(token, entry, suppressed, hits),
1104
+ ...evaluateRequiredSuppression(token, entry, hits, ticketPattern),
1105
+ ];
1106
+ }
1107
+
1108
+ /**
1109
+ * Checks whether one token's declaration agrees with the required snapshot.
1110
+ *
1111
+ * @param {string} token - Skip token
1112
+ * @param {object} entry - Token declaration
1113
+ * @param {ReadonlyArray<string>} suppressed - Contexts the token suppresses
1114
+ * @param {ReadonlyArray<string>} hits - Suppressed contexts that are required
1115
+ * @returns {object[]} Snapshot-coherence violations
1116
+ */
1117
+ function evaluateRequiredSnapshotCoherence(token, entry, suppressed, hits) {
1118
+ if (hits.length > 0 && entry.ruleset_required !== true) {
1119
+ return [
1120
+ {
993
1121
  kind: VIOLATIONS.incoherent,
994
1122
  token,
995
1123
  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({
1124
+ },
1125
+ ];
1126
+ }
1127
+ if (hits.length === 0 && entry.ruleset_required === true) {
1128
+ return [
1129
+ {
1000
1130
  kind: VIOLATIONS.stale,
1001
1131
  token,
1002
1132
  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
- }
1133
+ },
1134
+ ];
1025
1135
  }
1136
+ return [];
1137
+ }
1026
1138
 
1027
- for (const [token, entry] of Object.entries(declarations)) {
1028
- if (entry.exemption && !skipped.includes(token)) {
1029
- violations.push({
1030
- kind: VIOLATIONS.orphaned,
1139
+ /**
1140
+ * Checks whether a real required-context suppression has an owned exemption.
1141
+ *
1142
+ * @param {string} token - Skip token
1143
+ * @param {object} entry - Token declaration
1144
+ * @param {ReadonlyArray<string>} hits - Required contexts suppressed
1145
+ * @param {RegExp} ticketPattern - Accepted exemption ticket pattern
1146
+ * @returns {object[]} Suppression or exemption violations
1147
+ */
1148
+ function evaluateRequiredSuppression(token, entry, hits, ticketPattern) {
1149
+ if (hits.length === 0) return [];
1150
+ const exemption = entry.exemption;
1151
+ if (!exemption) {
1152
+ return [
1153
+ {
1154
+ kind: VIOLATIONS.suppressesRequired,
1031
1155
  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
- }
1156
+ contexts: hits,
1157
+ 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.`,
1158
+ },
1159
+ ];
1035
1160
  }
1161
+ if (
1162
+ typeof exemption.ticket !== "string" ||
1163
+ !ticketPattern.test(exemption.ticket)
1164
+ ) {
1165
+ return [
1166
+ {
1167
+ kind: VIOLATIONS.badExemption,
1168
+ token,
1169
+ 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.`,
1170
+ },
1171
+ ];
1172
+ }
1173
+ return [];
1174
+ }
1036
1175
 
1037
- return { violations, checked: skipped.length };
1176
+ /**
1177
+ * Finds exemptions whose tokens are no longer skipped.
1178
+ *
1179
+ * @param {Record<string, object>} declarations - All token declarations
1180
+ * @param {ReadonlyArray<string>} skipped - Tokens currently skipped
1181
+ * @returns {object[]} Orphaned-exemption violations
1182
+ */
1183
+ function findOrphanedExemptions(declarations, skipped) {
1184
+ const violations = [];
1185
+ for (const [token, entry] of Object.entries(declarations)) {
1186
+ if (!entry.exemption || skipped.includes(token)) continue;
1187
+ violations.push({
1188
+ kind: VIOLATIONS.orphaned,
1189
+ token,
1190
+ message: `\`${token}\` carries an exemption but is no longer skipped anywhere. Delete the exemption — leaving it teaches readers that the exemption list is fiction.`,
1191
+ });
1192
+ }
1193
+ return violations;
1038
1194
  }
1039
1195
 
1040
1196
  /**
@@ -1120,53 +1276,83 @@ export function evaluateVacuousChecks(declaration, checks, options = {}) {
1120
1276
  const at = citeHeadSha(options.headSha);
1121
1277
  const required = new Set(declaration.required_contexts ?? []);
1122
1278
  const violations = [];
1123
- let checked = 0;
1279
+ const entries = Object.entries(declared);
1124
1280
 
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);
1281
+ for (const [name, entry] of entries) {
1282
+ const violation = evaluateEvidenceBearingCheck(name, entry, checks, {
1283
+ at,
1284
+ required,
1285
+ trustRequired,
1286
+ });
1287
+ if (violation !== null) violations.push(violation);
1288
+ }
1129
1289
 
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
- }
1290
+ return { violations, checked: entries.length };
1291
+ }
1138
1292
 
1139
- const state = String(found.state ?? "").toUpperCase();
1140
- if (state === "FAILURE" || state === "ERROR") continue;
1293
+ /**
1294
+ * Explains whether the named evidence check is part of the trusted ruleset.
1295
+ *
1296
+ * @param {string} name - Check context name
1297
+ * @param {boolean} trustRequired - Whether the ruleset snapshot is trusted
1298
+ * @param {ReadonlySet<string>} required - Required-context snapshot
1299
+ * @returns {string} Sentence appended to a finding
1300
+ */
1301
+ function evidenceRequiredNote(name, trustRequired, required) {
1302
+ if (!trustRequired) {
1303
+ 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.";
1304
+ }
1305
+ return required.has(name)
1306
+ ? " This context IS ruleset-required, so branch protection recorded a satisfied review gate for a review that did not happen."
1307
+ : " This context is not in `required_contexts`, so no merge gate was falsified — but nothing reviewed this either.";
1308
+ }
1141
1309
 
1142
- const verdict = classifyCheckDescription(found.description, vocabulary);
1143
- if (verdict === DESCRIPTION_VERDICTS.proved && state === "SUCCESS") {
1144
- continue;
1145
- }
1310
+ /**
1311
+ * Evaluates one declared evidence-bearing check.
1312
+ *
1313
+ * @param {string} name - Declared check name
1314
+ * @param {object} entry - Per-check description vocabulary
1315
+ * @param {ReadonlyArray<object>} checks - Reported checks
1316
+ * @param {{at: string, required: ReadonlySet<string>, trustRequired: boolean}} context - Shared evaluation context
1317
+ * @returns {object|null} A finding, or null when this check proved work or is red
1318
+ */
1319
+ function evaluateEvidenceBearingCheck(name, entry, checks, context) {
1320
+ const vocabulary = typeof entry === "object" && entry !== null ? entry : {};
1321
+ const found = checks.find(check => check.name === name);
1322
+ if (found === undefined) {
1323
+ return {
1324
+ kind: VIOLATIONS.unproven,
1325
+ token: name,
1326
+ 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.)`,
1327
+ };
1328
+ }
1146
1329
 
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.";
1330
+ const state = String(found.state ?? "").toUpperCase();
1331
+ if (state === "FAILURE" || state === "ERROR") return null;
1152
1332
 
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
- );
1333
+ const verdict = classifyCheckDescription(found.description, vocabulary);
1334
+ if (verdict === DESCRIPTION_VERDICTS.proved && state === "SUCCESS") {
1335
+ return null;
1167
1336
  }
1168
1337
 
1169
- return { violations, checked };
1338
+ const requiredNote = evidenceRequiredNote(
1339
+ name,
1340
+ context.trustRequired,
1341
+ context.required
1342
+ );
1343
+ if (verdict === DESCRIPTION_VERDICTS.noWork && state === "SUCCESS") {
1344
+ return {
1345
+ kind: VIOLATIONS.vacuous,
1346
+ token: name,
1347
+ contexts: [name],
1348
+ 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.`,
1349
+ };
1350
+ }
1351
+ return {
1352
+ kind: VIOLATIONS.unproven,
1353
+ token: name,
1354
+ 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.`,
1355
+ };
1170
1356
  }
1171
1357
 
1172
1358
  /**
@@ -1349,46 +1535,100 @@ function normalizeDescription(description) {
1349
1535
  * @returns {{state: string, condition: string, why: string}} Severity, the condition observed, and a one-line reason
1350
1536
  */
1351
1537
  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
- }
1538
+ if (!reading.present) return unreportedReviewGateState(reading.waitExpired);
1539
+
1368
1540
  const state = String(reading.state ?? "").toUpperCase();
1369
1541
  const text = normalizeDescription(reading.description);
1370
1542
  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
- };
1543
+ return objectedReviewGateState(state, text, reading.description);
1376
1544
  }
1377
1545
  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
- };
1546
+ return pendingReviewGateState(state, reading.waitExpired);
1547
+ }
1548
+ return successfulReviewGateState(text, reading.description, vocabulary);
1549
+ }
1550
+
1551
+ /**
1552
+ * Builds the common verdict for a settle window that expired inconclusively.
1553
+ *
1554
+ * @param {string} seen - What the final poll observed
1555
+ * @returns {{state: string, condition: string, why: string}} Review gate verdict
1556
+ */
1557
+ function undeterminedReviewGateState(seen) {
1558
+ return {
1559
+ state: REVIEW_GATE_STATES.unsatisfied,
1560
+ condition: REVIEW_GATE_CONDITIONS.undetermined,
1561
+ 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.`,
1562
+ };
1563
+ }
1564
+
1565
+ /**
1566
+ * Classifies a check that did not report at all.
1567
+ *
1568
+ * @param {boolean|undefined} waitExpired - Whether the settle window expired
1569
+ * @returns {{state: string, condition: string, why: string}} Review gate verdict
1570
+ */
1571
+ function unreportedReviewGateState(waitExpired) {
1572
+ if (waitExpired === true) {
1573
+ return undeterminedReviewGateState("still unreported");
1385
1574
  }
1575
+ return {
1576
+ state: REVIEW_GATE_STATES.unsatisfied,
1577
+ condition: REVIEW_GATE_CONDITIONS.absent,
1578
+ 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.",
1579
+ };
1580
+ }
1581
+
1582
+ /**
1583
+ * Classifies a review check that ran and objected.
1584
+ *
1585
+ * @param {string} state - Normalized failure state
1586
+ * @param {string} text - Normalized description
1587
+ * @param {string|undefined} description - Original description
1588
+ * @returns {{state: string, condition: string, why: string}} Review gate verdict
1589
+ */
1590
+ function objectedReviewGateState(state, text, description) {
1591
+ return {
1592
+ state: REVIEW_GATE_STATES.unsatisfied,
1593
+ condition: REVIEW_GATE_CONDITIONS.objected,
1594
+ 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.`,
1595
+ };
1596
+ }
1597
+
1598
+ /**
1599
+ * Classifies a check that has not reached success or failure.
1600
+ *
1601
+ * @param {string} state - Normalized in-flight state
1602
+ * @param {boolean|undefined} waitExpired - Whether the settle window expired
1603
+ * @returns {{state: string, condition: string, why: string}} Review gate verdict
1604
+ */
1605
+ function pendingReviewGateState(state, waitExpired) {
1606
+ const observed = state === "" ? "unreported" : state;
1607
+ if (waitExpired === true) {
1608
+ return undeterminedReviewGateState(`still ${observed}`);
1609
+ }
1610
+ return {
1611
+ state: REVIEW_GATE_STATES.unsatisfied,
1612
+ condition: REVIEW_GATE_CONDITIONS.pending,
1613
+ 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.`,
1614
+ };
1615
+ }
1616
+
1617
+ /**
1618
+ * Classifies a successful check from its exact description vocabulary.
1619
+ *
1620
+ * @param {string} text - Normalized description
1621
+ * @param {string|undefined} description - Original description
1622
+ * @param {{waive?: readonly string[], satisfy?: readonly string[]}} vocabulary - Per-check extensions
1623
+ * @returns {{state: string, condition: string, why: string}} Review gate verdict
1624
+ */
1625
+ function successfulReviewGateState(text, description, vocabulary) {
1386
1626
  const satisfies = [...REVIEW_SATISFACTIONS, ...(vocabulary.satisfy ?? [])];
1387
1627
  if (satisfies.some(phrase => text === normalizeDescription(phrase))) {
1388
1628
  return {
1389
1629
  state: REVIEW_GATE_STATES.satisfied,
1390
1630
  condition: REVIEW_GATE_CONDITIONS.satisfied,
1391
- why: `reported ${JSON.stringify(reading.description ?? "")}, which is a review that ran.`,
1631
+ why: `reported ${JSON.stringify(description ?? "")}, which is a review that ran.`,
1392
1632
  };
1393
1633
  }
1394
1634
  const waivers = [...ENTITLEMENT_WAIVERS, ...(vocabulary.waive ?? [])];
@@ -1396,13 +1636,13 @@ export function reviewGateState(reading, vocabulary = {}) {
1396
1636
  return {
1397
1637
  state: REVIEW_GATE_STATES.waived,
1398
1638
  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.`,
1639
+ 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
1640
  };
1401
1641
  }
1402
1642
  return {
1403
1643
  state: REVIEW_GATE_STATES.unsatisfied,
1404
1644
  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.`,
1645
+ 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
1646
  };
1407
1647
  }
1408
1648
 
@@ -2628,235 +2868,380 @@ export function runGuard(argv, options = {}) {
2628
2868
  * @returns {void}
2629
2869
  */
2630
2870
  function main(argv) {
2631
- /** @type {{violations: object[], checked: number, tokens: string[], enforcement: string, trust: {trusted: boolean, reason: string}, recipe: string}} */
2632
- let result;
2871
+ const attempt = attemptGuardRun(argv);
2872
+ if (attempt.error !== undefined) {
2873
+ writeGuardError(argv, attempt.error);
2874
+ return;
2875
+ }
2876
+
2877
+ const result = attempt.result;
2878
+ const policy = cliPolicy(argv, result);
2879
+ const outcome = cliOutcome(result, policy);
2880
+
2881
+ // Published BEFORE the report and before the exit code, because it is the only
2882
+ // artefact of this run that a merge decision reads.
2883
+ if (result.verdict !== undefined) writeVerdictOutputs(result.verdict);
2884
+
2885
+ if (argv.includes("--json")) {
2886
+ writeJsonResult(result, outcome.failed);
2887
+ return;
2888
+ }
2889
+ writeTextResult(result, policy, outcome);
2890
+ }
2891
+
2892
+ /**
2893
+ * Runs the guard without making error presentation part of the entry point.
2894
+ *
2895
+ * @param {ReadonlyArray<string>} argv - Arguments
2896
+ * @returns {{result?: object, error?: unknown}} One guard result or error
2897
+ */
2898
+ function attemptGuardRun(argv) {
2633
2899
  try {
2634
- result = runGuard(argv);
2900
+ return { result: runGuard(argv) };
2635
2901
  } 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;
2902
+ return { error };
2649
2903
  }
2904
+ }
2650
2905
 
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);
2906
+ /**
2907
+ * Prints an exception in the selected output format.
2908
+ *
2909
+ * @param {ReadonlyArray<string>} argv - Arguments
2910
+ * @param {unknown} error - Guard exception
2911
+ * @returns {void}
2912
+ */
2913
+ function writeGuardError(argv, error) {
2914
+ const message = error instanceof Error ? error.message : String(error);
2915
+ if (argv.includes("--json")) {
2916
+ process.stdout.write(
2917
+ `${JSON.stringify({ ok: false, error: message }, null, 2)}\n`
2918
+ );
2919
+ } else {
2920
+ process.stderr.write(
2921
+ `::error title=Skipped-required-check guard::${message}\n`
2922
+ );
2923
+ process.stdout.write(`❌ ${message}\n`);
2924
+ }
2925
+ process.exitCode = 1;
2926
+ }
2927
+
2928
+ /**
2929
+ * Reads the CLI switches that change which findings block.
2930
+ *
2931
+ * @param {ReadonlyArray<string>} argv - Arguments
2932
+ * @param {object} result - Guard result
2933
+ * @returns {{warnOnly: boolean, failOnVacuous: boolean, requireReviewEvidence: boolean}} Active policy
2934
+ */
2935
+ function cliPolicy(argv, result) {
2936
+ return {
2937
+ warnOnly: result.enforcement === "warn",
2938
+ // The supported alternative to a per-consumer `--json` wrapper.
2939
+ failOnVacuous: argv.includes("--fail-on-vacuous"),
2940
+ // Deliberately distinct from `--fail-on-vacuous`; entitlement waivers do
2941
+ // not become failures through a shared switch.
2942
+ requireReviewEvidence: argv.includes(REQUIRE_REVIEW_EVIDENCE_FLAG),
2678
2943
  };
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);
2944
+ }
2945
+
2946
+ /**
2947
+ * True when one violation blocks under the active CLI policy.
2948
+ *
2949
+ * @param {{kind: string}} violation - One violation
2950
+ * @param {{warnOnly: boolean, failOnVacuous: boolean, requireReviewEvidence: boolean}} policy - Active policy
2951
+ * @returns {boolean} True when the finding blocks
2952
+ */
2953
+ function violationBlocks(violation, policy) {
2954
+ if (REVIEW_GATE_BLOCKING.includes(violation.kind)) {
2955
+ return policy.requireReviewEvidence;
2956
+ }
2957
+ if (violation.kind === VIOLATIONS.reviewWaived) return false;
2958
+ if (NEVER_BLOCKING.includes(violation.kind)) return policy.failOnVacuous;
2959
+ return !policy.warnOnly || ALWAYS_BLOCKING.includes(violation.kind);
2960
+ }
2961
+
2962
+ /**
2963
+ * Computes the blocking set and refusal state once for both renderers.
2964
+ *
2965
+ * @param {object} result - Guard result
2966
+ * @param {object} policy - Active CLI policy
2967
+ * @returns {{blocking: object[], refusal: object|null, refusalBlocks: boolean, failed: boolean}} CLI outcome
2968
+ */
2969
+ function cliOutcome(result, policy) {
2970
+ const blocking = result.violations.filter(violation =>
2971
+ violationBlocks(violation, policy)
2972
+ );
2685
2973
  const refusal = result.vacuity?.refusal ?? null;
2686
2974
  const refusalBlocks =
2687
- refusal !== null && (!warnOnly || requireReviewEvidence);
2975
+ refusal !== null && (!policy.warnOnly || policy.requireReviewEvidence);
2688
2976
  const failed =
2689
2977
  blocking.length > 0 ||
2690
- (!result.trust.trusted && !warnOnly) ||
2978
+ (!result.trust.trusted && !policy.warnOnly) ||
2691
2979
  refusalBlocks;
2980
+ return { blocking, refusal, refusalBlocks, failed };
2981
+ }
2692
2982
 
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
- }
2983
+ /**
2984
+ * Writes the machine-readable report.
2985
+ *
2986
+ * @param {object} result - Guard result
2987
+ * @param {boolean} failed - Whether the run blocks
2988
+ * @returns {void}
2989
+ */
2990
+ function writeJsonResult(result, failed) {
2991
+ process.stdout.write(
2992
+ `${JSON.stringify(
2993
+ {
2994
+ ok: !failed,
2995
+ answered: result.trust.trusted,
2996
+ inspected:
2997
+ result.vacuity !== undefined && result.vacuity.refusal === null,
2998
+ ...result,
2999
+ },
3000
+ null,
3001
+ 2
3002
+ )}\n`
3003
+ );
3004
+ if (failed) process.exitCode = 1;
3005
+ }
2710
3006
 
2711
- const lines = ["## 🔒 Required checks that prove nothing", ""];
3007
+ /**
3008
+ * Appends the published review verdict to the human report.
3009
+ *
3010
+ * @param {string[]} lines - Report lines
3011
+ * @param {object} result - Guard result
3012
+ * @returns {void}
3013
+ */
3014
+ function appendVerdictReport(lines, result) {
3015
+ if (result.verdict === undefined) return;
3016
+ lines.push(
3017
+ `**${result.verdict.title}**`,
3018
+ "",
3019
+ `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.`,
3020
+ "",
3021
+ result.waiveRate === undefined
3022
+ ? "The waive rate over recently merged pull requests was NOT sampled on this run, so no rate is claimed here."
3023
+ : `Recently merged pull requests sampled: ${result.waiveRate.sampled} — ${result.waiveRate.satisfied} reviewed, ${result.waiveRate.waived} waived, ${result.waiveRate.unsatisfied} unsatisfied.`,
3024
+ ""
3025
+ );
3026
+ }
2712
3027
 
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
- }
3028
+ /**
3029
+ * Appends and annotates an untrusted ruleset snapshot.
3030
+ *
3031
+ * @param {string[]} lines - Report lines
3032
+ * @param {object} result - Guard result
3033
+ * @param {object} policy - Active CLI policy
3034
+ * @returns {void}
3035
+ */
3036
+ function appendSnapshotRefusal(lines, result, policy) {
3037
+ if (result.trust.trusted) return;
3038
+ lines.push(
3039
+ "⛔ **NOT CHECKED** — this guard cannot say whether any skip silences a required status check, and will not pretend to.",
3040
+ "",
3041
+ result.trust.reason,
3042
+ "",
3043
+ "```",
3044
+ result.recipe,
3045
+ "```",
3046
+ ""
3047
+ );
3048
+ process.stderr.write(
3049
+ `::${policy.warnOnly ? "warning" : "error"} title=Skipped-required checks NOT CHECKED::${result.trust.reason.split("\n")[0]}\n`
3050
+ );
3051
+ }
2728
3052
 
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).
3053
+ /**
3054
+ * Appends and annotates a vacuity inspection that could not run.
3055
+ *
3056
+ * @param {string[]} lines - Report lines
3057
+ * @param {{refusal: object|null, refusalBlocks: boolean}} outcome - CLI outcome
3058
+ * @returns {void}
3059
+ */
3060
+ function appendInspectionRefusal(lines, outcome) {
3061
+ if (outcome.refusal === null) return;
3062
+ lines.push(
3063
+ `⛔ **NOT INSPECTED** (\`${outcome.refusal.kind}\`) — the vacuity arm did not examine a single check, and will not report that nothing was vacuous.`,
3064
+ "",
3065
+ outcome.refusal.reason,
3066
+ ""
3067
+ );
3068
+ process.stderr.write(
3069
+ `::${outcome.refusalBlocks ? "error" : "warning"} title=${outcome.refusal.kind}::${outcome.refusal.reason.split("\n")[0]}\n`
3070
+ );
3071
+ }
3072
+
3073
+ /**
3074
+ * Appends the clean result, respecting a refused inspection.
3075
+ *
3076
+ * @param {string[]} lines - Report lines
3077
+ * @param {object} result - Guard result
3078
+ * @param {object|null} refusal - Vacuity refusal
3079
+ * @returns {void}
3080
+ */
3081
+ function appendCleanResult(lines, result, refusal) {
3082
+ if (refusal !== null) return;
2733
3083
  if (!result.trust.trusted) {
2734
3084
  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
- ""
3085
+ `The rules that do NOT read \`required_contexts\` were still applied to ${result.checked} token(s) and found nothing.`
2743
3086
  );
3087
+ return;
3088
+ }
3089
+
3090
+ lines.push(
3091
+ `✅ ${result.checked} \`skip_jobs\` token(s) examined; none silences a ruleset-required status check.`
3092
+ );
3093
+ if (result.pr === undefined) return;
3094
+ const settleNote =
3095
+ result.vacuity?.settled === false
3096
+ ? " (One or more had not settled when the wait expired, so this is what was true at that moment.)"
3097
+ : "";
3098
+ lines.push(
3099
+ `✅ ${result.evidenceChecked} evidence-bearing check(s) examined on PR #${result.pr}; each proved it did work.${settleNote}`
3100
+ );
3101
+ }
3102
+
3103
+ /**
3104
+ * Appends annotations and prose for every finding.
3105
+ *
3106
+ * @param {string[]} lines - Report lines
3107
+ * @param {object} result - Guard result
3108
+ * @param {object} policy - Active CLI policy
3109
+ * @param {object[]} blocking - Blocking subset
3110
+ * @returns {void}
3111
+ */
3112
+ function appendFindingReport(lines, result, policy, blocking) {
3113
+ lines.push(
3114
+ `${blocking.length > 0 ? "❌" : "⚠️"} ${result.violations.length} violation(s) across ${result.checked} \`skip_jobs\` token(s):`,
3115
+ ""
3116
+ );
3117
+ for (const violation of result.violations) {
3118
+ lines.push(`- **${violation.kind}** — ${violation.message}`);
2744
3119
  process.stderr.write(
2745
- `::${warnOnly ? "warning" : "error"} title=Skipped-required checks NOT CHECKED::${result.trust.reason.split("\n")[0]}\n`
3120
+ `::${violationBlocks(violation, policy) ? "error" : "warning"} title=${violation.kind}::${violation.message.split("\n")[0]}\n`
2746
3121
  );
2747
3122
  }
3123
+ appendFindingContext(lines, result, policy);
3124
+ }
2748
3125
 
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) {
3126
+ /**
3127
+ * Appends contextual guidance that applies to groups of findings.
3128
+ *
3129
+ * @param {string[]} lines - Report lines
3130
+ * @param {object} result - Guard result
3131
+ * @param {object} policy - Active CLI policy
3132
+ * @returns {void}
3133
+ */
3134
+ function appendFindingContext(lines, result, policy) {
3135
+ if (result.vacuity?.settled === false) {
2752
3136
  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
3137
  "",
2755
- refusal.reason,
2756
- ""
2757
- );
2758
- process.stderr.write(
2759
- `::${refusalBlocks ? "error" : "warning"} title=${refusal.kind}::${refusal.reason.split("\n")[0]}\n`
3138
+ "⏳ 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
3139
  );
2761
3140
  }
3141
+ if (policy.warnOnly) appendWarnModeGuidance(lines, policy);
3142
+ if (hasVacuityFinding(result.violations)) {
3143
+ appendVacuityGuidance(lines, policy.failOnVacuous);
3144
+ }
3145
+ if (hasReviewWaiver(result.violations)) appendReviewWaiverGuidance(lines);
3146
+ }
2762
3147
 
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
- }
3148
+ /** @param {ReadonlyArray<object>} violations @returns {boolean} */
3149
+ function hasVacuityFinding(violations) {
3150
+ return violations.some(violation =>
3151
+ [VIOLATIONS.vacuous, VIOLATIONS.unproven].includes(violation.kind)
3152
+ );
3153
+ }
3154
+
3155
+ /** @param {ReadonlyArray<object>} violations @returns {boolean} */
3156
+ function hasReviewWaiver(violations) {
3157
+ return violations.some(
3158
+ violation => violation.kind === VIOLATIONS.reviewWaived
3159
+ );
3160
+ }
3161
+
3162
+ /** @param {string[]} lines @param {object} policy @returns {void} */
3163
+ function appendWarnModeGuidance(lines, policy) {
3164
+ const reviewNote = policy.requireReviewEvidence
3165
+ ? ` This run also passed \`--require-review-evidence\`, so \`${VIOLATIONS.reviewUnsatisfied}\` blocks.`
3166
+ : "";
3167
+ const vacuityNote = policy.failOnVacuous
3168
+ ? ` This run also passed \`--fail-on-vacuous\`, so \`${VIOLATIONS.vacuous}\` and \`${VIOLATIONS.unproven}\` block.`
3169
+ : "";
3170
+ lines.push(
3171
+ "",
3172
+ `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.`
3173
+ );
3174
+ }
3175
+
3176
+ /** @param {string[]} lines @param {boolean} failOnVacuous @returns {void} */
3177
+ function appendVacuityGuidance(lines, failOnVacuous) {
3178
+ lines.push(
3179
+ "",
3180
+ failOnVacuous
3181
+ ? `\`${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.`
3182
+ : `\`${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.`
3183
+ );
3184
+ }
3185
+
3186
+ /** @param {string[]} lines @returns {void} */
3187
+ function appendReviewWaiverGuidance(lines) {
3188
+ lines.push(
3189
+ "",
3190
+ `\`${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.`
3191
+ );
3192
+ }
3193
+
3194
+ /**
3195
+ * Builds the complete human-readable report.
3196
+ *
3197
+ * @param {object} result - Guard result
3198
+ * @param {object} policy - Active CLI policy
3199
+ * @param {object} outcome - CLI outcome
3200
+ * @returns {string} Markdown report
3201
+ */
3202
+ function buildTextReport(result, policy, outcome) {
3203
+ const lines = ["## 🔒 Required checks that prove nothing", ""];
3204
+ appendVerdictReport(lines, result);
3205
+ appendSnapshotRefusal(lines, result, policy);
3206
+ appendInspectionRefusal(lines, outcome);
3207
+ if (result.violations.length === 0) {
3208
+ appendCleanResult(lines, result, outcome.refusal);
2787
3209
  } 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
- }
3210
+ appendFindingReport(lines, result, policy, outcome.blocking);
2836
3211
  }
2837
- const report = `${lines.join("\n")}\n`;
3212
+ return `${lines.join("\n")}\n`;
3213
+ }
3214
+
3215
+ /**
3216
+ * Writes the human report, best-effort step summary, and exit status.
3217
+ *
3218
+ * @param {object} result - Guard result
3219
+ * @param {object} policy - Active CLI policy
3220
+ * @param {object} outcome - CLI outcome
3221
+ * @returns {void}
3222
+ */
3223
+ function writeTextResult(result, policy, outcome) {
3224
+ const report = buildTextReport(result, policy, outcome);
2838
3225
  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
- }
3226
+ writeStepSummary(report);
3227
+ if (outcome.failed) process.exitCode = 1;
3228
+ }
3229
+
3230
+ /**
3231
+ * Appends the report to the Actions summary without changing the verdict.
3232
+ *
3233
+ * @param {string} report - Rendered report
3234
+ * @returns {void}
3235
+ */
3236
+ function writeStepSummary(report) {
3237
+ if (!process.env.GITHUB_STEP_SUMMARY) return;
3238
+ try {
3239
+ appendFileSync(process.env.GITHUB_STEP_SUMMARY, report);
3240
+ } catch (error) {
3241
+ console.error(
3242
+ `[skipped-required-checks] step summary not written: ${error.message}`
3243
+ );
2854
3244
  }
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
3245
  }
2861
3246
 
2862
3247
  if (invokedAsScript(import.meta.url)) {