@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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Threshold ratchet gate — quality thresholds may tighten, never weaken.
|
|
4
|
+
*
|
|
5
|
+
* Deterministic comparator shared by three enforcement layers:
|
|
6
|
+
* 1. Agent-time soft block: PostToolUse hook via threshold-ratchet.sh
|
|
7
|
+
* (`--hook`, exit 2 on weakening so the agent gets actionable feedback).
|
|
8
|
+
* 2. Pre-commit backstop: husky / lefthook (`--staged`, exit 1).
|
|
9
|
+
* 3. CI gate: reusable quality workflows (`--base <ref>`, exit 1),
|
|
10
|
+
* comparing against the merge-base so nothing weakened lands in a PR.
|
|
11
|
+
*
|
|
12
|
+
* Tier 1 — designed tunables: vitest/jest/simplecov/e2e thresholds
|
|
13
|
+
* (minimums) and eslint/rubocop thresholds (maximums). Tier 2 — stryker's
|
|
14
|
+
* break score and k6 expression bounds. Tier 3 — exemption additions
|
|
15
|
+
* (audit-ignore entries, stryker mutate exclusions, thresholdRatchet.allow
|
|
16
|
+
* entries) which weaken a gate without touching a number.
|
|
17
|
+
*
|
|
18
|
+
* Human override: `.lisa.config.json` → `thresholdRatchet.allow` entries
|
|
19
|
+
* ({ file, key, reason }). Honored ONLY from the baseline side (HEAD /
|
|
20
|
+
* merge-base), never from the change under review — an agent cannot grant
|
|
21
|
+
* itself an exception in the same change that weakens a gate. `key: "*"`
|
|
22
|
+
* allows every key in the file.
|
|
23
|
+
*
|
|
24
|
+
* Extraction lives in threshold-ratchet-families.mjs; comparison rules in
|
|
25
|
+
* threshold-ratchet-compare.mjs. Zero dependencies.
|
|
26
|
+
*/
|
|
27
|
+
import { execFileSync } from "node:child_process";
|
|
28
|
+
import * as fs from "node:fs";
|
|
29
|
+
import * as path from "node:path";
|
|
30
|
+
import { fileURLToPath } from "node:url";
|
|
31
|
+
import {
|
|
32
|
+
extractAllowEntries,
|
|
33
|
+
familyFor,
|
|
34
|
+
parseJson,
|
|
35
|
+
} from "./threshold-ratchet-families.mjs";
|
|
36
|
+
import {
|
|
37
|
+
applyAllowList,
|
|
38
|
+
compareFile,
|
|
39
|
+
formatReport,
|
|
40
|
+
} from "./threshold-ratchet-compare.mjs";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Standard git locations, checked in order so the executable comes from a
|
|
44
|
+
* fixed, unwriteable directory rather than a PATH lookup. The bare "git"
|
|
45
|
+
* fallback keeps unusual layouts (e.g. Windows git-bash) working.
|
|
46
|
+
*/
|
|
47
|
+
const GIT_LOCATIONS = [
|
|
48
|
+
"/usr/bin/git",
|
|
49
|
+
"/usr/local/bin/git",
|
|
50
|
+
"/opt/homebrew/bin/git",
|
|
51
|
+
];
|
|
52
|
+
const GIT = GIT_LOCATIONS.find(candidate => fs.existsSync(candidate)) ?? "git";
|
|
53
|
+
|
|
54
|
+
/** Git flag shared by every changed-file listing. */
|
|
55
|
+
const NAME_ONLY = "--name-only";
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Run git, returning stdout or null on any failure.
|
|
59
|
+
* @param {string[]} args Git arguments
|
|
60
|
+
* @param {string} [cwd] Working directory
|
|
61
|
+
* @returns {string | null} Captured stdout, or null when git failed
|
|
62
|
+
*/
|
|
63
|
+
function git(args, cwd) {
|
|
64
|
+
try {
|
|
65
|
+
return execFileSync(GIT, args, {
|
|
66
|
+
cwd,
|
|
67
|
+
encoding: "utf-8",
|
|
68
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
69
|
+
});
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Resolve mode-specific candidate files and content readers.
|
|
77
|
+
* @param {"hook"|"staged"|"base"} mode Comparison mode
|
|
78
|
+
* @param {string} root Repo root
|
|
79
|
+
* @param {string | undefined} baseRef Base ref (base mode only)
|
|
80
|
+
* @param {string[] | undefined} onlyFiles Restrict to these repo-relative
|
|
81
|
+
* paths (hook mode with a known edited file)
|
|
82
|
+
* @returns {{ files: string[], baselineRef: string, readCurrent: (f: string) => string | null } | null}
|
|
83
|
+
* The comparison plan, or null when git state can't support the mode
|
|
84
|
+
*/
|
|
85
|
+
function resolvePlan(mode, root, baseRef, onlyFiles) {
|
|
86
|
+
if (mode === "staged") {
|
|
87
|
+
const diff = git(["diff", "--cached", NAME_ONLY], root);
|
|
88
|
+
if (diff === null) return null;
|
|
89
|
+
return {
|
|
90
|
+
files: diff.split("\n").filter(Boolean),
|
|
91
|
+
baselineRef: "HEAD",
|
|
92
|
+
readCurrent: f => git(["show", `:${f}`], root),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
if (mode === "base") {
|
|
96
|
+
if (!baseRef) return null;
|
|
97
|
+
const mergeBase = git(["merge-base", baseRef, "HEAD"], root)?.trim();
|
|
98
|
+
if (!mergeBase) return null;
|
|
99
|
+
const diff = git(["diff", NAME_ONLY, mergeBase, "HEAD"], root);
|
|
100
|
+
if (diff === null) return null;
|
|
101
|
+
return {
|
|
102
|
+
files: diff.split("\n").filter(Boolean),
|
|
103
|
+
baselineRef: mergeBase,
|
|
104
|
+
readCurrent: f => git(["show", `HEAD:${f}`], root),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
const diff = git(["diff", NAME_ONLY, "HEAD"], root);
|
|
108
|
+
if (diff === null) return null;
|
|
109
|
+
return {
|
|
110
|
+
files: onlyFiles ?? diff.split("\n").filter(Boolean),
|
|
111
|
+
baselineRef: "HEAD",
|
|
112
|
+
readCurrent: f => {
|
|
113
|
+
try {
|
|
114
|
+
return fs.readFileSync(path.join(root, f), "utf-8");
|
|
115
|
+
} catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Run the ratchet for a mode, print the report, and return the exit code.
|
|
124
|
+
* @param {"hook"|"staged"|"base"} mode Comparison mode
|
|
125
|
+
* @param {string | undefined} [baseRef] Base ref (base mode only)
|
|
126
|
+
* @param {string[] | undefined} [onlyFiles] Restrict to these paths (hook mode)
|
|
127
|
+
* @returns {number} Process exit code (2 for hook mode, 1 otherwise; 0 clean)
|
|
128
|
+
*/
|
|
129
|
+
function run(mode, baseRef, onlyFiles) {
|
|
130
|
+
const root = git(["rev-parse", "--show-toplevel"])?.trim();
|
|
131
|
+
if (!root) return 0;
|
|
132
|
+
const plan = resolvePlan(mode, root, baseRef, onlyFiles);
|
|
133
|
+
if (!plan) return 0;
|
|
134
|
+
|
|
135
|
+
const watched = plan.files.filter(f => familyFor(f));
|
|
136
|
+
if (watched.length === 0) return 0;
|
|
137
|
+
|
|
138
|
+
const findings = watched.flatMap(f =>
|
|
139
|
+
compareFile(
|
|
140
|
+
f,
|
|
141
|
+
git(["show", `${plan.baselineRef}:${f}`], root),
|
|
142
|
+
plan.readCurrent(f)
|
|
143
|
+
)
|
|
144
|
+
);
|
|
145
|
+
if (findings.length === 0) return 0;
|
|
146
|
+
|
|
147
|
+
const baselineConfig = parseJson(
|
|
148
|
+
git(["show", `${plan.baselineRef}:.lisa.config.json`], root)
|
|
149
|
+
);
|
|
150
|
+
const { blocked, allowed } = applyAllowList(
|
|
151
|
+
findings,
|
|
152
|
+
extractAllowEntries(baselineConfig)
|
|
153
|
+
);
|
|
154
|
+
for (const finding of allowed) {
|
|
155
|
+
process.stdout.write(
|
|
156
|
+
`threshold-ratchet: allowed by .lisa.config.json exception — ${finding.message}\n`
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
if (blocked.length === 0) return 0;
|
|
160
|
+
process.stderr.write(`${formatReport(blocked)}\n`);
|
|
161
|
+
return mode === "hook" ? 2 : 1;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Handle `--hook` mode: parse the tool-use event from stdin and scope the
|
|
166
|
+
* check to the edited file (Edit/Write/NotebookEdit) or every changed
|
|
167
|
+
* watched file (Bash).
|
|
168
|
+
* @returns {number} Process exit code
|
|
169
|
+
*/
|
|
170
|
+
function runHookMode() {
|
|
171
|
+
const state = { stdin: "" };
|
|
172
|
+
try {
|
|
173
|
+
state.stdin = fs.readFileSync(0, "utf-8");
|
|
174
|
+
} catch {
|
|
175
|
+
return 0;
|
|
176
|
+
}
|
|
177
|
+
const input = parseJson(state.stdin);
|
|
178
|
+
if (!input || typeof input !== "object") return 0;
|
|
179
|
+
if (input.tool_name === "Bash") return run("hook");
|
|
180
|
+
if (!["Edit", "Write", "NotebookEdit"].includes(input.tool_name)) return 0;
|
|
181
|
+
const filePath = input.tool_input?.file_path;
|
|
182
|
+
if (typeof filePath !== "string") return 0;
|
|
183
|
+
const root = git(["rev-parse", "--show-toplevel"])?.trim();
|
|
184
|
+
if (!root) return 0;
|
|
185
|
+
const rel = path
|
|
186
|
+
.relative(root, path.resolve(filePath))
|
|
187
|
+
.split(path.sep)
|
|
188
|
+
.join("/");
|
|
189
|
+
if (rel.startsWith("..") || !familyFor(rel)) return 0;
|
|
190
|
+
return run("hook", undefined, [rel]);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* CLI entrypoint.
|
|
195
|
+
* @returns {number} Process exit code
|
|
196
|
+
*/
|
|
197
|
+
function main() {
|
|
198
|
+
const args = process.argv.slice(2);
|
|
199
|
+
if (args[0] === "--staged") return run("staged");
|
|
200
|
+
if (args[0] === "--base") return run("base", args[1]);
|
|
201
|
+
if (args[0] === "--hook") return runHookMode();
|
|
202
|
+
process.stderr.write(
|
|
203
|
+
"usage: threshold-ratchet.mjs --hook | --staged | --base <ref>\n"
|
|
204
|
+
);
|
|
205
|
+
return 0;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const isDirectRun =
|
|
209
|
+
process.argv[1] &&
|
|
210
|
+
fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
|
|
211
|
+
if (isDirectRun) {
|
|
212
|
+
process.exit(main());
|
|
213
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# PostToolUse hook for Edit|Write|NotebookEdit|Bash: the threshold ratchet.
|
|
3
|
+
# Quality thresholds (coverage minimums, complexity maximums, mutation break
|
|
4
|
+
# score, e2e route floors, k6 bounds, audit-ignore lists) may tighten but never
|
|
5
|
+
# weaken. The deterministic comparator lives in threshold-ratchet.mjs and is
|
|
6
|
+
# shared with the pre-commit (husky/lefthook --staged) and CI (--base) layers,
|
|
7
|
+
# so this hook is fast feedback — not the only line of defense.
|
|
8
|
+
#
|
|
9
|
+
# Exit 2 on weakening (soft block: the agent gets the report on stderr and can
|
|
10
|
+
# fix the code or escalate to a human). Every infrastructure gap — no node, no
|
|
11
|
+
# git repo, unreadable stdin — exits 0: the CI layer still guarantees the gate,
|
|
12
|
+
# and a broken hook must never wedge an agent session.
|
|
13
|
+
set -euo pipefail
|
|
14
|
+
|
|
15
|
+
input="$(cat)"
|
|
16
|
+
|
|
17
|
+
command -v node >/dev/null 2>&1 || exit 0
|
|
18
|
+
git rev-parse --is-inside-work-tree >/dev/null 2>&1 || exit 0
|
|
19
|
+
|
|
20
|
+
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
21
|
+
|
|
22
|
+
printf '%s' "$input" | node "$script_dir/threshold-ratchet.mjs" --hook
|
|
@@ -29,9 +29,12 @@ Project tracker (`jira` / `github` / `linear`) is read from `.lisa.config.json`
|
|
|
29
29
|
|
|
30
30
|
Resolve hand-authored project rules from `.lisa.config.json`
|
|
31
31
|
`projectRulesFile`, defaulting to `.claude/rules/PROJECT_RULES.md`. Automated
|
|
32
|
-
learnings never append to that file: they use the separate
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
learnings never append to that file: they use the separate machine-managed
|
|
33
|
+
ledger resolved from `.lisa.config.json` — the optional `learnings.file`
|
|
34
|
+
override, else the default `.lisa/PROJECT_LEARNINGS.md`. The ledger lives in the
|
|
35
|
+
cold `.lisa/` tree (never an auto-loaded rules directory) and is consumed only
|
|
36
|
+
through the contract's bounded projection, never read raw wholesale. Both
|
|
37
|
+
writers and budget checks import the executable contract from
|
|
35
38
|
`@codyswann/lisa/learnings`; they must not copy its numeric limits.
|
|
36
39
|
|
|
37
40
|
## Env → base branch
|
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
# Project Learnings (load-bearing)
|
|
2
2
|
|
|
3
3
|
Before normal task work, resolve this repository's committed `.lisa.config.json`
|
|
4
|
-
and derive the
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
4
|
+
and derive the machine-managed learnings ledger: the optional `learnings.file`
|
|
5
|
+
override, else the default `.lisa/PROJECT_LEARNINGS.md`. The ledger lives cold in
|
|
6
|
+
`.lisa/`, NOT in an auto-loaded rules tree — so it is never injected raw into the
|
|
7
|
+
session.
|
|
8
|
+
|
|
9
|
+
Consume it ONLY through the executable Lisa learnings contract exported by
|
|
10
|
+
`@codyswann/lisa/learnings`: parse and validate with `parseLearningsFile`, then
|
|
11
|
+
take the bounded serving slice from `projectLearnings` (the highest-priority
|
|
12
|
+
entries within the token/entry budget). **Never read the raw ledger file
|
|
13
|
+
wholesale into context** — the whole point of the relocation is that sessions
|
|
14
|
+
receive the contract's bounded projection, not the full file.
|
|
9
15
|
|
|
10
16
|
Missing learnings are a silent no-op. Malformed, non-canonical, unsafe, or
|
|
11
17
|
over-budget learnings produce one readable warning, apply no entry, and must not
|
|
@@ -179,7 +179,8 @@ fi
|
|
|
179
179
|
|-------|----------|---------|-------|
|
|
180
180
|
| `tracker` | **yes** | — | Destination for ticket writes. One of `"jira"`, `"github"`, `"linear"`. Missing → fail with instruction to run the matching `/lisa:setup:*` skill. |
|
|
181
181
|
| `source` | no | — | Default PRD source for batch skills (`/lisa:intake`) and arg-less single-PRD skills. One of `"notion"`, `"confluence"`, `"linear"`, `"github"`, `"jira"`. Explicit URLs/keys passed to a skill always win over `source`; this is a default, not a lock. |
|
|
182
|
-
| `projectRulesFile` | no | `.claude/rules/PROJECT_RULES.md` | Safe repo-relative Markdown path for hand-authored project rules.
|
|
182
|
+
| `projectRulesFile` | no | `.claude/rules/PROJECT_RULES.md` | Safe repo-relative Markdown path for hand-authored project rules. Independent of the learnings ledger — relocating the rules file never moves the ledger. |
|
|
183
|
+
| `learnings.file` | no | `.lisa/PROJECT_LEARNINGS.md` | Safe repo-relative Markdown path for the machine-managed learnings ledger, overriding the `.lisa/` default. Rejected if it resolves inside any auto-loaded rules tree (`.claude/rules`, `.cursor/rules`, `.github/instructions`, `.agents/rules`) — the ledger must stay out of eager context. |
|
|
183
184
|
| `usage` | no | — | Optional token/cost pricing metadata consumed by the `usage-accounting` rule. Missing pricing never blocks a lifecycle flow; Lisa records token counts with `estimated_cost: null` when no trustworthy price source is configured. |
|
|
184
185
|
| `wiki` | no | — | Wiki location for the `wiki-knowledge-source` rule. Omit for a local in-repo wiki (`wiki/`). See **Wiki source** below. |
|
|
185
186
|
|
|
@@ -1,14 +1,21 @@
|
|
|
1
1
|
# Project Learnings
|
|
2
2
|
|
|
3
|
-
Project learnings are Lisa's bounded, repo-local memory surface.
|
|
4
|
-
|
|
5
|
-
`.lisa.config.json
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
3
|
+
Project learnings are Lisa's bounded, repo-local memory surface. The
|
|
4
|
+
machine-managed ledger is `PROJECT_LEARNINGS.md`, resolved from
|
|
5
|
+
`.lisa.config.json`: the optional `learnings.file` override, else the default
|
|
6
|
+
`.lisa/PROJECT_LEARNINGS.md`. The ledger deliberately lives in the cold `.lisa/`
|
|
7
|
+
directory — **not** under `.claude/rules/` or any other auto-loaded rules tree —
|
|
8
|
+
because anything in those trees is injected raw into every session, which
|
|
9
|
+
double-loads the file and bypasses the contract's budget and validation.
|
|
10
|
+
|
|
11
|
+
Consume learnings before normal task work whenever the file exists, and consume
|
|
12
|
+
them ONLY through the executable contract from `@codyswann/lisa/learnings`:
|
|
13
|
+
`parseLearningsFile` to parse and validate, then `projectLearnings` to take the
|
|
14
|
+
bounded serving slice (the highest-priority entries — ordered by confidence,
|
|
15
|
+
then recency — that fit the token/entry budget, plus how many were omitted).
|
|
16
|
+
**Never read the raw ledger file wholesale into context**; the session receives
|
|
17
|
+
the bounded projection, not the full document. Do not duplicate numeric caps in
|
|
18
|
+
rules or prompts; the exported contract is the source of truth.
|
|
12
19
|
|
|
13
20
|
Each persisted entry has seven fields:
|
|
14
21
|
|
|
@@ -139,7 +139,7 @@ Continue to Phase 3.
|
|
|
139
139
|
No learning content is ever committed without a PR — there is no other write path, and the PR must touch **only** the learnings surface (any other changed file is a bug).
|
|
140
140
|
|
|
141
141
|
1. **PR dedupe.** Search all PRs for the marker `[lisa-learning-pr] key=<fingerprint>` in the body (`gh pr list --state all --search '"<marker>" in:body' --json number,url`), with the stale-index guard above. If one exists, reference it and stop — never open a duplicate.
|
|
142
|
-
2. **Resolve the learnings surface path — never hardcode it.** The canonical path is `resolveProjectLearningsFile` from `@codyswann/lisa/learnings`: the
|
|
142
|
+
2. **Resolve the learnings surface path — never hardcode it.** The canonical path is `resolveProjectLearningsFile` from `@codyswann/lisa/learnings`: the machine-managed ledger resolved from `.lisa.config.json` (the `learnings.file` override, else the default `.lisa/PROJECT_LEARNINGS.md` — a cold path, never an auto-loaded rules tree):
|
|
143
143
|
|
|
144
144
|
```bash
|
|
145
145
|
LEARNINGS_FILE=$(node -e 'import("@codyswann/lisa/learnings").then(async m => { const c = await m.readProjectConfig(process.cwd()); console.log(m.resolveProjectLearningsFile(c)); })')
|
|
@@ -4,6 +4,12 @@ pre-commit:
|
|
|
4
4
|
rubocop:
|
|
5
5
|
glob: '*.rb'
|
|
6
6
|
run: bundle exec rubocop --force-exclusion {staged_files}
|
|
7
|
+
threshold-ratchet:
|
|
8
|
+
# Quality thresholds (simplecov minimums, rubocop Metrics maximums, …)
|
|
9
|
+
# may tighten, never weaken. Same deterministic comparator as the
|
|
10
|
+
# agent-time hooks and CI; self-skips when node or the script is absent
|
|
11
|
+
# (CI still enforces the gate).
|
|
12
|
+
run: sh -c 'command -v node >/dev/null 2>&1 || exit 0; [ -f scripts/check-threshold-ratchet.mjs ] || exit 0; node scripts/check-threshold-ratchet.mjs --staged'
|
|
7
13
|
bundler-audit:
|
|
8
14
|
# Unset GIT_DIR/GIT_WORK_TREE so `--update`'s internal git pull of the
|
|
9
15
|
# ruby-advisory-db (~/.local/share/ruby-advisory-db) does not inherit the
|