@codyswann/lisa 4.71.6 → 4.71.8
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/all/copy-overwrite/scripts/check-threshold-ratchet.mjs +571 -0
- package/all/copy-overwrite/scripts/check-workflow-load-failures.mjs +31 -9
- package/all/copy-overwrite/scripts/lib/automation-provenance-contract.mjs +19 -0
- package/all/copy-overwrite/scripts/lib/automation-provenance-files.mjs +49 -0
- package/all/copy-overwrite/scripts/lib/automation-provenance-local.mjs +54 -38
- package/all/copy-overwrite/scripts/lib/npm-update-allocate.mjs +8 -2
- package/all/copy-overwrite/scripts/lib/npm-update-bun.mjs +189 -0
- package/all/copy-overwrite/scripts/lib/npm-update-cancel-origin.mjs +7 -3
- package/all/copy-overwrite/scripts/lib/npm-update-cancellation-proof.mjs +6 -1
- package/all/copy-overwrite/scripts/lib/npm-update-contract.mjs +26 -5
- package/all/copy-overwrite/scripts/lib/npm-update-gate.mjs +8 -5
- package/all/copy-overwrite/scripts/lib/npm-update-github.mjs +2 -2
- package/all/copy-overwrite/scripts/lib/npm-update-helper-inventory.mjs +3 -4
- package/all/copy-overwrite/scripts/lib/npm-update-hook-installation.mjs +68 -1
- package/all/copy-overwrite/scripts/lib/npm-update-hosted-gate.mjs +13 -80
- package/all/copy-overwrite/scripts/lib/npm-update-leaf-contract.mjs +6 -5
- package/all/copy-overwrite/scripts/lib/npm-update-prepare.mjs +35 -7
- package/all/copy-overwrite/scripts/lib/npm-update-publish.mjs +1 -1
- package/all/copy-overwrite/scripts/npm-updater-helper-graph.json +43 -16
- package/all/copy-overwrite/scripts/threshold-ratchet-compare.mjs +521 -0
- package/all/copy-overwrite/scripts/threshold-ratchet-families.mjs +546 -0
- package/dist/configs/repo-scan.js +1 -1
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +62 -0
- package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
- package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +46 -32
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/package.json +4 -4
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/hooks/threshold-ratchet-compare.mjs +3 -3
- package/plugins/lisa/hooks/threshold-ratchet.mjs +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-copilot/hooks/threshold-ratchet-compare.mjs +3 -3
- package/plugins/lisa-copilot/hooks/threshold-ratchet.mjs +1 -1
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/hooks/threshold-ratchet-compare.mjs +3 -3
- package/plugins/lisa-cursor/hooks/threshold-ratchet.mjs +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/plugins/materialized-artifacts.json +3 -0
- package/plugins/src/base/hooks/threshold-ratchet-compare.mjs +3 -3
- package/plugins/src/base/hooks/threshold-ratchet.mjs +1 -1
- package/rails/copy-overwrite/scripts/check-threshold-ratchet.mjs +1 -1
- package/rails/copy-overwrite/scripts/threshold-ratchet-compare.mjs +3 -3
- package/scripts/build-plugins.sh +2 -1
- package/scripts/generate-scratch-supervisor-profile.mjs +1 -1
- package/scripts/two-channel-couplings.json +9 -7
- package/typescript/copy-overwrite/scripts/check-skipped-required-checks.mjs +22 -3
- package/typescript/copy-overwrite/scripts/check-threshold-ratchet.mjs +1 -1
- package/typescript/copy-overwrite/scripts/threshold-ratchet-compare.mjs +3 -3
- /package/{rails → all}/copy-contents/scripts/lisa-mutation.sh +0 -0
- /package/{rails → all}/copy-overwrite/scripts/lisa-clean-git-env.sh +0 -0
- /package/{rails → all}/copy-overwrite/scripts/lisa-scratch-run.sh +0 -0
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
// This file is managed by Lisa and IS replaced on each `lisa` run.
|
|
2
|
+
// Do not edit directly — durable changes belong upstream in Lisa.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Threshold ratchet — comparison rules and reporting.
|
|
6
|
+
*
|
|
7
|
+
* Pure comparison layer: given a watched file's baseline and current
|
|
8
|
+
* contents, report every weakening. No filesystem or git access. See
|
|
9
|
+
* threshold-ratchet-families.mjs for extraction and threshold-ratchet.mjs
|
|
10
|
+
* for the CLI.
|
|
11
|
+
*/
|
|
12
|
+
import {
|
|
13
|
+
ALLOW_STATE,
|
|
14
|
+
classifyAllowEntry,
|
|
15
|
+
describeAllowScope,
|
|
16
|
+
extractAllowEntries,
|
|
17
|
+
extractK6Constraints,
|
|
18
|
+
extractLighthouseAssertions,
|
|
19
|
+
extractNumericLeaves,
|
|
20
|
+
extractRubocopThresholds,
|
|
21
|
+
extractStrykerConstraints,
|
|
22
|
+
extractStrykerMutate,
|
|
23
|
+
familyFor,
|
|
24
|
+
globRoot,
|
|
25
|
+
parseJson,
|
|
26
|
+
} from "./threshold-ratchet-families.mjs";
|
|
27
|
+
|
|
28
|
+
/** Finding type: a numeric bound or boolean gate moved the weakening way. */
|
|
29
|
+
const TYPE_WEAKENED = "weakened";
|
|
30
|
+
/** Finding type: a gate-shrinking exemption was added (Tier 3). */
|
|
31
|
+
const TYPE_EXEMPTION_ADDED = "exemption-added";
|
|
32
|
+
/** Family kind for .lisa.config.json (the thresholdRatchet.allow carrier). */
|
|
33
|
+
const KIND_ALLOW_LIST = "allow-list";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @typedef {object} Finding
|
|
37
|
+
* @property {string} file Repo-relative path of the gate file
|
|
38
|
+
* @property {string} key Dotted key path within the file
|
|
39
|
+
* @property {"weakened"|"removed"|"exemption-added"|"file-deleted"|"allow-added"|"unparseable"|"unparseable-baseline"} type
|
|
40
|
+
* Which ratchet rule the change violated
|
|
41
|
+
* @property {number|string} [base] Baseline value
|
|
42
|
+
* @property {number|string} [current] Current value
|
|
43
|
+
* @property {string} message Operator-readable explanation
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Build the "file could not be parsed" finding.
|
|
48
|
+
* @param {string} relPath Repo-relative path
|
|
49
|
+
* @returns {Finding} The unparseable-file finding
|
|
50
|
+
*/
|
|
51
|
+
function unparseable(relPath) {
|
|
52
|
+
return {
|
|
53
|
+
file: relPath,
|
|
54
|
+
key: "*",
|
|
55
|
+
type: "unparseable",
|
|
56
|
+
message: `${relPath} is no longer valid JSON — a broken gate file disables the gate.`,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Build the "baseline could not be parsed" finding.
|
|
62
|
+
*
|
|
63
|
+
* Separate from `unparseable` because the two send an operator to different
|
|
64
|
+
* files. Told only that `vitest.thresholds.json` is not valid JSON, they open
|
|
65
|
+
* the current file, find it well-formed, and conclude the gate is broken; the
|
|
66
|
+
* defect is at the base ref.
|
|
67
|
+
* @param {string} relPath Repo-relative path
|
|
68
|
+
* @returns {Finding} The unparseable-baseline finding
|
|
69
|
+
*/
|
|
70
|
+
function unparseableBaseline(relPath) {
|
|
71
|
+
return {
|
|
72
|
+
file: relPath,
|
|
73
|
+
key: "*",
|
|
74
|
+
type: "unparseable-baseline",
|
|
75
|
+
message: `${relPath} is not valid JSON in the baseline — with no baseline to compare against, the ratchet cannot see a loosening in this file and will not see one in any later change either, until the baseline is repaired. A UTF-8 BOM, a trailing comma or an empty file all land here.`,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Compare two constraint maps: report removals and direction violations.
|
|
81
|
+
* @param {string} relPath Repo-relative path the constraints came from
|
|
82
|
+
* @param {Map<string, { value: number, direction: "min"|"max" }>} base
|
|
83
|
+
* Baseline constraints
|
|
84
|
+
* @param {Map<string, { value: number, direction: "min"|"max" }>} current
|
|
85
|
+
* Current constraints
|
|
86
|
+
* @returns {Finding[]} One finding per removed or weakened constraint
|
|
87
|
+
*/
|
|
88
|
+
export function compareConstraints(relPath, base, current) {
|
|
89
|
+
const findings = [];
|
|
90
|
+
for (const [key, baseC] of base) {
|
|
91
|
+
const currentC = current.get(key);
|
|
92
|
+
if (!currentC) {
|
|
93
|
+
findings.push({
|
|
94
|
+
file: relPath,
|
|
95
|
+
key,
|
|
96
|
+
type: "removed",
|
|
97
|
+
base: baseC.value,
|
|
98
|
+
message: `${relPath}: ${key} was removed — the tuned floor would silently fall back to a default.`,
|
|
99
|
+
});
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
// A bound's DIRECTION carries as much of the gate as its number. Flipping
|
|
103
|
+
// `rate>=0.99` to `rate<=0.99` keeps the key and the value and inverts the
|
|
104
|
+
// meaning: "at least 99% success" becomes "at most 99% success", a gate
|
|
105
|
+
// that now passes when the system is broken. Comparing only values, that
|
|
106
|
+
// read as unchanged.
|
|
107
|
+
//
|
|
108
|
+
// Rejected rather than re-evaluated in the new direction, because the two
|
|
109
|
+
// bounds are not commensurable — there is no value at which `<=0.99` is
|
|
110
|
+
// "no weaker than" `>=0.99`. The honest verdict is that the change cannot
|
|
111
|
+
// be proven safe, so it belongs in the existing allow-list path where a
|
|
112
|
+
// human records why, not in a comparison that would have to invent an
|
|
113
|
+
// ordering between incomparable gates.
|
|
114
|
+
if (currentC.direction !== baseC.direction) {
|
|
115
|
+
findings.push({
|
|
116
|
+
file: relPath,
|
|
117
|
+
key,
|
|
118
|
+
type: TYPE_WEAKENED,
|
|
119
|
+
base: baseC.value,
|
|
120
|
+
current: currentC.value,
|
|
121
|
+
message: `${relPath}: ${key} changed bound direction (${baseC.direction} → ${currentC.direction}) — the gate's meaning is inverted, so preserving it cannot be proven from the value alone.`,
|
|
122
|
+
});
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const weakened =
|
|
126
|
+
baseC.direction === "min"
|
|
127
|
+
? currentC.value < baseC.value
|
|
128
|
+
: currentC.value > baseC.value;
|
|
129
|
+
if (weakened) {
|
|
130
|
+
const verb =
|
|
131
|
+
baseC.direction === "min" ? "may only increase" : "may only decrease";
|
|
132
|
+
findings.push({
|
|
133
|
+
file: relPath,
|
|
134
|
+
key,
|
|
135
|
+
type: TYPE_WEAKENED,
|
|
136
|
+
base: baseC.value,
|
|
137
|
+
current: currentC.value,
|
|
138
|
+
message: `${relPath}: ${key} changed ${baseC.value} → ${currentC.value} (this value ${verb}).`,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return findings;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Compare a stryker.conf.json pair: the break threshold plus mutate-list
|
|
147
|
+
* exemptions (new negations or removed targets shrink the gate).
|
|
148
|
+
* @param {string} relPath Repo-relative path
|
|
149
|
+
* @param {unknown} base Parsed baseline config
|
|
150
|
+
* @param {unknown} current Parsed current config
|
|
151
|
+
* @returns {Finding[]} Break-threshold and mutate-scope findings
|
|
152
|
+
*/
|
|
153
|
+
function compareStryker(relPath, base, current) {
|
|
154
|
+
const findings = compareConstraints(
|
|
155
|
+
relPath,
|
|
156
|
+
extractStrykerConstraints(base),
|
|
157
|
+
extractStrykerConstraints(current)
|
|
158
|
+
);
|
|
159
|
+
const baseMutate = extractStrykerMutate(base);
|
|
160
|
+
const currentMutate = extractStrykerMutate(current);
|
|
161
|
+
// Roots the gate already covered, and roots this change adds. An exclusion
|
|
162
|
+
// scoped to a newly added root narrows territory that was not being mutated
|
|
163
|
+
// a moment ago, so it cannot shrink the gate — the change is a net widening.
|
|
164
|
+
//
|
|
165
|
+
// Without this, repairing an INERT `mutate` list is unreachable without a
|
|
166
|
+
// human-approved ratchet exception: the repair necessarily adds source roots
|
|
167
|
+
// AND the test-file exclusions that belong with them, and every one of those
|
|
168
|
+
// exclusions read as a weakening. Measured on two repositories whose real
|
|
169
|
+
// effect was 0 -> 69 and 0 -> 75 files mutated (CodySwannGT/lisa#4243).
|
|
170
|
+
const baseRoots = [...baseMutate.positives].map(globRoot);
|
|
171
|
+
/**
|
|
172
|
+
* Whether an exclusion might touch any baseline positive.
|
|
173
|
+
*
|
|
174
|
+
* Both containment directions matter: excluding `src` removes a baseline
|
|
175
|
+
* rooted at `src/existing`, too. An empty prefix (wildcards, braces, classes
|
|
176
|
+
* or extglobs at the root) might cover everything. We grant an exemption
|
|
177
|
+
* only for provably disjoint territory, never from a guessed glob overlap.
|
|
178
|
+
* @param {string} root A rooted glob prefix
|
|
179
|
+
* @returns {boolean} True when overlap cannot be ruled out
|
|
180
|
+
*/
|
|
181
|
+
const overlapsBase = root =>
|
|
182
|
+
baseRoots.some(
|
|
183
|
+
base =>
|
|
184
|
+
root === "" ||
|
|
185
|
+
base === "" ||
|
|
186
|
+
root === base ||
|
|
187
|
+
root.startsWith(`${base}/`) ||
|
|
188
|
+
base.startsWith(`${root}/`)
|
|
189
|
+
);
|
|
190
|
+
const addedRoots = [...currentMutate.positives]
|
|
191
|
+
.filter(positive => !baseMutate.positives.has(positive))
|
|
192
|
+
.map(globRoot)
|
|
193
|
+
.filter(root => root !== "");
|
|
194
|
+
/**
|
|
195
|
+
* Whether a root lies inside territory THIS change is adding.
|
|
196
|
+
* @param {string} root A rooted glob prefix
|
|
197
|
+
* @returns {boolean} True when an added root equals or contains it
|
|
198
|
+
*/
|
|
199
|
+
const underAdded = root =>
|
|
200
|
+
addedRoots.some(added => root === added || root.startsWith(`${added}/`));
|
|
201
|
+
for (const negation of currentMutate.negations) {
|
|
202
|
+
if (baseMutate.negations.has(negation)) continue;
|
|
203
|
+
const root = globRoot(negation);
|
|
204
|
+
if (!overlapsBase(root) && underAdded(root)) continue;
|
|
205
|
+
findings.push({
|
|
206
|
+
file: relPath,
|
|
207
|
+
key: `mutate ${negation}`,
|
|
208
|
+
type: TYPE_EXEMPTION_ADDED,
|
|
209
|
+
message: `${relPath}: new mutation-testing exclusion "${negation}" — excluding files from a gate is a weakening.`,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
for (const positive of baseMutate.positives) {
|
|
213
|
+
if (!currentMutate.positives.has(positive)) {
|
|
214
|
+
findings.push({
|
|
215
|
+
file: relPath,
|
|
216
|
+
key: `mutate ${positive}`,
|
|
217
|
+
type: TYPE_EXEMPTION_ADDED,
|
|
218
|
+
message: `${relPath}: mutation-testing target "${positive}" was removed — shrinking a gate's coverage is a weakening.`,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return findings;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Compare a k6 thresholds pair: numeric bounds plus abortOnFail downgrades.
|
|
227
|
+
* @param {string} relPath Repo-relative path
|
|
228
|
+
* @param {unknown} base Parsed baseline thresholds
|
|
229
|
+
* @param {unknown} current Parsed current thresholds
|
|
230
|
+
* @returns {Finding[]} Bound and abortOnFail findings
|
|
231
|
+
*/
|
|
232
|
+
function compareK6(relPath, base, current) {
|
|
233
|
+
const baseC = extractK6Constraints(base);
|
|
234
|
+
const currentC = extractK6Constraints(current);
|
|
235
|
+
const findings = compareConstraints(relPath, baseC.numeric, currentC.numeric);
|
|
236
|
+
for (const [key, wasOn] of baseC.booleans) {
|
|
237
|
+
// k6 defaults abortOnFail to false, so DELETING an explicit `true` is as
|
|
238
|
+
// much a weakening as flipping it — anything but a current `true` blocks.
|
|
239
|
+
if (wasOn && currentC.booleans.get(key) !== true) {
|
|
240
|
+
findings.push({
|
|
241
|
+
file: relPath,
|
|
242
|
+
key,
|
|
243
|
+
type: TYPE_WEAKENED,
|
|
244
|
+
base: "true",
|
|
245
|
+
current: "false",
|
|
246
|
+
message: `${relPath}: ${key} turned off — the gate no longer stops the run on failure.`,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return findings;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Compare .lisa.config.json allow lists: report added exception entries so a
|
|
255
|
+
* change can never grant itself an exception.
|
|
256
|
+
* @param {string} relPath Repo-relative path
|
|
257
|
+
* @param {unknown} base Parsed baseline config
|
|
258
|
+
* @param {unknown} current Parsed current config
|
|
259
|
+
* @returns {Finding[]} One finding per newly added allow entry
|
|
260
|
+
*/
|
|
261
|
+
function compareAllowList(relPath, base, current) {
|
|
262
|
+
const baseKeys = new Set(
|
|
263
|
+
extractAllowEntries(base).map(e => `${e.file} ${e.key}`)
|
|
264
|
+
);
|
|
265
|
+
const findings = [];
|
|
266
|
+
for (const entry of extractAllowEntries(current)) {
|
|
267
|
+
if (!baseKeys.has(`${entry.file} ${entry.key}`)) {
|
|
268
|
+
findings.push({
|
|
269
|
+
file: relPath,
|
|
270
|
+
key: `thresholdRatchet.allow ${entry.file}#${entry.key}`,
|
|
271
|
+
type: "allow-added",
|
|
272
|
+
message: `${relPath}: new threshold exception — ${describeAllowScope(entry)}. Exceptions are a human decision: land this entry in its own human-approved change first, then make the threshold change. It needs a reason saying what resolves it and an "until": "YYYY-MM-DD" naming the day it is reviewed, because an exemption that cannot end is a permanent reduction in what the ratchet covers.`,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return findings;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Compare Lighthouse assertions, including the shipped detail checker's default.
|
|
281
|
+
* Only lighthouserc-config.json's top-level forced-reflow setting is read by
|
|
282
|
+
* check-lighthouse-details.mjs. Other filenames and audits have no inferred
|
|
283
|
+
* baseline. The real checker/comparator boundary is covered by a parity test.
|
|
284
|
+
* @param {string} relPath Repo-relative path
|
|
285
|
+
* @param {unknown} base Parsed baseline config
|
|
286
|
+
* @param {unknown} current Parsed current config
|
|
287
|
+
* @returns {Finding[]} Removed or weakened assertions
|
|
288
|
+
*/
|
|
289
|
+
function compareLighthouse(relPath, base, current) {
|
|
290
|
+
const baseline = extractLighthouseAssertions(base);
|
|
291
|
+
const updated = extractLighthouseAssertions(current);
|
|
292
|
+
const findings = compareConstraints(relPath, baseline, updated);
|
|
293
|
+
const previous = base?.assertions?.forcedReflowInsight?.maxNumericValue;
|
|
294
|
+
const added = current?.assertions?.forcedReflowInsight?.maxNumericValue;
|
|
295
|
+
if (
|
|
296
|
+
relPath.split("/").at(-1) === "lighthouserc-config.json" &&
|
|
297
|
+
(previous == null ||
|
|
298
|
+
base?.ci?.assert?.assertions != null ||
|
|
299
|
+
current?.ci?.assert?.assertions != null) &&
|
|
300
|
+
(previous == null ||
|
|
301
|
+
(typeof previous === "number" && Number.isFinite(previous))) &&
|
|
302
|
+
(added == null || (typeof added === "number" && Number.isFinite(added))) &&
|
|
303
|
+
!(previous == null && added == null)
|
|
304
|
+
) {
|
|
305
|
+
// This checker reads top-level settings even beside canonical assertions.
|
|
306
|
+
// Keep its comparison separate so a new setting cannot replace a canonical
|
|
307
|
+
// bound with the same audit name and hide that bound's weakening.
|
|
308
|
+
const key = "forcedReflowInsight.maxNumericValue";
|
|
309
|
+
findings.push(
|
|
310
|
+
...compareConstraints(
|
|
311
|
+
relPath,
|
|
312
|
+
new Map([[key, { value: previous ?? 100, direction: "max" }]]),
|
|
313
|
+
new Map([[key, { value: added ?? 100, direction: "max" }]])
|
|
314
|
+
)
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
return findings;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Compare one watched file's baseline and current contents and report every
|
|
322
|
+
* weakening. Pure: no filesystem or git access.
|
|
323
|
+
* @param {string} relPath Repo-relative path (forward slashes)
|
|
324
|
+
* @param {string | null} baselineText Baseline contents (null = file is new)
|
|
325
|
+
* @param {string | null} currentText Current contents (null = file deleted)
|
|
326
|
+
* @returns {Finding[]} Every ratchet violation in the change (empty = clean)
|
|
327
|
+
*/
|
|
328
|
+
export function compareFile(relPath, baselineText, currentText) {
|
|
329
|
+
const family = familyFor(relPath);
|
|
330
|
+
if (!family || baselineText === null || baselineText === undefined) return [];
|
|
331
|
+
|
|
332
|
+
if (currentText === null || currentText === undefined) {
|
|
333
|
+
if (family.kind === KIND_ALLOW_LIST) return [];
|
|
334
|
+
return [
|
|
335
|
+
{
|
|
336
|
+
file: relPath,
|
|
337
|
+
key: "*",
|
|
338
|
+
type: "file-deleted",
|
|
339
|
+
message: `${relPath} was deleted — deleting a quality gate is a weakening.`,
|
|
340
|
+
},
|
|
341
|
+
];
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (family.kind === "rubocop-yaml") {
|
|
345
|
+
return compareConstraints(
|
|
346
|
+
relPath,
|
|
347
|
+
extractRubocopThresholds(baselineText, family.direction),
|
|
348
|
+
extractRubocopThresholds(currentText, family.direction)
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const base = parseJson(baselineText);
|
|
353
|
+
const current = parseJson(currentText);
|
|
354
|
+
// Both sides are reported, and both used to not be. An unparseable baseline
|
|
355
|
+
// returned no findings at all, which did not merely miss one change: once a
|
|
356
|
+
// malformed threshold file is on the base branch, every later pull request
|
|
357
|
+
// compares against a baseline that yields no constraints, so the ratchet
|
|
358
|
+
// stops having an opinion about that file — permanently, and in silence.
|
|
359
|
+
//
|
|
360
|
+
// This is only reached for a file that EXISTS at the baseline and did not
|
|
361
|
+
// parse. A file absent from the base ref arrives as a null `baselineText`
|
|
362
|
+
// and returned above: new gate files have nothing to weaken, and the caller
|
|
363
|
+
// separates absent from present-but-unreadable with `cat-file -e` before
|
|
364
|
+
// calling.
|
|
365
|
+
//
|
|
366
|
+
// The allow-list carve-out is symmetric with the current side and holds for
|
|
367
|
+
// the same reason: an allow list nobody can read grants no exceptions, so an
|
|
368
|
+
// unreadable one on either side already fails closed. Reporting it would
|
|
369
|
+
// block every change touching the file without making anything safer.
|
|
370
|
+
if (base === undefined) {
|
|
371
|
+
return family.kind === KIND_ALLOW_LIST
|
|
372
|
+
? []
|
|
373
|
+
: [unparseableBaseline(relPath)];
|
|
374
|
+
}
|
|
375
|
+
if (current === undefined) {
|
|
376
|
+
return family.kind === KIND_ALLOW_LIST ? [] : [unparseable(relPath)];
|
|
377
|
+
}
|
|
378
|
+
switch (family.kind) {
|
|
379
|
+
case "json-num":
|
|
380
|
+
return compareConstraints(
|
|
381
|
+
relPath,
|
|
382
|
+
extractNumericLeaves(base, family.direction),
|
|
383
|
+
extractNumericLeaves(current, family.direction)
|
|
384
|
+
);
|
|
385
|
+
case "stryker":
|
|
386
|
+
return compareStryker(relPath, base, current);
|
|
387
|
+
case "k6":
|
|
388
|
+
return compareK6(relPath, base, current);
|
|
389
|
+
case "lighthouse":
|
|
390
|
+
return compareLighthouse(relPath, base, current);
|
|
391
|
+
case KIND_ALLOW_LIST:
|
|
392
|
+
return compareAllowList(relPath, base, current);
|
|
393
|
+
default:
|
|
394
|
+
return [];
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Whether an allow entry's file and key cover a finding.
|
|
400
|
+
* @param {{ file: string, key: string }} entry An allow entry
|
|
401
|
+
* @param {Finding} finding A finding from the change
|
|
402
|
+
* @returns {boolean} True when the entry's scope reaches this finding
|
|
403
|
+
*/
|
|
404
|
+
function entryCovers(entry, finding) {
|
|
405
|
+
return (
|
|
406
|
+
(finding.file === entry.file || finding.file.endsWith(`/${entry.file}`)) &&
|
|
407
|
+
(entry.key === "*" || entry.key === finding.key)
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Report the allow entries a human has to act on, whether or not this change
|
|
413
|
+
* touched anything they cover.
|
|
414
|
+
*
|
|
415
|
+
* Separate from {@link applyAllowList} because the two answer different
|
|
416
|
+
* questions and only one of them depends on the change under review. An
|
|
417
|
+
* exemption that has outlived its condition is dead weight the moment the
|
|
418
|
+
* condition passes — the way `_thresholdsDivergence` is stale the moment the
|
|
419
|
+
* two floors agree — and saying so is not conditional on anybody happening to
|
|
420
|
+
* touch the gate it covered.
|
|
421
|
+
* @param {Array<{ file: string, key: string, reason?: string, until?: unknown }>} allowEntries
|
|
422
|
+
* The allow list being honoured
|
|
423
|
+
* @param {number} [now] Clock reading, injected so the check is deterministic
|
|
424
|
+
* @returns {string[]} One line per entry that is expired or unevaluable; empty
|
|
425
|
+
* when every entry still names a condition that holds
|
|
426
|
+
*/
|
|
427
|
+
export function describeAllowList(allowEntries, now = Date.now()) {
|
|
428
|
+
const lines = [];
|
|
429
|
+
for (const entry of allowEntries) {
|
|
430
|
+
const { state, detail } = classifyAllowEntry(entry, now);
|
|
431
|
+
if (state !== ALLOW_STATE.LIVE) lines.push(detail);
|
|
432
|
+
}
|
|
433
|
+
return lines;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Drop findings covered by a LIVE baseline-side allow entry.
|
|
438
|
+
*
|
|
439
|
+
* Three ways a finding leaves here, and they are deliberately distinct:
|
|
440
|
+
*
|
|
441
|
+
* allowed a covering entry's condition still holds. Reported, not blocked —
|
|
442
|
+
* a project mid-migration is not red-walled by expiry.
|
|
443
|
+
* expired every covering entry's condition has passed. The weakening the
|
|
444
|
+
* entry used to permit is REFUSED again, and the refusal names the
|
|
445
|
+
* entry, its scope, its reason and the remedy. An expiry that
|
|
446
|
+
* lapsed into "allowed" would be worse than no expiry.
|
|
447
|
+
* blocked nothing covered it, or everything that covered it has expired.
|
|
448
|
+
*
|
|
449
|
+
* An entry whose condition nothing can evaluate counts as covering, so the
|
|
450
|
+
* allow list keeps working for entries written before `until` existed;
|
|
451
|
+
* `describeAllowList` reports those every run instead.
|
|
452
|
+
*
|
|
453
|
+
* `allow-added` findings are never dropped, by any entry in any state — an
|
|
454
|
+
* exception cannot approve its own creation, which is the baseline-side fence
|
|
455
|
+
* and is untouched here.
|
|
456
|
+
* @param {Finding[]} findings All findings from the change
|
|
457
|
+
* @param {Array<{ file: string, key: string, reason?: string, until?: unknown }>} allowEntries
|
|
458
|
+
* Baseline (already-merged) allow list
|
|
459
|
+
* @param {number} [now] Clock reading, injected so the check is deterministic
|
|
460
|
+
* @returns {{
|
|
461
|
+
* blocked: Finding[],
|
|
462
|
+
* allowed: Array<{ finding: Finding, entry: { file: string, key: string }, message: string }>,
|
|
463
|
+
* expired: Array<{ finding: Finding, entry: { file: string, key: string }, message: string }>,
|
|
464
|
+
* }} Findings that still block, findings a live exemption covers, and the
|
|
465
|
+
* refusals produced by exemptions that have ended
|
|
466
|
+
*/
|
|
467
|
+
export function applyAllowList(findings, allowEntries, now = Date.now()) {
|
|
468
|
+
const classified = allowEntries.map(entry => ({
|
|
469
|
+
entry,
|
|
470
|
+
...classifyAllowEntry(entry, now),
|
|
471
|
+
}));
|
|
472
|
+
const blocked = [];
|
|
473
|
+
const allowed = [];
|
|
474
|
+
const expired = [];
|
|
475
|
+
for (const finding of findings) {
|
|
476
|
+
const covering =
|
|
477
|
+
finding.type === "allow-added"
|
|
478
|
+
? []
|
|
479
|
+
: classified.filter(c => entryCovers(c.entry, finding));
|
|
480
|
+
const live = covering.find(c => c.state !== ALLOW_STATE.EXPIRED);
|
|
481
|
+
if (live) {
|
|
482
|
+
allowed.push({
|
|
483
|
+
finding,
|
|
484
|
+
entry: live.entry,
|
|
485
|
+
message: `allowed by ${live.scope}, which ${live.summary}. It covers: ${finding.message}`,
|
|
486
|
+
});
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
blocked.push(finding);
|
|
490
|
+
for (const dead of covering) {
|
|
491
|
+
expired.push({
|
|
492
|
+
finding,
|
|
493
|
+
entry: dead.entry,
|
|
494
|
+
message: `${dead.detail} It used to cover: ${finding.message}`,
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
return { blocked, allowed, expired };
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Render the operator-facing block message.
|
|
503
|
+
* @param {Finding[]} findings Blocked findings
|
|
504
|
+
* @returns {string} Multi-line report explaining what weakened and the
|
|
505
|
+
* human-approved exception path
|
|
506
|
+
*/
|
|
507
|
+
export function formatReport(findings) {
|
|
508
|
+
return [
|
|
509
|
+
"⛔ Quality gate weakened — blocked by the threshold ratchet.",
|
|
510
|
+
"",
|
|
511
|
+
...findings.map(f => ` • ${f.message}`),
|
|
512
|
+
"",
|
|
513
|
+
"Quality thresholds are a one-way ratchet: they may tighten but never",
|
|
514
|
+
"loosen. Fix the code so it meets the current gate instead of lowering the",
|
|
515
|
+
"gate. If a human decides an exception is genuinely correct, they record it",
|
|
516
|
+
"in .lisa.config.json under thresholdRatchet.allow — with a reason saying",
|
|
517
|
+
'what resolves it, and an "until": "YYYY-MM-DD" naming the day it is',
|
|
518
|
+
"reviewed — in a separate human-approved change; this check honors an",
|
|
519
|
+
"exception only after it is merged, and only until its until date passes.",
|
|
520
|
+
].join("\n");
|
|
521
|
+
}
|