@codyswann/lisa 4.71.7 → 4.71.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/README.md +7 -0
  2. package/all/copy-overwrite/scripts/check-threshold-ratchet.mjs +571 -0
  3. package/all/copy-overwrite/scripts/check-workflow-load-failures.mjs +31 -9
  4. package/all/copy-overwrite/scripts/lib/automation-provenance-contract.mjs +19 -0
  5. package/all/copy-overwrite/scripts/lib/automation-provenance-files.mjs +49 -0
  6. package/all/copy-overwrite/scripts/lib/automation-provenance-local.mjs +54 -38
  7. package/all/copy-overwrite/scripts/lib/npm-update-allocate.mjs +8 -2
  8. package/all/copy-overwrite/scripts/lib/npm-update-bun.mjs +189 -0
  9. package/all/copy-overwrite/scripts/lib/npm-update-cancel-origin.mjs +7 -3
  10. package/all/copy-overwrite/scripts/lib/npm-update-cancellation-proof.mjs +6 -1
  11. package/all/copy-overwrite/scripts/lib/npm-update-contract.mjs +26 -5
  12. package/all/copy-overwrite/scripts/lib/npm-update-gate.mjs +8 -5
  13. package/all/copy-overwrite/scripts/lib/npm-update-github.mjs +2 -2
  14. package/all/copy-overwrite/scripts/lib/npm-update-helper-inventory.mjs +3 -4
  15. package/all/copy-overwrite/scripts/lib/npm-update-hook-installation.mjs +68 -1
  16. package/all/copy-overwrite/scripts/lib/npm-update-hosted-gate.mjs +13 -80
  17. package/all/copy-overwrite/scripts/lib/npm-update-leaf-contract.mjs +6 -5
  18. package/all/copy-overwrite/scripts/lib/npm-update-prepare.mjs +35 -7
  19. package/all/copy-overwrite/scripts/lib/npm-update-publish.mjs +1 -1
  20. package/all/copy-overwrite/scripts/npm-updater-helper-graph.json +43 -16
  21. package/all/copy-overwrite/scripts/threshold-ratchet-compare.mjs +521 -0
  22. package/all/copy-overwrite/scripts/threshold-ratchet-families.mjs +546 -0
  23. package/dist/configs/repo-scan.js +1 -1
  24. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  25. package/dist/core/lisa-owned-hash-ledger.js +60 -0
  26. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  27. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  28. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  29. package/dist/core/upstream-evidence-manifest.js +46 -32
  30. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  31. package/dist/strategies/merge.d.ts +1 -1
  32. package/dist/strategies/merge.d.ts.map +1 -1
  33. package/dist/strategies/merge.js +22 -2
  34. package/dist/strategies/merge.js.map +1 -1
  35. package/package.json +4 -4
  36. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  37. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  38. package/plugins/lisa/hooks/threshold-ratchet-compare.mjs +3 -3
  39. package/plugins/lisa/hooks/threshold-ratchet.mjs +1 -1
  40. package/plugins/lisa-agy/plugin.json +1 -1
  41. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  43. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  44. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-copilot/hooks/threshold-ratchet-compare.mjs +3 -3
  48. package/plugins/lisa-copilot/hooks/threshold-ratchet.mjs +1 -1
  49. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-cursor/hooks/threshold-ratchet-compare.mjs +3 -3
  51. package/plugins/lisa-cursor/hooks/threshold-ratchet.mjs +1 -1
  52. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  54. package/plugins/lisa-expo-agy/plugin.json +1 -1
  55. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  59. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  60. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  62. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  63. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  64. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  65. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  66. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  67. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  68. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  69. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  70. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  71. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  72. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  73. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  74. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  75. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  76. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  77. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  78. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  79. package/plugins/lisa-rails-agy/plugin.json +1 -1
  80. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  81. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  82. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  83. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  84. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  85. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  86. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  87. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  88. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  89. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  90. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  91. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  92. package/plugins/materialized-artifacts.json +3 -0
  93. package/plugins/src/base/hooks/threshold-ratchet-compare.mjs +3 -3
  94. package/plugins/src/base/hooks/threshold-ratchet.mjs +1 -1
  95. package/rails/copy-overwrite/scripts/check-threshold-ratchet.mjs +1 -1
  96. package/rails/copy-overwrite/scripts/threshold-ratchet-compare.mjs +3 -3
  97. package/scripts/build-plugins.sh +2 -1
  98. package/scripts/generate-scratch-supervisor-profile.mjs +1 -1
  99. package/scripts/install-claude-plugins.sh +8 -4
  100. package/scripts/two-channel-couplings.json +9 -7
  101. package/typescript/copy-overwrite/scripts/check-threshold-ratchet.mjs +1 -1
  102. package/typescript/copy-overwrite/scripts/threshold-ratchet-compare.mjs +3 -3
  103. /package/{rails → all}/copy-contents/scripts/lisa-mutation.sh +0 -0
  104. /package/{rails → all}/copy-overwrite/scripts/lisa-clean-git-env.sh +0 -0
  105. /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
+ }