@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.
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +14 -6
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/harper-fabric/package-lisa/package.lisa.json +1 -0
- package/package.json +3 -1
- package/phaser/copy-overwrite/ast-grep/rule-tests/.gitkeep +20 -0
- package/phaser/package-lisa/package.lisa.json +1 -0
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/rails/copy-overwrite/ast-grep/rule-tests/.gitkeep +20 -0
- package/scripts/check-required-check-promotions.mjs +683 -0
- package/typescript/copy-overwrite/ast-grep/rule-tests/.gitkeep +20 -3
- 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
|
-
#
|
|
2
|
-
#
|
|
3
|
-
#
|
|
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
|