@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.
Files changed (100) hide show
  1. package/all/copy-overwrite/scripts/check-threshold-ratchet.mjs +571 -0
  2. package/all/copy-overwrite/scripts/check-workflow-load-failures.mjs +31 -9
  3. package/all/copy-overwrite/scripts/lib/automation-provenance-contract.mjs +19 -0
  4. package/all/copy-overwrite/scripts/lib/automation-provenance-files.mjs +49 -0
  5. package/all/copy-overwrite/scripts/lib/automation-provenance-local.mjs +54 -38
  6. package/all/copy-overwrite/scripts/lib/npm-update-allocate.mjs +8 -2
  7. package/all/copy-overwrite/scripts/lib/npm-update-bun.mjs +189 -0
  8. package/all/copy-overwrite/scripts/lib/npm-update-cancel-origin.mjs +7 -3
  9. package/all/copy-overwrite/scripts/lib/npm-update-cancellation-proof.mjs +6 -1
  10. package/all/copy-overwrite/scripts/lib/npm-update-contract.mjs +26 -5
  11. package/all/copy-overwrite/scripts/lib/npm-update-gate.mjs +8 -5
  12. package/all/copy-overwrite/scripts/lib/npm-update-github.mjs +2 -2
  13. package/all/copy-overwrite/scripts/lib/npm-update-helper-inventory.mjs +3 -4
  14. package/all/copy-overwrite/scripts/lib/npm-update-hook-installation.mjs +68 -1
  15. package/all/copy-overwrite/scripts/lib/npm-update-hosted-gate.mjs +13 -80
  16. package/all/copy-overwrite/scripts/lib/npm-update-leaf-contract.mjs +6 -5
  17. package/all/copy-overwrite/scripts/lib/npm-update-prepare.mjs +35 -7
  18. package/all/copy-overwrite/scripts/lib/npm-update-publish.mjs +1 -1
  19. package/all/copy-overwrite/scripts/npm-updater-helper-graph.json +43 -16
  20. package/all/copy-overwrite/scripts/threshold-ratchet-compare.mjs +521 -0
  21. package/all/copy-overwrite/scripts/threshold-ratchet-families.mjs +546 -0
  22. package/dist/configs/repo-scan.js +1 -1
  23. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  24. package/dist/core/lisa-owned-hash-ledger.js +62 -0
  25. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  26. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  27. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  28. package/dist/core/upstream-evidence-manifest.js +46 -32
  29. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  30. package/package.json +4 -4
  31. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  32. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  33. package/plugins/lisa/hooks/threshold-ratchet-compare.mjs +3 -3
  34. package/plugins/lisa/hooks/threshold-ratchet.mjs +1 -1
  35. package/plugins/lisa-agy/plugin.json +1 -1
  36. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  37. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  38. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  39. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  40. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-copilot/hooks/threshold-ratchet-compare.mjs +3 -3
  43. package/plugins/lisa-copilot/hooks/threshold-ratchet.mjs +1 -1
  44. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-cursor/hooks/threshold-ratchet-compare.mjs +3 -3
  46. package/plugins/lisa-cursor/hooks/threshold-ratchet.mjs +1 -1
  47. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  49. package/plugins/lisa-expo-agy/plugin.json +1 -1
  50. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  54. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  55. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  59. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  60. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  62. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  63. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  64. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  65. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  66. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  67. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  68. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  69. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  70. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  71. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  72. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  73. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  74. package/plugins/lisa-rails-agy/plugin.json +1 -1
  75. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  76. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  77. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  78. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  79. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  80. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  81. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  82. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  83. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  84. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  85. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  86. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  87. package/plugins/materialized-artifacts.json +3 -0
  88. package/plugins/src/base/hooks/threshold-ratchet-compare.mjs +3 -3
  89. package/plugins/src/base/hooks/threshold-ratchet.mjs +1 -1
  90. package/rails/copy-overwrite/scripts/check-threshold-ratchet.mjs +1 -1
  91. package/rails/copy-overwrite/scripts/threshold-ratchet-compare.mjs +3 -3
  92. package/scripts/build-plugins.sh +2 -1
  93. package/scripts/generate-scratch-supervisor-profile.mjs +1 -1
  94. package/scripts/two-channel-couplings.json +9 -7
  95. package/typescript/copy-overwrite/scripts/check-skipped-required-checks.mjs +22 -3
  96. package/typescript/copy-overwrite/scripts/check-threshold-ratchet.mjs +1 -1
  97. package/typescript/copy-overwrite/scripts/threshold-ratchet-compare.mjs +3 -3
  98. /package/{rails → all}/copy-contents/scripts/lisa-mutation.sh +0 -0
  99. /package/{rails → all}/copy-overwrite/scripts/lisa-clean-git-env.sh +0 -0
  100. /package/{rails → all}/copy-overwrite/scripts/lisa-scratch-run.sh +0 -0
@@ -0,0 +1,546 @@
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 — watched file families and value extractors.
6
+ *
7
+ * Pure extraction layer: given file contents, produce comparable constraint
8
+ * maps. No filesystem or git access. See threshold-ratchet.mjs for the CLI
9
+ * and threshold-ratchet-compare.mjs for the comparison rules.
10
+ */
11
+
12
+ /**
13
+ * File families the ratchet watches. `kind` selects the extractor;
14
+ * `direction` applies to numeric-leaf kinds ("min" values may only rise,
15
+ * "max" values may only fall).
16
+ *
17
+ * PER-FAMILY POLICY, reviewed 2026-08-12. A family keeps its ratchet unless a
18
+ * NAMED deterministic non-regression invariant replaces the property the
19
+ * ratchet was providing — deleting a ratchet deletes that property with it, so
20
+ * "this generates churn" is a reason to find a replacement, never a reason to
21
+ * delete on its own:
22
+ *
23
+ * coverage / simplecov / e2e KEPT. Here the ratchet IS the non-regression
24
+ * invariant, and no per-item equivalent exists short of committing a
25
+ * per-file coverage baseline — an artifact that only ever accumulates.
26
+ * Revisit if one appears.
27
+ * stryker KEPT. `thresholds.break` is an absolute floor,
28
+ * kept outright; the mutate-list comparison detects EXEMPTION ADDITIONS
29
+ * rather than creep, and has no number for a pull request to nudge.
30
+ * eslint / rubocop / k6 / lisa-config KEPT, unchanged.
31
+ *
32
+ * Exactly one family was replaced, and it is not watched here and never was:
33
+ * the BDD traceability floor, whose numeric ratchet gave way to per-obligation
34
+ * checks. See `expo/copy-overwrite/scripts/bdd/baseline.mjs`.
35
+ *
36
+ * Retiring a family is SEQUENCED: the replacement invariant lands, then a
37
+ * `thresholdRatchet.allow` entry merges from the base side, then the mechanism
38
+ * changes. Never the reverse — this checker is precisely what stops a change
39
+ * granting itself the exception that permits it.
40
+ */
41
+ export const FAMILIES = [
42
+ {
43
+ id: "coverage",
44
+ match: /(^|\/)(vitest|jest)\.thresholds\.json$/,
45
+ kind: "json-num",
46
+ direction: "min",
47
+ },
48
+ {
49
+ id: "simplecov",
50
+ match: /(^|\/)simplecov\.thresholds\.json$/,
51
+ kind: "json-num",
52
+ direction: "min",
53
+ },
54
+ {
55
+ id: "e2e",
56
+ match: /(^|\/)e2e\.thresholds\.json$/,
57
+ kind: "json-num",
58
+ direction: "min",
59
+ },
60
+ {
61
+ id: "eslint",
62
+ match: /(^|\/)eslint\.thresholds\.json$/,
63
+ kind: "json-num",
64
+ direction: "max",
65
+ },
66
+ {
67
+ id: "rubocop",
68
+ match: /(^|\/)rubocop\.thresholds\.yml$/,
69
+ kind: "rubocop-yaml",
70
+ direction: "max",
71
+ },
72
+ { id: "stryker", match: /(^|\/)stryker\.conf\.json$/, kind: "stryker" },
73
+ {
74
+ id: "k6",
75
+ match: /(^|\/)\.github\/k6\/thresholds\/[^/]+\.json$/,
76
+ kind: "k6",
77
+ },
78
+ {
79
+ // #3992. The always-on eager rule tier is concatenated into every session
80
+ // AND every subagent start, so its byte ceiling is a quality threshold like
81
+ // any other. `max` because the number is a ceiling: lowering it tightens the
82
+ // gate and is free, raising it re-authorises context the cleanup removed and
83
+ // is the change a human has to see. The tier was cut to 26,540 bytes once
84
+ // and regrew to 201,083 in fourteen weeks with nothing watching it.
85
+ id: "eager-rules",
86
+ match: /(^|\/)eager-rules\.thresholds\.json$/,
87
+ kind: "json-num",
88
+ direction: "max",
89
+ },
90
+ {
91
+ id: "lisa-config",
92
+ match: /(^|\/)\.lisa\.config\.json$/,
93
+ kind: "allow-list",
94
+ },
95
+ {
96
+ // The #3811 quarantine. `allow-list` is exactly right here: the list may
97
+ // shrink freely, and ADDING a file — re-authorising type debt that the
98
+ // gate would otherwise refuse — is the change that needs a human to see it.
99
+ id: "typecheck-quarantine",
100
+ match: /(^|\/)typecheck-quarantine\.json$/,
101
+ kind: "allow-list",
102
+ },
103
+ {
104
+ id: "lighthouse",
105
+ match: /(^|\/)(?:lighthouserc-config|lighthouserc|\.lighthouserc)\.json$/,
106
+ kind: "lighthouse",
107
+ },
108
+ ];
109
+
110
+ /**
111
+ * Which way each Lighthouse assertion key ratchets.
112
+ *
113
+ * Lighthouse mixes floors and ceilings in one file, so assigning one direction
114
+ * to the whole family would silently approve half of the possible weakenings.
115
+ * Unknown keys stay unwatched until their direction is explicit here.
116
+ * @type {Readonly<Record<string, "min"|"max">>}
117
+ */
118
+ export const LIGHTHOUSE_ASSERTION_DIRECTIONS = Object.freeze({
119
+ minScore: "min",
120
+ maxNumericValue: "max",
121
+ maxLength: "max",
122
+ });
123
+
124
+ const LIGHTHOUSE_LEVELS = Object.freeze({ off: 0, warn: 1, error: 2 });
125
+
126
+ /**
127
+ * Extract numeric Lighthouse assertions with a direction chosen per key.
128
+ *
129
+ * A malformed array is deliberately ignored instead of being walked as an
130
+ * object whose numeric indices look like audit names.
131
+ * @param {unknown} conf Parsed lighthouserc-config.json
132
+ * @returns {Map<string, { value: number, direction: "min"|"max" }>} Dotted
133
+ * `assertion.key` path to numeric constraint
134
+ */
135
+ export function extractLighthouseAssertions(conf) {
136
+ const out = new Map();
137
+ const root =
138
+ conf && typeof conf === "object" && !Array.isArray(conf)
139
+ ? /** @type {Record<string, unknown>} */ (conf)
140
+ : undefined;
141
+ const ci =
142
+ root?.ci && typeof root.ci === "object" && !Array.isArray(root.ci)
143
+ ? /** @type {Record<string, unknown>} */ (root.ci)
144
+ : undefined;
145
+ const assert =
146
+ ci?.assert && typeof ci.assert === "object" && !Array.isArray(ci.assert)
147
+ ? /** @type {Record<string, unknown>} */ (ci.assert)
148
+ : undefined;
149
+ // Lighthouse CI's canonical shape is `ci.assert.assertions`. Keep the
150
+ // historical top-level shape as a compatibility lane for already-generated
151
+ // project configs, but never merge the two into an ambiguous hybrid.
152
+ const assertions = assert?.assertions ?? root?.assertions;
153
+ if (
154
+ !assertions ||
155
+ typeof assertions !== "object" ||
156
+ Array.isArray(assertions)
157
+ ) {
158
+ return out;
159
+ }
160
+ for (const [audit, spec] of Object.entries(assertions)) {
161
+ const level = Array.isArray(spec) ? spec[0] : spec;
162
+ if (typeof level === "string" && Object.hasOwn(LIGHTHOUSE_LEVELS, level)) {
163
+ out.set(`${audit}.$level`, {
164
+ value: LIGHTHOUSE_LEVELS[level],
165
+ direction: "min",
166
+ });
167
+ }
168
+ const options =
169
+ Array.isArray(spec) &&
170
+ spec.length >= 2 &&
171
+ spec[1] &&
172
+ typeof spec[1] === "object" &&
173
+ !Array.isArray(spec[1])
174
+ ? /** @type {Record<string, unknown>} */ (spec[1])
175
+ : spec && typeof spec === "object" && !Array.isArray(spec)
176
+ ? /** @type {Record<string, unknown>} */ (spec)
177
+ : undefined;
178
+ if (!options) continue;
179
+ for (const [key, value] of Object.entries(options)) {
180
+ const direction = Object.hasOwn(LIGHTHOUSE_ASSERTION_DIRECTIONS, key)
181
+ ? LIGHTHOUSE_ASSERTION_DIRECTIONS[key]
182
+ : undefined;
183
+ if (direction && typeof value === "number" && Number.isFinite(value)) {
184
+ out.set(`${audit}.${key}`, { value, direction });
185
+ }
186
+ }
187
+ }
188
+ return out;
189
+ }
190
+
191
+ /**
192
+ * Find the family a repo-relative path belongs to.
193
+ * @param {string} relPath Repo-relative path (forward slashes)
194
+ * @returns {(typeof FAMILIES)[number] | undefined} The matching family, or
195
+ * undefined when the path is not a watched gate file
196
+ */
197
+ export function familyFor(relPath) {
198
+ return FAMILIES.find(f => f.match.test(relPath));
199
+ }
200
+
201
+ /**
202
+ * Safe JSON parse.
203
+ * @param {string | null | undefined} text JSON text
204
+ * @returns {unknown | undefined} Parsed value, or undefined on failure
205
+ */
206
+ export function parseJson(text) {
207
+ if (typeof text !== "string") return undefined;
208
+ try {
209
+ return JSON.parse(text);
210
+ } catch {
211
+ return undefined;
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Walk a JSON object and collect numeric leaves as dotted-path constraints.
217
+ * Keys starting with "_" (e.g. `_comment`) are documentation, not thresholds.
218
+ * @param {unknown} node Parsed JSON value
219
+ * @param {"min"|"max"} direction Ratchet direction for every leaf
220
+ * @param {string} [prefix] Dotted path accumulated so far
221
+ * @returns {Map<string, { value: number, direction: "min"|"max" }>} Dotted
222
+ * path → numeric constraint for every finite numeric leaf
223
+ */
224
+ export function extractNumericLeaves(node, direction, prefix = "") {
225
+ const out = new Map();
226
+ if (node === null || typeof node !== "object" || Array.isArray(node)) {
227
+ return out;
228
+ }
229
+ for (const [key, value] of Object.entries(node)) {
230
+ if (key.startsWith("_")) continue;
231
+ const p = prefix ? `${prefix}.${key}` : key;
232
+ if (typeof value === "number" && Number.isFinite(value)) {
233
+ out.set(p, { value, direction });
234
+ } else if (value && typeof value === "object" && !Array.isArray(value)) {
235
+ for (const [cp, c] of extractNumericLeaves(value, direction, p)) {
236
+ out.set(cp, c);
237
+ }
238
+ }
239
+ }
240
+ return out;
241
+ }
242
+
243
+ /**
244
+ * Minimal parser for rubocop.thresholds.yml — a two-level document of
245
+ * `Section:` headers with indented `Key: <number>` scalars (comments and
246
+ * blank lines ignored). Deliberately NOT a general YAML parser: the file is
247
+ * Lisa-authored with this exact shape, and hand-parsing keeps the gate
248
+ * dependency-free with no backtracking-prone regexes.
249
+ * @param {string} text File contents
250
+ * @param {"min"|"max"} direction Ratchet direction for every scalar
251
+ * @returns {Map<string, { value: number, direction: "min"|"max" }>} Dotted
252
+ * `Section.Key` path → numeric constraint
253
+ */
254
+ export function extractRubocopThresholds(text, direction) {
255
+ const out = new Map();
256
+ const state = { section: "" };
257
+ for (const rawLine of text.split("\n")) {
258
+ const hash = rawLine.indexOf("#");
259
+ const line = (hash >= 0 ? rawLine.slice(0, hash) : rawLine).trimEnd();
260
+ if (!line.trim()) continue;
261
+ const indented = line.startsWith(" ") || line.startsWith("\t");
262
+ if (!indented && line.endsWith(":")) {
263
+ state.section = line.slice(0, -1).trim();
264
+ continue;
265
+ }
266
+ const colon = line.indexOf(":");
267
+ if (!indented || colon < 0 || !state.section) continue;
268
+ const key = line.slice(0, colon).trim();
269
+ const rawValue = line.slice(colon + 1).trim();
270
+ // Number("") is 0, so an empty value (e.g. a nested `Exclude:` list
271
+ // header) must be skipped, not recorded as a zero threshold.
272
+ const value = Number(rawValue);
273
+ if (key && rawValue !== "" && Number.isFinite(value)) {
274
+ out.set(`${state.section}.${key}`, { value, direction });
275
+ }
276
+ }
277
+ return out;
278
+ }
279
+
280
+ /**
281
+ * Extract the gating constraint from stryker.conf.json: only
282
+ * `thresholds.break` fails a run (`high`/`low` are reporting bands).
283
+ * @param {unknown} conf Parsed stryker.conf.json
284
+ * @returns {Map<string, { value: number, direction: "min"|"max" }>} The
285
+ * `thresholds.break` constraint when present, otherwise an empty map
286
+ */
287
+ export function extractStrykerConstraints(conf) {
288
+ const out = new Map();
289
+ const breakValue = conf?.thresholds?.break;
290
+ if (typeof breakValue === "number" && Number.isFinite(breakValue)) {
291
+ out.set("thresholds.break", { value: breakValue, direction: "min" });
292
+ }
293
+ return out;
294
+ }
295
+
296
+ /**
297
+ * Split a stryker `mutate` array into positive globs and negations.
298
+ * @param {unknown} conf Parsed stryker.conf.json
299
+ * @returns {{ positives: Set<string>, negations: Set<string> }} Globs that
300
+ * include files vs. `!`-prefixed globs that exclude them
301
+ */
302
+ export function extractStrykerMutate(conf) {
303
+ const positives = new Set();
304
+ const negations = new Set();
305
+ const mutate = Array.isArray(conf?.mutate) ? conf.mutate : [];
306
+ for (const glob of mutate) {
307
+ if (typeof glob !== "string") continue;
308
+ if (glob.startsWith("!")) negations.add(glob);
309
+ else positives.add(glob);
310
+ }
311
+ return { positives, negations };
312
+ }
313
+
314
+ /**
315
+ * The literal directory prefix a glob is rooted at, before any wildcard.
316
+ *
317
+ * `lib/**\/*.spec.ts` and `!lib/**\/*.d.ts` are both rooted at `lib`. This is
318
+ * what lets the ratchet tell territory a change ADDED from territory it
319
+ * already covered: an exclusion inside a newly added root cannot shrink a
320
+ * gate, because nothing there was being mutated a moment ago.
321
+ * @param {string} glob A stryker mutate entry, with or without a leading `!`
322
+ * @returns {string} The rooted prefix, or "" when the glob starts with a wildcard
323
+ */
324
+ export function globRoot(glob) {
325
+ const bare = glob.startsWith("!") ? glob.slice(1) : glob;
326
+ // Backslash escapes and parent traversal make textual containment unsafe.
327
+ // An unknown prefix overlaps everything rather than granting an exemption.
328
+ if (bare.includes("\\") || bare.split("/").includes("..")) return "";
329
+ const rooted = [];
330
+ for (const segment of bare.split("/")) {
331
+ if (segment === "." || segment === "") continue;
332
+ if (["*", "?", "{", "[", "("].some(meta => segment.includes(meta))) break;
333
+ rooted.push(segment);
334
+ }
335
+ return rooted.join("/");
336
+ }
337
+
338
+ /**
339
+ * Parse one k6 threshold expression (`p(95)<1000`, `rate>=0.99`, …) into a
340
+ * ratchet constraint. Upper bounds (`<`, `<=`) may only decrease; lower
341
+ * bounds (`>`, `>=`) may only increase. Hand-parsed — no regex.
342
+ * @param {string} expr Threshold expression
343
+ * @returns {{ value: number, direction: "min"|"max" } | undefined} The bound
344
+ * as a constraint, or undefined when the expression has no numeric bound
345
+ */
346
+ export function parseK6Expression(expr) {
347
+ for (const op of ["<=", ">=", "<", ">"]) {
348
+ const idx = expr.indexOf(op);
349
+ if (idx < 0) continue;
350
+ const value = Number(expr.slice(idx + op.length).trim());
351
+ if (!Number.isFinite(value)) return undefined;
352
+ return { value, direction: op.startsWith("<") ? "max" : "min" };
353
+ }
354
+ return undefined;
355
+ }
356
+
357
+ /**
358
+ * Extract constraints from a k6 thresholds file. Each metric contributes its
359
+ * expression bound(s) plus (when present) `abortOnFail` boolean constraints —
360
+ * turning abortOnFail off makes the gate advisory, which is a weakening.
361
+ * Handles every documented k6 shape: a bare expression string, a single long
362
+ * form `{ threshold, abortOnFail }` object, and arrays mixing both.
363
+ * @param {unknown} conf Parsed k6 thresholds JSON
364
+ * @returns {{
365
+ * numeric: Map<string, { value: number, direction: "min"|"max" }>,
366
+ * booleans: Map<string, boolean>,
367
+ * }} Numeric bounds keyed by `<metric>.threshold[i]` and abortOnFail flags
368
+ * keyed by `<metric>.abortOnFail` (`[i]`-suffixed for array items)
369
+ */
370
+ export function extractK6Constraints(conf) {
371
+ const numeric = new Map();
372
+ const booleans = new Map();
373
+ const thresholds = conf?.thresholds;
374
+ if (thresholds && typeof thresholds === "object") {
375
+ for (const [metric, spec] of Object.entries(thresholds)) {
376
+ const isArray = Array.isArray(spec);
377
+ const items = isArray ? spec : [spec];
378
+ items.forEach((item, i) => {
379
+ const expr =
380
+ typeof item === "string"
381
+ ? item
382
+ : item && typeof item === "object" && !Array.isArray(item)
383
+ ? item.threshold
384
+ : undefined;
385
+ if (typeof expr === "string") {
386
+ const c = parseK6Expression(expr);
387
+ if (c) numeric.set(`${metric}.threshold[${i}]`, c);
388
+ }
389
+ if (
390
+ item &&
391
+ typeof item === "object" &&
392
+ !Array.isArray(item) &&
393
+ typeof item.abortOnFail === "boolean"
394
+ ) {
395
+ booleans.set(
396
+ isArray ? `${metric}.abortOnFail[${i}]` : `${metric}.abortOnFail`,
397
+ item.abortOnFail
398
+ );
399
+ }
400
+ });
401
+ }
402
+ }
403
+ return { numeric, booleans };
404
+ }
405
+
406
+ /**
407
+ * Extract thresholdRatchet.allow entries from parsed .lisa.config.json.
408
+ * @param {unknown} config Parsed config (may be undefined)
409
+ * @returns {Array<{ file: string, key: string, reason?: string, until?: unknown }>}
410
+ * The well-formed allow entries; malformed entries are dropped
411
+ */
412
+ export function extractAllowEntries(config) {
413
+ const raw = config?.thresholdRatchet?.allow;
414
+ if (!Array.isArray(raw)) return [];
415
+ return raw.filter(
416
+ e => e && typeof e.file === "string" && typeof e.key === "string"
417
+ );
418
+ }
419
+
420
+ /**
421
+ * The three states an allow entry's expiry condition can be in.
422
+ *
423
+ * THE DEFECT THESE EXIST FOR (#3856). An allow entry is the human-approved way
424
+ * for a weakening to pass the ratchet, and it had no expiry, no review and no
425
+ * removal path — nothing read the `reason`, so an exemption granted for a
426
+ * temporary condition (a migration in flight, a suite being rewritten, a
427
+ * dependency waiting upstream) outlived the condition and kept applying,
428
+ * silently, forever. The set of gates actually enforced therefore shrank
429
+ * monotonically while every individual decision that shrank it was correct at
430
+ * the time. A control whose exception list only grows converges on no control.
431
+ *
432
+ * The shape is taken from `_thresholdsDivergence` in `stryker.conf.json`,
433
+ * governed by `src/sync/stryker-thresholds-ownership.ts` — the one exemption
434
+ * surface in this repository that already stops exempting when it goes stale.
435
+ * Its three properties are reproduced here: the entry records its condition in
436
+ * a form something can evaluate, the reason says what resolves it, and once the
437
+ * condition passes the entry stops exempting AND says so, naming the remedy.
438
+ *
439
+ * `unchecked` is deliberately NOT `expired`. An entry written before `until`
440
+ * existed names a condition nothing can evaluate, which is the prose situation
441
+ * the ticket describes — it is reported every run, loudly, and it keeps
442
+ * exempting. Refusing it instead would red-wall every project mid-migration
443
+ * that carries a valid exception, which is the failure the ticket names as
444
+ * worse than the defect.
445
+ * @type {Readonly<Record<string, "live"|"expired"|"unchecked">>}
446
+ */
447
+ export const ALLOW_STATE = Object.freeze({
448
+ LIVE: "live",
449
+ EXPIRED: "expired",
450
+ UNCHECKED: "unchecked",
451
+ });
452
+
453
+ /** A calendar condition, and the only shape `until` may take. */
454
+ const ISO_DAY = /^(\d{4})-(\d{2})-(\d{2})$/u;
455
+
456
+ /** An entry is live through the END of its named day, not up to its start. */
457
+ const DAY_MS = 86_400_000;
458
+
459
+ /**
460
+ * The instant an `until` condition stops holding.
461
+ *
462
+ * Round-tripped through `toISOString` on purpose: `Date.UTC` accepts
463
+ * out-of-range components and slides them, so `2026-02-31` would silently
464
+ * become March 3rd and `0099-01-01` would become 1999. An exemption dated a day
465
+ * that does not exist is a condition nobody can evaluate, and saying so is the
466
+ * honest answer — quietly moving it would hand the entry a different, longer
467
+ * life than the one written down.
468
+ * @param {unknown} until The entry's `until` field
469
+ * @returns {number | undefined} Epoch ms after which the entry has expired, or
470
+ * undefined when `until` is not a calendar day anything can evaluate
471
+ */
472
+ export function allowEntryExpiry(until) {
473
+ if (typeof until !== "string") return undefined;
474
+ const day = until.trim();
475
+ const match = ISO_DAY.exec(day);
476
+ if (!match) return undefined;
477
+ const midnight = Date.UTC(
478
+ Number(match[1]),
479
+ Number(match[2]) - 1,
480
+ Number(match[3])
481
+ );
482
+ if (new Date(midnight).toISOString().slice(0, 10) !== day) return undefined;
483
+ return midnight + DAY_MS;
484
+ }
485
+
486
+ /**
487
+ * Name an allow entry so a report says WHICH exemption it is talking about,
488
+ * and how much it covers.
489
+ *
490
+ * File-wide and key-scoped entries are spelled differently because they are
491
+ * different risks wearing the same shape: `key: "*"` turns off ratchet
492
+ * protection for every threshold in the file, and in a config diff that reads
493
+ * as one more approved exception.
494
+ * @param {{ file: string, key: string }} entry An allow entry, already
495
+ * well-formed — `extractAllowEntries` is the only source of these
496
+ * @returns {string} Operator-readable identification of the entry
497
+ */
498
+ export function describeAllowScope(entry) {
499
+ return entry.key === "*"
500
+ ? `thresholdRatchet.allow entry ${entry.file} → * (file-wide: every key in the file)`
501
+ : `thresholdRatchet.allow entry ${entry.file} → ${entry.key} (key-scoped)`;
502
+ }
503
+
504
+ /**
505
+ * Classify one allow entry's expiry condition, and say what to do about it.
506
+ * @param {{ file: string, key: string, reason?: unknown, until?: unknown }} entry
507
+ * An allow entry
508
+ * @param {number} now Clock reading, injected so the check is deterministic
509
+ * @returns {{ state: "live"|"expired"|"unchecked", scope: string, summary: string, detail: string }}
510
+ * The state, the entry's identification, a short phrase for a one-line
511
+ * mention, and the full sentence naming the entry, its reason and the remedy
512
+ */
513
+ export function classifyAllowEntry(entry, now) {
514
+ const scope = describeAllowScope(entry);
515
+ const because =
516
+ typeof entry.reason === "string" && entry.reason.trim() !== ""
517
+ ? ` Reason on record: ${entry.reason.trim()}`
518
+ : " No reason is recorded.";
519
+ const expiresAt = allowEntryExpiry(entry.until);
520
+ if (expiresAt === undefined) {
521
+ const summary = `names no condition anything can evaluate (until = ${JSON.stringify(entry.until ?? null)})`;
522
+ return {
523
+ state: ALLOW_STATE.UNCHECKED,
524
+ scope,
525
+ summary,
526
+ detail: `${scope} ${summary}, so nothing can ever end it. Give it "until": "YYYY-MM-DD" naming the day the exemption is reviewed, or delete it.${because}`,
527
+ };
528
+ }
529
+ const day = String(entry.until).trim();
530
+ if (now >= expiresAt) {
531
+ const summary = `expired after ${day}`;
532
+ return {
533
+ state: ALLOW_STATE.EXPIRED,
534
+ scope,
535
+ summary,
536
+ detail: `${scope} ${summary} and no longer exempts anything. Delete it, or record a fresh human-approved entry naming the new condition.${because}`,
537
+ };
538
+ }
539
+ const summary = `is live until ${day}`;
540
+ return {
541
+ state: ALLOW_STATE.LIVE,
542
+ scope,
543
+ summary,
544
+ detail: `${scope} ${summary}.`,
545
+ };
546
+ }
@@ -12,7 +12,7 @@
12
12
  * A scratch sandbox is the dangerous one, because it is a **full second copy of
13
13
  * the tree**. Measured on this repository: a `stryker` run terminated under
14
14
  * fleet saturation left 42 MB at
15
- * `.stryker-tmp/bite-guard-intact/sandbox-<id>/rails/copy-overwrite/scripts/lisa-scratch-run.sh`,
15
+ * `.stryker-tmp/bite-guard-intact/sandbox-<id>/all/copy-overwrite/scripts/lisa-scratch-run.sh`,
16
16
  * a second copy of a file one scan asserts is unique by basename. That scan
17
17
  * failed on the NEXT run, with a clean, specific, entirely plausible message
18
18
  * about a duplicate file — and nothing in its output mentioned mutation,
@@ -1 +1 @@
1
- {"version":3,"file":"lisa-owned-hash-ledger.d.ts","sourceRoot":"","sources":["../../src/core/lisa-owned-hash-ledger.ts"],"names":[],"mappings":"AACA,6EAA6E;AAE7E;;;;;;;GAOG;AACH,eAAO,MAAM,sBAAsB,EAAE,QAAQ,CAC3C,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CA2yDjC,CAAC;AAEH;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,+BAA+B,EAAE,QAAQ,CACpD,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CA8sDjC,CAAC"}
1
+ {"version":3,"file":"lisa-owned-hash-ledger.d.ts","sourceRoot":"","sources":["../../src/core/lisa-owned-hash-ledger.ts"],"names":[],"mappings":"AACA,6EAA6E;AAE7E;;;;;;;GAOG;AACH,eAAO,MAAM,sBAAsB,EAAE,QAAQ,CAC3C,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CA00DjC,CAAC;AAEH;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,+BAA+B,EAAE,QAAQ,CACpD,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CA6uDjC,CAAC"}