@codyswann/lisa 3.47.4 → 3.47.6
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/lib/gate-failure-diagnosis.mjs +270 -0
- package/all/copy-overwrite/scripts/lisa-gates.mjs +30 -0
- package/all/copy-overwrite/scripts/lisa-run-gates.mjs +294 -34
- package/all/copy-overwrite/scripts/lisa-schema-validate.mjs +126 -32
- package/all/copy-overwrite/scripts/lisa-work-item.mjs +83 -26
- package/dist/configs/eslint/expo.d.ts +50 -1
- package/dist/configs/eslint/expo.d.ts.map +1 -1
- package/dist/configs/eslint/expo.js +86 -5
- package/dist/configs/eslint/expo.js.map +1 -1
- package/dist/core/downstream-names.d.ts +83 -0
- package/dist/core/downstream-names.d.ts.map +1 -0
- package/dist/core/downstream-names.js +289 -0
- package/dist/core/downstream-names.js.map +1 -0
- package/dist/core/downstream-references.d.ts +21 -6
- package/dist/core/downstream-references.d.ts.map +1 -1
- package/dist/core/downstream-references.js +19 -16
- package/dist/core/downstream-references.js.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +16 -0
- package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +23 -10
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/dist/migrations/ensure-tsconfig-local-includes.d.ts +1 -1
- package/dist/migrations/ensure-tsconfig-local-includes.js +1 -1
- package/expo/copy-overwrite/eslint.config.ts +9 -0
- package/expo/copy-overwrite/eslint.expo.ts +92 -4
- package/package.json +5 -2
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/rules/reference/design-value-binding.md +4 -0
- 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/rules/reference/design-value-binding.md +4 -0
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/rules/design-value-binding-reference.mdc +4 -0
- 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/rules/reference/design-value-binding.md +4 -0
- package/scripts/lisa-mutation.mjs +13 -0
- package/typescript/copy-contents/.husky/pre-push +7 -1
- package/typescript/copy-overwrite/scripts/lisa-mutation.mjs +496 -122
- package/typescript/package-lisa/package.lisa.json +1 -0
|
@@ -1,151 +1,525 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// This file is managed by Lisa and IS replaced on each `lisa` run.
|
|
3
3
|
// Do not edit directly — durable changes belong upstream in Lisa.
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
4
|
+
/**
|
|
5
|
+
* Mutation-testing gate (StrykerJS) — opt-in, diff-only, shared by the pre-push
|
|
6
|
+
* hook and CI.
|
|
7
|
+
*
|
|
8
|
+
* @remarks
|
|
9
|
+
* ## What it does
|
|
10
|
+
*
|
|
11
|
+
* 1. Reads `mutation.gate.json`. Disabled (the default) prints a notice and
|
|
12
|
+
* exits 0, so pushes and CI are never slowed down until a project opts in.
|
|
13
|
+
* 2. When enabled, it computes the files changed on this branch (vs the
|
|
14
|
+
* merge-base with the configured `since` ref), keeps the ones the project's
|
|
15
|
+
* **own Stryker `mutate` configuration** selects, and runs Stryker on only
|
|
16
|
+
* those. Mutation testing is slow; a full-repo run is never done here.
|
|
17
|
+
* 3. The score threshold lives in `stryker.conf.*` (`thresholds.break`).
|
|
18
|
+
* Stryker exits non-zero below it, which fails the gate.
|
|
19
|
+
*
|
|
20
|
+
* ## Why eligibility is read from the project's config, not hardcoded
|
|
21
|
+
*
|
|
22
|
+
* This filter used to be `f.startsWith("src/") || f.startsWith("lib/")` with a
|
|
23
|
+
* `.ts`/`.tsx` extension test. That happened to agree with the two
|
|
24
|
+
* `stryker.conf.json` templates Lisa ships, and it disagreed with every other
|
|
25
|
+
* layout in the world. The failure it produces is the worst-shaped one
|
|
26
|
+
* available: a project whose sources live anywhere else gets a gate that
|
|
27
|
+
* selects **no files, generates no mutants and exits 0 on every run** — a
|
|
28
|
+
* control that reports green while proving nothing, which is the exact defect
|
|
29
|
+
* class mutation testing is here to find.
|
|
30
|
+
*
|
|
31
|
+
* It was not hypothetical. Lisa's own mutate targets are `.mjs` guard scripts
|
|
32
|
+
* outside `src/`, so adopting this gate in the repository that ships it was
|
|
33
|
+
* impossible until the filter learned to read the config.
|
|
34
|
+
*
|
|
35
|
+
* Reading `mutate` also removes a second, quieter disagreement: `--mutate`
|
|
36
|
+
* REPLACES the configured patterns, so a changed file that the hardcoded filter
|
|
37
|
+
* accepted but the project's config excludes used to get mutated anyway.
|
|
38
|
+
*
|
|
39
|
+
* ## Empty is not the same as clean
|
|
40
|
+
*
|
|
41
|
+
* A diff-only gate that mutates nothing looks exactly like one that passed, so
|
|
42
|
+
* the two are separated here rather than left to the reader:
|
|
43
|
+
*
|
|
44
|
+
* - **Nothing changed that this project mutates** — legitimate, and extremely
|
|
45
|
+
* common (a docs-only or workflow-only branch). Reported as
|
|
46
|
+
* `nothing-to-mutate`, in a block that states no mutant was generated and no
|
|
47
|
+
* score was computed. Exit 0.
|
|
48
|
+
* - **The mutate configuration selects nothing in this repository at all** —
|
|
49
|
+
* a misconfigured gate, permanently inert, green forever. Reported as
|
|
50
|
+
* `inert-mutate-config` and it FAILS, exit 1. Distinguishing the two costs
|
|
51
|
+
* one `git ls-files`.
|
|
52
|
+
*
|
|
53
|
+
* ## Configuration
|
|
54
|
+
*
|
|
55
|
+
* `mutation.gate.json` (project-owned / create-only):
|
|
56
|
+
* `{ "enabled": false, "since": "main" }`.
|
|
57
|
+
* Overridable via env: `MUTATION_ENABLED=true|false`, `MUTATION_SINCE=<ref>`.
|
|
58
|
+
* @module scripts/lisa-mutation
|
|
59
|
+
*/
|
|
60
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
27
61
|
import fs from "node:fs";
|
|
28
62
|
import path from "node:path";
|
|
29
|
-
import
|
|
63
|
+
import process from "node:process";
|
|
30
64
|
|
|
31
|
-
|
|
65
|
+
import { invokedAsScript } from "./lib/invoked-as-script.mjs";
|
|
32
66
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
67
|
+
/**
|
|
68
|
+
* Machine-readable outcome markers, one per way this gate can end.
|
|
69
|
+
*
|
|
70
|
+
* Printed verbatim so a human scrolling a CI log and a test asserting the gate
|
|
71
|
+
* did not silently no-op read the same token. The distinction that matters is
|
|
72
|
+
* between `nothingToMutate` and a real passing run: both exit 0, and only the
|
|
73
|
+
* marker says which one happened.
|
|
74
|
+
* @type {Readonly<Record<string, string>>}
|
|
75
|
+
*/
|
|
76
|
+
export const OUTCOMES = Object.freeze({
|
|
77
|
+
disabled: "mutation-gate: disabled",
|
|
78
|
+
noBase: "mutation-gate: no-diff-base",
|
|
79
|
+
nothingToMutate: "mutation-gate: nothing-to-mutate",
|
|
80
|
+
inertConfig: "mutation-gate: inert-mutate-config",
|
|
81
|
+
unrepresentablePath: "mutation-gate: unrepresentable-path",
|
|
82
|
+
scoped: "mutation-gate: scoped-run",
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Stryker config file names, in the order Stryker itself resolves them.
|
|
87
|
+
*
|
|
88
|
+
* Only the JSON family is parsed. A JavaScript config would have to be
|
|
89
|
+
* imported, and importing a project's config to decide what to mutate is a much
|
|
90
|
+
* larger promise than this script makes; the fallback below is used instead and
|
|
91
|
+
* says so out loud rather than pretending to have read the file.
|
|
92
|
+
* @type {readonly string[]}
|
|
93
|
+
*/
|
|
94
|
+
export const JSON_CONFIG_NAMES = Object.freeze([
|
|
95
|
+
"stryker.conf.json",
|
|
96
|
+
"stryker.config.json",
|
|
97
|
+
".stryker.conf.json",
|
|
98
|
+
".stryker.config.json",
|
|
99
|
+
]);
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Config file names this script can see but does not evaluate.
|
|
103
|
+
* @type {readonly string[]}
|
|
104
|
+
*/
|
|
105
|
+
export const UNREADABLE_CONFIG_NAMES = Object.freeze([
|
|
106
|
+
"stryker.conf.js",
|
|
107
|
+
"stryker.conf.mjs",
|
|
108
|
+
"stryker.conf.cjs",
|
|
109
|
+
"stryker.config.js",
|
|
110
|
+
"stryker.config.mjs",
|
|
111
|
+
"stryker.config.cjs",
|
|
112
|
+
]);
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The patterns used when the project declares none.
|
|
116
|
+
*
|
|
117
|
+
* Deliberately the exact behaviour this script had before it learned to read
|
|
118
|
+
* `mutate`: a project that was relying on the old hardcoded filter and has no
|
|
119
|
+
* `mutate` key keeps the gate it had. It is a fallback, never a default — when
|
|
120
|
+
* `mutate` is present it wins outright.
|
|
121
|
+
* @type {readonly string[]}
|
|
122
|
+
*/
|
|
123
|
+
export const FALLBACK_MUTATE = Object.freeze([
|
|
124
|
+
"src/**/*.ts",
|
|
125
|
+
"src/**/*.tsx",
|
|
126
|
+
"lib/**/*.ts",
|
|
127
|
+
"lib/**/*.tsx",
|
|
128
|
+
"!**/*.spec.ts",
|
|
129
|
+
"!**/*.spec.tsx",
|
|
130
|
+
"!**/*.test.ts",
|
|
131
|
+
"!**/*.test.tsx",
|
|
132
|
+
"!**/*.d.ts",
|
|
133
|
+
"!**/*.stories.tsx",
|
|
134
|
+
]);
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* A path in the one spelling every comparison here uses.
|
|
138
|
+
* @param {string} file - Any path spelling.
|
|
139
|
+
* @returns {string} POSIX separators, no leading `./`.
|
|
140
|
+
*/
|
|
141
|
+
export const normalizePath = file =>
|
|
142
|
+
file.replaceAll("\\", "/").replace(/^\.\//u, "");
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* A mutate entry with Stryker's optional mutation-range suffix removed.
|
|
146
|
+
*
|
|
147
|
+
* `src/a.ts:1-10` and `src/a.ts:1:5-2:10` name a file plus the lines within it
|
|
148
|
+
* to mutate. The range is Stryker's business; for deciding whether a changed
|
|
149
|
+
* file is in scope only the path part matters, and leaving the suffix on makes
|
|
150
|
+
* the pattern match nothing.
|
|
151
|
+
* @param {string} pattern - A raw `mutate` entry, negation already stripped.
|
|
152
|
+
* @returns {string} The path-or-glob part.
|
|
153
|
+
*/
|
|
154
|
+
export const stripMutationRange = pattern =>
|
|
155
|
+
pattern.replace(/:\d+(?::\d+)?(?:-\d+(?::\d+)?)?$/u, "");
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* A glob compiled to an anchored regular expression.
|
|
159
|
+
*
|
|
160
|
+
* Supports the subset Stryker's own patterns use: `**` across directories, `*`
|
|
161
|
+
* and `?` within a segment, and `{a,b}` alternation. A literal path — which is
|
|
162
|
+
* what a hand-enumerated mutate list contains — is a glob that matches itself,
|
|
163
|
+
* so it needs no special case.
|
|
164
|
+
* @param {string} glob - The pattern.
|
|
165
|
+
* @returns {RegExp} Anchored matcher over a normalized path.
|
|
166
|
+
*/
|
|
167
|
+
export const globToRegExp = glob => {
|
|
168
|
+
let source = "^";
|
|
169
|
+
let braceDepth = 0;
|
|
170
|
+
for (let index = 0; index < glob.length; index += 1) {
|
|
171
|
+
const char = glob[index];
|
|
172
|
+
if (char === "*" && glob[index + 1] === "*") {
|
|
173
|
+
// `**/` spans whole segments INCLUDING none, so `**/x` matches `x`.
|
|
174
|
+
const spansSegments = glob[index + 2] === "/";
|
|
175
|
+
source += spansSegments ? "(?:[^/]*/)*" : "[^]*";
|
|
176
|
+
index += spansSegments ? 2 : 1;
|
|
177
|
+
} else if (char === "*") source += "[^/]*";
|
|
178
|
+
else if (char === "?") source += "[^/]";
|
|
179
|
+
else if (char === "{") {
|
|
180
|
+
braceDepth += 1;
|
|
181
|
+
source += "(?:";
|
|
182
|
+
} else if (char === "}" && braceDepth > 0) {
|
|
183
|
+
braceDepth -= 1;
|
|
184
|
+
source += ")";
|
|
185
|
+
} else if (char === "," && braceDepth > 0) source += "|";
|
|
186
|
+
else source += char.replaceAll(/[.+^${}()|[\]\\]/gu, "\\$&");
|
|
37
187
|
}
|
|
188
|
+
return new RegExp(`${source}$`, "u");
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* A `mutate` list split into what it selects and what it takes back out.
|
|
193
|
+
* @param {readonly string[]} mutate - Raw `mutate` entries.
|
|
194
|
+
* @returns {{include: RegExp[], exclude: RegExp[]}} Compiled matchers.
|
|
195
|
+
*/
|
|
196
|
+
export const compileMutatePatterns = mutate => {
|
|
197
|
+
const compile = entry => globToRegExp(stripMutationRange(entry));
|
|
198
|
+
return {
|
|
199
|
+
include: mutate.filter(entry => !entry.startsWith("!")).map(compile),
|
|
200
|
+
exclude: mutate
|
|
201
|
+
.filter(entry => entry.startsWith("!"))
|
|
202
|
+
.map(entry => compile(entry.slice(1))),
|
|
203
|
+
};
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Whether a file is one this project mutates.
|
|
208
|
+
* @param {string} file - Repository-relative path.
|
|
209
|
+
* @param {{include: RegExp[], exclude: RegExp[]}} patterns - Compiled matchers.
|
|
210
|
+
* @returns {boolean} True when at least one include and no exclude matches.
|
|
211
|
+
*/
|
|
212
|
+
export const isMutateTarget = (file, patterns) => {
|
|
213
|
+
const candidate = normalizePath(file);
|
|
214
|
+
if (!patterns.include.some(rule => rule.test(candidate))) return false;
|
|
215
|
+
return !patterns.exclude.some(rule => rule.test(candidate));
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Read and parse a Stryker JSON config, or report why it could not be used.
|
|
220
|
+
* @param {string} cwd - Project root.
|
|
221
|
+
* @param {string} name - Config file name that exists.
|
|
222
|
+
* @returns {{mutate: readonly string[], source: string}} Declaration and origin.
|
|
223
|
+
*/
|
|
224
|
+
const declarationFromJson = (cwd, name) => {
|
|
38
225
|
try {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
226
|
+
const conf = JSON.parse(fs.readFileSync(path.join(cwd, name), "utf8"));
|
|
227
|
+
if (Array.isArray(conf.mutate) && conf.mutate.length > 0) {
|
|
228
|
+
return { mutate: conf.mutate, source: name };
|
|
229
|
+
}
|
|
230
|
+
return {
|
|
231
|
+
mutate: FALLBACK_MUTATE,
|
|
232
|
+
source: `Lisa's fallback patterns (${name} declares no "mutate")`,
|
|
233
|
+
};
|
|
234
|
+
} catch (error) {
|
|
235
|
+
return {
|
|
236
|
+
mutate: FALLBACK_MUTATE,
|
|
237
|
+
source: `Lisa's fallback patterns (${name} could not be parsed: ${error.message})`,
|
|
238
|
+
};
|
|
43
239
|
}
|
|
44
240
|
};
|
|
45
241
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
242
|
+
/**
|
|
243
|
+
* The project's mutate declaration, and where it came from.
|
|
244
|
+
*
|
|
245
|
+
* The provenance is returned rather than logged from in here because it is
|
|
246
|
+
* printed on every run: a reader has to be able to tell "your config chose
|
|
247
|
+
* these" from "no config was found, so the fallback did".
|
|
248
|
+
* @param {string} cwd - Project root.
|
|
249
|
+
* @returns {{mutate: readonly string[], source: string}} Declaration and origin.
|
|
250
|
+
*/
|
|
251
|
+
export const resolveMutateDeclaration = cwd => {
|
|
252
|
+
const found = JSON_CONFIG_NAMES.find(name =>
|
|
253
|
+
fs.existsSync(path.join(cwd, name))
|
|
254
|
+
);
|
|
255
|
+
if (found) return declarationFromJson(cwd, found);
|
|
256
|
+
|
|
257
|
+
const unreadable = UNREADABLE_CONFIG_NAMES.find(name =>
|
|
258
|
+
fs.existsSync(path.join(cwd, name))
|
|
259
|
+
);
|
|
260
|
+
return {
|
|
261
|
+
mutate: FALLBACK_MUTATE,
|
|
262
|
+
source: unreadable
|
|
263
|
+
? `Lisa's fallback patterns (${unreadable} is JavaScript, which this gate does not evaluate)`
|
|
264
|
+
: "Lisa's fallback patterns (no Stryker config found)",
|
|
265
|
+
};
|
|
50
266
|
};
|
|
51
267
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
268
|
+
/**
|
|
269
|
+
* Read the project-owned gate switch.
|
|
270
|
+
* @param {string} cwd - Project root.
|
|
271
|
+
* @returns {{enabled?: boolean, since?: string}} The gate file, or the default.
|
|
272
|
+
*/
|
|
273
|
+
export const readGate = cwd => {
|
|
274
|
+
const gatePath = path.join(cwd, "mutation.gate.json");
|
|
275
|
+
if (!fs.existsSync(gatePath)) return { enabled: false, since: "main" };
|
|
276
|
+
try {
|
|
277
|
+
const parsed = JSON.parse(fs.readFileSync(gatePath, "utf8"));
|
|
278
|
+
// `null` and `[1,2]` are both valid JSON and neither is a gate. Returning
|
|
279
|
+
// them would make the caller read `.enabled` off a non-object and die with
|
|
280
|
+
// a TypeError that says nothing about the file that caused it.
|
|
281
|
+
if (
|
|
282
|
+
parsed === null ||
|
|
283
|
+
typeof parsed !== "object" ||
|
|
284
|
+
Array.isArray(parsed)
|
|
285
|
+
) {
|
|
286
|
+
console.error(
|
|
287
|
+
"⚠️ mutation.gate.json is not a JSON object; using the disabled default."
|
|
288
|
+
);
|
|
289
|
+
return { enabled: false, since: "main" };
|
|
290
|
+
}
|
|
291
|
+
return parsed;
|
|
292
|
+
} catch (error) {
|
|
293
|
+
console.error(`⚠️ Could not parse mutation.gate.json: ${error.message}`);
|
|
294
|
+
return { enabled: false, since: "main" };
|
|
295
|
+
}
|
|
296
|
+
};
|
|
55
297
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
298
|
+
/**
|
|
299
|
+
* A boolean environment override, or undefined when unset.
|
|
300
|
+
* @param {string} name - Variable name.
|
|
301
|
+
* @returns {boolean | undefined} The override.
|
|
302
|
+
*/
|
|
303
|
+
export const envFlag = name => {
|
|
304
|
+
const value = process.env[name];
|
|
305
|
+
if (value === undefined) return undefined;
|
|
306
|
+
return value === "true" || value === "1";
|
|
307
|
+
};
|
|
63
308
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
309
|
+
/**
|
|
310
|
+
* Run git, returning trimmed stdout.
|
|
311
|
+
*
|
|
312
|
+
* stderr is discarded on purpose: the merge-base probes below try candidate
|
|
313
|
+
* refs that are EXPECTED not to exist, and the caller decides what a failure
|
|
314
|
+
* means.
|
|
315
|
+
* @param {string} cwd - Project root.
|
|
316
|
+
* @param {readonly string[]} args - Git arguments.
|
|
317
|
+
* @returns {string} Trimmed stdout.
|
|
318
|
+
*/
|
|
319
|
+
const git = (cwd, args) =>
|
|
68
320
|
execFileSync("git", args, {
|
|
69
|
-
cwd
|
|
321
|
+
cwd,
|
|
70
322
|
encoding: "utf8",
|
|
71
323
|
stdio: ["ignore", "pipe", "ignore"],
|
|
72
324
|
}).trim();
|
|
73
325
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
326
|
+
/**
|
|
327
|
+
* The merge-base to diff against, preferring the remote ref.
|
|
328
|
+
*
|
|
329
|
+
* CI checks out detached, so `origin/<ref>` is tried first and the local name
|
|
330
|
+
* second.
|
|
331
|
+
* @param {string} cwd - Project root.
|
|
332
|
+
* @param {string} since - Configured base ref.
|
|
333
|
+
* @returns {string} The merge-base sha, or `""` when none resolves.
|
|
334
|
+
*/
|
|
335
|
+
export const resolveDiffBase = (cwd, since) => {
|
|
336
|
+
for (const ref of [`origin/${since}`, since]) {
|
|
80
337
|
try {
|
|
81
|
-
resolved = git(["merge-base", ref, "HEAD"]);
|
|
82
|
-
if (resolved)
|
|
338
|
+
const resolved = git(cwd, ["merge-base", ref, "HEAD"]);
|
|
339
|
+
if (resolved) return resolved;
|
|
83
340
|
} catch {
|
|
84
|
-
/* try next candidate */
|
|
341
|
+
/* try the next candidate */
|
|
85
342
|
}
|
|
86
343
|
}
|
|
87
|
-
|
|
88
|
-
}
|
|
89
|
-
base = "";
|
|
90
|
-
}
|
|
344
|
+
return "";
|
|
345
|
+
};
|
|
91
346
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
347
|
+
/**
|
|
348
|
+
* How many tracked files the project's patterns select.
|
|
349
|
+
*
|
|
350
|
+
* This is the difference between "this branch changed nothing we mutate" and
|
|
351
|
+
* "this gate can never mutate anything". Both produce an empty selection; only
|
|
352
|
+
* one of them is a defect, and without this probe the defect is invisible
|
|
353
|
+
* forever.
|
|
354
|
+
* @param {string} cwd - Project root.
|
|
355
|
+
* @param {{include: RegExp[], exclude: RegExp[]}} patterns - Compiled matchers.
|
|
356
|
+
* @returns {number} How many tracked files the patterns select.
|
|
357
|
+
*/
|
|
358
|
+
export const countMutateTargetsInRepo = (cwd, patterns) => {
|
|
359
|
+
try {
|
|
360
|
+
return git(cwd, ["ls-files"])
|
|
361
|
+
.split("\n")
|
|
362
|
+
.filter(file => file && isMutateTarget(file, patterns)).length;
|
|
363
|
+
} catch {
|
|
364
|
+
// An unreadable index is not evidence of an inert config, so report a
|
|
365
|
+
// target and let the run proceed. Failing here would block pushes for a
|
|
366
|
+
// reason that has nothing to do with mutation testing.
|
|
367
|
+
return 1;
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Files changed on this branch that this project mutates.
|
|
373
|
+
* @param {string} cwd - Project root.
|
|
374
|
+
* @param {string} base - Merge-base sha.
|
|
375
|
+
* @param {{include: RegExp[], exclude: RegExp[]}} patterns - Compiled matchers.
|
|
376
|
+
* @returns {{changed: number, selected: string[]}} Totals and selection.
|
|
377
|
+
*/
|
|
378
|
+
export const selectChangedTargets = (cwd, base, patterns) => {
|
|
379
|
+
const changed = git(cwd, [
|
|
380
|
+
"diff",
|
|
381
|
+
"--name-only",
|
|
382
|
+
"--diff-filter=ACMR",
|
|
383
|
+
`${base}...HEAD`,
|
|
384
|
+
])
|
|
111
385
|
.split("\n")
|
|
112
|
-
.map(
|
|
113
|
-
.filter(Boolean)
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
|
|
386
|
+
.map(file => file.trim())
|
|
387
|
+
.filter(Boolean);
|
|
388
|
+
return {
|
|
389
|
+
changed: changed.length,
|
|
390
|
+
selected: changed
|
|
391
|
+
.filter(file => isMutateTarget(file, patterns))
|
|
392
|
+
.filter(file => fs.existsSync(path.join(cwd, file))),
|
|
393
|
+
};
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Hand the selected files to Stryker.
|
|
398
|
+
* @param {string} cwd - Project root.
|
|
399
|
+
* @param {readonly string[]} selected - Repository-relative paths.
|
|
400
|
+
* @returns {number} Stryker's exit status.
|
|
401
|
+
*/
|
|
402
|
+
const runStryker = (cwd, selected) => {
|
|
403
|
+
const bin = path.join(
|
|
404
|
+
cwd,
|
|
405
|
+
"node_modules",
|
|
406
|
+
".bin",
|
|
407
|
+
process.platform === "win32" ? "stryker.cmd" : "stryker"
|
|
408
|
+
);
|
|
409
|
+
const local = fs.existsSync(bin);
|
|
410
|
+
const result = spawnSync(
|
|
411
|
+
local ? bin : "npx",
|
|
412
|
+
local
|
|
413
|
+
? ["run", "--mutate", selected.join(",")]
|
|
414
|
+
: ["--yes", "stryker", "run", "--mutate", selected.join(",")],
|
|
415
|
+
{
|
|
416
|
+
cwd,
|
|
417
|
+
stdio: "inherit",
|
|
418
|
+
shell: process.platform === "win32",
|
|
419
|
+
// What the run was scoped to, for a test-runner config that wants to
|
|
420
|
+
// narrow with it. A project that ignores it loses nothing, and a project
|
|
421
|
+
// that reads it can only ever REMOVE suites — which removes kills and
|
|
422
|
+
// lowers the score — so no value of this can turn a failing gate green.
|
|
423
|
+
env: { ...process.env, MUTATION_SCOPE: selected.join(",") },
|
|
424
|
+
}
|
|
425
|
+
);
|
|
426
|
+
return result.status ?? 1;
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* The whole gate, as one function so it can be driven from a test.
|
|
431
|
+
* @param {string} [cwd] - Project root; defaults to the process working dir.
|
|
432
|
+
* @returns {number} The exit code the caller should use.
|
|
433
|
+
*/
|
|
434
|
+
export const runGate = (cwd = process.cwd()) => {
|
|
435
|
+
const gate = readGate(cwd);
|
|
436
|
+
const enabled = envFlag("MUTATION_ENABLED") ?? gate.enabled === true;
|
|
437
|
+
const since = process.env.MUTATION_SINCE || gate.since || "main";
|
|
438
|
+
|
|
439
|
+
if (!enabled) {
|
|
440
|
+
console.log(
|
|
441
|
+
`⚪ ${OUTCOMES.disabled} — mutation.gate.json says "enabled": false. Skipping.\n` +
|
|
442
|
+
' Flip "enabled": true (and tune thresholds.break in stryker.conf.json) to turn it on.'
|
|
443
|
+
);
|
|
444
|
+
return 0;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const declaration = resolveMutateDeclaration(cwd);
|
|
448
|
+
const patterns = compileMutatePatterns(declaration.mutate);
|
|
449
|
+
|
|
450
|
+
if (countMutateTargetsInRepo(cwd, patterns) === 0) {
|
|
451
|
+
console.error(
|
|
452
|
+
`❌ ${OUTCOMES.inertConfig}\n` +
|
|
453
|
+
` The mutate patterns from ${declaration.source} select NO tracked file\n` +
|
|
454
|
+
" in this repository, so this gate can never generate a mutant and would\n" +
|
|
455
|
+
" report success on every run forever. That is not a pass — it is a gate\n" +
|
|
456
|
+
" that is switched on and wired to nothing.\n" +
|
|
457
|
+
" Fix the `mutate` patterns in your Stryker config, or turn the gate off."
|
|
458
|
+
);
|
|
459
|
+
return 1;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const base = resolveDiffBase(cwd, since);
|
|
463
|
+
if (!base) {
|
|
464
|
+
console.log(
|
|
465
|
+
`⚪ ${OUTCOMES.noBase} — no merge-base against "${since}" (shallow clone or\n` +
|
|
466
|
+
" unknown ref). Skipping rather than mutating the whole repository.\n" +
|
|
467
|
+
" Nothing was measured; this is not a mutation score."
|
|
468
|
+
);
|
|
469
|
+
return 0;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
let scope;
|
|
473
|
+
try {
|
|
474
|
+
scope = selectChangedTargets(cwd, base, patterns);
|
|
475
|
+
} catch (error) {
|
|
476
|
+
console.error(`⚠️ Could not compute changed files: ${error.message}`);
|
|
477
|
+
return 0;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (scope.selected.length === 0) {
|
|
481
|
+
console.log(
|
|
482
|
+
`⚪ ${OUTCOMES.nothingToMutate}\n` +
|
|
483
|
+
` ${scope.changed} file(s) changed vs ${since}; 0 of them are mutate targets\n` +
|
|
484
|
+
` under the patterns from ${declaration.source}.\n` +
|
|
485
|
+
" NO mutant was generated and NO score was computed. Nothing was measured,\n" +
|
|
486
|
+
" so nothing passed — do not read this as evidence about your tests."
|
|
487
|
+
);
|
|
488
|
+
return 0;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// `--mutate` is one comma-separated argument, so a path containing a comma
|
|
492
|
+
// reaches Stryker as two paths that do not exist. It would mutate neither,
|
|
493
|
+
// find nothing, and exit 0 — the silent-green shape again, arriving through a
|
|
494
|
+
// filename. Refusing is the safe direction: a push blocked by a name nobody
|
|
495
|
+
// can act on is loud, and a gate that quietly measured nothing is not.
|
|
496
|
+
const unrepresentable = scope.selected.filter(file => file.includes(","));
|
|
497
|
+
if (unrepresentable.length > 0) {
|
|
498
|
+
const listed = unrepresentable.map(file => ` • ${file}`).join("\n");
|
|
499
|
+
console.error(
|
|
500
|
+
`❌ ${OUTCOMES.unrepresentablePath}\n` +
|
|
501
|
+
" Stryker takes --mutate as ONE comma-separated argument, so these paths\n" +
|
|
502
|
+
" cannot be passed to it without being split into paths that do not exist:\n" +
|
|
503
|
+
`${listed}\n` +
|
|
504
|
+
" Rename them, or exclude them in your Stryker config."
|
|
505
|
+
);
|
|
506
|
+
return 1;
|
|
507
|
+
}
|
|
508
|
+
|
|
122
509
|
console.log(
|
|
123
|
-
|
|
510
|
+
`🧬 ${OUTCOMES.scoped} — Stryker on ${scope.selected.length} of ` +
|
|
511
|
+
`${scope.changed} changed file(s), selected by ${declaration.source}:`
|
|
124
512
|
);
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
process.platform === "win32" ? "stryker.cmd" : "stryker"
|
|
139
|
-
);
|
|
140
|
-
const useLocal = fs.existsSync(strykerBin);
|
|
141
|
-
const command = useLocal ? strykerBin : "npx";
|
|
142
|
-
const args = useLocal
|
|
143
|
-
? ["run", "--mutate", changed.join(",")]
|
|
144
|
-
: ["--yes", "stryker", "run", "--mutate", changed.join(",")];
|
|
145
|
-
|
|
146
|
-
const result = spawnSync(command, args, {
|
|
147
|
-
cwd: CWD,
|
|
148
|
-
stdio: "inherit",
|
|
149
|
-
shell: process.platform === "win32",
|
|
150
|
-
});
|
|
151
|
-
process.exit(result.status ?? 1);
|
|
513
|
+
for (const file of scope.selected) console.log(` • ${file}`);
|
|
514
|
+
return runStryker(cwd, scope.selected);
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* CLI entry point.
|
|
519
|
+
* @returns {void}
|
|
520
|
+
*/
|
|
521
|
+
export const runCli = () => {
|
|
522
|
+
process.exit(runGate());
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
if (invokedAsScript(import.meta.url)) runCli();
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
"test": "vitest run",
|
|
9
9
|
"test:unit": "vitest run --exclude='**/integration/**'",
|
|
10
10
|
"test:cov": "vitest run --coverage",
|
|
11
|
+
"test:cov:unit": "vitest run --coverage --exclude='**/integration/**'",
|
|
11
12
|
"test:mutation": "node scripts/lisa-mutation.mjs",
|
|
12
13
|
"test:watch": "vitest",
|
|
13
14
|
"lint:fix": "oxlint --fix && eslint . --fix",
|