@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.
Files changed (70) hide show
  1. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  2. package/dist/core/upstream-evidence-manifest.js +35 -10
  3. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  4. package/expo/copy-overwrite/scripts/bdd/baseline.mjs +46 -7
  5. package/expo/copy-overwrite/scripts/bdd/parse.mjs +22 -2
  6. package/expo/copy-overwrite/scripts/bdd/render.mjs +34 -7
  7. package/expo/copy-overwrite/scripts/bdd/report.mjs +118 -18
  8. package/expo/copy-overwrite/scripts/bdd/validate.mjs +95 -22
  9. package/expo/copy-overwrite/scripts/bdd-matrix.mjs +5 -32
  10. package/expo/copy-overwrite/scripts/check-bdd-coverage.mjs +29 -3
  11. package/expo/create-only/.github/required-checks.json +52 -0
  12. package/expo/create-only/.github/workflows/nightly-e2e-bypass-reaper.yml +83 -0
  13. package/expo/create-only/.github/workflows/nightly-e2e-health.yml +140 -0
  14. package/expo/github-rulesets/{playwright.json → nightly-e2e-health.json} +3 -8
  15. package/package.json +1 -1
  16. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  17. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  18. package/plugins/lisa-agy/plugin.json +1 -1
  19. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  20. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  21. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  22. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  23. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  24. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  25. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  26. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  27. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  28. package/plugins/lisa-expo-agy/plugin.json +1 -1
  29. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  30. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  31. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  32. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  33. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  34. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  35. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  36. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  37. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  38. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  39. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  40. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  43. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  44. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  48. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  49. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  53. package/plugins/lisa-rails-agy/plugin.json +1 -1
  54. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  58. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  59. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  62. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  63. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  64. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  66. package/typescript/copy-overwrite/scripts/check-nightly-e2e-health.mjs +1516 -0
  67. package/typescript/copy-overwrite/scripts/check-skipped-required-checks.mjs +580 -0
  68. package/typescript/copy-overwrite/scripts/nightly-e2e-suites.schema.json +85 -0
  69. package/typescript/create-only/.github/required-checks.json +28 -0
  70. 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
+ }