@codyswann/lisa 3.9.2 → 3.11.0

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 (61) hide show
  1. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  2. package/dist/core/upstream-evidence-manifest.js +14 -6
  3. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  4. package/harper-fabric/package-lisa/package.lisa.json +1 -0
  5. package/package.json +3 -1
  6. package/phaser/copy-overwrite/ast-grep/rule-tests/.gitkeep +20 -0
  7. package/phaser/package-lisa/package.lisa.json +1 -0
  8. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  9. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  10. package/plugins/lisa-agy/plugin.json +1 -1
  11. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  12. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  13. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  14. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  15. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  16. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  17. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  18. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  19. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  20. package/plugins/lisa-expo-agy/plugin.json +1 -1
  21. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  22. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  23. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  24. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  25. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  26. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  27. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  28. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  29. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  30. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  31. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  32. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  33. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  34. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  35. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  36. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  37. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  38. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  39. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  40. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  41. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  45. package/plugins/lisa-rails-agy/plugin.json +1 -1
  46. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  50. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  51. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  55. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  56. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  58. package/rails/copy-overwrite/ast-grep/rule-tests/.gitkeep +20 -0
  59. package/scripts/check-required-check-promotions.mjs +683 -0
  60. package/typescript/copy-overwrite/ast-grep/rule-tests/.gitkeep +20 -3
  61. package/typescript/package-lisa/package.lisa.json +1 -0
@@ -0,0 +1,683 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * check-required-check-promotions — refuse a required status context whose
4
+ * safety nobody proved (CodySwannGT/lisa#2509).
5
+ *
6
+ * ## The rule
7
+ *
8
+ * **A check may only become a required status context if its budget has proven
9
+ * headroom.** "Proven" means measured against a run that actually REPRODUCED
10
+ * the failure the budget exists to prevent — not inferred from runs that
11
+ * passed. Sizing a budget from passing samples is circular: the sample already
12
+ * excludes the failure mode.
13
+ *
14
+ * ## Why this is a control and not a paragraph
15
+ *
16
+ * Promoting a check to required changes the cost of every marginal time budget
17
+ * on its path from "an agent re-runs" to "nothing merges." Nothing connected
18
+ * those two decisions: promotion happens in `<type>/github-rulesets/*.json` and
19
+ * in `.lisa.config.json`, while budgets live independently in the suites. A
20
+ * check can be promoted carrying a budget that loses occasionally at any load,
21
+ * and the first symptom is a permanently flaky merge gate, org-wide.
22
+ *
23
+ * It ships as a control rather than a rule document because that is what the
24
+ * measurement supports. In this repository, over one session, executable
25
+ * controls were obeyed 50 of 50 times and prose rules roughly 0 of 13 —
26
+ * including by the agents who had just written them. `.claude/rules` also
27
+ * explicitly excludes "prose restating a lint rule, hook, or CI gate."
28
+ *
29
+ * ## What it enforces, and which measured failure each clause came from
30
+ *
31
+ * Every declared required context must have an entry in
32
+ * `.github/required-check-promotions.json`. A context with no entry FAILS —
33
+ * that is the precondition itself. For each entry:
34
+ *
35
+ * 1. **The job must exist and be named exactly right.**
36
+ * `rails/github-rulesets/quality-checks.json` names four contexts, three
37
+ * non-emoji (`Quality Checks / Lint`) and one emoji
38
+ * (`Quality Checks / 🔗 Work-Item Traceability`). Adding a context to that
39
+ * file by symmetry with the TypeScript template produces a context no job
40
+ * ever reports, which blocks every pull request in the repository forever.
41
+ * So the ledger names the workflow and job id, and this guard reads the
42
+ * YAML and compares the `name:` against the context.
43
+ *
44
+ * 2. **The reporting workflow may not be `paths:`-filtered.** Measured on PR
45
+ * #2496: a filtered workflow does not run, so its context never reports and
46
+ * GitHub shows "Expected — waiting for status to be reported" forever. Not
47
+ * "runs and passes" — never reports at all.
48
+ *
49
+ * 3. **The headroom must be evidenced.** A `proven` entry must publish the
50
+ * budget, the observed worst case, the machine conditions it was measured
51
+ * under, and a description of the run that reproduced the failure. A
52
+ * figure without its conditions is not a measurement:
53
+ * `check-learnings-budget` was reported at 45.8s "in isolation" while ~56
54
+ * sibling vitest processes were live, and that retracted number nearly
55
+ * shipped as a permanent comment.
56
+ *
57
+ * 4. **The margin must be at least {@link MIN_HEADROOM_RATIO}x.**
58
+ * `learnings-writer` failed at 10,259ms against a 10,000ms budget — 2.6%
59
+ * over — then passed 5/5 on immediate re-run under UNCHANGED conditions.
60
+ * If the box did not change and the result did, the budget is not measuring
61
+ * the box; it is measuring nothing, because it has no headroom.
62
+ * `plugin-sync-scripts` consumed 92% of its budget (1.09x) and failed 15 of
63
+ * 16 concurrent runs; at 60s (~2.5x) it failed 0 of 16. The floor therefore
64
+ * sits inside (1.09, 2.54], and 2 is the conservative choice in that range.
65
+ *
66
+ * 5. **A budget may not be sized from a different subject.** #2490 raised five
67
+ * budgets and sized `sonar-secrets`' 60s BY ANALOGY — its 16-way paired
68
+ * probe ran against `plugin-sync-scripts`. Declaring `subject` and
69
+ * `measured_on_subject` separately makes that mechanically visible instead
70
+ * of a footnote in a report.
71
+ *
72
+ * ## The ratchet, and why incumbents are not simply exempted
73
+ *
74
+ * Contexts already required when this guard shipped may declare
75
+ * `"status": "grandfathered"`, which turns their problems into reported DEBT
76
+ * (exit 0) rather than violations. That is not an amnesty: the entry must state
77
+ * in `debt` exactly what is unproven, and the context must appear in the
78
+ * ledger's frozen `grandfathered_contexts` list. That list was fixed when the
79
+ * ledger was written, so a NEW promotion cannot buy its way in by claiming to
80
+ * be old. Reddening `main` to punish yesterday's promotions would only get the
81
+ * guard deleted; recording what each incumbent has not proven is the part that
82
+ * keeps working.
83
+ *
84
+ * ## Where it runs
85
+ *
86
+ * `tests/unit/scripts/required-check-promotions.repo.test.ts` calls
87
+ * {@link evaluate} against this repository, so it executes inside
88
+ * `🔍 Quality Checks / 🧪 Run Unit Tests` — itself a required context. An
89
+ * operator can also run `npm run check:required-check-promotions`.
90
+ *
91
+ * Usage:
92
+ * node scripts/check-required-check-promotions.mjs [rootDir] [--json]
93
+ *
94
+ * @module scripts/check-required-check-promotions
95
+ */
96
+ import fs from "node:fs";
97
+ import path from "node:path";
98
+ import { fileURLToPath } from "node:url";
99
+ import yaml from "js-yaml";
100
+
101
+ /** Integration id GitHub Actions reports status checks under. */
102
+ export const ACTIONS_INTEGRATION_ID = 15_368;
103
+
104
+ /** Ledger location, relative to the repository root. */
105
+ export const LEDGER_RELATIVE_PATH = ".github/required-check-promotions.json";
106
+
107
+ /**
108
+ * Smallest acceptable budget-to-observed-worst-case ratio.
109
+ *
110
+ * Derived, not chosen: 1.09x failed 15/16 and ~2.54x failed 0/16 on the only
111
+ * budget in this repository ever proven under load, so the floor lies in
112
+ * (1.09, 2.54]. See the module preamble.
113
+ */
114
+ export const MIN_HEADROOM_RATIO = 2;
115
+
116
+ /** Directory names never scanned for ruleset templates. */
117
+ const SKIPPED_DIRECTORIES = new Set([
118
+ "node_modules",
119
+ "dist",
120
+ "coverage",
121
+ ".git",
122
+ ]);
123
+
124
+ /** Separator GitHub puts between a caller job name and a called job name. */
125
+ const CONTEXT_SEPARATOR = " / ";
126
+
127
+ /** Raised for operator error (bad arguments, unreadable ledger). */
128
+ export class UsageError extends Error {}
129
+
130
+ /**
131
+ * Split a status context into the caller job name and the called job name.
132
+ *
133
+ * A reusable-workflow job reports as `<caller job name> / <called job name>`;
134
+ * a job in the calling workflow reports under its own name alone. Splitting on
135
+ * the FIRST separator only, because a called job name may itself contain one.
136
+ *
137
+ * @param {string} context - the status check context.
138
+ * @returns {{ callerName: string, calledName: string | null }} the two halves.
139
+ */
140
+ export function splitContext(context) {
141
+ const index = context.indexOf(CONTEXT_SEPARATOR);
142
+ if (index === -1) return { callerName: context, calledName: null };
143
+ return {
144
+ callerName: context.slice(0, index),
145
+ calledName: context.slice(index + CONTEXT_SEPARATOR.length),
146
+ };
147
+ }
148
+
149
+ /**
150
+ * Read the required contexts declared by one ruleset template.
151
+ *
152
+ * @param {string} absolute - absolute path to the template JSON.
153
+ * @param {string} relative - path reported as the source.
154
+ * @returns {{ context: string, integrationId: number, source: string }[]} declarations.
155
+ */
156
+ function contextsFromTemplate(absolute, relative) {
157
+ let parsed;
158
+ try {
159
+ parsed = JSON.parse(fs.readFileSync(absolute, "utf8"));
160
+ } catch {
161
+ return [];
162
+ }
163
+ const found = [];
164
+ for (const rule of parsed?.rules ?? []) {
165
+ if (rule?.type !== "required_status_checks") continue;
166
+ for (const check of rule?.parameters?.required_status_checks ?? []) {
167
+ if (typeof check?.context !== "string") continue;
168
+ found.push({
169
+ context: check.context,
170
+ integrationId:
171
+ typeof check.integration_id === "number"
172
+ ? check.integration_id
173
+ : ACTIONS_INTEGRATION_ID,
174
+ source: relative,
175
+ });
176
+ }
177
+ }
178
+ return found;
179
+ }
180
+
181
+ /**
182
+ * Read the per-repository `addRequiredChecks` opt-in.
183
+ *
184
+ * This surface is NOT templated and exists precisely because a Lisa-only
185
+ * context must never ship in a shared template — host projects would inherit a
186
+ * context they never report (the #2476 defect). A guard blind to it would clear
187
+ * every context added this way.
188
+ *
189
+ * @param {string} root - absolute repository root.
190
+ * @returns {{ context: string, integrationId: number, source: string }[]} declarations.
191
+ */
192
+ function contextsFromConfig(root) {
193
+ const absolute = path.join(root, ".lisa.config.json");
194
+ if (!fs.existsSync(absolute)) return [];
195
+ let parsed;
196
+ try {
197
+ parsed = JSON.parse(fs.readFileSync(absolute, "utf8"));
198
+ } catch {
199
+ return [];
200
+ }
201
+ const added = parsed?.github?.rulesets?.addRequiredChecks ?? {};
202
+ const found = [];
203
+ for (const entries of Object.values(added)) {
204
+ for (const entry of Array.isArray(entries) ? entries : []) {
205
+ if (typeof entry?.context !== "string") continue;
206
+ found.push({
207
+ context: entry.context,
208
+ integrationId:
209
+ typeof entry.integration_id === "number"
210
+ ? entry.integration_id
211
+ : ACTIONS_INTEGRATION_ID,
212
+ source: ".lisa.config.json",
213
+ });
214
+ }
215
+ }
216
+ return found;
217
+ }
218
+
219
+ /**
220
+ * Collect every required context this repository declares, from both surfaces.
221
+ *
222
+ * @param {string} root - absolute repository root.
223
+ * @returns {{ context: string, integrationId: number, source: string }[]}
224
+ * declarations, sorted by context then source.
225
+ */
226
+ export function collectDeclaredContexts(root) {
227
+ const found = [];
228
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
229
+ if (!entry.isDirectory() || SKIPPED_DIRECTORIES.has(entry.name)) continue;
230
+ const rulesetDir = path.join(root, entry.name, "github-rulesets");
231
+ if (!fs.existsSync(rulesetDir)) continue;
232
+ for (const file of fs.readdirSync(rulesetDir).sort()) {
233
+ if (!file.endsWith(".json")) continue;
234
+ found.push(
235
+ ...contextsFromTemplate(
236
+ path.join(rulesetDir, file),
237
+ `${entry.name}/github-rulesets/${file}`
238
+ )
239
+ );
240
+ }
241
+ }
242
+ found.push(...contextsFromConfig(root));
243
+ return found.sort(
244
+ (a, b) =>
245
+ a.context.localeCompare(b.context) || a.source.localeCompare(b.source)
246
+ );
247
+ }
248
+
249
+ /**
250
+ * Parse a workflow into the two facts a required context depends on: which
251
+ * jobs it declares, and whether it actually runs on every pull request.
252
+ *
253
+ * @param {string} absolute - absolute path to the workflow YAML.
254
+ * @returns {{ jobs: Map<string, string>, onPullRequest: boolean, pathFilterKeys: string[] } | null}
255
+ * the parsed facts, or null when the file is absent or unparseable.
256
+ */
257
+ export function readWorkflow(absolute) {
258
+ if (!fs.existsSync(absolute)) return null;
259
+ let doc;
260
+ try {
261
+ doc = yaml.load(fs.readFileSync(absolute, "utf8"));
262
+ } catch {
263
+ return null;
264
+ }
265
+ if (typeof doc !== "object" || doc === null) return null;
266
+ // `on:` is a YAML 1.1 boolean. js-yaml's default schema keeps it a string,
267
+ // but a schema change upstream would silently move the key to `true` and a
268
+ // guard reading only one spelling would then report "no pull_request
269
+ // trigger" for every workflow in the fleet.
270
+ const triggers = doc.on ?? doc[true] ?? {};
271
+ const pullRequest =
272
+ typeof triggers === "object" && triggers !== null
273
+ ? triggers.pull_request
274
+ : undefined;
275
+ const onPullRequest =
276
+ (typeof triggers === "object" &&
277
+ triggers !== null &&
278
+ "pull_request" in triggers) ||
279
+ triggers === "pull_request" ||
280
+ (Array.isArray(triggers) && triggers.includes("pull_request"));
281
+ const pathFilterKeys =
282
+ typeof pullRequest === "object" && pullRequest !== null
283
+ ? ["paths", "paths-ignore"].filter(key => key in pullRequest)
284
+ : [];
285
+ const jobs = new Map();
286
+ for (const [id, job] of Object.entries(doc.jobs ?? {})) {
287
+ jobs.set(id, typeof job?.name === "string" ? job.name : id);
288
+ }
289
+ return { jobs, onPullRequest, pathFilterKeys };
290
+ }
291
+
292
+ /**
293
+ * Validate one declared budget inside a headroom block.
294
+ *
295
+ * @param {object} budget - a `headroom.budgets[]` entry.
296
+ * @returns {{ rule: string, detail: string }[]} problems, empty when sound.
297
+ */
298
+ function budgetProblems(budget) {
299
+ const subject = budget?.subject;
300
+ const measuredOn = budget?.measured_on_subject;
301
+ if (typeof subject !== "string" || typeof measuredOn !== "string") {
302
+ return [
303
+ {
304
+ rule: "headroom-evidence-missing",
305
+ detail:
306
+ "every headroom.budgets[] entry needs a subject and a measured_on_subject",
307
+ },
308
+ ];
309
+ }
310
+ if (subject !== measuredOn) {
311
+ return [
312
+ {
313
+ rule: "budget-sized-by-analogy",
314
+ detail: `budget for '${subject}' was measured on '${measuredOn}'; a budget must be measured on the subject that consumes it`,
315
+ },
316
+ ];
317
+ }
318
+ return ratioProblems(budget.budget_ms, budget.observed_worst_ms);
319
+ }
320
+
321
+ /**
322
+ * Compare an observed worst case against its budget.
323
+ *
324
+ * @param {unknown} budgetMs - the declared wall-clock budget.
325
+ * @param {unknown} observedMs - the worst case actually observed.
326
+ * @returns {{ rule: string, detail: string }[]} problems, empty when sound.
327
+ */
328
+ function ratioProblems(budgetMs, observedMs) {
329
+ if (
330
+ typeof budgetMs !== "number" ||
331
+ typeof observedMs !== "number" ||
332
+ budgetMs <= 0 ||
333
+ observedMs <= 0
334
+ ) {
335
+ return [
336
+ {
337
+ rule: "headroom-evidence-missing",
338
+ detail:
339
+ "headroom needs a positive budget_ms and a positive observed_worst_ms",
340
+ },
341
+ ];
342
+ }
343
+ const ratio = budgetMs / observedMs;
344
+ if (ratio < MIN_HEADROOM_RATIO) {
345
+ return [
346
+ {
347
+ rule: "headroom-ratio-too-thin",
348
+ detail: `observed worst ${observedMs}ms against a ${budgetMs}ms budget is ${ratio.toFixed(2)}x; ${MIN_HEADROOM_RATIO}x is the floor`,
349
+ },
350
+ ];
351
+ }
352
+ return [];
353
+ }
354
+
355
+ /**
356
+ * Validate a headroom block.
357
+ *
358
+ * A `grandfathered` block is shape-checked by the caller instead, because its
359
+ * obligation is to NAME what is unproven rather than to prove it.
360
+ *
361
+ * @param {object} headroom - the `headroom` object from a ledger entry.
362
+ * @returns {{ rule: string, detail: string }[]} problems, empty when sound.
363
+ */
364
+ export function headroomProblems(headroom) {
365
+ const status = headroom?.status;
366
+ if (status !== "proven" && status !== "grandfathered") {
367
+ return [
368
+ {
369
+ rule: "unknown-headroom-status",
370
+ detail: `headroom.status must be 'proven' or 'grandfathered', got '${String(status)}'`,
371
+ },
372
+ ];
373
+ }
374
+ if (status === "grandfathered") return [];
375
+ const problems = [];
376
+ const prose = [
377
+ [
378
+ "reproduced",
379
+ "headroom.reproduced must describe the run that reproduced the failure the budget prevents",
380
+ ],
381
+ [
382
+ "measured_on",
383
+ "headroom.measured_on must name what was measured, so the next person can re-measure it",
384
+ ],
385
+ [
386
+ "conditions",
387
+ "headroom.conditions must state the machine state the measurement was taken under",
388
+ ],
389
+ ];
390
+ for (const [field, detail] of prose) {
391
+ const value = headroom[field];
392
+ if (typeof value !== "string" || value.trim() === "") {
393
+ problems.push({ rule: "headroom-evidence-missing", detail });
394
+ }
395
+ }
396
+ if (problems.length > 0) return problems;
397
+ problems.push(
398
+ ...ratioProblems(headroom.budget_ms, headroom.observed_worst_ms)
399
+ );
400
+ if (problems.length > 0) return problems;
401
+ for (const budget of headroom.budgets ?? []) {
402
+ problems.push(...budgetProblems(budget));
403
+ }
404
+ return problems;
405
+ }
406
+
407
+ /**
408
+ * Verify that the workflow wiring a ledger entry declares actually reports the
409
+ * context it claims to.
410
+ *
411
+ * @param {object} entry - the ledger entry.
412
+ * @param {string} root - absolute repository root.
413
+ * @returns {{ rule: string, detail: string }[]} problems, empty when sound.
414
+ */
415
+ export function wiringProblems(entry, root) {
416
+ const { callerName, calledName } = splitContext(entry.context);
417
+ if (typeof entry.caller_workflow !== "string") {
418
+ return [
419
+ {
420
+ rule: "caller-workflow-missing",
421
+ detail:
422
+ "an Actions-reported context must declare caller_workflow and caller_job",
423
+ },
424
+ ];
425
+ }
426
+ const caller = readWorkflow(path.join(root, entry.caller_workflow));
427
+ if (caller === null) {
428
+ return [
429
+ {
430
+ rule: "caller-workflow-missing",
431
+ detail: `caller_workflow '${entry.caller_workflow}' is absent or unparseable`,
432
+ },
433
+ ];
434
+ }
435
+ const problems = [];
436
+ if (!caller.onPullRequest) {
437
+ problems.push({
438
+ rule: "pull-request-trigger-missing",
439
+ detail: `'${entry.caller_workflow}' does not run on pull_request, so this context never reports on a pull request`,
440
+ });
441
+ }
442
+ if (caller.pathFilterKeys.length > 0) {
443
+ problems.push({
444
+ rule: "path-filtered-workflow",
445
+ detail: `'${entry.caller_workflow}' filters pull_request by ${caller.pathFilterKeys.join("/")}; a filtered workflow does not run, so a required context on it waits forever (#2496)`,
446
+ });
447
+ }
448
+ problems.push(
449
+ ...jobNameProblems(caller, entry.caller_job, callerName, "caller")
450
+ );
451
+ if (calledName !== null) {
452
+ problems.push(...calledJobProblems(entry, root, calledName));
453
+ }
454
+ return problems;
455
+ }
456
+
457
+ /**
458
+ * Compare a workflow job's display name against the half of the context it is
459
+ * supposed to report as.
460
+ *
461
+ * @param {{ jobs: Map<string, string> }} workflow - parsed workflow.
462
+ * @param {unknown} jobId - the job id the ledger declares.
463
+ * @param {string} expected - the context half the job must render as.
464
+ * @param {"caller" | "called"} side - which half, for the rule name.
465
+ * @returns {{ rule: string, detail: string }[]} problems, empty when sound.
466
+ */
467
+ function jobNameProblems(workflow, jobId, expected, side) {
468
+ if (typeof jobId !== "string" || !workflow.jobs.has(jobId)) {
469
+ return [
470
+ {
471
+ rule: `${side}-job-missing`,
472
+ detail: `no job '${String(jobId)}' in the ${side} workflow; a context no job reports blocks every pull request forever`,
473
+ },
474
+ ];
475
+ }
476
+ const actual = workflow.jobs.get(jobId);
477
+ if (actual !== expected) {
478
+ return [
479
+ {
480
+ rule: `${side}-job-name-mismatch`,
481
+ detail: `job '${jobId}' is named '${actual}' but the context expects '${expected}'`,
482
+ },
483
+ ];
484
+ }
485
+ return [];
486
+ }
487
+
488
+ /**
489
+ * Verify the reusable workflow half of a `caller / called` context.
490
+ *
491
+ * @param {object} entry - the ledger entry.
492
+ * @param {string} root - absolute repository root.
493
+ * @param {string} calledName - the expected called-job display name.
494
+ * @returns {{ rule: string, detail: string }[]} problems, empty when sound.
495
+ */
496
+ function calledJobProblems(entry, root, calledName) {
497
+ if (typeof entry.called_workflow !== "string") {
498
+ return [
499
+ {
500
+ rule: "called-workflow-missing",
501
+ detail: `context '${entry.context}' names a reusable-workflow job, so the entry must declare called_workflow and job_id`,
502
+ },
503
+ ];
504
+ }
505
+ const called = readWorkflow(path.join(root, entry.called_workflow));
506
+ if (called === null) {
507
+ return [
508
+ {
509
+ rule: "called-workflow-missing",
510
+ detail: `called_workflow '${entry.called_workflow}' is absent or unparseable`,
511
+ },
512
+ ];
513
+ }
514
+ return jobNameProblems(called, entry.job_id, calledName, "called");
515
+ }
516
+
517
+ /**
518
+ * Read the promotion ledger.
519
+ *
520
+ * @param {string} root - absolute repository root.
521
+ * @returns {object | null} the parsed ledger, or null when absent/unparseable.
522
+ */
523
+ export function loadLedger(root) {
524
+ const absolute = path.join(root, LEDGER_RELATIVE_PATH);
525
+ if (!fs.existsSync(absolute)) return null;
526
+ try {
527
+ return JSON.parse(fs.readFileSync(absolute, "utf8"));
528
+ } catch {
529
+ return null;
530
+ }
531
+ }
532
+
533
+ /**
534
+ * Check one matched ledger entry and file its problems as violations or debt.
535
+ *
536
+ * @param {object} args - entry, declaration, root, frozen set, and sinks.
537
+ * @returns {void}
538
+ */
539
+ function evaluateEntry({ entry, integrationId, root, frozen, out }) {
540
+ const grandfathered = entry.headroom?.status === "grandfathered";
541
+ const file = (rule, detail) =>
542
+ (grandfathered ? out.debts : out.violations).push({
543
+ context: entry.context,
544
+ rule,
545
+ detail,
546
+ });
547
+ if (grandfathered) {
548
+ if (!frozen.has(entry.context)) {
549
+ out.violations.push({
550
+ context: entry.context,
551
+ rule: "grandfather-not-frozen",
552
+ detail:
553
+ "only contexts already required when the ledger was written may be grandfathered; a new promotion must prove its headroom",
554
+ });
555
+ return;
556
+ }
557
+ const debt = entry.headroom?.debt;
558
+ if (typeof debt !== "string" || debt.trim() === "") {
559
+ out.violations.push({
560
+ context: entry.context,
561
+ rule: "grandfather-missing-debt",
562
+ detail:
563
+ "a grandfathered entry must say in `debt` exactly what about its headroom is unproven",
564
+ });
565
+ return;
566
+ }
567
+ out.debts.push({
568
+ context: entry.context,
569
+ rule: "grandfathered-headroom",
570
+ detail: debt,
571
+ });
572
+ }
573
+ if (integrationId === ACTIONS_INTEGRATION_ID) {
574
+ for (const p of wiringProblems(entry, root)) file(p.rule, p.detail);
575
+ }
576
+ for (const p of headroomProblems(entry.headroom)) file(p.rule, p.detail);
577
+ }
578
+
579
+ /**
580
+ * Evaluate every required context this repository declares against the ledger.
581
+ *
582
+ * @param {string} root - absolute repository root.
583
+ * @returns {{ violations: {context: string|null, rule: string, detail: string}[],
584
+ * debts: {context: string|null, rule: string, detail: string}[], covered: number }}
585
+ * the report. A non-empty `violations` is a failed promotion precondition.
586
+ */
587
+ export function evaluate(root) {
588
+ const out = { violations: [], debts: [], covered: 0 };
589
+ const ledger = loadLedger(root);
590
+ if (ledger === null) {
591
+ out.violations.push({
592
+ context: null,
593
+ rule: "ledger-missing",
594
+ detail: `${LEDGER_RELATIVE_PATH} is absent or unparseable; refusing to report that promotions are sound when nothing was read`,
595
+ });
596
+ return out;
597
+ }
598
+ const frozen = new Set(ledger.grandfathered_contexts ?? []);
599
+ const declared = new Map();
600
+ for (const d of collectDeclaredContexts(root)) {
601
+ if (!declared.has(d.context)) declared.set(d.context, d.integrationId);
602
+ }
603
+ const seen = new Set();
604
+ for (const entry of ledger.promotions ?? []) {
605
+ if (seen.has(entry.context)) {
606
+ out.violations.push({
607
+ context: entry.context,
608
+ rule: "duplicate-promotion-entry",
609
+ detail: "the ledger records this context more than once",
610
+ });
611
+ continue;
612
+ }
613
+ seen.add(entry.context);
614
+ if (!declared.has(entry.context)) {
615
+ out.violations.push({
616
+ context: entry.context,
617
+ rule: "orphan-promotion-entry",
618
+ detail:
619
+ "the ledger promotes a context no ruleset template or config declares; delete the entry or restore the declaration",
620
+ });
621
+ continue;
622
+ }
623
+ out.covered += 1;
624
+ evaluateEntry({
625
+ entry,
626
+ integrationId: declared.get(entry.context),
627
+ root,
628
+ frozen,
629
+ out,
630
+ });
631
+ }
632
+ for (const context of declared.keys()) {
633
+ if (seen.has(context)) continue;
634
+ out.violations.push({
635
+ context,
636
+ rule: "missing-promotion-entry",
637
+ detail: `'${context}' is required but has no entry in ${LEDGER_RELATIVE_PATH}; a promotion must record what proves it safe`,
638
+ });
639
+ }
640
+ return out;
641
+ }
642
+
643
+ /**
644
+ * Render a human-readable report.
645
+ *
646
+ * @param {ReturnType<typeof evaluate>} result - the evaluation.
647
+ * @returns {string} the report text.
648
+ */
649
+ export function formatReport(result) {
650
+ const lines = [
651
+ `Required-check promotions: ${result.covered} recorded, ${result.violations.length} violation(s), ${result.debts.length} outstanding debt(s).`,
652
+ ];
653
+ for (const v of result.violations) {
654
+ lines.push(` ✖ [${v.rule}] ${v.context ?? "(ledger)"}: ${v.detail}`);
655
+ }
656
+ for (const d of result.debts) {
657
+ lines.push(` • [${d.rule}] ${d.context ?? "(ledger)"}: ${d.detail}`);
658
+ }
659
+ if (result.violations.length === 0) {
660
+ lines.push(
661
+ "No unproven promotion. Debt lines are recorded, not enforced — see #2509."
662
+ );
663
+ }
664
+ return lines.join("\n");
665
+ }
666
+
667
+ /**
668
+ * CLI entry point.
669
+ *
670
+ * @returns {void}
671
+ */
672
+ function main() {
673
+ const args = process.argv.slice(2);
674
+ const json = args.includes("--json");
675
+ const root = path.resolve(args.find(a => !a.startsWith("--")) ?? ".");
676
+ const result = evaluate(root);
677
+ console.log(json ? JSON.stringify(result, null, 2) : formatReport(result));
678
+ if (result.violations.length > 0) process.exitCode = 1;
679
+ }
680
+
681
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
682
+ main();
683
+ }
@@ -1,3 +1,20 @@
1
- # This directory contains test cases for ast-grep rules.
2
- # Each test file should match its corresponding rule file name.
3
- # See: https://ast-grep.github.io/reference/yaml.html#rule-tests
1
+ # Test cases for the ast-grep rules in ../rules/.
2
+ #
3
+ # These run. `ast-grep test` is wired to the `sg:test` package script and to
4
+ # the AST Grep Scan job in Lisa's quality workflow, so a wrong assertion here
5
+ # fails CI. The job counts the YAML files in this directory first: an empty
6
+ # directory is reported as a skipped step with a warning, never as a pass,
7
+ # because `ast-grep test` exits 0 when it finds nothing to run.
8
+ #
9
+ # A test file binds to a rule by its `id:` field, not by its filename — the
10
+ # `<rule-id>-test.yml` convention is for humans reading the tree. Each case
11
+ # listed under `valid:` must produce no diagnostic and each under `invalid:`
12
+ # must produce one, so every rule is asserted in both directions.
13
+ #
14
+ # Every `invalid:` case also needs a snapshot under __snapshots__/ pinning
15
+ # where the rule matched; a missing snapshot fails the run rather than being
16
+ # skipped. Record them with:
17
+ #
18
+ # ast-grep test --update-all (or: <package manager> run sg:test -U)
19
+ #
20
+ # See: https://ast-grep.github.io/guide/test-rule.html