@codyswann/lisa 2.348.0 → 2.349.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.
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +35 -10
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/expo/copy-overwrite/scripts/bdd/baseline.mjs +46 -7
- package/expo/copy-overwrite/scripts/bdd/parse.mjs +22 -2
- package/expo/copy-overwrite/scripts/bdd/render.mjs +34 -7
- package/expo/copy-overwrite/scripts/bdd/report.mjs +118 -18
- package/expo/copy-overwrite/scripts/bdd/validate.mjs +95 -22
- package/expo/copy-overwrite/scripts/bdd-matrix.mjs +5 -32
- package/expo/copy-overwrite/scripts/check-bdd-coverage.mjs +29 -3
- package/expo/create-only/.github/required-checks.json +52 -0
- package/expo/create-only/.github/workflows/nightly-e2e-bypass-reaper.yml +83 -0
- package/expo/create-only/.github/workflows/nightly-e2e-health.yml +140 -0
- package/expo/github-rulesets/{playwright.json → nightly-e2e-health.json} +3 -8
- package/package.json +1 -1
- 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/typescript/copy-overwrite/scripts/check-nightly-e2e-health.mjs +1516 -0
- package/typescript/copy-overwrite/scripts/check-skipped-required-checks.mjs +580 -0
- package/typescript/copy-overwrite/scripts/nightly-e2e-suites.schema.json +85 -0
- package/typescript/create-only/.github/required-checks.json +28 -0
- package/typescript/package-lisa/package.lisa.json +4 -1
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* check-skipped-required-checks — refuse a `skip_jobs` token that silences a
|
|
4
|
+
* ruleset-required status check.
|
|
5
|
+
*
|
|
6
|
+
* Shipped by Lisa (copy-overwrite). Generalized from tunnl's TUN-402 guard: the
|
|
7
|
+
* logic is Lisa's and gets updated fleet-wide, the two REVIEWED SNAPSHOTS it
|
|
8
|
+
* rests on are per-repo and live in `.github/required-checks.json` (create-only,
|
|
9
|
+
* yours to edit).
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* node scripts/check-skipped-required-checks.mjs [rootDir] [--remote] [--json]
|
|
13
|
+
*
|
|
14
|
+
* ## Why this exists
|
|
15
|
+
*
|
|
16
|
+
* **GitHub counts a `skipped` required status check as SATISFIED.** A job named
|
|
17
|
+
* in a reusable workflow's `skip_jobs` input still reports a green checkmark
|
|
18
|
+
* against its required context, having run zero steps in zero seconds. The merge
|
|
19
|
+
* gate is then decorative: it can never be red, so it can never block anything.
|
|
20
|
+
* A repository in that state looks fully gated while shipping code past a check
|
|
21
|
+
* nobody ever ran.
|
|
22
|
+
*
|
|
23
|
+
* This is measured, not theoretical, in at least two repositories in this
|
|
24
|
+
* portfolio — tunnl (`🔍 Quality Checks / 🧪 Run E2E Tests`, TUN-402) and gemini
|
|
25
|
+
* (ruleset 14297996 requiring `🔍 Quality Checks / 🎭 Playwright E2E Tests`,
|
|
26
|
+
* which `ci.yml` skipped unconditionally, so the ruleset enforced nothing).
|
|
27
|
+
*
|
|
28
|
+
* ## Why this is a DECLARATION guard, not a derivation guard
|
|
29
|
+
*
|
|
30
|
+
* The `skip_jobs` token → context-name map is not derivable inside an adopting
|
|
31
|
+
* repository: the callee workflow is not vendored there, the mapping is not a
|
|
32
|
+
* mechanical transform (one token can silence TWO jobs), and the required-context
|
|
33
|
+
* list exists in NO repo file — it lives in the GitHub ruleset, which humans edit
|
|
34
|
+
* in an admin console.
|
|
35
|
+
*
|
|
36
|
+
* So the guard commits two reviewed snapshots — `required_contexts` and
|
|
37
|
+
* `skip_job_declarations` — and makes them POLICE EACH OTHER. Neither is
|
|
38
|
+
* authoritative alone; the coherence rules below are what stop either rotting
|
|
39
|
+
* into decoration.
|
|
40
|
+
*
|
|
41
|
+
* That mutual policing has one blind spot, and `--remote` exists for it: two
|
|
42
|
+
* snapshots in one repo can only catch each other drifting from the CODE.
|
|
43
|
+
* Neither can see the ruleset itself change in the admin console — which is
|
|
44
|
+
* exactly how tunnl's list silently went from ten contexts to eleven, with every
|
|
45
|
+
* test still green, because the "independent" transcription was made from the
|
|
46
|
+
* same reading at the same moment.
|
|
47
|
+
*
|
|
48
|
+
* `--remote` is opt-in so the ENFORCED path stays offline. A guard that needed
|
|
49
|
+
* network and `gh` auth on every run would flake, and a flaky guard gets
|
|
50
|
+
* skipped — which reintroduces exactly the false-green class this file refuses.
|
|
51
|
+
*
|
|
52
|
+
* ## Exact string equality, everywhere
|
|
53
|
+
*
|
|
54
|
+
* Every comparison here is `===`. Repos routinely carry confusable pairs — an
|
|
55
|
+
* external app's required `SonarCloud Code Analysis` beside a skippable,
|
|
56
|
+
* NOT-required in-workflow `🔍 SonarCloud SAST`; `🧪 Run Tests` beside
|
|
57
|
+
* `🧪 Run Unit Tests`. A `includes` / `startsWith` / case-folded match would
|
|
58
|
+
* report a false positive on a legitimate skip, and the natural fix for a false
|
|
59
|
+
* alarm is to delete the guard.
|
|
60
|
+
*
|
|
61
|
+
* @module scripts/check-skipped-required-checks
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
import { execFileSync } from "node:child_process";
|
|
65
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
66
|
+
import { resolve } from "node:path";
|
|
67
|
+
import { pathToFileURL } from "node:url";
|
|
68
|
+
|
|
69
|
+
/** Repo-relative path of the per-repo declaration file. */
|
|
70
|
+
export const DECLARATION_PATH = ".github/required-checks.json";
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Matches a `skip_jobs:` key and captures everything after the colon.
|
|
74
|
+
*
|
|
75
|
+
* The `^` anchor is load-bearing, not decoration. Without it the naive form
|
|
76
|
+
* (`/skip_jobs\s*:\s*(.*)$/`) matches a key sitting inside a YAML COMMENT — and
|
|
77
|
+
* workflow headers routinely quote `skip_jobs` in prose, so the unanchored
|
|
78
|
+
* version really does harvest tokens out of documentation.
|
|
79
|
+
*
|
|
80
|
+
* `[ \t]` rather than `\s`: this matches one already-split LINE, so `\s`'s
|
|
81
|
+
* newline class buys nothing and three adjacent `\s*` runs give a backtracking
|
|
82
|
+
* engine work to do for no gain.
|
|
83
|
+
*/
|
|
84
|
+
const SKIP_JOBS_LINE = /^[ \t]*skip_jobs[ \t]*:[ \t]*(.*)$/;
|
|
85
|
+
|
|
86
|
+
/** Matches the tail permitted after a quoted scalar's closing quote. */
|
|
87
|
+
const TRAILING_COMMENT_ONLY = /^\s*(?:#.*)?$/;
|
|
88
|
+
|
|
89
|
+
/** Matches a value that is a GitHub Actions expression, optionally quoted. */
|
|
90
|
+
const GITHUB_EXPRESSION = /^(['"]?)\$\{\{([\s\S]*)\}\}\1\s*(?:#.*)?$/;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Matches a comparison and its operand — `== 'schedule'`, `!= 'main'`.
|
|
94
|
+
*
|
|
95
|
+
* Deleted from an expression BEFORE literals are read. Without this a
|
|
96
|
+
* condition's own operand reads as a value, and `schedule` / `main` get reported
|
|
97
|
+
* as skip tokens that silence nothing.
|
|
98
|
+
*/
|
|
99
|
+
const COMPARISON_OPERAND = /[=!]=[ \t]*'[^']*'/g;
|
|
100
|
+
|
|
101
|
+
/** Matches a single-quoted literal in value position. */
|
|
102
|
+
const QUOTED_LITERAL = /'([^']*)'/g;
|
|
103
|
+
|
|
104
|
+
/** Violation kinds, as stable tokens the tests assert on. */
|
|
105
|
+
export const VIOLATIONS = Object.freeze({
|
|
106
|
+
undeclared: "undeclared_skip_token",
|
|
107
|
+
suppressesRequired: "skipped_required_check",
|
|
108
|
+
incoherent: "declaration_understates_requirement",
|
|
109
|
+
stale: "declaration_overstates_requirement",
|
|
110
|
+
orphaned: "orphaned_exemption",
|
|
111
|
+
badExemption: "exemption_without_valid_ticket",
|
|
112
|
+
remoteDrift: "ruleset_snapshot_drift",
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* True when a line is a whole-line YAML comment.
|
|
117
|
+
*
|
|
118
|
+
* Redundant with `SKIP_JOBS_LINE`'s anchor today, and kept anyway: the anchor is
|
|
119
|
+
* one character a refactor can delete without noticing, and comment exclusion is
|
|
120
|
+
* the property that actually matters.
|
|
121
|
+
*
|
|
122
|
+
* @param {string} line - A single line
|
|
123
|
+
* @returns {boolean} True when the whole line is a comment
|
|
124
|
+
*/
|
|
125
|
+
function isCommentLine(line) {
|
|
126
|
+
return line.trimStart().startsWith("#");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* True when text after the colon is not an inline scalar this reader decodes.
|
|
131
|
+
*
|
|
132
|
+
* @param {string} rawValue - Trimmed text after the colon
|
|
133
|
+
* @returns {boolean} True when the value cannot be read inline
|
|
134
|
+
*/
|
|
135
|
+
function isUnreadableScalar(rawValue) {
|
|
136
|
+
return (
|
|
137
|
+
rawValue === "" ||
|
|
138
|
+
rawValue.startsWith(">") ||
|
|
139
|
+
rawValue.startsWith("|") ||
|
|
140
|
+
rawValue.startsWith("#")
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Strips a surrounding matched quote pair and a trailing ` #` comment.
|
|
146
|
+
*
|
|
147
|
+
* Inside a quoted scalar a `#` is DATA, so the closing quote is located as the
|
|
148
|
+
* first quote whose remainder is empty or a comment. That keeps `'a#b'` intact
|
|
149
|
+
* while still reading `'a,b' # note` — a naive scan truncates the former, and an
|
|
150
|
+
* `endsWith(quote)` test rejects the latter outright and falls through to the
|
|
151
|
+
* bare branch, emitting `'a` and `b'` as two bogus tokens.
|
|
152
|
+
*
|
|
153
|
+
* @param {string} rawValue - Text after the colon, already trimmed
|
|
154
|
+
* @returns {string} The scalar's value, unquoted and un-commented
|
|
155
|
+
*/
|
|
156
|
+
export function unquoteScalar(rawValue) {
|
|
157
|
+
const quote = rawValue.slice(0, 1);
|
|
158
|
+
if (quote === "'" || quote === '"') {
|
|
159
|
+
for (let at = rawValue.indexOf(quote, 1); at > 0; ) {
|
|
160
|
+
if (TRAILING_COMMENT_ONLY.test(rawValue.slice(at + 1))) {
|
|
161
|
+
return rawValue.slice(1, at);
|
|
162
|
+
}
|
|
163
|
+
at = rawValue.indexOf(quote, at + 1);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const commentAt = rawValue.indexOf(" #");
|
|
167
|
+
return commentAt === -1 ? rawValue : rawValue.slice(0, commentAt).trim();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Splits a comma list into unique, non-empty tokens.
|
|
172
|
+
*
|
|
173
|
+
* @param {string} commaList - Tokens joined by commas
|
|
174
|
+
* @returns {string[]} Unique tokens in first-seen order
|
|
175
|
+
*/
|
|
176
|
+
function tokenize(commaList) {
|
|
177
|
+
const out = [];
|
|
178
|
+
for (const token of commaList.split(",").map(part => part.trim())) {
|
|
179
|
+
if (token !== "" && !out.includes(token)) out.push(token);
|
|
180
|
+
}
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Reads every `skip_jobs` token declared in one workflow file.
|
|
186
|
+
*
|
|
187
|
+
* A `${{ … }}` expression is read CONSERVATIVELY: comparison operands are
|
|
188
|
+
* deleted, every remaining single-quoted literal is tokenized, and all of it is
|
|
189
|
+
* treated as reachable. A conditional that skips e2e only on the nightly
|
|
190
|
+
* schedule therefore still demands a declaration. That over-reports rather than
|
|
191
|
+
* under-reports on purpose — an under-reporting guard is the failure mode this
|
|
192
|
+
* file exists to prevent, and the cost of over-reporting is one line in
|
|
193
|
+
* `skip_job_declarations` explaining why the skip is fine.
|
|
194
|
+
*
|
|
195
|
+
* @param {string} contents - Full text of a workflow file
|
|
196
|
+
* @param {string} sourcePath - Repo-relative path, used only to name the file in
|
|
197
|
+
* a throw. Never an absolute path.
|
|
198
|
+
* @returns {string[]} Every declared skip token, de-duplicated
|
|
199
|
+
* @throws {Error} When a `skip_jobs` key is not an inline scalar
|
|
200
|
+
*/
|
|
201
|
+
export function readSkipJobs(contents, sourcePath) {
|
|
202
|
+
const all = [];
|
|
203
|
+
contents.split("\n").forEach((line, index) => {
|
|
204
|
+
if (isCommentLine(line)) return;
|
|
205
|
+
const match = SKIP_JOBS_LINE.exec(line);
|
|
206
|
+
if (match === null) return;
|
|
207
|
+
|
|
208
|
+
const rawValue = (match[1] ?? "").trim();
|
|
209
|
+
if (isUnreadableScalar(rawValue)) {
|
|
210
|
+
throw new Error(
|
|
211
|
+
`check-skipped-required-checks: cannot read the \`skip_jobs\` value on line ${index + 1} of ${sourcePath} as an inline scalar. Only the inline form (\`skip_jobs: 'a,b'\`, double-quoted, or bare) is understood — a block scalar, a sequence, or a bare key would otherwise read as "nothing is skipped", which is a silent pass rather than a check.`
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const expression = GITHUB_EXPRESSION.exec(rawValue);
|
|
216
|
+
const source =
|
|
217
|
+
expression === null
|
|
218
|
+
? unquoteScalar(rawValue)
|
|
219
|
+
: [
|
|
220
|
+
...expression[2]
|
|
221
|
+
.replace(COMPARISON_OPERAND, " ")
|
|
222
|
+
.matchAll(QUOTED_LITERAL),
|
|
223
|
+
]
|
|
224
|
+
.map(found => found[1])
|
|
225
|
+
.join(",");
|
|
226
|
+
|
|
227
|
+
for (const token of tokenize(source)) {
|
|
228
|
+
if (!all.includes(token)) all.push(token);
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
return all;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Loads and validates the per-repo declaration.
|
|
236
|
+
*
|
|
237
|
+
* @param {string} rootDir - Repository root
|
|
238
|
+
* @returns {object} The parsed declaration
|
|
239
|
+
* @throws {Error} When the file is absent or structurally unusable
|
|
240
|
+
*/
|
|
241
|
+
export function loadDeclaration(rootDir) {
|
|
242
|
+
const path = resolve(rootDir, DECLARATION_PATH);
|
|
243
|
+
if (!existsSync(path)) {
|
|
244
|
+
throw new Error(
|
|
245
|
+
`check-skipped-required-checks: ${DECLARATION_PATH} does not exist. This guard rests on two REVIEWED SNAPSHOTS that cannot be derived from the repository — the ruleset's required contexts, and what each \`skip_jobs\` token silences. Create it (Lisa ships a seed) rather than deleting the guard.`
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
const declaration = JSON.parse(readFileSync(path, "utf8"));
|
|
249
|
+
for (const key of [
|
|
250
|
+
"required_contexts",
|
|
251
|
+
"workflows",
|
|
252
|
+
"skip_job_declarations",
|
|
253
|
+
]) {
|
|
254
|
+
if (declaration[key] === undefined) {
|
|
255
|
+
throw new Error(
|
|
256
|
+
`check-skipped-required-checks: ${DECLARATION_PATH} is missing \`${key}\`.`
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
if (!Array.isArray(declaration.required_contexts)) {
|
|
261
|
+
throw new Error(
|
|
262
|
+
`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).`
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
if (
|
|
266
|
+
!Array.isArray(declaration.workflows) ||
|
|
267
|
+
declaration.workflows.length === 0
|
|
268
|
+
) {
|
|
269
|
+
throw new Error(
|
|
270
|
+
`check-skipped-required-checks: \`workflows\` must list at least one workflow file whose \`skip_jobs\` this guard reads.`
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Validate the SHAPE of every declaration, not just its presence. A truthy
|
|
275
|
+
// non-object entry (`"test:e2e": true`, or a string) reads as "declared" at
|
|
276
|
+
// the point of use, then yields `suppressed_contexts: undefined` — no hits,
|
|
277
|
+
// no violation. The guard silently becomes a no-op for exactly the token
|
|
278
|
+
// someone was trying to document, which is the fail-open class this file
|
|
279
|
+
// exists to refuse. A bad snapshot fails loudly HERE instead, where it is
|
|
280
|
+
// unambiguously a configuration error rather than a clean bill of health.
|
|
281
|
+
for (const [token, entry] of Object.entries(
|
|
282
|
+
declaration.skip_job_declarations
|
|
283
|
+
)) {
|
|
284
|
+
if (
|
|
285
|
+
typeof entry !== "object" ||
|
|
286
|
+
entry === null ||
|
|
287
|
+
Array.isArray(entry) ||
|
|
288
|
+
!Array.isArray(entry.suppressed_contexts) ||
|
|
289
|
+
typeof entry.ruleset_required !== "boolean"
|
|
290
|
+
) {
|
|
291
|
+
throw new Error(
|
|
292
|
+
`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.`
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
if (entry.suppressed_contexts.some(name => typeof name !== "string")) {
|
|
296
|
+
throw new Error(
|
|
297
|
+
`check-skipped-required-checks: \`${token}.suppressed_contexts\` must contain only context strings — they are compared byte for byte against \`required_contexts\`.`
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return declaration;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Reads every skip token across every declared workflow.
|
|
306
|
+
*
|
|
307
|
+
* A declared workflow that does not exist is an ERROR, not an empty read: a
|
|
308
|
+
* guard that silently reads nothing reports a clean bill of health for a
|
|
309
|
+
* repository it never looked at.
|
|
310
|
+
*
|
|
311
|
+
* @param {string} rootDir - Repository root
|
|
312
|
+
* @param {ReadonlyArray<string>} workflows - Repo-relative workflow paths
|
|
313
|
+
* @returns {{tokens: string[], sources: Record<string, string[]>}} Tokens and where each came from
|
|
314
|
+
*/
|
|
315
|
+
export function collectSkipJobTokens(rootDir, workflows) {
|
|
316
|
+
const tokens = [];
|
|
317
|
+
/** @type {Record<string, string[]>} */
|
|
318
|
+
const sources = {};
|
|
319
|
+
for (const relative of workflows) {
|
|
320
|
+
const path = resolve(rootDir, relative);
|
|
321
|
+
if (!existsSync(path)) {
|
|
322
|
+
throw new Error(
|
|
323
|
+
`check-skipped-required-checks: \`workflows\` names ${relative}, which does not exist. A guard that reads nothing reports a clean bill of health for a repository it never looked at.`
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
for (const token of readSkipJobs(readFileSync(path, "utf8"), relative)) {
|
|
327
|
+
if (!tokens.includes(token)) tokens.push(token);
|
|
328
|
+
sources[token] = [...(sources[token] ?? []), relative];
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return { tokens, sources };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* The whole verdict, as a pure function of the two snapshots and what the
|
|
336
|
+
* workflows actually declare.
|
|
337
|
+
*
|
|
338
|
+
* The coherence rules are what stop either snapshot rotting into decoration:
|
|
339
|
+
*
|
|
340
|
+
* 1. Every token a workflow skips must be DECLARED. An undeclared skip is a
|
|
341
|
+
* skip nobody reviewed.
|
|
342
|
+
* 2. A declaration whose `suppressed_contexts` intersect `required_contexts`
|
|
343
|
+
* but whose `ruleset_required` says `false` is INCOHERENT — the declaration
|
|
344
|
+
* is out of date with the ruleset snapshot beside it.
|
|
345
|
+
* 3. A declaration claiming `ruleset_required: true` whose contexts intersect
|
|
346
|
+
* nothing is STALE — the context was de-required or renamed, and the
|
|
347
|
+
* declaration is now describing a world that no longer exists.
|
|
348
|
+
* 4. A token that is ACTUALLY SKIPPED and suppresses a required context is the
|
|
349
|
+
* false green this guard exists to refuse. It fails unless it carries an
|
|
350
|
+
* exemption naming a real tracker ticket — an exemption is a decision
|
|
351
|
+
* someone owns, not a way to silence the check.
|
|
352
|
+
* 5. An exemption for a token nobody skips any more is ORPHANED. Deleting it is
|
|
353
|
+
* one line, and leaving it teaches readers the exemption list is fiction.
|
|
354
|
+
*
|
|
355
|
+
* @param {object} declaration - The per-repo declaration
|
|
356
|
+
* @param {ReadonlyArray<string>} skipped - Tokens the workflows actually skip
|
|
357
|
+
* @returns {{violations: object[], checked: number}} Violations and how many tokens were examined
|
|
358
|
+
*/
|
|
359
|
+
export function evaluateSkippedRequiredChecks(declaration, skipped) {
|
|
360
|
+
const required = new Set(declaration.required_contexts);
|
|
361
|
+
const declarations = declaration.skip_job_declarations ?? {};
|
|
362
|
+
const ticketPattern = new RegExp(
|
|
363
|
+
declaration.exemption_ticket_pattern ?? "^[A-Z][A-Z0-9]+-\\d+$"
|
|
364
|
+
);
|
|
365
|
+
const violations = [];
|
|
366
|
+
|
|
367
|
+
for (const token of skipped) {
|
|
368
|
+
const entry = declarations[token];
|
|
369
|
+
if (!entry) {
|
|
370
|
+
violations.push({
|
|
371
|
+
kind: VIOLATIONS.undeclared,
|
|
372
|
+
token,
|
|
373
|
+
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.`,
|
|
374
|
+
});
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
const suppressed = entry.suppressed_contexts ?? [];
|
|
378
|
+
const hits = suppressed.filter(context => required.has(context));
|
|
379
|
+
|
|
380
|
+
if (hits.length > 0 && entry.ruleset_required !== true) {
|
|
381
|
+
violations.push({
|
|
382
|
+
kind: VIOLATIONS.incoherent,
|
|
383
|
+
token,
|
|
384
|
+
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.`,
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
if (hits.length === 0 && entry.ruleset_required === true) {
|
|
388
|
+
violations.push({
|
|
389
|
+
kind: VIOLATIONS.stale,
|
|
390
|
+
token,
|
|
391
|
+
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.`,
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
if (hits.length > 0) {
|
|
395
|
+
const exemption = entry.exemption;
|
|
396
|
+
if (!exemption) {
|
|
397
|
+
violations.push({
|
|
398
|
+
kind: VIOLATIONS.suppressesRequired,
|
|
399
|
+
token,
|
|
400
|
+
contexts: hits,
|
|
401
|
+
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.`,
|
|
402
|
+
});
|
|
403
|
+
} else if (
|
|
404
|
+
typeof exemption.ticket !== "string" ||
|
|
405
|
+
!ticketPattern.test(exemption.ticket)
|
|
406
|
+
) {
|
|
407
|
+
violations.push({
|
|
408
|
+
kind: VIOLATIONS.badExemption,
|
|
409
|
+
token,
|
|
410
|
+
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.`,
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
for (const [token, entry] of Object.entries(declarations)) {
|
|
417
|
+
if (entry.exemption && !skipped.includes(token)) {
|
|
418
|
+
violations.push({
|
|
419
|
+
kind: VIOLATIONS.orphaned,
|
|
420
|
+
token,
|
|
421
|
+
message: `\`${token}\` carries an exemption but is no longer skipped anywhere. Delete the exemption — leaving it teaches readers that the exemption list is fiction.`,
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
return { violations, checked: skipped.length };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Fetches the live required contexts for every declared ruleset.
|
|
431
|
+
*
|
|
432
|
+
* @param {object} ruleset - `{ repo, ids }` from the declaration
|
|
433
|
+
* @returns {string[]} Live contexts across all declared rulesets
|
|
434
|
+
* @throws {Error} When `gh` is unavailable or the API cannot be read
|
|
435
|
+
*/
|
|
436
|
+
export function fetchLiveRequiredContexts(ruleset) {
|
|
437
|
+
if (
|
|
438
|
+
!ruleset?.repo ||
|
|
439
|
+
!Array.isArray(ruleset.ids) ||
|
|
440
|
+
ruleset.ids.length === 0
|
|
441
|
+
) {
|
|
442
|
+
throw new Error(
|
|
443
|
+
`check-skipped-required-checks: --remote needs \`ruleset.repo\` and \`ruleset.ids\` in ${DECLARATION_PATH}.`
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
const contexts = [];
|
|
447
|
+
for (const id of ruleset.ids) {
|
|
448
|
+
const raw = execFileSync(
|
|
449
|
+
"gh",
|
|
450
|
+
[
|
|
451
|
+
"api",
|
|
452
|
+
`repos/${ruleset.repo}/rulesets/${id}`,
|
|
453
|
+
"--jq",
|
|
454
|
+
'.rules[] | select(.type=="required_status_checks") | .parameters.required_status_checks[].context',
|
|
455
|
+
],
|
|
456
|
+
{ encoding: "utf8" }
|
|
457
|
+
);
|
|
458
|
+
for (const line of raw.split("\n").map(value => value.trim())) {
|
|
459
|
+
if (line !== "" && !contexts.includes(line)) contexts.push(line);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
return contexts;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Diffs the committed snapshot against the live ruleset, in BOTH directions.
|
|
467
|
+
*
|
|
468
|
+
* Both directions matter. A context added in the admin console makes the
|
|
469
|
+
* snapshot UNDER-detect (tunnl's ten-to-eleven drift, unnoticed for a day with
|
|
470
|
+
* every test green). A context removed there makes it OVER-detect, and the
|
|
471
|
+
* obvious fix for a false alarm is to weaken the guard.
|
|
472
|
+
*
|
|
473
|
+
* @param {ReadonlyArray<string>} snapshot - Committed contexts
|
|
474
|
+
* @param {ReadonlyArray<string>} live - Contexts from the API
|
|
475
|
+
* @returns {object[]} Drift violations
|
|
476
|
+
*/
|
|
477
|
+
export function compareRulesetBaseline(snapshot, live) {
|
|
478
|
+
const added = live.filter(context => !snapshot.includes(context));
|
|
479
|
+
const removed = snapshot.filter(context => !live.includes(context));
|
|
480
|
+
if (added.length === 0 && removed.length === 0) return [];
|
|
481
|
+
return [
|
|
482
|
+
{
|
|
483
|
+
kind: VIOLATIONS.remoteDrift,
|
|
484
|
+
token: null,
|
|
485
|
+
message: `\`required_contexts\` has drifted from the live ruleset.${added.length ? `\n Live but not committed (the snapshot UNDER-detects): ${added.map(name => `"${name}"`).join(", ")}` : ""}${removed.length ? `\n Committed but not live (the snapshot OVER-detects): ${removed.map(name => `"${name}"`).join(", ")}` : ""}\n Update the snapshot and re-read what it now implies about the skip declarations.`,
|
|
486
|
+
},
|
|
487
|
+
];
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Runs the guard.
|
|
492
|
+
*
|
|
493
|
+
* @param {ReadonlyArray<string>} argv - CLI arguments
|
|
494
|
+
* @returns {{violations: object[], checked: number, tokens: string[]}} The result
|
|
495
|
+
*/
|
|
496
|
+
export function runGuard(argv) {
|
|
497
|
+
const positional = argv.filter(arg => !arg.startsWith("--"));
|
|
498
|
+
const rootDir = positional[0] ?? process.cwd();
|
|
499
|
+
const declaration = loadDeclaration(rootDir);
|
|
500
|
+
const { tokens } = collectSkipJobTokens(rootDir, declaration.workflows);
|
|
501
|
+
const result = evaluateSkippedRequiredChecks(declaration, tokens);
|
|
502
|
+
const violations = [...result.violations];
|
|
503
|
+
if (argv.includes("--remote")) {
|
|
504
|
+
violations.push(
|
|
505
|
+
...compareRulesetBaseline(
|
|
506
|
+
declaration.required_contexts,
|
|
507
|
+
fetchLiveRequiredContexts(declaration.ruleset)
|
|
508
|
+
)
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
return { violations, checked: result.checked, tokens };
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* CLI entry point.
|
|
516
|
+
*
|
|
517
|
+
* @param {ReadonlyArray<string>} argv - Arguments
|
|
518
|
+
* @returns {void}
|
|
519
|
+
*/
|
|
520
|
+
function main(argv) {
|
|
521
|
+
/** @type {{violations: object[], checked: number, tokens: string[]}} */
|
|
522
|
+
let result;
|
|
523
|
+
try {
|
|
524
|
+
result = runGuard(argv);
|
|
525
|
+
} catch (error) {
|
|
526
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
527
|
+
if (argv.includes("--json")) {
|
|
528
|
+
process.stdout.write(
|
|
529
|
+
`${JSON.stringify({ ok: false, error: message }, null, 2)}\n`
|
|
530
|
+
);
|
|
531
|
+
} else {
|
|
532
|
+
process.stderr.write(
|
|
533
|
+
`::error title=Skipped-required-check guard::${message}\n`
|
|
534
|
+
);
|
|
535
|
+
process.stdout.write(`❌ ${message}\n`);
|
|
536
|
+
}
|
|
537
|
+
process.exitCode = 1;
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
if (argv.includes("--json")) {
|
|
542
|
+
process.stdout.write(
|
|
543
|
+
`${JSON.stringify({ ok: result.violations.length === 0, ...result }, null, 2)}\n`
|
|
544
|
+
);
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const lines = ["## 🔒 Skipped required checks", ""];
|
|
549
|
+
if (result.violations.length === 0) {
|
|
550
|
+
lines.push(
|
|
551
|
+
`✅ ${result.checked} \`skip_jobs\` token(s) examined; none silences a ruleset-required status check.`
|
|
552
|
+
);
|
|
553
|
+
} else {
|
|
554
|
+
lines.push(
|
|
555
|
+
`❌ ${result.violations.length} violation(s) across ${result.checked} \`skip_jobs\` token(s):`,
|
|
556
|
+
""
|
|
557
|
+
);
|
|
558
|
+
for (const violation of result.violations) {
|
|
559
|
+
lines.push(`- **${violation.kind}** — ${violation.message}`);
|
|
560
|
+
process.stderr.write(
|
|
561
|
+
`::error title=${violation.kind}::${violation.message.split("\n")[0]}\n`
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
const report = `${lines.join("\n")}\n`;
|
|
566
|
+
process.stdout.write(report);
|
|
567
|
+
if (process.env.GITHUB_STEP_SUMMARY) {
|
|
568
|
+
import("node:fs").then(({ appendFileSync }) => {
|
|
569
|
+
appendFileSync(process.env.GITHUB_STEP_SUMMARY, report);
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
if (result.violations.length > 0) process.exitCode = 1;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
if (
|
|
576
|
+
process.argv[1] &&
|
|
577
|
+
import.meta.url === pathToFileURL(process.argv[1]).href
|
|
578
|
+
) {
|
|
579
|
+
main(process.argv.slice(2));
|
|
580
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/CodySwannGT/lisa/typescript/copy-overwrite/scripts/nightly-e2e-suites.schema.json",
|
|
4
|
+
"title": "Nightly E2E Health — suites table",
|
|
5
|
+
"description": "The structured `suites` input of the nightly-e2e-health reusable workflow. Passed as a JSON string because workflow_call inputs cannot be objects. Validated by check-nightly-e2e-health.mjs BEFORE any API call, so a malformed table fails the gate rather than silently gating less than its author believes. Deliberately NOT a delimiter-joined mini-language: a `;`-separated table cannot express optional per-suite freshness, cannot distinguish an exact job name from a pattern, and cannot reject a duplicate.",
|
|
6
|
+
"type": "array",
|
|
7
|
+
"minItems": 1,
|
|
8
|
+
"items": { "$ref": "#/$defs/suite" },
|
|
9
|
+
"$defs": {
|
|
10
|
+
"suite": {
|
|
11
|
+
"type": "object",
|
|
12
|
+
"additionalProperties": false,
|
|
13
|
+
"required": ["label", "workflow", "match"],
|
|
14
|
+
"properties": {
|
|
15
|
+
"label": {
|
|
16
|
+
"type": "string",
|
|
17
|
+
"minLength": 1,
|
|
18
|
+
"description": "Human-readable name for this suite, used in the report. Must be unique across the table — two suites sharing a label produce one report line for two verdicts."
|
|
19
|
+
},
|
|
20
|
+
"workflow": {
|
|
21
|
+
"type": "string",
|
|
22
|
+
"minLength": 1,
|
|
23
|
+
"description": "Workflow FILE name (e.g. `maestro-e2e.yml`). The Actions API accepts the file name as the workflow id, which survives a rename of the workflow's display `name:`. A file the API cannot find is a hard failure, not a missing-evidence bootstrap: it means the workflow was renamed or deleted out from under the gate."
|
|
24
|
+
},
|
|
25
|
+
"match": {
|
|
26
|
+
"description": "How this suite's verdict is read. `run` and `job` are the machine-readable contract and are preferred; `job_pattern` is a last resort for matrix job names no single string can cover.",
|
|
27
|
+
"oneOf": [
|
|
28
|
+
{ "$ref": "#/$defs/matchRun" },
|
|
29
|
+
{ "$ref": "#/$defs/matchJob" },
|
|
30
|
+
{ "$ref": "#/$defs/matchJobPattern" }
|
|
31
|
+
]
|
|
32
|
+
},
|
|
33
|
+
"freshness_hours": {
|
|
34
|
+
"type": "number",
|
|
35
|
+
"exclusiveMinimum": 0,
|
|
36
|
+
"maximum": 720,
|
|
37
|
+
"description": "Override the workflow-level freshness window for this suite. A run older than this is treated as no run at all (missing evidence), which fails once the bootstrap window has closed."
|
|
38
|
+
},
|
|
39
|
+
"required_sha": {
|
|
40
|
+
"type": "string",
|
|
41
|
+
"pattern": "^[0-9a-f]{40}$",
|
|
42
|
+
"description": "When present, the run's head_sha must equal this exact commit. Use only where the suite is expected to run on a pinned commit; freshness plus the branch filter is the normal staleness control."
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
"matchRun": {
|
|
47
|
+
"type": "object",
|
|
48
|
+
"additionalProperties": false,
|
|
49
|
+
"required": ["mode"],
|
|
50
|
+
"properties": {
|
|
51
|
+
"mode": { "const": "run" }
|
|
52
|
+
},
|
|
53
|
+
"description": "Read the run's own conclusion. Correct when the whole workflow IS the suite."
|
|
54
|
+
},
|
|
55
|
+
"matchJob": {
|
|
56
|
+
"type": "object",
|
|
57
|
+
"additionalProperties": false,
|
|
58
|
+
"required": ["mode", "name"],
|
|
59
|
+
"properties": {
|
|
60
|
+
"mode": { "const": "job" },
|
|
61
|
+
"name": {
|
|
62
|
+
"type": "string",
|
|
63
|
+
"minLength": 1,
|
|
64
|
+
"description": "The job's name, EXACTLY as the Actions API reports it. For a job inside a nested reusable workflow the API reports `<caller job name> / <called job name>` — the same composition the required status-check context uses. A run that completes without ever producing this job is a renamed job, which fails once bootstrap closes."
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
"description": "Read one named job's conclusion. Correct when the suite is one job inside a larger workflow (so a Lighthouse regression elsewhere in that workflow cannot block every PR)."
|
|
68
|
+
},
|
|
69
|
+
"matchJobPattern": {
|
|
70
|
+
"type": "object",
|
|
71
|
+
"additionalProperties": false,
|
|
72
|
+
"required": ["mode", "pattern"],
|
|
73
|
+
"properties": {
|
|
74
|
+
"mode": { "const": "job_pattern" },
|
|
75
|
+
"pattern": {
|
|
76
|
+
"type": "string",
|
|
77
|
+
"minLength": 3,
|
|
78
|
+
"pattern": "^\\^.*\\$$",
|
|
79
|
+
"description": "An ECMAScript regular expression ANCHORED at both ends. Anchoring is enforced, not advised: an unanchored regex is a substring test wearing a regex's clothes, so `Playwright` would match `Playwright (skipped placeholder)`. Matching ZERO jobs is an error — zero matches is the signature of a renamed job, which is exactly how a gate stops gating unnoticed. Any non-success match decides the verdict."
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
"description": "Read every job whose name matches an anchored regex. Last resort: use it only for matrix job names (`… (android)`, `… (shard 2/4)`) that no single exact string can cover."
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|