@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.
- package/all/copy-overwrite/scripts/check-third-party-action-pins.mjs +6 -2
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +12 -0
- package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
- package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
- package/dist/core/upstream-evidence-manifest.js +3 -3
- package/package.json +4 -4
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/scripts/check-third-party-action-pins.mjs +6 -2
- 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
|
|
567
|
-
|
|
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
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
979
|
-
|
|
980
|
-
|
|
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
|
-
|
|
986
|
-
|
|
987
|
-
|
|
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
|
-
|
|
992
|
-
|
|
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
|
-
|
|
999
|
-
|
|
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
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1281
|
+
const entries = Object.entries(declared);
|
|
1124
1282
|
|
|
1125
|
-
for (const [name, entry] of
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
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
|
-
|
|
1131
|
-
|
|
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
|
-
|
|
1140
|
-
|
|
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
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
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
|
-
|
|
1148
|
-
|
|
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
|
-
|
|
1154
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1353
|
-
|
|
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
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
2632
|
-
|
|
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
|
|
2902
|
+
return { result: runGuard(argv) };
|
|
2635
2903
|
} catch (error) {
|
|
2636
|
-
|
|
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
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
|
|
2666
|
-
|
|
2667
|
-
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
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
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
|
|
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
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
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
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
|
|
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
|
-
|
|
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
|
-
`::${
|
|
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
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
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)) {
|