@codyswann/lisa 2.241.0 → 2.243.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/instruction-files-migration.d.ts +38 -0
- package/dist/core/instruction-files-migration.d.ts.map +1 -1
- package/dist/core/instruction-files-migration.js +76 -7
- package/dist/core/instruction-files-migration.js.map +1 -1
- package/dist/core/learnings-projection.d.ts +38 -0
- package/dist/core/learnings-projection.d.ts.map +1 -0
- package/dist/core/learnings-projection.js +94 -0
- package/dist/core/learnings-projection.js.map +1 -0
- package/dist/core/learnings.d.ts +2 -1
- package/dist/core/learnings.d.ts.map +1 -1
- package/dist/core/learnings.js +2 -1
- package/dist/core/learnings.js.map +1 -1
- package/dist/core/lisa.d.ts +40 -0
- package/dist/core/lisa.d.ts.map +1 -1
- package/dist/core/lisa.js +90 -12
- package/dist/core/lisa.js.map +1 -1
- package/dist/core/project-config.d.ts +36 -3
- package/dist/core/project-config.d.ts.map +1 -1
- package/dist/core/project-config.js +127 -10
- package/dist/core/project-config.js.map +1 -1
- package/dist/sync/registry.js +1 -1
- package/dist/sync/registry.js.map +1 -1
- package/package.json +1 -1
- package/plugins/lisa/.claude-plugin/plugin.json +10 -1
- package/plugins/lisa/.codex-plugin/hooks.json +9 -0
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-persist-learning/SKILL.md +1 -1
- package/plugins/lisa/hooks/threshold-ratchet-compare.mjs +315 -0
- package/plugins/lisa/hooks/threshold-ratchet-families.mjs +295 -0
- package/plugins/lisa/hooks/threshold-ratchet.mjs +213 -0
- package/plugins/lisa/hooks/threshold-ratchet.sh +22 -0
- package/plugins/lisa/rules/eager/config-resolution.md +6 -3
- package/plugins/lisa/rules/eager/project-learnings.md +11 -5
- package/plugins/lisa/rules/reference/config-resolution.md +2 -1
- package/plugins/lisa/rules/reference/project-learnings.md +16 -9
- package/plugins/lisa/skills/lisa-persist-learning/SKILL.md +1 -1
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-agy/skills/lisa-persist-learning/SKILL.md +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 +10 -1
- package/plugins/lisa-copilot/hooks/threshold-ratchet-compare.mjs +315 -0
- package/plugins/lisa-copilot/hooks/threshold-ratchet-families.mjs +295 -0
- package/plugins/lisa-copilot/hooks/threshold-ratchet.mjs +213 -0
- package/plugins/lisa-copilot/hooks/threshold-ratchet.sh +22 -0
- package/plugins/lisa-copilot/rules/eager/config-resolution.md +6 -3
- package/plugins/lisa-copilot/rules/eager/project-learnings.md +11 -5
- package/plugins/lisa-copilot/rules/reference/config-resolution.md +2 -1
- package/plugins/lisa-copilot/rules/reference/project-learnings.md +16 -9
- package/plugins/lisa-copilot/skills/lisa-persist-learning/SKILL.md +1 -1
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/hooks/hooks.json +4 -0
- package/plugins/lisa-cursor/hooks/threshold-ratchet-compare.mjs +315 -0
- package/plugins/lisa-cursor/hooks/threshold-ratchet-families.mjs +295 -0
- package/plugins/lisa-cursor/hooks/threshold-ratchet.mjs +213 -0
- package/plugins/lisa-cursor/hooks/threshold-ratchet.sh +22 -0
- package/plugins/lisa-cursor/rules/config-resolution-reference.mdc +2 -1
- package/plugins/lisa-cursor/rules/config-resolution.mdc +6 -3
- package/plugins/lisa-cursor/rules/project-learnings-reference.mdc +16 -9
- package/plugins/lisa-cursor/rules/project-learnings.mdc +11 -5
- package/plugins/lisa-cursor/skills/lisa-persist-learning/SKILL.md +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/src/base/.claude-plugin/plugin.json +9 -0
- package/plugins/src/base/hooks/threshold-ratchet-compare.mjs +315 -0
- package/plugins/src/base/hooks/threshold-ratchet-families.mjs +295 -0
- package/plugins/src/base/hooks/threshold-ratchet.mjs +213 -0
- package/plugins/src/base/hooks/threshold-ratchet.sh +22 -0
- package/plugins/src/base/rules/eager/config-resolution.md +6 -3
- package/plugins/src/base/rules/eager/project-learnings.md +11 -5
- package/plugins/src/base/rules/reference/config-resolution.md +2 -1
- package/plugins/src/base/rules/reference/project-learnings.md +16 -9
- package/plugins/src/base/skills/lisa-persist-learning/SKILL.md +1 -1
- package/rails/copy-overwrite/lefthook.yml +6 -0
- package/rails/copy-overwrite/scripts/check-threshold-ratchet.mjs +213 -0
- package/rails/copy-overwrite/scripts/threshold-ratchet-compare.mjs +315 -0
- package/rails/copy-overwrite/scripts/threshold-ratchet-families.mjs +295 -0
- package/scripts/build-plugins.sh +20 -0
- package/scripts/check-learnings-budget.ts +1 -2
- package/scripts/lib/per-agent-hook-filter.mjs +7 -0
- package/typescript/copy-contents/.husky/pre-commit +17 -0
- package/typescript/copy-overwrite/scripts/check-threshold-ratchet.mjs +213 -0
- package/typescript/copy-overwrite/scripts/threshold-ratchet-compare.mjs +315 -0
- package/typescript/copy-overwrite/scripts/threshold-ratchet-families.mjs +295 -0
- /package/all/create-only/{.claude/rules → .lisa}/PROJECT_LEARNINGS.md +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lisa",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.243.0",
|
|
4
4
|
"description": "Universal governance — agents, skills, commands, hooks, and rules for all projects",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Cody Swann"
|
|
@@ -26,6 +26,15 @@
|
|
|
26
26
|
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/shell-write-nudge.sh"
|
|
27
27
|
}
|
|
28
28
|
]
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"matcher": "Edit|Write|NotebookEdit|Bash",
|
|
32
|
+
"hooks": [
|
|
33
|
+
{
|
|
34
|
+
"type": "command",
|
|
35
|
+
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/threshold-ratchet.sh"
|
|
36
|
+
}
|
|
37
|
+
]
|
|
29
38
|
}
|
|
30
39
|
],
|
|
31
40
|
"preToolUse": [
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Threshold ratchet — comparison rules and reporting.
|
|
3
|
+
*
|
|
4
|
+
* Pure comparison layer: given a watched file's baseline and current
|
|
5
|
+
* contents, report every weakening. No filesystem or git access. See
|
|
6
|
+
* threshold-ratchet-families.mjs for extraction and threshold-ratchet.mjs
|
|
7
|
+
* for the CLI.
|
|
8
|
+
*/
|
|
9
|
+
import {
|
|
10
|
+
extractAllowEntries,
|
|
11
|
+
extractExemptionEntries,
|
|
12
|
+
extractK6Constraints,
|
|
13
|
+
extractNumericLeaves,
|
|
14
|
+
extractRubocopThresholds,
|
|
15
|
+
extractStrykerConstraints,
|
|
16
|
+
extractStrykerMutate,
|
|
17
|
+
familyFor,
|
|
18
|
+
parseJson,
|
|
19
|
+
} from "./threshold-ratchet-families.mjs";
|
|
20
|
+
|
|
21
|
+
/** Finding type: a numeric bound or boolean gate moved the weakening way. */
|
|
22
|
+
const TYPE_WEAKENED = "weakened";
|
|
23
|
+
/** Finding type: an exemption entry was added (Tier 3). */
|
|
24
|
+
const TYPE_EXEMPTION_ADDED = "exemption-added";
|
|
25
|
+
/** Family kind for .lisa.config.json (the thresholdRatchet.allow carrier). */
|
|
26
|
+
const KIND_ALLOW_LIST = "allow-list";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @typedef {object} Finding
|
|
30
|
+
* @property {string} file Repo-relative path of the gate file
|
|
31
|
+
* @property {string} key Dotted key path within the file
|
|
32
|
+
* @property {"weakened"|"removed"|"exemption-added"|"file-deleted"|"allow-added"|"unparseable"} type
|
|
33
|
+
* Which ratchet rule the change violated
|
|
34
|
+
* @property {number|string} [base] Baseline value
|
|
35
|
+
* @property {number|string} [current] Current value
|
|
36
|
+
* @property {string} message Operator-readable explanation
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Build the "file could not be parsed" finding.
|
|
41
|
+
* @param {string} relPath Repo-relative path
|
|
42
|
+
* @returns {Finding} The unparseable-file finding
|
|
43
|
+
*/
|
|
44
|
+
function unparseable(relPath) {
|
|
45
|
+
return {
|
|
46
|
+
file: relPath,
|
|
47
|
+
key: "*",
|
|
48
|
+
type: "unparseable",
|
|
49
|
+
message: `${relPath} is no longer valid JSON — a broken gate file disables the gate.`,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Compare two constraint maps: report removals and direction violations.
|
|
55
|
+
* @param {string} relPath Repo-relative path the constraints came from
|
|
56
|
+
* @param {Map<string, { value: number, direction: "min"|"max" }>} base
|
|
57
|
+
* Baseline constraints
|
|
58
|
+
* @param {Map<string, { value: number, direction: "min"|"max" }>} current
|
|
59
|
+
* Current constraints
|
|
60
|
+
* @returns {Finding[]} One finding per removed or weakened constraint
|
|
61
|
+
*/
|
|
62
|
+
export function compareConstraints(relPath, base, current) {
|
|
63
|
+
const findings = [];
|
|
64
|
+
for (const [key, baseC] of base) {
|
|
65
|
+
const currentC = current.get(key);
|
|
66
|
+
if (!currentC) {
|
|
67
|
+
findings.push({
|
|
68
|
+
file: relPath,
|
|
69
|
+
key,
|
|
70
|
+
type: "removed",
|
|
71
|
+
base: baseC.value,
|
|
72
|
+
message: `${relPath}: ${key} was removed — the tuned floor would silently fall back to a default.`,
|
|
73
|
+
});
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const weakened =
|
|
77
|
+
baseC.direction === "min"
|
|
78
|
+
? currentC.value < baseC.value
|
|
79
|
+
: currentC.value > baseC.value;
|
|
80
|
+
if (weakened) {
|
|
81
|
+
const verb =
|
|
82
|
+
baseC.direction === "min" ? "may only increase" : "may only decrease";
|
|
83
|
+
findings.push({
|
|
84
|
+
file: relPath,
|
|
85
|
+
key,
|
|
86
|
+
type: TYPE_WEAKENED,
|
|
87
|
+
base: baseC.value,
|
|
88
|
+
current: currentC.value,
|
|
89
|
+
message: `${relPath}: ${key} changed ${baseC.value} → ${currentC.value} (this value ${verb}).`,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return findings;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Compare a stryker.conf.json pair: the break threshold plus mutate-list
|
|
98
|
+
* exemptions (new negations or removed targets shrink the gate).
|
|
99
|
+
* @param {string} relPath Repo-relative path
|
|
100
|
+
* @param {unknown} base Parsed baseline config
|
|
101
|
+
* @param {unknown} current Parsed current config
|
|
102
|
+
* @returns {Finding[]} Break-threshold and mutate-scope findings
|
|
103
|
+
*/
|
|
104
|
+
function compareStryker(relPath, base, current) {
|
|
105
|
+
const findings = compareConstraints(
|
|
106
|
+
relPath,
|
|
107
|
+
extractStrykerConstraints(base),
|
|
108
|
+
extractStrykerConstraints(current)
|
|
109
|
+
);
|
|
110
|
+
const baseMutate = extractStrykerMutate(base);
|
|
111
|
+
const currentMutate = extractStrykerMutate(current);
|
|
112
|
+
for (const negation of currentMutate.negations) {
|
|
113
|
+
if (!baseMutate.negations.has(negation)) {
|
|
114
|
+
findings.push({
|
|
115
|
+
file: relPath,
|
|
116
|
+
key: `mutate ${negation}`,
|
|
117
|
+
type: TYPE_EXEMPTION_ADDED,
|
|
118
|
+
message: `${relPath}: new mutation-testing exclusion "${negation}" — excluding files from a gate is a weakening.`,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
for (const positive of baseMutate.positives) {
|
|
123
|
+
if (!currentMutate.positives.has(positive)) {
|
|
124
|
+
findings.push({
|
|
125
|
+
file: relPath,
|
|
126
|
+
key: `mutate ${positive}`,
|
|
127
|
+
type: TYPE_EXEMPTION_ADDED,
|
|
128
|
+
message: `${relPath}: mutation-testing target "${positive}" was removed — shrinking a gate's coverage is a weakening.`,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return findings;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Compare a k6 thresholds pair: numeric bounds plus abortOnFail downgrades.
|
|
137
|
+
* @param {string} relPath Repo-relative path
|
|
138
|
+
* @param {unknown} base Parsed baseline thresholds
|
|
139
|
+
* @param {unknown} current Parsed current thresholds
|
|
140
|
+
* @returns {Finding[]} Bound and abortOnFail findings
|
|
141
|
+
*/
|
|
142
|
+
function compareK6(relPath, base, current) {
|
|
143
|
+
const baseC = extractK6Constraints(base);
|
|
144
|
+
const currentC = extractK6Constraints(current);
|
|
145
|
+
const findings = compareConstraints(relPath, baseC.numeric, currentC.numeric);
|
|
146
|
+
for (const [key, wasOn] of baseC.booleans) {
|
|
147
|
+
// k6 defaults abortOnFail to false, so DELETING an explicit `true` is as
|
|
148
|
+
// much a weakening as flipping it — anything but a current `true` blocks.
|
|
149
|
+
if (wasOn && currentC.booleans.get(key) !== true) {
|
|
150
|
+
findings.push({
|
|
151
|
+
file: relPath,
|
|
152
|
+
key,
|
|
153
|
+
type: TYPE_WEAKENED,
|
|
154
|
+
base: "true",
|
|
155
|
+
current: "false",
|
|
156
|
+
message: `${relPath}: ${key} turned off — the gate no longer stops the run on failure.`,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return findings;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Compare an exemption-list pair (audit ignore files): report added entries.
|
|
165
|
+
* @param {string} relPath Repo-relative path
|
|
166
|
+
* @param {unknown} base Parsed baseline list
|
|
167
|
+
* @param {unknown} current Parsed current list
|
|
168
|
+
* @returns {Finding[]} One finding per newly added ignore entry
|
|
169
|
+
*/
|
|
170
|
+
function compareExemptions(relPath, base, current) {
|
|
171
|
+
const baseEntries = extractExemptionEntries(base);
|
|
172
|
+
const findings = [];
|
|
173
|
+
for (const entry of extractExemptionEntries(current)) {
|
|
174
|
+
if (!baseEntries.has(entry)) {
|
|
175
|
+
findings.push({
|
|
176
|
+
file: relPath,
|
|
177
|
+
key: entry,
|
|
178
|
+
type: TYPE_EXEMPTION_ADDED,
|
|
179
|
+
message: `${relPath}: new security-audit ignore entry "${entry}" — ignoring a finding weakens the gate and needs a human's sign-off.`,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return findings;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Compare .lisa.config.json allow lists: report added exception entries so a
|
|
188
|
+
* change can never grant itself an exception.
|
|
189
|
+
* @param {string} relPath Repo-relative path
|
|
190
|
+
* @param {unknown} base Parsed baseline config
|
|
191
|
+
* @param {unknown} current Parsed current config
|
|
192
|
+
* @returns {Finding[]} One finding per newly added allow entry
|
|
193
|
+
*/
|
|
194
|
+
function compareAllowList(relPath, base, current) {
|
|
195
|
+
const baseKeys = new Set(
|
|
196
|
+
extractAllowEntries(base).map(e => `${e.file} ${e.key}`)
|
|
197
|
+
);
|
|
198
|
+
const findings = [];
|
|
199
|
+
for (const entry of extractAllowEntries(current)) {
|
|
200
|
+
if (!baseKeys.has(`${entry.file} ${entry.key}`)) {
|
|
201
|
+
findings.push({
|
|
202
|
+
file: relPath,
|
|
203
|
+
key: `thresholdRatchet.allow ${entry.file}#${entry.key}`,
|
|
204
|
+
type: "allow-added",
|
|
205
|
+
message: `${relPath}: new threshold exception for ${entry.file} → ${entry.key}. Exceptions are a human decision: land this entry in its own human-approved change first, then make the threshold change.`,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return findings;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Compare one watched file's baseline and current contents and report every
|
|
214
|
+
* weakening. Pure: no filesystem or git access.
|
|
215
|
+
* @param {string} relPath Repo-relative path (forward slashes)
|
|
216
|
+
* @param {string | null} baselineText Baseline contents (null = file is new)
|
|
217
|
+
* @param {string | null} currentText Current contents (null = file deleted)
|
|
218
|
+
* @returns {Finding[]} Every ratchet violation in the change (empty = clean)
|
|
219
|
+
*/
|
|
220
|
+
export function compareFile(relPath, baselineText, currentText) {
|
|
221
|
+
const family = familyFor(relPath);
|
|
222
|
+
if (!family || baselineText === null || baselineText === undefined) return [];
|
|
223
|
+
|
|
224
|
+
if (currentText === null || currentText === undefined) {
|
|
225
|
+
if (family.kind === KIND_ALLOW_LIST) return [];
|
|
226
|
+
return [
|
|
227
|
+
{
|
|
228
|
+
file: relPath,
|
|
229
|
+
key: "*",
|
|
230
|
+
type: "file-deleted",
|
|
231
|
+
message: `${relPath} was deleted — deleting a quality gate is a weakening.`,
|
|
232
|
+
},
|
|
233
|
+
];
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (family.kind === "rubocop-yaml") {
|
|
237
|
+
return compareConstraints(
|
|
238
|
+
relPath,
|
|
239
|
+
extractRubocopThresholds(baselineText, family.direction),
|
|
240
|
+
extractRubocopThresholds(currentText, family.direction)
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const base = parseJson(baselineText);
|
|
245
|
+
const current = parseJson(currentText);
|
|
246
|
+
if (base === undefined) return [];
|
|
247
|
+
if (current === undefined) {
|
|
248
|
+
return family.kind === KIND_ALLOW_LIST ? [] : [unparseable(relPath)];
|
|
249
|
+
}
|
|
250
|
+
switch (family.kind) {
|
|
251
|
+
case "json-num":
|
|
252
|
+
return compareConstraints(
|
|
253
|
+
relPath,
|
|
254
|
+
extractNumericLeaves(base, family.direction),
|
|
255
|
+
extractNumericLeaves(current, family.direction)
|
|
256
|
+
);
|
|
257
|
+
case "stryker":
|
|
258
|
+
return compareStryker(relPath, base, current);
|
|
259
|
+
case "k6":
|
|
260
|
+
return compareK6(relPath, base, current);
|
|
261
|
+
case "exemption-list":
|
|
262
|
+
return compareExemptions(relPath, base, current);
|
|
263
|
+
case KIND_ALLOW_LIST:
|
|
264
|
+
return compareAllowList(relPath, base, current);
|
|
265
|
+
default:
|
|
266
|
+
return [];
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Drop findings covered by baseline-side allow entries. `allow-added`
|
|
272
|
+
* findings are never dropped — an exception cannot approve its own creation.
|
|
273
|
+
* @param {Finding[]} findings All findings from the change
|
|
274
|
+
* @param {Array<{ file: string, key: string }>} allowEntries Baseline
|
|
275
|
+
* (already-merged) allow list
|
|
276
|
+
* @returns {{ blocked: Finding[], allowed: Finding[] }} Findings that still
|
|
277
|
+
* block vs. findings covered by a recorded exception
|
|
278
|
+
*/
|
|
279
|
+
export function applyAllowList(findings, allowEntries) {
|
|
280
|
+
const blocked = [];
|
|
281
|
+
const allowed = [];
|
|
282
|
+
for (const finding of findings) {
|
|
283
|
+
const isAllowed =
|
|
284
|
+
finding.type !== "allow-added" &&
|
|
285
|
+
allowEntries.some(
|
|
286
|
+
e =>
|
|
287
|
+
(finding.file === e.file || finding.file.endsWith(`/${e.file}`)) &&
|
|
288
|
+
(e.key === "*" || e.key === finding.key)
|
|
289
|
+
);
|
|
290
|
+
if (isAllowed) allowed.push(finding);
|
|
291
|
+
else blocked.push(finding);
|
|
292
|
+
}
|
|
293
|
+
return { blocked, allowed };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Render the operator-facing block message.
|
|
298
|
+
* @param {Finding[]} findings Blocked findings
|
|
299
|
+
* @returns {string} Multi-line report explaining what weakened and the
|
|
300
|
+
* human-approved exception path
|
|
301
|
+
*/
|
|
302
|
+
export function formatReport(findings) {
|
|
303
|
+
return [
|
|
304
|
+
"⛔ Quality gate weakened — blocked by the threshold ratchet.",
|
|
305
|
+
"",
|
|
306
|
+
...findings.map(f => ` • ${f.message}`),
|
|
307
|
+
"",
|
|
308
|
+
"Quality thresholds are a one-way ratchet: they may tighten but never",
|
|
309
|
+
"loosen. Fix the code so it meets the current gate instead of lowering the",
|
|
310
|
+
"gate. If a human decides an exception is genuinely correct, they record it",
|
|
311
|
+
"in .lisa.config.json under thresholdRatchet.allow (with a reason) in a",
|
|
312
|
+
"separate human-approved change; this check honors exceptions only after",
|
|
313
|
+
"they are merged.",
|
|
314
|
+
].join("\n");
|
|
315
|
+
}
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Threshold ratchet — watched file families and value extractors.
|
|
3
|
+
*
|
|
4
|
+
* Pure extraction layer: given file contents, produce comparable constraint
|
|
5
|
+
* maps. No filesystem or git access. See threshold-ratchet.mjs for the CLI
|
|
6
|
+
* and threshold-ratchet-compare.mjs for the comparison rules.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* File families the ratchet watches. `kind` selects the extractor;
|
|
11
|
+
* `direction` applies to numeric-leaf kinds ("min" values may only rise,
|
|
12
|
+
* "max" values may only fall).
|
|
13
|
+
*/
|
|
14
|
+
export const FAMILIES = [
|
|
15
|
+
{
|
|
16
|
+
id: "coverage",
|
|
17
|
+
match: /(^|\/)(vitest|jest)\.thresholds\.json$/,
|
|
18
|
+
kind: "json-num",
|
|
19
|
+
direction: "min",
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
id: "simplecov",
|
|
23
|
+
match: /(^|\/)simplecov\.thresholds\.json$/,
|
|
24
|
+
kind: "json-num",
|
|
25
|
+
direction: "min",
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
id: "e2e",
|
|
29
|
+
match: /(^|\/)e2e\.thresholds\.json$/,
|
|
30
|
+
kind: "json-num",
|
|
31
|
+
direction: "min",
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: "eslint",
|
|
35
|
+
match: /(^|\/)eslint\.thresholds\.json$/,
|
|
36
|
+
kind: "json-num",
|
|
37
|
+
direction: "max",
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
id: "rubocop",
|
|
41
|
+
match: /(^|\/)rubocop\.thresholds\.yml$/,
|
|
42
|
+
kind: "rubocop-yaml",
|
|
43
|
+
direction: "max",
|
|
44
|
+
},
|
|
45
|
+
{ id: "stryker", match: /(^|\/)stryker\.conf\.json$/, kind: "stryker" },
|
|
46
|
+
{
|
|
47
|
+
id: "k6",
|
|
48
|
+
match: /(^|\/)\.github\/k6\/thresholds\/[^/]+\.json$/,
|
|
49
|
+
kind: "k6",
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
id: "audit-ignore",
|
|
53
|
+
match: /(^|\/)audit\.ignore\.(config|local)\.json$/,
|
|
54
|
+
kind: "exemption-list",
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
id: "lisa-config",
|
|
58
|
+
match: /(^|\/)\.lisa\.config\.json$/,
|
|
59
|
+
kind: "allow-list",
|
|
60
|
+
},
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Find the family a repo-relative path belongs to.
|
|
65
|
+
* @param {string} relPath Repo-relative path (forward slashes)
|
|
66
|
+
* @returns {(typeof FAMILIES)[number] | undefined} The matching family, or
|
|
67
|
+
* undefined when the path is not a watched gate file
|
|
68
|
+
*/
|
|
69
|
+
export function familyFor(relPath) {
|
|
70
|
+
return FAMILIES.find(f => f.match.test(relPath));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Safe JSON parse.
|
|
75
|
+
* @param {string | null | undefined} text JSON text
|
|
76
|
+
* @returns {unknown | undefined} Parsed value, or undefined on failure
|
|
77
|
+
*/
|
|
78
|
+
export function parseJson(text) {
|
|
79
|
+
if (typeof text !== "string") return undefined;
|
|
80
|
+
try {
|
|
81
|
+
return JSON.parse(text);
|
|
82
|
+
} catch {
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Walk a JSON object and collect numeric leaves as dotted-path constraints.
|
|
89
|
+
* Keys starting with "_" (e.g. `_comment`) are documentation, not thresholds.
|
|
90
|
+
* @param {unknown} node Parsed JSON value
|
|
91
|
+
* @param {"min"|"max"} direction Ratchet direction for every leaf
|
|
92
|
+
* @param {string} [prefix] Dotted path accumulated so far
|
|
93
|
+
* @returns {Map<string, { value: number, direction: "min"|"max" }>} Dotted
|
|
94
|
+
* path → numeric constraint for every finite numeric leaf
|
|
95
|
+
*/
|
|
96
|
+
export function extractNumericLeaves(node, direction, prefix = "") {
|
|
97
|
+
const out = new Map();
|
|
98
|
+
if (node === null || typeof node !== "object" || Array.isArray(node)) {
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
for (const [key, value] of Object.entries(node)) {
|
|
102
|
+
if (key.startsWith("_")) continue;
|
|
103
|
+
const p = prefix ? `${prefix}.${key}` : key;
|
|
104
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
105
|
+
out.set(p, { value, direction });
|
|
106
|
+
} else if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
107
|
+
for (const [cp, c] of extractNumericLeaves(value, direction, p)) {
|
|
108
|
+
out.set(cp, c);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Minimal parser for rubocop.thresholds.yml — a two-level document of
|
|
117
|
+
* `Section:` headers with indented `Key: <number>` scalars (comments and
|
|
118
|
+
* blank lines ignored). Deliberately NOT a general YAML parser: the file is
|
|
119
|
+
* Lisa-authored with this exact shape, and hand-parsing keeps the gate
|
|
120
|
+
* dependency-free with no backtracking-prone regexes.
|
|
121
|
+
* @param {string} text File contents
|
|
122
|
+
* @param {"min"|"max"} direction Ratchet direction for every scalar
|
|
123
|
+
* @returns {Map<string, { value: number, direction: "min"|"max" }>} Dotted
|
|
124
|
+
* `Section.Key` path → numeric constraint
|
|
125
|
+
*/
|
|
126
|
+
export function extractRubocopThresholds(text, direction) {
|
|
127
|
+
const out = new Map();
|
|
128
|
+
const state = { section: "" };
|
|
129
|
+
for (const rawLine of text.split("\n")) {
|
|
130
|
+
const hash = rawLine.indexOf("#");
|
|
131
|
+
const line = (hash >= 0 ? rawLine.slice(0, hash) : rawLine).trimEnd();
|
|
132
|
+
if (!line.trim()) continue;
|
|
133
|
+
const indented = line.startsWith(" ") || line.startsWith("\t");
|
|
134
|
+
if (!indented && line.endsWith(":")) {
|
|
135
|
+
state.section = line.slice(0, -1).trim();
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
const colon = line.indexOf(":");
|
|
139
|
+
if (!indented || colon < 0 || !state.section) continue;
|
|
140
|
+
const key = line.slice(0, colon).trim();
|
|
141
|
+
const rawValue = line.slice(colon + 1).trim();
|
|
142
|
+
// Number("") is 0, so an empty value (e.g. a nested `Exclude:` list
|
|
143
|
+
// header) must be skipped, not recorded as a zero threshold.
|
|
144
|
+
const value = Number(rawValue);
|
|
145
|
+
if (key && rawValue !== "" && Number.isFinite(value)) {
|
|
146
|
+
out.set(`${state.section}.${key}`, { value, direction });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Extract the gating constraint from stryker.conf.json: only
|
|
154
|
+
* `thresholds.break` fails a run (`high`/`low` are reporting bands).
|
|
155
|
+
* @param {unknown} conf Parsed stryker.conf.json
|
|
156
|
+
* @returns {Map<string, { value: number, direction: "min"|"max" }>} The
|
|
157
|
+
* `thresholds.break` constraint when present, otherwise an empty map
|
|
158
|
+
*/
|
|
159
|
+
export function extractStrykerConstraints(conf) {
|
|
160
|
+
const out = new Map();
|
|
161
|
+
const breakValue = conf?.thresholds?.break;
|
|
162
|
+
if (typeof breakValue === "number" && Number.isFinite(breakValue)) {
|
|
163
|
+
out.set("thresholds.break", { value: breakValue, direction: "min" });
|
|
164
|
+
}
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Split a stryker `mutate` array into positive globs and negations.
|
|
170
|
+
* @param {unknown} conf Parsed stryker.conf.json
|
|
171
|
+
* @returns {{ positives: Set<string>, negations: Set<string> }} Globs that
|
|
172
|
+
* include files vs. `!`-prefixed globs that exclude them
|
|
173
|
+
*/
|
|
174
|
+
export function extractStrykerMutate(conf) {
|
|
175
|
+
const positives = new Set();
|
|
176
|
+
const negations = new Set();
|
|
177
|
+
const mutate = Array.isArray(conf?.mutate) ? conf.mutate : [];
|
|
178
|
+
for (const glob of mutate) {
|
|
179
|
+
if (typeof glob !== "string") continue;
|
|
180
|
+
if (glob.startsWith("!")) negations.add(glob);
|
|
181
|
+
else positives.add(glob);
|
|
182
|
+
}
|
|
183
|
+
return { positives, negations };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Parse one k6 threshold expression (`p(95)<1000`, `rate>=0.99`, …) into a
|
|
188
|
+
* ratchet constraint. Upper bounds (`<`, `<=`) may only decrease; lower
|
|
189
|
+
* bounds (`>`, `>=`) may only increase. Hand-parsed — no regex.
|
|
190
|
+
* @param {string} expr Threshold expression
|
|
191
|
+
* @returns {{ value: number, direction: "min"|"max" } | undefined} The bound
|
|
192
|
+
* as a constraint, or undefined when the expression has no numeric bound
|
|
193
|
+
*/
|
|
194
|
+
export function parseK6Expression(expr) {
|
|
195
|
+
for (const op of ["<=", ">=", "<", ">"]) {
|
|
196
|
+
const idx = expr.indexOf(op);
|
|
197
|
+
if (idx < 0) continue;
|
|
198
|
+
const value = Number(expr.slice(idx + op.length).trim());
|
|
199
|
+
if (!Number.isFinite(value)) return undefined;
|
|
200
|
+
return { value, direction: op.startsWith("<") ? "max" : "min" };
|
|
201
|
+
}
|
|
202
|
+
return undefined;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Extract constraints from a k6 thresholds file. Each metric contributes its
|
|
207
|
+
* expression bound(s) plus (when present) `abortOnFail` boolean constraints —
|
|
208
|
+
* turning abortOnFail off makes the gate advisory, which is a weakening.
|
|
209
|
+
* Handles every documented k6 shape: a bare expression string, a single long
|
|
210
|
+
* form `{ threshold, abortOnFail }` object, and arrays mixing both.
|
|
211
|
+
* @param {unknown} conf Parsed k6 thresholds JSON
|
|
212
|
+
* @returns {{
|
|
213
|
+
* numeric: Map<string, { value: number, direction: "min"|"max" }>,
|
|
214
|
+
* booleans: Map<string, boolean>,
|
|
215
|
+
* }} Numeric bounds keyed by `<metric>.threshold[i]` and abortOnFail flags
|
|
216
|
+
* keyed by `<metric>.abortOnFail` (`[i]`-suffixed for array items)
|
|
217
|
+
*/
|
|
218
|
+
export function extractK6Constraints(conf) {
|
|
219
|
+
const numeric = new Map();
|
|
220
|
+
const booleans = new Map();
|
|
221
|
+
const thresholds = conf?.thresholds;
|
|
222
|
+
if (thresholds && typeof thresholds === "object") {
|
|
223
|
+
for (const [metric, spec] of Object.entries(thresholds)) {
|
|
224
|
+
const isArray = Array.isArray(spec);
|
|
225
|
+
const items = isArray ? spec : [spec];
|
|
226
|
+
items.forEach((item, i) => {
|
|
227
|
+
const expr =
|
|
228
|
+
typeof item === "string"
|
|
229
|
+
? item
|
|
230
|
+
: item && typeof item === "object" && !Array.isArray(item)
|
|
231
|
+
? item.threshold
|
|
232
|
+
: undefined;
|
|
233
|
+
if (typeof expr === "string") {
|
|
234
|
+
const c = parseK6Expression(expr);
|
|
235
|
+
if (c) numeric.set(`${metric}.threshold[${i}]`, c);
|
|
236
|
+
}
|
|
237
|
+
if (
|
|
238
|
+
item &&
|
|
239
|
+
typeof item === "object" &&
|
|
240
|
+
!Array.isArray(item) &&
|
|
241
|
+
typeof item.abortOnFail === "boolean"
|
|
242
|
+
) {
|
|
243
|
+
booleans.set(
|
|
244
|
+
isArray ? `${metric}.abortOnFail[${i}]` : `${metric}.abortOnFail`,
|
|
245
|
+
item.abortOnFail
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return { numeric, booleans };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Flatten an exemption file (audit ignore list) into a set of entry tokens.
|
|
256
|
+
* Arrays contribute their string items; objects contribute their keys.
|
|
257
|
+
* @param {unknown} conf Parsed JSON
|
|
258
|
+
* @returns {Set<string>} One token per exemption entry
|
|
259
|
+
*/
|
|
260
|
+
export function extractExemptionEntries(conf) {
|
|
261
|
+
const out = new Set();
|
|
262
|
+
if (Array.isArray(conf)) {
|
|
263
|
+
for (const item of conf) {
|
|
264
|
+
if (typeof item === "string") out.add(item);
|
|
265
|
+
else if (item && typeof item === "object") out.add(JSON.stringify(item));
|
|
266
|
+
}
|
|
267
|
+
} else if (conf && typeof conf === "object") {
|
|
268
|
+
for (const [key, value] of Object.entries(conf)) {
|
|
269
|
+
if (Array.isArray(value)) {
|
|
270
|
+
for (const item of value) {
|
|
271
|
+
out.add(
|
|
272
|
+
`${key}:${typeof item === "string" ? item : JSON.stringify(item)}`
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
} else {
|
|
276
|
+
out.add(key);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return out;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Extract thresholdRatchet.allow entries from parsed .lisa.config.json.
|
|
285
|
+
* @param {unknown} config Parsed config (may be undefined)
|
|
286
|
+
* @returns {Array<{ file: string, key: string, reason?: string }>} The
|
|
287
|
+
* well-formed allow entries; malformed entries are dropped
|
|
288
|
+
*/
|
|
289
|
+
export function extractAllowEntries(config) {
|
|
290
|
+
const raw = config?.thresholdRatchet?.allow;
|
|
291
|
+
if (!Array.isArray(raw)) return [];
|
|
292
|
+
return raw.filter(
|
|
293
|
+
e => e && typeof e.file === "string" && typeof e.key === "string"
|
|
294
|
+
);
|
|
295
|
+
}
|