@codyswann/lisa 3.4.1 → 3.5.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 (177) hide show
  1. package/README.md +22 -0
  2. package/all/copy-contents/gitignore +7 -0
  3. package/all/copy-overwrite/scripts/lisa-command-envelope.mjs +44 -4
  4. package/all/copy-overwrite/scripts/lisa-schema-validate.mjs +125 -47
  5. package/cdk/merge/.oxlintrc.json +1 -1
  6. package/dist/cli/apply.d.ts.map +1 -1
  7. package/dist/cli/apply.js +48 -13
  8. package/dist/cli/apply.js.map +1 -1
  9. package/dist/cli/doctor-apply-freshness.d.ts +29 -0
  10. package/dist/cli/doctor-apply-freshness.d.ts.map +1 -0
  11. package/dist/cli/doctor-apply-freshness.js +194 -0
  12. package/dist/cli/doctor-apply-freshness.js.map +1 -0
  13. package/dist/cli/doctor-learnings-merge-driver.d.ts +23 -0
  14. package/dist/cli/doctor-learnings-merge-driver.d.ts.map +1 -0
  15. package/dist/cli/doctor-learnings-merge-driver.js +73 -0
  16. package/dist/cli/doctor-learnings-merge-driver.js.map +1 -0
  17. package/dist/cli/doctor-readiness-workflows.js +2 -2
  18. package/dist/cli/doctor-readiness-workflows.js.map +1 -1
  19. package/dist/cli/doctor.d.ts.map +1 -1
  20. package/dist/cli/doctor.js +13 -0
  21. package/dist/cli/doctor.js.map +1 -1
  22. package/dist/cli/ui-ci-quality-jobs-parse.js +2 -2
  23. package/dist/cli/ui-ci-quality-jobs-parse.js.map +1 -1
  24. package/dist/cli/ui-deploy-pipeline-model.d.ts +4 -0
  25. package/dist/cli/ui-deploy-pipeline-model.d.ts.map +1 -1
  26. package/dist/cli/ui-deploy-pipeline-model.js +2 -2
  27. package/dist/cli/ui-deploy-pipeline-model.js.map +1 -1
  28. package/dist/codex/agent-transformer.js +2 -2
  29. package/dist/codex/agent-transformer.js.map +1 -1
  30. package/dist/codex/command-skill-transformer.js +3 -3
  31. package/dist/codex/command-skill-transformer.js.map +1 -1
  32. package/dist/core/apply-receipt.d.ts +68 -0
  33. package/dist/core/apply-receipt.d.ts.map +1 -0
  34. package/dist/core/apply-receipt.js +107 -0
  35. package/dist/core/apply-receipt.js.map +1 -0
  36. package/dist/core/learnings-merge-driver-install.d.ts +34 -0
  37. package/dist/core/learnings-merge-driver-install.d.ts.map +1 -1
  38. package/dist/core/learnings-merge-driver-install.js +36 -0
  39. package/dist/core/learnings-merge-driver-install.js.map +1 -1
  40. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  41. package/dist/core/upstream-evidence-manifest.js +57 -32
  42. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  43. package/dist/migrations/ensure-lisa-postinstall.d.ts +33 -0
  44. package/dist/migrations/ensure-lisa-postinstall.d.ts.map +1 -1
  45. package/dist/migrations/ensure-lisa-postinstall.js +47 -5
  46. package/dist/migrations/ensure-lisa-postinstall.js.map +1 -1
  47. package/dist/migrations/ensure-oxlint-base-configs.d.ts +42 -0
  48. package/dist/migrations/ensure-oxlint-base-configs.d.ts.map +1 -0
  49. package/dist/migrations/ensure-oxlint-base-configs.js +212 -0
  50. package/dist/migrations/ensure-oxlint-base-configs.js.map +1 -0
  51. package/dist/migrations/index.d.ts +1 -0
  52. package/dist/migrations/index.d.ts.map +1 -1
  53. package/dist/migrations/index.js +3 -0
  54. package/dist/migrations/index.js.map +1 -1
  55. package/dist/opencode/command-transformer.js +3 -3
  56. package/dist/opencode/command-transformer.js.map +1 -1
  57. package/dist/utils/yaml.d.ts +54 -0
  58. package/dist/utils/yaml.d.ts.map +1 -0
  59. package/dist/utils/yaml.js +124 -0
  60. package/dist/utils/yaml.js.map +1 -0
  61. package/expo/copy-overwrite/scripts/bdd/baseline.mjs +104 -19
  62. package/expo/copy-overwrite/scripts/bdd/contract.mjs +20 -1
  63. package/expo/copy-overwrite/scripts/bdd/discover.mjs +53 -10
  64. package/expo/copy-overwrite/scripts/bdd/parse.mjs +25 -3
  65. package/expo/copy-overwrite/scripts/bdd/render.mjs +2 -1
  66. package/expo/copy-overwrite/scripts/bdd/report.mjs +26 -12
  67. package/expo/copy-overwrite/scripts/bdd/validate.mjs +2 -2
  68. package/expo/copy-overwrite/scripts/bdd/waivers.mjs +19 -4
  69. package/expo/copy-overwrite/scripts/bdd-matrix.mjs +2 -2
  70. package/expo/copy-overwrite/scripts/check-bdd-coverage.mjs +58 -16
  71. package/expo/create-only/.github/required-checks.json +10 -4
  72. package/expo/create-only/.github/workflows/maestro-e2e.yml +33 -5
  73. package/expo/merge/.oxlintrc.json +1 -1
  74. package/harper-fabric/merge/.oxlintrc.json +1 -1
  75. package/nestjs/create-only/.github/required-checks.json +57 -0
  76. package/nestjs/merge/.oxlintrc.json +1 -1
  77. package/package.json +1 -1
  78. package/phaser/merge/.oxlintrc.json +1 -1
  79. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  80. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  81. package/plugins/lisa/.codex-plugin/skills/lisa-atlassian-access/SKILL.md +7 -0
  82. package/plugins/lisa/.codex-plugin/skills/lisa-jam-access/SKILL.md +37 -2
  83. package/plugins/lisa/.codex-plugin/skills/lisa-notion-access/SKILL.md +7 -0
  84. package/plugins/lisa/.codex-plugin/skills/lisa-posthog-access/SKILL.md +40 -3
  85. package/plugins/lisa/.codex-plugin/skills/lisa-sentry-access/SKILL.md +44 -3
  86. package/plugins/lisa/.codex-plugin/skills/lisa-sonarcloud-access/SKILL.md +56 -0
  87. package/plugins/lisa/rules/eager/credential-substrate-precedence.md +3 -1
  88. package/plugins/lisa/rules/reference/credential-substrate-precedence.md +20 -0
  89. package/plugins/lisa/skills/lisa-atlassian-access/SKILL.md +7 -0
  90. package/plugins/lisa/skills/lisa-jam-access/SKILL.md +37 -2
  91. package/plugins/lisa/skills/lisa-notion-access/SKILL.md +7 -0
  92. package/plugins/lisa/skills/lisa-posthog-access/SKILL.md +40 -3
  93. package/plugins/lisa/skills/lisa-sentry-access/SKILL.md +44 -3
  94. package/plugins/lisa/skills/lisa-sonarcloud-access/SKILL.md +56 -0
  95. package/plugins/lisa-agy/plugin.json +1 -1
  96. package/plugins/lisa-agy/skills/lisa-atlassian-access/SKILL.md +7 -0
  97. package/plugins/lisa-agy/skills/lisa-jam-access/SKILL.md +37 -2
  98. package/plugins/lisa-agy/skills/lisa-notion-access/SKILL.md +7 -0
  99. package/plugins/lisa-agy/skills/lisa-posthog-access/SKILL.md +40 -3
  100. package/plugins/lisa-agy/skills/lisa-sentry-access/SKILL.md +44 -3
  101. package/plugins/lisa-agy/skills/lisa-sonarcloud-access/SKILL.md +56 -0
  102. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  103. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  104. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  105. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  106. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  107. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  108. package/plugins/lisa-copilot/rules/eager/credential-substrate-precedence.md +3 -1
  109. package/plugins/lisa-copilot/rules/reference/credential-substrate-precedence.md +20 -0
  110. package/plugins/lisa-copilot/skills/lisa-atlassian-access/SKILL.md +7 -0
  111. package/plugins/lisa-copilot/skills/lisa-jam-access/SKILL.md +37 -2
  112. package/plugins/lisa-copilot/skills/lisa-notion-access/SKILL.md +7 -0
  113. package/plugins/lisa-copilot/skills/lisa-posthog-access/SKILL.md +40 -3
  114. package/plugins/lisa-copilot/skills/lisa-sentry-access/SKILL.md +44 -3
  115. package/plugins/lisa-copilot/skills/lisa-sonarcloud-access/SKILL.md +56 -0
  116. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  117. package/plugins/lisa-cursor/rules/credential-substrate-precedence-reference.mdc +20 -0
  118. package/plugins/lisa-cursor/rules/credential-substrate-precedence.mdc +3 -1
  119. package/plugins/lisa-cursor/skills/lisa-atlassian-access/SKILL.md +7 -0
  120. package/plugins/lisa-cursor/skills/lisa-jam-access/SKILL.md +37 -2
  121. package/plugins/lisa-cursor/skills/lisa-notion-access/SKILL.md +7 -0
  122. package/plugins/lisa-cursor/skills/lisa-posthog-access/SKILL.md +40 -3
  123. package/plugins/lisa-cursor/skills/lisa-sentry-access/SKILL.md +44 -3
  124. package/plugins/lisa-cursor/skills/lisa-sonarcloud-access/SKILL.md +56 -0
  125. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  126. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  127. package/plugins/lisa-expo-agy/plugin.json +1 -1
  128. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  129. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  130. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  131. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  132. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  133. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  134. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  135. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  136. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  137. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  138. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  139. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  140. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  141. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  142. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  143. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  144. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  145. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  146. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  147. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  148. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  149. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  150. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  151. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  152. package/plugins/lisa-rails-agy/plugin.json +1 -1
  153. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  154. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  155. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  156. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  157. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  158. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  159. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  160. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  161. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  162. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  163. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  164. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  165. package/plugins/src/base/rules/eager/credential-substrate-precedence.md +3 -1
  166. package/plugins/src/base/rules/reference/credential-substrate-precedence.md +20 -0
  167. package/plugins/src/base/skills/lisa-atlassian-access/SKILL.md +7 -0
  168. package/plugins/src/base/skills/lisa-jam-access/SKILL.md +37 -2
  169. package/plugins/src/base/skills/lisa-notion-access/SKILL.md +7 -0
  170. package/plugins/src/base/skills/lisa-posthog-access/SKILL.md +40 -3
  171. package/plugins/src/base/skills/lisa-sentry-access/SKILL.md +44 -3
  172. package/plugins/src/base/skills/lisa-sonarcloud-access/SKILL.md +56 -0
  173. package/scripts/install-claude-plugins.sh +5 -1
  174. package/typescript/copy-overwrite/scripts/check-skipped-required-checks.mjs +298 -26
  175. package/typescript/create-only/.github/required-checks.json +11 -6
  176. package/typescript/create-only/.github/workflows/required-checks-drift.yml +84 -0
  177. package/typescript/merge/.oxlintrc.json +1 -1
@@ -11,6 +11,53 @@
11
11
  * Usage:
12
12
  * node scripts/check-skipped-required-checks.mjs [rootDir] [--remote] [--json]
13
13
  *
14
+ * ## Where this runs
15
+ *
16
+ * Lisa's `quality.yml` runs the OFFLINE arm on every pull request, and the
17
+ * shipped `required-checks-drift.yml` runs `--remote` on a weekly schedule.
18
+ * That split is deliberate and is argued under "Why this is a DECLARATION
19
+ * guard" below: the enforced PR path may not depend on network or `gh` auth,
20
+ * and the snapshot it enforces may not be allowed to rot unwatched.
21
+ *
22
+ * ## `enforcement`
23
+ *
24
+ * A declaration may set `"enforcement": "warn"` to report violations without
25
+ * failing the build. The seeds Lisa ships start there ON PURPOSE: a seed cannot
26
+ * know which contexts a given repository's ruleset requires, so a seeded repo
27
+ * that has never been reviewed would otherwise go red on its first Lisa update
28
+ * for skips that may be entirely legitimate — and a gate that reddens a
29
+ * repository the day it arrives gets deleted, not fixed. Absent the key the
30
+ * guard ENFORCES: a declaration somebody hand-wrote is an opt-in.
31
+ *
32
+ * `warn` is a ramp, not a licence: `skipped_required_check` — the repository's
33
+ * own declaration stating that a token it really skips silences a context its
34
+ * ruleset really requires — blocks in every mode.
35
+ *
36
+ * ## `required_contexts` is a CACHE, and an unstamped cache is NOT AN ANSWER
37
+ *
38
+ * The single worst thing this guard can do is render a confident verdict from a
39
+ * list nobody ever compared against a real ruleset. Measured (#2476): the seed
40
+ * Lisa shipped claimed `🔗 Work-Item Traceability` was required — no ruleset
41
+ * required it — and OMITTED SIX contexts that genuinely were. A guard reading
42
+ * that would clear a genuinely-skipped required check and flag a non-required
43
+ * one. That is worse than a guard nobody runs, because it teaches people to
44
+ * trust it.
45
+ *
46
+ * So `required_contexts` is treated as a cache of a live fetch, not as
47
+ * testimony. It is trusted only while `ruleset.baseline_fetched_at` carries a
48
+ * parseable timestamp no older than SNAPSHOT_MAX_AGE_DAYS. Untranscribed or
49
+ * expired, the guard REFUSES to answer: every rule that depends on
50
+ * `required_contexts` is skipped, no ✅ is printed, and the report says NOT
51
+ * CHECKED and why. The rules that do NOT read `required_contexts` — an
52
+ * undeclared token, a token written with whitespace — still run, because they
53
+ * are true regardless of what the ruleset requires.
54
+ *
55
+ * Refusal is exit 1, so an explicit `npm run check:skipped-required-checks`
56
+ * cannot be mistaken for a pass. Under `"enforcement": "warn"` — which is what
57
+ * Lisa's seeds ship, and only its seeds — refusal is loud and exit 0, because
58
+ * reddening every repository in a fleet the day an untranscribed seed arrives
59
+ * is how a gate gets deleted rather than transcribed.
60
+ *
14
61
  * ## Why this exists
15
62
  *
16
63
  * **GitHub counts a `skipped` required status check as SATISFIED.** A job named
@@ -69,6 +116,18 @@ import { pathToFileURL } from "node:url";
69
116
  /** Repo-relative path of the per-repo declaration file. */
70
117
  export const DECLARATION_PATH = ".github/required-checks.json";
71
118
 
119
+ /**
120
+ * How long a transcribed `required_contexts` snapshot stays trustworthy.
121
+ *
122
+ * A SOURCE CONSTANT, not an input: a ceiling somebody can widen from a config
123
+ * file fails open on exactly the runs nobody tests. Rulesets are edited in an
124
+ * admin console with no signal in the repository, so an old transcription is
125
+ * not evidence — it is a memory of evidence. Ninety days is long enough that
126
+ * the weekly `--remote` run refreshes it many times over, and short enough that
127
+ * an abandoned repository stops being told its skips are fine.
128
+ */
129
+ export const SNAPSHOT_MAX_AGE_DAYS = 90;
130
+
72
131
  /**
73
132
  * Matches a `skip_jobs:` key and captures everything after the colon.
74
133
  *
@@ -110,8 +169,32 @@ export const VIOLATIONS = Object.freeze({
110
169
  orphaned: "orphaned_exemption",
111
170
  badExemption: "exemption_without_valid_ticket",
112
171
  remoteDrift: "ruleset_snapshot_drift",
172
+ whitespace: "whitespace_in_skip_token",
113
173
  });
114
174
 
175
+ /** Enforcement modes a declaration may select. */
176
+ const ENFORCEMENT_MODES = Object.freeze(["error", "warn"]);
177
+
178
+ /**
179
+ * Violation kinds that block even under `"enforcement": "warn"`.
180
+ *
181
+ * `warn` is an ADOPTION RAMP for hygiene findings — an undeclared token, a
182
+ * snapshot that has drifted from the code — in a repository nobody has reviewed
183
+ * yet. It is not a licence for a PROVEN false green. Reaching
184
+ * `skipped_required_check` requires the repository's own declaration to say
185
+ * both that a context is ruleset-required AND that a token it actually skips
186
+ * silences it; that is a reviewed state, and shipping past it is the exact
187
+ * defect this file exists to refuse.
188
+ *
189
+ * `ruleset_snapshot_drift` blocks for the same reason: it only fires under an
190
+ * explicit `--remote` on a repository that filled in its ruleset ids, and it is
191
+ * measured against live truth rather than inferred from a seed.
192
+ */
193
+ const ALWAYS_BLOCKING = Object.freeze([
194
+ VIOLATIONS.suppressesRequired,
195
+ VIOLATIONS.remoteDrift,
196
+ ]);
197
+
115
198
  /**
116
199
  * True when a line is a whole-line YAML comment.
117
200
  *
@@ -170,13 +253,25 @@ export function unquoteScalar(rawValue) {
170
253
  /**
171
254
  * Splits a comma list into unique, non-empty tokens.
172
255
  *
256
+ * `preserveWhitespace` keeps each token EXACTLY as written, which is the only
257
+ * way to see the defect in `skip_jobs: 'test, test:e2e'`. GitHub Actions has no
258
+ * string-replace in expression syntax, so the sentinel-comma idiom
259
+ * (`contains(format(',{0},', inputs.skip_jobs), ',test:e2e,')`) compares raw
260
+ * bytes: the token there is `" test:e2e"`, which never matches `,test:e2e,` and
261
+ * the job runs. That fails CLOSED, so it is a papercut rather than a hole — but
262
+ * it silently does not do what the operator asked, and nothing else in the
263
+ * pipeline can see it, because by the time the guard has trimmed it looks
264
+ * identical to a correctly written skip.
265
+ *
173
266
  * @param {string} commaList - Tokens joined by commas
267
+ * @param {boolean} [preserveWhitespace] - Return tokens exactly as written
174
268
  * @returns {string[]} Unique tokens in first-seen order
175
269
  */
176
- function tokenize(commaList) {
270
+ function tokenize(commaList, preserveWhitespace = false) {
177
271
  const out = [];
178
- for (const token of commaList.split(",").map(part => part.trim())) {
179
- if (token !== "" && !out.includes(token)) out.push(token);
272
+ for (const part of commaList.split(",")) {
273
+ const token = preserveWhitespace ? part : part.trim();
274
+ if (token.trim() !== "" && !out.includes(token)) out.push(token);
180
275
  }
181
276
  return out;
182
277
  }
@@ -195,10 +290,12 @@ function tokenize(commaList) {
195
290
  * @param {string} contents - Full text of a workflow file
196
291
  * @param {string} sourcePath - Repo-relative path, used only to name the file in
197
292
  * a throw. Never an absolute path.
293
+ * @param {{preserveWhitespace?: boolean}} [options] - `preserveWhitespace`
294
+ * returns each token exactly as written instead of trimmed
198
295
  * @returns {string[]} Every declared skip token, de-duplicated
199
296
  * @throws {Error} When a `skip_jobs` key is not an inline scalar
200
297
  */
201
- export function readSkipJobs(contents, sourcePath) {
298
+ export function readSkipJobs(contents, sourcePath, options = {}) {
202
299
  const all = [];
203
300
  contents.split("\n").forEach((line, index) => {
204
301
  if (isCommentLine(line)) return;
@@ -224,7 +321,7 @@ export function readSkipJobs(contents, sourcePath) {
224
321
  .map(found => found[1])
225
322
  .join(",");
226
323
 
227
- for (const token of tokenize(source)) {
324
+ for (const token of tokenize(source, options.preserveWhitespace === true)) {
228
325
  if (!all.includes(token)) all.push(token);
229
326
  }
230
327
  });
@@ -270,6 +367,14 @@ export function loadDeclaration(rootDir) {
270
367
  `check-skipped-required-checks: \`workflows\` must list at least one workflow file whose \`skip_jobs\` this guard reads.`
271
368
  );
272
369
  }
370
+ if (
371
+ declaration.enforcement !== undefined &&
372
+ !ENFORCEMENT_MODES.includes(declaration.enforcement)
373
+ ) {
374
+ throw new Error(
375
+ `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.`
376
+ );
377
+ }
273
378
 
274
379
  // Validate the SHAPE of every declaration, not just its presence. A truthy
275
380
  // non-object entry (`"test:e2e": true`, or a string) reads as "declared" at
@@ -308,14 +413,21 @@ export function loadDeclaration(rootDir) {
308
413
  * guard that silently reads nothing reports a clean bill of health for a
309
414
  * repository it never looked at.
310
415
  *
416
+ * A token written with surrounding whitespace is reported HERE rather than
417
+ * downstream, because trimming has already erased the evidence by the time the
418
+ * coherence rules run. The trimmed form is still carried into the evaluation:
419
+ * over-reporting a skip that does not actually happen is the safe direction,
420
+ * and the whitespace violation beside it explains why the token appeared.
421
+ *
311
422
  * @param {string} rootDir - Repository root
312
423
  * @param {ReadonlyArray<string>} workflows - Repo-relative workflow paths
313
- * @returns {{tokens: string[], sources: Record<string, string[]>}} Tokens and where each came from
424
+ * @returns {{tokens: string[], sources: Record<string, string[]>, violations: object[]}} Tokens, where each came from, and how each was written
314
425
  */
315
426
  export function collectSkipJobTokens(rootDir, workflows) {
316
427
  const tokens = [];
317
428
  /** @type {Record<string, string[]>} */
318
429
  const sources = {};
430
+ const violations = [];
319
431
  for (const relative of workflows) {
320
432
  const path = resolve(rootDir, relative);
321
433
  if (!existsSync(path)) {
@@ -323,12 +435,75 @@ export function collectSkipJobTokens(rootDir, workflows) {
323
435
  `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
436
  );
325
437
  }
326
- for (const token of readSkipJobs(readFileSync(path, "utf8"), relative)) {
438
+ const contents = readFileSync(path, "utf8");
439
+ for (const token of readSkipJobs(contents, relative)) {
327
440
  if (!tokens.includes(token)) tokens.push(token);
328
441
  sources[token] = [...(sources[token] ?? []), relative];
329
442
  }
443
+ for (const raw of readSkipJobs(contents, relative, {
444
+ preserveWhitespace: true,
445
+ })) {
446
+ if (raw === raw.trim()) continue;
447
+ violations.push({
448
+ kind: VIOLATIONS.whitespace,
449
+ token: raw.trim(),
450
+ message: `\`skip_jobs\` in ${relative} lists ${JSON.stringify(raw)} — the token carries whitespace. GitHub Actions expression syntax has no string-replace, so \`skip_jobs\` is matched as an exact comma-delimited token and the surrounding space makes it match NOTHING: the job runs as if you had never listed it. That fails closed, so nothing unverified ships — but the skip you asked for silently did not happen. Write the list with no spaces: \`skip_jobs: 'a,b'\`, never \`skip_jobs: 'a, b'\`.`,
451
+ });
452
+ }
330
453
  }
331
- return { tokens, sources };
454
+ return { tokens, sources, violations };
455
+ }
456
+
457
+ /**
458
+ * Decides whether `required_contexts` may be believed at all.
459
+ *
460
+ * The stamp is `ruleset.baseline_fetched_at`, and it means one specific thing:
461
+ * somebody read this list off a live ruleset on that date. An empty stamp is
462
+ * the state Lisa's seeds ship in, and the seeds are a GUESS — a guess that was
463
+ * measured wrong (#2476). No stamp, no answer.
464
+ *
465
+ * @param {object} declaration - The per-repo declaration
466
+ * @param {number} [now] - Current epoch milliseconds, injectable for tests
467
+ * @returns {{trusted: boolean, reason: string}} Whether to believe the snapshot, and why not
468
+ */
469
+ export function snapshotTrust(declaration, now = Date.now()) {
470
+ const stamp = declaration.ruleset?.baseline_fetched_at;
471
+ if (typeof stamp !== "string" || stamp.trim() === "") {
472
+ return {
473
+ trusted: false,
474
+ reason: `\`ruleset.baseline_fetched_at\` is empty, so \`required_contexts\` has never been transcribed from a live ruleset. Lisa's seed ships a GUESS, and the guess was measured WRONG in this fleet: it claimed "🔍 Quality Checks / 🔗 Work-Item Traceability" was required when no ruleset required it, and omitted six contexts that were. Transcribe the real list, stamp the date, and this guard starts answering.`,
475
+ };
476
+ }
477
+ const fetchedAt = Date.parse(stamp);
478
+ if (Number.isNaN(fetchedAt)) {
479
+ return {
480
+ trusted: false,
481
+ reason: `\`ruleset.baseline_fetched_at\` is ${JSON.stringify(stamp)}, which is not a date this can read. Use an ISO-8601 timestamp, e.g. "2026-08-13".`,
482
+ };
483
+ }
484
+ const ageDays = (now - fetchedAt) / 86_400_000;
485
+ if (ageDays > SNAPSHOT_MAX_AGE_DAYS) {
486
+ return {
487
+ trusted: false,
488
+ reason: `\`required_contexts\` was last transcribed ${Math.floor(ageDays)} days ago, past the ${SNAPSHOT_MAX_AGE_DAYS}-day ceiling. Rulesets are edited in an admin console with no signal in this repository, so a stale transcription is a memory of evidence rather than evidence.`,
489
+ };
490
+ }
491
+ return { trusted: true, reason: "" };
492
+ }
493
+
494
+ /**
495
+ * The transcription instructions, printed wherever the snapshot is refused.
496
+ *
497
+ * @param {object} declaration - The per-repo declaration
498
+ * @returns {string} A copy-pasteable recipe
499
+ */
500
+ export function transcriptionRecipe(declaration) {
501
+ const repo = declaration.ruleset?.repo || "OWNER/NAME";
502
+ return [
503
+ ` gh api repos/${repo}/rulesets --jq '.[] | "\\(.id) \\(.name)"'`,
504
+ ` gh api repos/${repo}/rulesets/RULESET_ID --jq '.rules[] | select(.type=="required_status_checks") | .parameters.required_status_checks[].context'`,
505
+ `Paste the output into \`required_contexts\` byte for byte, record the ids in \`ruleset.ids\`, and set \`ruleset.baseline_fetched_at\` to today.`,
506
+ ].join("\n");
332
507
  }
333
508
 
334
509
  /**
@@ -352,11 +527,25 @@ export function collectSkipJobTokens(rootDir, workflows) {
352
527
  * 5. An exemption for a token nobody skips any more is ORPHANED. Deleting it is
353
528
  * one line, and leaving it teaches readers the exemption list is fiction.
354
529
  *
530
+ * Rules 2, 3 and 4 all read `required_contexts`, so all three are SUPPRESSED
531
+ * when `trustRequiredContexts` is false. Rules 1 and 5 do not read it and keep
532
+ * running: whether a token was reviewed, and whether an exemption still refers
533
+ * to a real skip, are true regardless of what any ruleset requires. Suppressing
534
+ * a rule is not the same as passing it — the caller reports NOT CHECKED.
535
+ *
355
536
  * @param {object} declaration - The per-repo declaration
356
537
  * @param {ReadonlyArray<string>} skipped - Tokens the workflows actually skip
538
+ * @param {{trustRequiredContexts?: boolean}} [options] - Set
539
+ * `trustRequiredContexts: false` to suppress every rule that reads the
540
+ * `required_contexts` snapshot
357
541
  * @returns {{violations: object[], checked: number}} Violations and how many tokens were examined
358
542
  */
359
- export function evaluateSkippedRequiredChecks(declaration, skipped) {
543
+ export function evaluateSkippedRequiredChecks(
544
+ declaration,
545
+ skipped,
546
+ options = {}
547
+ ) {
548
+ const trustRequired = options.trustRequiredContexts !== false;
360
549
  const required = new Set(declaration.required_contexts);
361
550
  const declarations = declaration.skip_job_declarations ?? {};
362
551
  const ticketPattern = new RegExp(
@@ -374,6 +563,7 @@ export function evaluateSkippedRequiredChecks(declaration, skipped) {
374
563
  });
375
564
  continue;
376
565
  }
566
+ if (!trustRequired) continue;
377
567
  const suppressed = entry.suppressed_contexts ?? [];
378
568
  const hits = suppressed.filter(context => required.has(context));
379
569
 
@@ -490,25 +680,48 @@ export function compareRulesetBaseline(snapshot, live) {
490
680
  /**
491
681
  * Runs the guard.
492
682
  *
683
+ * `--remote` reads the ruleset live, so it does not need the cache to be
684
+ * trustworthy — it is the thing that MAKES it trustworthy. Under `--remote` the
685
+ * required-context rules therefore run regardless of the stamp, and any
686
+ * disagreement surfaces as `ruleset_snapshot_drift` rather than as a verdict
687
+ * about skips.
688
+ *
493
689
  * @param {ReadonlyArray<string>} argv - CLI arguments
494
- * @returns {{violations: object[], checked: number, tokens: string[]}} The result
690
+ * @returns {{violations: object[], checked: number, tokens: string[], enforcement: string, trust: {trusted: boolean, reason: string}, recipe: string}} The result
495
691
  */
496
692
  export function runGuard(argv) {
497
693
  const positional = argv.filter(arg => !arg.startsWith("--"));
498
694
  const rootDir = positional[0] ?? process.cwd();
499
695
  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")) {
696
+ const collected = collectSkipJobTokens(rootDir, declaration.workflows);
697
+ const remote = argv.includes("--remote");
698
+ const live = remote
699
+ ? fetchLiveRequiredContexts(declaration.ruleset)
700
+ : undefined;
701
+ const trust = remote
702
+ ? { trusted: true, reason: "" }
703
+ : snapshotTrust(declaration);
704
+ const result = evaluateSkippedRequiredChecks(
705
+ live === undefined
706
+ ? declaration
707
+ : { ...declaration, required_contexts: live },
708
+ collected.tokens,
709
+ { trustRequiredContexts: trust.trusted }
710
+ );
711
+ const violations = [...collected.violations, ...result.violations];
712
+ if (live !== undefined) {
504
713
  violations.push(
505
- ...compareRulesetBaseline(
506
- declaration.required_contexts,
507
- fetchLiveRequiredContexts(declaration.ruleset)
508
- )
714
+ ...compareRulesetBaseline(declaration.required_contexts, live)
509
715
  );
510
716
  }
511
- return { violations, checked: result.checked, tokens };
717
+ return {
718
+ violations,
719
+ checked: result.checked,
720
+ tokens: collected.tokens,
721
+ enforcement: declaration.enforcement ?? "error",
722
+ trust,
723
+ recipe: transcriptionRecipe(declaration),
724
+ };
512
725
  }
513
726
 
514
727
  /**
@@ -518,7 +731,7 @@ export function runGuard(argv) {
518
731
  * @returns {void}
519
732
  */
520
733
  function main(argv) {
521
- /** @type {{violations: object[], checked: number, tokens: string[]}} */
734
+ /** @type {{violations: object[], checked: number, tokens: string[], enforcement: string, trust: {trusted: boolean, reason: string}, recipe: string}} */
522
735
  let result;
523
736
  try {
524
737
  result = runGuard(argv);
@@ -540,25 +753,78 @@ function main(argv) {
540
753
 
541
754
  if (argv.includes("--json")) {
542
755
  process.stdout.write(
543
- `${JSON.stringify({ ok: result.violations.length === 0, ...result }, null, 2)}\n`
756
+ `${JSON.stringify(
757
+ {
758
+ ok: result.violations.length === 0 && result.trust.trusted,
759
+ answered: result.trust.trusted,
760
+ ...result,
761
+ },
762
+ null,
763
+ 2
764
+ )}\n`
544
765
  );
545
766
  return;
546
767
  }
547
768
 
769
+ const warnOnly = result.enforcement === "warn";
770
+ /**
771
+ * True when a violation still fails the build under the active mode.
772
+ *
773
+ * @param {{kind: string}} violation - One violation
774
+ * @returns {boolean} True when it blocks
775
+ */
776
+ const blocks = violation =>
777
+ !warnOnly || ALWAYS_BLOCKING.includes(violation.kind);
778
+ const blocking = result.violations.filter(blocks);
548
779
  const lines = ["## 🔒 Skipped required checks", ""];
549
- if (result.violations.length === 0) {
780
+
781
+ // The refusal comes FIRST and replaces the verdict. Printing "✅ none
782
+ // silences a required check" from a snapshot nobody transcribed is the one
783
+ // outcome that is worse than never running: it is a confident wrong answer,
784
+ // and it teaches people to trust it (#2476).
785
+ if (!result.trust.trusted) {
550
786
  lines.push(
551
- `✅ ${result.checked} \`skip_jobs\` token(s) examined; none silences a ruleset-required status check.`
787
+ `⛔ **NOT CHECKED** this guard cannot say whether any skip silences a required status check, and will not pretend to.`,
788
+ "",
789
+ result.trust.reason,
790
+ "",
791
+ "```",
792
+ result.recipe,
793
+ "```",
794
+ "",
795
+ `Meanwhile \`--remote\` answers WITHOUT the cache, because it reads the ruleset live: \`npm run check:skipped-required-checks:remote\`.`,
796
+ ""
797
+ );
798
+ process.stderr.write(
799
+ `::${warnOnly ? "warning" : "error"} title=Skipped-required checks NOT CHECKED::${result.trust.reason.split("\n")[0]}\n`
552
800
  );
801
+ }
802
+
803
+ if (result.violations.length === 0) {
804
+ if (result.trust.trusted) {
805
+ lines.push(
806
+ `✅ ${result.checked} \`skip_jobs\` token(s) examined; none silences a ruleset-required status check.`
807
+ );
808
+ } else {
809
+ lines.push(
810
+ `The rules that do NOT read \`required_contexts\` were still applied to ${result.checked} token(s) and found nothing.`
811
+ );
812
+ }
553
813
  } else {
554
814
  lines.push(
555
- `❌ ${result.violations.length} violation(s) across ${result.checked} \`skip_jobs\` token(s):`,
815
+ `${blocking.length > 0 ? "❌" : "⚠️"} ${result.violations.length} violation(s) across ${result.checked} \`skip_jobs\` token(s):`,
556
816
  ""
557
817
  );
558
818
  for (const violation of result.violations) {
559
819
  lines.push(`- **${violation.kind}** — ${violation.message}`);
560
820
  process.stderr.write(
561
- `::error title=${violation.kind}::${violation.message.split("\n")[0]}\n`
821
+ `::${blocks(violation) ? "error" : "warning"} title=${violation.kind}::${violation.message.split("\n")[0]}\n`
822
+ );
823
+ }
824
+ if (warnOnly) {
825
+ lines.push(
826
+ "",
827
+ `This declaration sets \`"enforcement": "warn"\`, so everything above except a proven false green (\`${VIOLATIONS.suppressesRequired}\`) is reported without failing the build. Review each finding, fix or declare it, then delete the \`enforcement\` key so this guard can block.`
562
828
  );
563
829
  }
564
830
  }
@@ -569,7 +835,13 @@ function main(argv) {
569
835
  appendFileSync(process.env.GITHUB_STEP_SUMMARY, report);
570
836
  });
571
837
  }
572
- if (result.violations.length > 0) process.exitCode = 1;
838
+ // Refusal is a failure, not a pass: an explicit run must never be mistaken
839
+ // for a clean bill of health. `warn` — which only Lisa's untranscribed seeds
840
+ // ship — downgrades it, because reddening a whole fleet the day a seed
841
+ // arrives is how a gate gets deleted instead of transcribed.
842
+ if (blocking.length > 0 || (!result.trust.trusted && !warnOnly)) {
843
+ process.exitCode = 1;
844
+ }
573
845
  }
574
846
 
575
847
  if (
@@ -3,10 +3,15 @@
3
3
  "Two REVIEWED SNAPSHOTS that scripts/check-skipped-required-checks.mjs makes police each other.",
4
4
  "Neither is derivable from this repository: `required_contexts` lives in a GitHub ruleset that humans edit in an admin console, and the skip_jobs token -> job name map lives in Lisa's quality.yml, which is not vendored here.",
5
5
  "Why it matters: GitHub counts a SKIPPED required status check as SATISFIED. A job named in `skip_jobs` still reports green against its required context having run zero steps, so the merge gate can never be red and therefore can never block anything.",
6
- "Transcribe `required_contexts` BYTE FOR BYTE from the ruleset, emoji and the ' / ' separator included. The guard compares with exact string equality on purpose: a repo routinely carries confusable pairs (an external app's required `SonarCloud Code Analysis` beside a skippable, not-required `SonarCloud SAST`; `Run Tests` beside `Run Unit Tests`), and a fuzzy match would raise a false alarm whose obvious fix is to delete the guard.",
7
- "Run `node scripts/check-skipped-required-checks.mjs --remote` periodically: two snapshots in one repo can only catch each other drifting from the CODE, never from a ruleset edited in the console.",
8
- "Fill in `ruleset.repo` / `ruleset.ids` before --remote can work. Find the ids with: gh api repos/OWNER/NAME/rulesets --jq '.[] | \"\\(.id) \\(.name)\"'"
6
+ "⚠️ `required_contexts` SHIPS EMPTY AND UNSTAMPED ON PURPOSE. Lisa cannot know what YOUR ruleset requires, and an earlier version of this seed shipped a guess that was measured WRONG (#2476): it claimed `🔗 Work-Item Traceability` was required when no ruleset required it, and omitted six contexts that were. Until `ruleset.baseline_fetched_at` carries the date you transcribed the real list, the guard reports NOT CHECKED rather than answering from fiction.",
7
+ "To arm it: gh api repos/OWNER/NAME/rulesets --jq '.[] | \"\\(.id) \\(.name)\"' to find the ids, then gh api repos/OWNER/NAME/rulesets/ID --jq '.rules[] | select(.type==\"required_status_checks\") | .parameters.required_status_checks[].context' for the contexts. Paste them into `required_contexts` BYTE FOR BYTE (emoji and the ' / ' separator included), fill in `ruleset.repo` / `ruleset.ids`, and set `baseline_fetched_at` to today.",
8
+ "The guard compares with exact string equality on purpose: a repo routinely carries confusable pairs (an external app's required `SonarCloud Code Analysis` beside a skippable, not-required `SonarCloud SAST`; `Run Tests` beside `Run Unit Tests`), and a fuzzy match would raise a false alarm whose obvious fix is to delete the guard.",
9
+ "A transcription expires after 90 days, because a ruleset can be edited with no signal in this repository. The shipped `.github/workflows/required-checks-drift.yml` runs `--remote` weekly to catch that; `--remote` reads the ruleset live and so answers even when the cache is untrusted.",
10
+ "Lisa's quality.yml runs the offline arm on every pull request. This seed ships `\"enforcement\": \"warn\"`, which downgrades findings AND the NOT-CHECKED refusal to reports so a fresh install does not go red on arrival. Delete the key once you have transcribed the list — then it blocks.",
11
+ "`skip_jobs` is matched as an exact comma-delimited token and GitHub Actions expression syntax has no string-replace, so write the list with no spaces: `skip_jobs: 'a,b'`, never `skip_jobs: 'a, b'`. A spaced token matches nothing and the job runs — reported as `whitespace_in_skip_token`.",
12
+ "`_example_required_contexts` below is a STARTING POINT FOR TYPING, never read by the guard. Verify every line against your own ruleset before promoting any of it."
9
13
  ],
14
+ "enforcement": "warn",
10
15
  "ruleset": {
11
16
  "repo": "",
12
17
  "ids": [],
@@ -14,15 +19,15 @@
14
19
  },
15
20
  "workflows": [".github/workflows/ci.yml"],
16
21
  "exemption_ticket_pattern": "^[A-Z][A-Z0-9]+-\\d+$",
17
- "required_contexts": [
22
+ "required_contexts": [],
23
+ "_example_required_contexts": [
18
24
  "🔍 Quality Checks / 🧹 Lint",
19
25
  "🔍 Quality Checks / 🔍 Type Check",
20
26
  "🔍 Quality Checks / 🏗️ Build",
21
27
  "🔍 Quality Checks / 📐 Check Formatting",
22
28
  "🔍 Quality Checks / 🔒 Security Scan",
23
29
  "🔍 Quality Checks / 🧪 Run Unit Tests",
24
- "🔍 Quality Checks / 🧪 Run Integration Tests",
25
- "🔍 Quality Checks / 🔗 Work-Item Traceability"
30
+ "🔍 Quality Checks / 🧪 Run Integration Tests"
26
31
  ],
27
32
  "skip_job_declarations": {}
28
33
  }
@@ -0,0 +1,84 @@
1
+ # This file was created by Lisa and is yours to edit.
2
+ # -----------------------------------------------------------------------------
3
+ # Required-checks snapshot drift — the REMOTE arm (#2426)
4
+ # -----------------------------------------------------------------------------
5
+ # `.github/required-checks.json` holds two reviewed snapshots that police each
6
+ # other: the contexts your ruleset requires, and what each `skip_jobs` token
7
+ # silences. Two snapshots inside one repository can only catch each other
8
+ # drifting from the CODE. Neither can see the RULESET change, because rulesets
9
+ # are edited in an admin console — which is exactly how one repo's list went
10
+ # from ten required contexts to eleven with every test still green for a day.
11
+ #
12
+ # So this runs the `--remote` arm on a schedule, and the pull-request path stays
13
+ # offline. That split is the whole design: an enforced gate that needed network
14
+ # and `gh` auth on every run would flake, and a flaky gate gets skipped — which
15
+ # reintroduces the false-green class the guard exists to refuse. This workflow
16
+ # never runs on `pull_request`, so it cannot wedge a merge.
17
+ #
18
+ # `--remote` is also the arm that STRUCTURALLY CANNOT LIE (#2476). It evaluates
19
+ # the skip rules against the contexts it just read from the live ruleset, not
20
+ # against the committed cache, so it answers correctly even in a repository
21
+ # whose `required_contexts` has never been transcribed — which is exactly the
22
+ # state the offline arm refuses to answer in. Arming this workflow is therefore
23
+ # the fastest way to get a real answer.
24
+ #
25
+ # Reading a repository ruleset needs a token with administration:read. The
26
+ # default GITHUB_TOKEN generally does not have it, so provide a PAT as the
27
+ # repository secret RULESET_READ_TOKEN. Without it this job goes red rather
28
+ # than reporting a clean bill of health it never checked.
29
+ #
30
+ # Until `ruleset.repo` / `ruleset.ids` are filled in, this reports UNCONFIGURED
31
+ # and passes. Find the ids with:
32
+ # gh api repos/OWNER/NAME/rulesets --jq '.[] | "\(.id) \(.name)"'
33
+
34
+ name: 🔒 Required-Checks Drift
35
+
36
+ on:
37
+ schedule:
38
+ # Weekly. A ruleset can be edited in the admin console on a day nobody
39
+ # touched the repository, so change-triggered checking alone would miss it.
40
+ - cron: '0 8 * * 1'
41
+ workflow_dispatch:
42
+
43
+ permissions:
44
+ contents: read
45
+
46
+ jobs:
47
+ drift:
48
+ name: 🔒 Ruleset snapshot vs live ruleset
49
+ runs-on: ubuntu-latest
50
+ timeout-minutes: 10
51
+
52
+ steps:
53
+ - name: 📥 Checkout repository
54
+ uses: actions/checkout@v6
55
+ with:
56
+ persist-credentials: false
57
+
58
+ - name: 🔧 Setup Node.js
59
+ uses: actions/setup-node@v6
60
+ with:
61
+ package-manager-cache: false
62
+ node-version: '22.21.1'
63
+
64
+ - name: 🔒 Compare the committed snapshot against the live ruleset
65
+ env:
66
+ GH_TOKEN: ${{ secrets.RULESET_READ_TOKEN || github.token }}
67
+ run: |
68
+ set -euo pipefail
69
+ if [ ! -f scripts/check-skipped-required-checks.mjs ]; then
70
+ echo "scripts/check-skipped-required-checks.mjs not present — project not yet on this template; skipping."
71
+ exit 0
72
+ fi
73
+ if [ ! -f .github/required-checks.json ]; then
74
+ echo "No .github/required-checks.json — nothing to compare. Lisa ships a seed; run \`lisa apply\` to get it."
75
+ exit 0
76
+ fi
77
+ configured="$(node -e "const d=require('./.github/required-checks.json');process.stdout.write(String(Boolean(d.ruleset&&d.ruleset.repo&&Array.isArray(d.ruleset.ids)&&d.ruleset.ids.length)))")"
78
+ if [ "$configured" != "true" ]; then
79
+ echo "UNCONFIGURED: .github/required-checks.json has no \`ruleset.repo\` / \`ruleset.ids\`, so drift cannot be checked."
80
+ echo "Fill them in to arm this: gh api repos/OWNER/NAME/rulesets --jq '.[] | \"\\(.id) \\(.name)\"'"
81
+ echo "::warning title=Required-checks drift UNCHECKED::Fill in ruleset.repo and ruleset.ids in .github/required-checks.json to arm the remote drift check."
82
+ exit 0
83
+ fi
84
+ node scripts/check-skipped-required-checks.mjs --remote
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$schema": "./node_modules/oxlint/configuration_schema.json",
3
- "extends": ["./node_modules/@codyswann/lisa/oxlint/typescript.json"],
3
+ "extends": ["./.lisa/lisa-oxlint/typescript.json"],
4
4
  "ignorePatterns": [
5
5
  "build/**",
6
6
  "dist/**",