@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,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>/
|
|
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,
|
|
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"}
|