@codyswann/lisa 3.11.4 → 3.11.7
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 +9 -8
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/package.json +1 -1
- package/phaser/copy-overwrite/ast-grep/rule-tests/.gitkeep +3 -3
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-parity-safety-net-rules/SKILL.md +18 -3
- package/plugins/lisa/hooks/threshold-ratchet.mjs +173 -9
- package/plugins/lisa/skills/lisa-parity-safety-net-rules/SKILL.md +18 -3
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-agy/skills/lisa-parity-safety-net-rules/SKILL.md +18 -3
- 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-copilot/hooks/threshold-ratchet.mjs +173 -9
- package/plugins/lisa-copilot/skills/lisa-parity-safety-net-rules/SKILL.md +18 -3
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/hooks/threshold-ratchet.mjs +173 -9
- package/plugins/lisa-cursor/skills/lisa-parity-safety-net-rules/SKILL.md +18 -3
- 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/plugins/src/base/hooks/threshold-ratchet.mjs +173 -9
- package/plugins/src/base/skills/lisa-parity-safety-net-rules/SKILL.md +18 -3
- package/rails/copy-overwrite/ast-grep/rule-tests/.gitkeep +3 -3
- package/rails/copy-overwrite/scripts/check-threshold-ratchet.mjs +173 -9
- package/scripts/check-required-check-promotions.mjs +93 -0
- package/typescript/copy-overwrite/ast-grep/rule-tests/.gitkeep +3 -3
- package/typescript/copy-overwrite/scripts/check-threshold-ratchet.mjs +173 -9
|
@@ -24,6 +24,13 @@
|
|
|
24
24
|
* itself an exception in the same change that weakens a gate. `key: "*"`
|
|
25
25
|
* allows every key in the file.
|
|
26
26
|
*
|
|
27
|
+
* One exception, and only one: a PROMOTION between deploy-chain branches
|
|
28
|
+
* (`--base` + `--head`, both named in `deploy.branches`, head upstream of
|
|
29
|
+
* base, head fully containing base). There the allow list is read from the
|
|
30
|
+
* head, because the change under review is the baseline plus history that has
|
|
31
|
+
* already passed this same gate. See `isPromotion` for why that is the only
|
|
32
|
+
* discriminator that holds.
|
|
33
|
+
*
|
|
27
34
|
* Extraction lives in threshold-ratchet-families.mjs; comparison rules in
|
|
28
35
|
* threshold-ratchet-compare.mjs. Zero dependencies.
|
|
29
36
|
*/
|
|
@@ -131,6 +138,148 @@ function resolvePlan(mode, root, baseRef, onlyFiles) {
|
|
|
131
138
|
};
|
|
132
139
|
}
|
|
133
140
|
|
|
141
|
+
/**
|
|
142
|
+
* Strip a remote prefix so `origin/staging` and `staging` compare equal to a
|
|
143
|
+
* branch name declared in `.lisa.config.json`.
|
|
144
|
+
* @param {string} ref Git ref, possibly remote-qualified
|
|
145
|
+
* @returns {string} Bare branch name
|
|
146
|
+
*/
|
|
147
|
+
function bareBranch(ref) {
|
|
148
|
+
return ref.replace(/^refs\/heads\//u, "").replace(/^origin\//u, "");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The deploy chain, earliest environment first, from `deploy.branches`.
|
|
153
|
+
*
|
|
154
|
+
* Declaration order IS the chain order — that is already how Lisa reads it
|
|
155
|
+
* (dev → staging → production), and it is what makes "upstream of" decidable.
|
|
156
|
+
* @param {unknown} config Parsed `.lisa.config.json`
|
|
157
|
+
* @returns {string[]} Branch names in chain order
|
|
158
|
+
*/
|
|
159
|
+
function deployChain(config) {
|
|
160
|
+
const branches = config?.deploy?.branches;
|
|
161
|
+
if (!branches || typeof branches !== "object") return [];
|
|
162
|
+
return Object.values(branches).filter(b => typeof b === "string" && b !== "");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Whether this is a promotion of one deploy-chain branch into the next.
|
|
167
|
+
*
|
|
168
|
+
* A promotion carries approved history into a branch that is behind, so the
|
|
169
|
+
* exemptions it brings with it are not new — each one already faced this gate
|
|
170
|
+
* on the upstream branch. Reading the allow list from the baseline there
|
|
171
|
+
* reports every one of them as newly added, and the documented remedy is
|
|
172
|
+
* circular: recording them means adding `thresholdRatchet.allow` entries,
|
|
173
|
+
* which is itself the Tier 3 change being blocked. That deadlocked the whole
|
|
174
|
+
* promotion lane (#2531).
|
|
175
|
+
*
|
|
176
|
+
* The discriminator is branch IDENTITY, not ancestry. "The head contains the
|
|
177
|
+
* base" is true of any ordinary topic branch that is up to date with its base
|
|
178
|
+
* — and a repository with a strict up-to-date branch-protection rule REQUIRES
|
|
179
|
+
* that of every PR — so ancestry alone would hand self-approval to exactly the
|
|
180
|
+
* changes Tier 3 exists to stop. Being a deploy-chain branch cannot be
|
|
181
|
+
* arranged by a topic branch: those branches are protected, so everything on
|
|
182
|
+
* them arrived through a reviewed PR that passed this same ratchet.
|
|
183
|
+
*
|
|
184
|
+
* Ancestry is still required, as a second condition rather than the only one:
|
|
185
|
+
* a head that has diverged from its base is not "the baseline plus approved
|
|
186
|
+
* history", and the strict reading should stand.
|
|
187
|
+
*
|
|
188
|
+
* The chain is read from the BASELINE config, so a change cannot declare
|
|
189
|
+
* itself a promotion by adding `deploy.branches` entries in the same commit.
|
|
190
|
+
* @param {string} root Repo root
|
|
191
|
+
* @param {unknown} baselineConfig `.lisa.config.json` at the baseline
|
|
192
|
+
* @param {string | undefined} baseRef Ref being merged into
|
|
193
|
+
* @param {string | undefined} headRef Ref being merged from
|
|
194
|
+
* @returns {boolean} True when the allow list may be read from the head
|
|
195
|
+
*/
|
|
196
|
+
function isPromotion(root, baselineConfig, baseRef, headRef) {
|
|
197
|
+
if (!baseRef || !headRef) return false;
|
|
198
|
+
const chain = deployChain(baselineConfig);
|
|
199
|
+
const basePosition = chain.indexOf(bareBranch(baseRef));
|
|
200
|
+
const headPosition = chain.indexOf(bareBranch(headRef));
|
|
201
|
+
if (basePosition === -1 || headPosition === -1) return false;
|
|
202
|
+
if (headPosition >= basePosition) return false;
|
|
203
|
+
// Empty string on success, null when git exits non-zero or the ref is bogus.
|
|
204
|
+
if (git(["merge-base", "--is-ancestor", baseRef, headRef], root) !== null) {
|
|
205
|
+
return true;
|
|
206
|
+
}
|
|
207
|
+
// Both ARE deploy-chain branches, so this is a promotion that has diverged —
|
|
208
|
+
// typically a hotfix that landed on the base and was never synced down. The
|
|
209
|
+
// strict reading is correct here, but silence would leave an operator
|
|
210
|
+
// guessing why this promotion behaves differently from the last one.
|
|
211
|
+
process.stderr.write(
|
|
212
|
+
`threshold-ratchet: ${bareBranch(headRef)} does not contain ` +
|
|
213
|
+
`${bareBranch(baseRef)}, so this promotion is not the baseline plus ` +
|
|
214
|
+
`approved history and the allow list is read from the baseline. Sync ` +
|
|
215
|
+
`${bareBranch(baseRef)} down into ${bareBranch(headRef)} first.\n`
|
|
216
|
+
);
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Resolve the allow list and say where it came from.
|
|
222
|
+
* @param {string} root Repo root
|
|
223
|
+
* @param {string} baselineRef Ref the comparison baselines against
|
|
224
|
+
* @param {"hook"|"staged"|"base"} mode Comparison mode
|
|
225
|
+
* @param {string | undefined} baseRef Base ref (base mode only)
|
|
226
|
+
* @param {string | undefined} headRef Head ref (base mode only)
|
|
227
|
+
* @returns {{ entries: object[], promotion: boolean, note: string | null }}
|
|
228
|
+
* Entries, whether this is a promotion, and an audit line to print when the
|
|
229
|
+
* entries came from anywhere but the baseline
|
|
230
|
+
*/
|
|
231
|
+
function resolveAllowList(root, baselineRef, mode, baseRef, headRef) {
|
|
232
|
+
const baselineConfig = parseJson(
|
|
233
|
+
git(["show", `${baselineRef}:.lisa.config.json`], root)
|
|
234
|
+
);
|
|
235
|
+
if (mode !== "base" || !isPromotion(root, baselineConfig, baseRef, headRef)) {
|
|
236
|
+
return {
|
|
237
|
+
entries: extractAllowEntries(baselineConfig),
|
|
238
|
+
promotion: false,
|
|
239
|
+
note: null,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
return {
|
|
243
|
+
entries: extractAllowEntries(
|
|
244
|
+
parseJson(git(["show", "HEAD:.lisa.config.json"], root))
|
|
245
|
+
),
|
|
246
|
+
promotion: true,
|
|
247
|
+
note:
|
|
248
|
+
`threshold-ratchet: promotion ${bareBranch(headRef)} → ` +
|
|
249
|
+
`${bareBranch(baseRef)}; both are deploy-chain branches declared at ` +
|
|
250
|
+
`${baselineRef} and the head fully contains the base, so the allow list ` +
|
|
251
|
+
`is read from the head. Exemptions below were approved upstream, not by ` +
|
|
252
|
+
`this change.`,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Split off the allow-entry additions a promotion is carrying forward.
|
|
258
|
+
*
|
|
259
|
+
* `applyAllowList` never drops an `allow-added` finding, from either side —
|
|
260
|
+
* an exception must not approve its own creation. That is right for an
|
|
261
|
+
* ordinary PR and wrong for a promotion, where the entry is not being created:
|
|
262
|
+
* it already exists on the upstream branch, where its creation faced this same
|
|
263
|
+
* unconditional block and needed a human to clear it. Without this the fix
|
|
264
|
+
* would be cosmetic — a promotion carrying an approved exemption also carries
|
|
265
|
+
* the `.lisa.config.json` diff that records it, so it would still be blocked
|
|
266
|
+
* by the finding for the record of its own approval.
|
|
267
|
+
*
|
|
268
|
+
* Only `allow-added` is carried. An actual threshold weakening in the same
|
|
269
|
+
* promotion still has to be covered by an allow entry.
|
|
270
|
+
* @param {Array<{ type: string }>} findings All findings from the change
|
|
271
|
+
* @param {boolean} promotion Whether the change is a recognised promotion
|
|
272
|
+
* @returns {{ carried: object[], rest: object[] }} Findings excused as already
|
|
273
|
+
* approved upstream, and findings still subject to the allow list
|
|
274
|
+
*/
|
|
275
|
+
function partitionCarriedEntries(findings, promotion) {
|
|
276
|
+
if (!promotion) return { carried: [], rest: findings };
|
|
277
|
+
return {
|
|
278
|
+
carried: findings.filter(f => f.type === "allow-added"),
|
|
279
|
+
rest: findings.filter(f => f.type !== "allow-added"),
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
134
283
|
/**
|
|
135
284
|
* Decide the exit code when the ratchet cannot determine what changed.
|
|
136
285
|
*
|
|
@@ -161,9 +310,11 @@ function undeterminable(mode, reason) {
|
|
|
161
310
|
* @param {"hook"|"staged"|"base"} mode Comparison mode
|
|
162
311
|
* @param {string | undefined} [baseRef] Base ref (base mode only)
|
|
163
312
|
* @param {string[] | undefined} [onlyFiles] Restrict to these paths (hook mode)
|
|
313
|
+
* @param {string | undefined} [headRef] Head ref (base mode only), used solely
|
|
314
|
+
* to recognise a promotion between deploy-chain branches
|
|
164
315
|
* @returns {number} Process exit code (2 for hook mode, 1 otherwise; 0 clean)
|
|
165
316
|
*/
|
|
166
|
-
function run(mode, baseRef, onlyFiles) {
|
|
317
|
+
function run(mode, baseRef, onlyFiles, headRef) {
|
|
167
318
|
const root = git(["rev-parse", "--show-toplevel"])?.trim();
|
|
168
319
|
if (!root) return undeterminable(mode, "not a git repository");
|
|
169
320
|
const plan = resolvePlan(mode, root, baseRef, onlyFiles);
|
|
@@ -205,13 +356,21 @@ function run(mode, baseRef, onlyFiles) {
|
|
|
205
356
|
);
|
|
206
357
|
if (findings.length === 0) return 0;
|
|
207
358
|
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
359
|
+
const allow = resolveAllowList(
|
|
360
|
+
root,
|
|
361
|
+
plan.baselineRef,
|
|
362
|
+
mode,
|
|
363
|
+
baseRef,
|
|
364
|
+
headRef
|
|
214
365
|
);
|
|
366
|
+
const split = partitionCarriedEntries(findings, allow.promotion);
|
|
367
|
+
const { blocked, allowed } = applyAllowList(split.rest, allow.entries);
|
|
368
|
+
if (allow.note) process.stdout.write(`${allow.note}\n`);
|
|
369
|
+
for (const finding of split.carried) {
|
|
370
|
+
process.stdout.write(
|
|
371
|
+
`threshold-ratchet: carried forward by this promotion, approved upstream — ${finding.message}\n`
|
|
372
|
+
);
|
|
373
|
+
}
|
|
215
374
|
for (const finding of allowed) {
|
|
216
375
|
process.stdout.write(
|
|
217
376
|
`threshold-ratchet: allowed by .lisa.config.json exception — ${finding.message}\n`
|
|
@@ -257,11 +416,16 @@ function runHookMode() {
|
|
|
257
416
|
*/
|
|
258
417
|
function main() {
|
|
259
418
|
const args = process.argv.slice(2);
|
|
419
|
+
const headIndex = args.indexOf("--head");
|
|
420
|
+
const headRef = headIndex === -1 ? undefined : args[headIndex + 1];
|
|
260
421
|
if (args[0] === "--staged") return run("staged");
|
|
261
|
-
|
|
422
|
+
// `--head` is optional and additive: a caller that omits it gets exactly the
|
|
423
|
+
// behavior that shipped before promotions were recognised, so an older
|
|
424
|
+
// workflow driving a newer script stays strict rather than silently relaxing.
|
|
425
|
+
if (args[0] === "--base") return run("base", args[1], undefined, headRef);
|
|
262
426
|
if (args[0] === "--hook") return runHookMode();
|
|
263
427
|
process.stderr.write(
|
|
264
|
-
"usage: threshold-ratchet.mjs --hook | --staged | --base <ref
|
|
428
|
+
"usage: threshold-ratchet.mjs --hook | --staged | --base <ref> [--head <ref>]\n"
|
|
265
429
|
);
|
|
266
430
|
return 0;
|
|
267
431
|
}
|
|
@@ -69,6 +69,48 @@
|
|
|
69
69
|
* `measured_on_subject` separately makes that mechanically visible instead
|
|
70
70
|
* of a footnote in a report.
|
|
71
71
|
*
|
|
72
|
+
* 6. **A worst case must come from a run that COMPLETED, and must be below the
|
|
73
|
+
* budget it justifies** (#2528). Two clauses, one rule:
|
|
74
|
+
*
|
|
75
|
+
* *Do not size a budget from the duration of a run that failed on time.*
|
|
76
|
+
* This is the mirror of clause 3. #2509 says do not size from runs that
|
|
77
|
+
* passed, because the sample excludes the failure mode; the mirror says do
|
|
78
|
+
* not size from the DURATION of a run the budget terminated, because that
|
|
79
|
+
* duration is the starvation, not the work. The failing-run version is
|
|
80
|
+
* strictly worse: it does not merely omit information, it INVERTS the
|
|
81
|
+
* ratio — the more contended the box was, the safer the resulting budget
|
|
82
|
+
* looks. So `observed_on` must say `"pass"`, meaning the measured run
|
|
83
|
+
* completed inside its budget. (That is a statement about TIME, not about
|
|
84
|
+
* the check's verdict: a run that reproduces a real violation and exits 1
|
|
85
|
+
* in 30s completed, and is `"pass"` here.)
|
|
86
|
+
*
|
|
87
|
+
* *And an entry claiming a worst case at or above its own budget refuses
|
|
88
|
+
* itself*, needing no knowledge of the test, the machine, or the workload.
|
|
89
|
+
* #2523 cited 60,245ms while setting the budget to 60,000ms. The ratio in
|
|
90
|
+
* clause 4 already rejects that arithmetically at 0.996x, but it reports a
|
|
91
|
+
* THIN MARGIN when the defect is an IMPOSSIBLE CLAIM, and a message that
|
|
92
|
+
* misnames the defect sends the reader off to re-measure when they should
|
|
93
|
+
* be re-reading. The tell was on the face of the number: a test cannot run
|
|
94
|
+
* 60s against a 10s budget, so 60,245ms was wall clock spent waiting.
|
|
95
|
+
* Re-measured in isolation at load 31 the same test takes 2,499ms — 24x,
|
|
96
|
+
* not 0.996x, a 24-fold error in the unsafe direction.
|
|
97
|
+
*
|
|
98
|
+
* The class is not specific to test timeouts. #2520 chased an actionlint
|
|
99
|
+
* invocation reported as taking "25 minutes". It was not slow: a
|
|
100
|
+
* 3,490-line workflow returns in 0s while a 217-line one hangs, and the
|
|
101
|
+
* minimal repro is 28 lines. It is a spin inside actionlint's shellcheck
|
|
102
|
+
* integration — ~850% CPU across 37 threads, zero children — so it never
|
|
103
|
+
* terminates. The 25 minutes was the observer's patience, not the
|
|
104
|
+
* command's cost, and NO budget would have been generous enough. That is
|
|
105
|
+
* what `observed_on` refuses: a number produced by a run that did not
|
|
106
|
+
* finish is not a measurement of how long the work takes.
|
|
107
|
+
*
|
|
108
|
+
* A missing `observed_on` is refused rather than grandfathered. Every
|
|
109
|
+
* `proven` entry in the ledger when this clause shipped had already
|
|
110
|
+
* recorded, in prose, that its worst case came from runs that completed, so
|
|
111
|
+
* backfilling the field only restates what was already proved. Exempting
|
|
112
|
+
* them would have exempted the only entries the clause could bind on.
|
|
113
|
+
*
|
|
72
114
|
* ## The ratchet, and why incumbents are not simply exempted
|
|
73
115
|
*
|
|
74
116
|
* Contexts already required when this guard shipped may declare
|
|
@@ -315,9 +357,47 @@ function budgetProblems(budget) {
|
|
|
315
357
|
},
|
|
316
358
|
];
|
|
317
359
|
}
|
|
360
|
+
// A budgets[] entry publishes its OWN observed_worst_ms, so it can be a
|
|
361
|
+
// starved figure exactly as the block-level one can. Checking provenance
|
|
362
|
+
// first: a ratio computed from a duration that measures contention is
|
|
363
|
+
// arithmetic on the wrong number, so the reader needs the provenance before
|
|
364
|
+
// the margin.
|
|
365
|
+
const provenance = provenanceProblems(budget.observed_on);
|
|
366
|
+
if (provenance.length > 0) return provenance;
|
|
318
367
|
return ratioProblems(budget.budget_ms, budget.observed_worst_ms);
|
|
319
368
|
}
|
|
320
369
|
|
|
370
|
+
/**
|
|
371
|
+
* Validate where an observed worst case was measured (#2528).
|
|
372
|
+
*
|
|
373
|
+
* `observed_on` records whether the run that produced `observed_worst_ms`
|
|
374
|
+
* COMPLETED within its budget (`"pass"`) or was terminated by it (`"fail"`).
|
|
375
|
+
* That is a claim about time, not about the check's verdict — a run that
|
|
376
|
+
* reproduces a real violation and exits 1 well inside its budget completed.
|
|
377
|
+
*
|
|
378
|
+
* @param {unknown} observedOn - the declared provenance.
|
|
379
|
+
* @returns {{ rule: string, detail: string }[]} problems, empty when sound.
|
|
380
|
+
*/
|
|
381
|
+
function provenanceProblems(observedOn) {
|
|
382
|
+
if (observedOn === "pass") return [];
|
|
383
|
+
if (observedOn === "fail") {
|
|
384
|
+
return [
|
|
385
|
+
{
|
|
386
|
+
rule: "headroom-measured-on-failing-run",
|
|
387
|
+
detail:
|
|
388
|
+
'observed_worst_ms was taken from a run the budget terminated ("observed_on": "fail"); that duration is the starvation, not the work, and the more contended the box was the safer the budget looks — re-measure on a run that completed, in isolation',
|
|
389
|
+
},
|
|
390
|
+
];
|
|
391
|
+
}
|
|
392
|
+
return [
|
|
393
|
+
{
|
|
394
|
+
rule: "headroom-evidence-missing",
|
|
395
|
+
detail:
|
|
396
|
+
'headroom.observed_on must be "pass": the run that produced observed_worst_ms must have COMPLETED within its budget, because a duration reported alongside a timeout measures contention, not cost',
|
|
397
|
+
},
|
|
398
|
+
];
|
|
399
|
+
}
|
|
400
|
+
|
|
321
401
|
/**
|
|
322
402
|
* Compare an observed worst case against its budget.
|
|
323
403
|
*
|
|
@@ -340,6 +420,17 @@ function ratioProblems(budgetMs, observedMs) {
|
|
|
340
420
|
},
|
|
341
421
|
];
|
|
342
422
|
}
|
|
423
|
+
// Checked before the ratio, and reported as its own defect: an entry whose
|
|
424
|
+
// worst case is not below the budget it justifies can never be valid, and
|
|
425
|
+
// "thin margin" would misname it. See clause 6 in the module preamble.
|
|
426
|
+
if (observedMs >= budgetMs) {
|
|
427
|
+
return [
|
|
428
|
+
{
|
|
429
|
+
rule: "headroom-worst-case-exceeds-budget",
|
|
430
|
+
detail: `observed worst ${observedMs}ms is not below the ${budgetMs}ms budget it justifies, so the entry disproves itself; a worst case at or above its own budget is usually elapsed time from a run the budget terminated, which measures contention rather than cost (#2528)`,
|
|
431
|
+
},
|
|
432
|
+
];
|
|
433
|
+
}
|
|
343
434
|
const ratio = budgetMs / observedMs;
|
|
344
435
|
if (ratio < MIN_HEADROOM_RATIO) {
|
|
345
436
|
return [
|
|
@@ -394,6 +485,8 @@ export function headroomProblems(headroom) {
|
|
|
394
485
|
}
|
|
395
486
|
}
|
|
396
487
|
if (problems.length > 0) return problems;
|
|
488
|
+
problems.push(...provenanceProblems(headroom.observed_on));
|
|
489
|
+
if (problems.length > 0) return problems;
|
|
397
490
|
problems.push(
|
|
398
491
|
...ratioProblems(headroom.budget_ms, headroom.observed_worst_ms)
|
|
399
492
|
);
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Test cases for the ast-grep rules in ../rules/.
|
|
2
2
|
#
|
|
3
|
-
# These run.
|
|
4
|
-
#
|
|
5
|
-
#
|
|
3
|
+
# These run. The AST Grep Scan job in Lisa's quality workflow invokes the
|
|
4
|
+
# `ast-grep` binary directly, so a wrong assertion here fails CI whether or not
|
|
5
|
+
# this project has run `lisa apply` since the step shipped. The job counts the YAML files in this directory first: an empty
|
|
6
6
|
# directory is reported as a skipped step with a warning, never as a pass,
|
|
7
7
|
# because `ast-grep test` exits 0 when it finds nothing to run.
|
|
8
8
|
#
|
|
@@ -24,6 +24,13 @@
|
|
|
24
24
|
* itself an exception in the same change that weakens a gate. `key: "*"`
|
|
25
25
|
* allows every key in the file.
|
|
26
26
|
*
|
|
27
|
+
* One exception, and only one: a PROMOTION between deploy-chain branches
|
|
28
|
+
* (`--base` + `--head`, both named in `deploy.branches`, head upstream of
|
|
29
|
+
* base, head fully containing base). There the allow list is read from the
|
|
30
|
+
* head, because the change under review is the baseline plus history that has
|
|
31
|
+
* already passed this same gate. See `isPromotion` for why that is the only
|
|
32
|
+
* discriminator that holds.
|
|
33
|
+
*
|
|
27
34
|
* Extraction lives in threshold-ratchet-families.mjs; comparison rules in
|
|
28
35
|
* threshold-ratchet-compare.mjs. Zero dependencies.
|
|
29
36
|
*/
|
|
@@ -131,6 +138,148 @@ function resolvePlan(mode, root, baseRef, onlyFiles) {
|
|
|
131
138
|
};
|
|
132
139
|
}
|
|
133
140
|
|
|
141
|
+
/**
|
|
142
|
+
* Strip a remote prefix so `origin/staging` and `staging` compare equal to a
|
|
143
|
+
* branch name declared in `.lisa.config.json`.
|
|
144
|
+
* @param {string} ref Git ref, possibly remote-qualified
|
|
145
|
+
* @returns {string} Bare branch name
|
|
146
|
+
*/
|
|
147
|
+
function bareBranch(ref) {
|
|
148
|
+
return ref.replace(/^refs\/heads\//u, "").replace(/^origin\//u, "");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The deploy chain, earliest environment first, from `deploy.branches`.
|
|
153
|
+
*
|
|
154
|
+
* Declaration order IS the chain order — that is already how Lisa reads it
|
|
155
|
+
* (dev → staging → production), and it is what makes "upstream of" decidable.
|
|
156
|
+
* @param {unknown} config Parsed `.lisa.config.json`
|
|
157
|
+
* @returns {string[]} Branch names in chain order
|
|
158
|
+
*/
|
|
159
|
+
function deployChain(config) {
|
|
160
|
+
const branches = config?.deploy?.branches;
|
|
161
|
+
if (!branches || typeof branches !== "object") return [];
|
|
162
|
+
return Object.values(branches).filter(b => typeof b === "string" && b !== "");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Whether this is a promotion of one deploy-chain branch into the next.
|
|
167
|
+
*
|
|
168
|
+
* A promotion carries approved history into a branch that is behind, so the
|
|
169
|
+
* exemptions it brings with it are not new — each one already faced this gate
|
|
170
|
+
* on the upstream branch. Reading the allow list from the baseline there
|
|
171
|
+
* reports every one of them as newly added, and the documented remedy is
|
|
172
|
+
* circular: recording them means adding `thresholdRatchet.allow` entries,
|
|
173
|
+
* which is itself the Tier 3 change being blocked. That deadlocked the whole
|
|
174
|
+
* promotion lane (#2531).
|
|
175
|
+
*
|
|
176
|
+
* The discriminator is branch IDENTITY, not ancestry. "The head contains the
|
|
177
|
+
* base" is true of any ordinary topic branch that is up to date with its base
|
|
178
|
+
* — and a repository with a strict up-to-date branch-protection rule REQUIRES
|
|
179
|
+
* that of every PR — so ancestry alone would hand self-approval to exactly the
|
|
180
|
+
* changes Tier 3 exists to stop. Being a deploy-chain branch cannot be
|
|
181
|
+
* arranged by a topic branch: those branches are protected, so everything on
|
|
182
|
+
* them arrived through a reviewed PR that passed this same ratchet.
|
|
183
|
+
*
|
|
184
|
+
* Ancestry is still required, as a second condition rather than the only one:
|
|
185
|
+
* a head that has diverged from its base is not "the baseline plus approved
|
|
186
|
+
* history", and the strict reading should stand.
|
|
187
|
+
*
|
|
188
|
+
* The chain is read from the BASELINE config, so a change cannot declare
|
|
189
|
+
* itself a promotion by adding `deploy.branches` entries in the same commit.
|
|
190
|
+
* @param {string} root Repo root
|
|
191
|
+
* @param {unknown} baselineConfig `.lisa.config.json` at the baseline
|
|
192
|
+
* @param {string | undefined} baseRef Ref being merged into
|
|
193
|
+
* @param {string | undefined} headRef Ref being merged from
|
|
194
|
+
* @returns {boolean} True when the allow list may be read from the head
|
|
195
|
+
*/
|
|
196
|
+
function isPromotion(root, baselineConfig, baseRef, headRef) {
|
|
197
|
+
if (!baseRef || !headRef) return false;
|
|
198
|
+
const chain = deployChain(baselineConfig);
|
|
199
|
+
const basePosition = chain.indexOf(bareBranch(baseRef));
|
|
200
|
+
const headPosition = chain.indexOf(bareBranch(headRef));
|
|
201
|
+
if (basePosition === -1 || headPosition === -1) return false;
|
|
202
|
+
if (headPosition >= basePosition) return false;
|
|
203
|
+
// Empty string on success, null when git exits non-zero or the ref is bogus.
|
|
204
|
+
if (git(["merge-base", "--is-ancestor", baseRef, headRef], root) !== null) {
|
|
205
|
+
return true;
|
|
206
|
+
}
|
|
207
|
+
// Both ARE deploy-chain branches, so this is a promotion that has diverged —
|
|
208
|
+
// typically a hotfix that landed on the base and was never synced down. The
|
|
209
|
+
// strict reading is correct here, but silence would leave an operator
|
|
210
|
+
// guessing why this promotion behaves differently from the last one.
|
|
211
|
+
process.stderr.write(
|
|
212
|
+
`threshold-ratchet: ${bareBranch(headRef)} does not contain ` +
|
|
213
|
+
`${bareBranch(baseRef)}, so this promotion is not the baseline plus ` +
|
|
214
|
+
`approved history and the allow list is read from the baseline. Sync ` +
|
|
215
|
+
`${bareBranch(baseRef)} down into ${bareBranch(headRef)} first.\n`
|
|
216
|
+
);
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Resolve the allow list and say where it came from.
|
|
222
|
+
* @param {string} root Repo root
|
|
223
|
+
* @param {string} baselineRef Ref the comparison baselines against
|
|
224
|
+
* @param {"hook"|"staged"|"base"} mode Comparison mode
|
|
225
|
+
* @param {string | undefined} baseRef Base ref (base mode only)
|
|
226
|
+
* @param {string | undefined} headRef Head ref (base mode only)
|
|
227
|
+
* @returns {{ entries: object[], promotion: boolean, note: string | null }}
|
|
228
|
+
* Entries, whether this is a promotion, and an audit line to print when the
|
|
229
|
+
* entries came from anywhere but the baseline
|
|
230
|
+
*/
|
|
231
|
+
function resolveAllowList(root, baselineRef, mode, baseRef, headRef) {
|
|
232
|
+
const baselineConfig = parseJson(
|
|
233
|
+
git(["show", `${baselineRef}:.lisa.config.json`], root)
|
|
234
|
+
);
|
|
235
|
+
if (mode !== "base" || !isPromotion(root, baselineConfig, baseRef, headRef)) {
|
|
236
|
+
return {
|
|
237
|
+
entries: extractAllowEntries(baselineConfig),
|
|
238
|
+
promotion: false,
|
|
239
|
+
note: null,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
return {
|
|
243
|
+
entries: extractAllowEntries(
|
|
244
|
+
parseJson(git(["show", "HEAD:.lisa.config.json"], root))
|
|
245
|
+
),
|
|
246
|
+
promotion: true,
|
|
247
|
+
note:
|
|
248
|
+
`threshold-ratchet: promotion ${bareBranch(headRef)} → ` +
|
|
249
|
+
`${bareBranch(baseRef)}; both are deploy-chain branches declared at ` +
|
|
250
|
+
`${baselineRef} and the head fully contains the base, so the allow list ` +
|
|
251
|
+
`is read from the head. Exemptions below were approved upstream, not by ` +
|
|
252
|
+
`this change.`,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Split off the allow-entry additions a promotion is carrying forward.
|
|
258
|
+
*
|
|
259
|
+
* `applyAllowList` never drops an `allow-added` finding, from either side —
|
|
260
|
+
* an exception must not approve its own creation. That is right for an
|
|
261
|
+
* ordinary PR and wrong for a promotion, where the entry is not being created:
|
|
262
|
+
* it already exists on the upstream branch, where its creation faced this same
|
|
263
|
+
* unconditional block and needed a human to clear it. Without this the fix
|
|
264
|
+
* would be cosmetic — a promotion carrying an approved exemption also carries
|
|
265
|
+
* the `.lisa.config.json` diff that records it, so it would still be blocked
|
|
266
|
+
* by the finding for the record of its own approval.
|
|
267
|
+
*
|
|
268
|
+
* Only `allow-added` is carried. An actual threshold weakening in the same
|
|
269
|
+
* promotion still has to be covered by an allow entry.
|
|
270
|
+
* @param {Array<{ type: string }>} findings All findings from the change
|
|
271
|
+
* @param {boolean} promotion Whether the change is a recognised promotion
|
|
272
|
+
* @returns {{ carried: object[], rest: object[] }} Findings excused as already
|
|
273
|
+
* approved upstream, and findings still subject to the allow list
|
|
274
|
+
*/
|
|
275
|
+
function partitionCarriedEntries(findings, promotion) {
|
|
276
|
+
if (!promotion) return { carried: [], rest: findings };
|
|
277
|
+
return {
|
|
278
|
+
carried: findings.filter(f => f.type === "allow-added"),
|
|
279
|
+
rest: findings.filter(f => f.type !== "allow-added"),
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
134
283
|
/**
|
|
135
284
|
* Decide the exit code when the ratchet cannot determine what changed.
|
|
136
285
|
*
|
|
@@ -161,9 +310,11 @@ function undeterminable(mode, reason) {
|
|
|
161
310
|
* @param {"hook"|"staged"|"base"} mode Comparison mode
|
|
162
311
|
* @param {string | undefined} [baseRef] Base ref (base mode only)
|
|
163
312
|
* @param {string[] | undefined} [onlyFiles] Restrict to these paths (hook mode)
|
|
313
|
+
* @param {string | undefined} [headRef] Head ref (base mode only), used solely
|
|
314
|
+
* to recognise a promotion between deploy-chain branches
|
|
164
315
|
* @returns {number} Process exit code (2 for hook mode, 1 otherwise; 0 clean)
|
|
165
316
|
*/
|
|
166
|
-
function run(mode, baseRef, onlyFiles) {
|
|
317
|
+
function run(mode, baseRef, onlyFiles, headRef) {
|
|
167
318
|
const root = git(["rev-parse", "--show-toplevel"])?.trim();
|
|
168
319
|
if (!root) return undeterminable(mode, "not a git repository");
|
|
169
320
|
const plan = resolvePlan(mode, root, baseRef, onlyFiles);
|
|
@@ -205,13 +356,21 @@ function run(mode, baseRef, onlyFiles) {
|
|
|
205
356
|
);
|
|
206
357
|
if (findings.length === 0) return 0;
|
|
207
358
|
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
359
|
+
const allow = resolveAllowList(
|
|
360
|
+
root,
|
|
361
|
+
plan.baselineRef,
|
|
362
|
+
mode,
|
|
363
|
+
baseRef,
|
|
364
|
+
headRef
|
|
214
365
|
);
|
|
366
|
+
const split = partitionCarriedEntries(findings, allow.promotion);
|
|
367
|
+
const { blocked, allowed } = applyAllowList(split.rest, allow.entries);
|
|
368
|
+
if (allow.note) process.stdout.write(`${allow.note}\n`);
|
|
369
|
+
for (const finding of split.carried) {
|
|
370
|
+
process.stdout.write(
|
|
371
|
+
`threshold-ratchet: carried forward by this promotion, approved upstream — ${finding.message}\n`
|
|
372
|
+
);
|
|
373
|
+
}
|
|
215
374
|
for (const finding of allowed) {
|
|
216
375
|
process.stdout.write(
|
|
217
376
|
`threshold-ratchet: allowed by .lisa.config.json exception — ${finding.message}\n`
|
|
@@ -257,11 +416,16 @@ function runHookMode() {
|
|
|
257
416
|
*/
|
|
258
417
|
function main() {
|
|
259
418
|
const args = process.argv.slice(2);
|
|
419
|
+
const headIndex = args.indexOf("--head");
|
|
420
|
+
const headRef = headIndex === -1 ? undefined : args[headIndex + 1];
|
|
260
421
|
if (args[0] === "--staged") return run("staged");
|
|
261
|
-
|
|
422
|
+
// `--head` is optional and additive: a caller that omits it gets exactly the
|
|
423
|
+
// behavior that shipped before promotions were recognised, so an older
|
|
424
|
+
// workflow driving a newer script stays strict rather than silently relaxing.
|
|
425
|
+
if (args[0] === "--base") return run("base", args[1], undefined, headRef);
|
|
262
426
|
if (args[0] === "--hook") return runHookMode();
|
|
263
427
|
process.stderr.write(
|
|
264
|
-
"usage: threshold-ratchet.mjs --hook | --staged | --base <ref
|
|
428
|
+
"usage: threshold-ratchet.mjs --hook | --staged | --base <ref> [--head <ref>]\n"
|
|
265
429
|
);
|
|
266
430
|
return 0;
|
|
267
431
|
}
|