@codyswann/lisa 3.47.5 → 3.47.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/all/copy-overwrite/scripts/lib/gate-failure-diagnosis.mjs +270 -0
  2. package/all/copy-overwrite/scripts/lisa-gates.mjs +30 -0
  3. package/all/copy-overwrite/scripts/lisa-run-gates.mjs +294 -34
  4. package/all/copy-overwrite/scripts/lisa-work-item.mjs +83 -26
  5. package/dist/configs/eslint/expo.d.ts +50 -1
  6. package/dist/configs/eslint/expo.d.ts.map +1 -1
  7. package/dist/configs/eslint/expo.js +86 -5
  8. package/dist/configs/eslint/expo.js.map +1 -1
  9. package/dist/core/downstream-names.d.ts +83 -0
  10. package/dist/core/downstream-names.d.ts.map +1 -0
  11. package/dist/core/downstream-names.js +289 -0
  12. package/dist/core/downstream-names.js.map +1 -0
  13. package/dist/core/downstream-references.d.ts +21 -6
  14. package/dist/core/downstream-references.d.ts.map +1 -1
  15. package/dist/core/downstream-references.js +19 -16
  16. package/dist/core/downstream-references.js.map +1 -1
  17. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  18. package/dist/core/lisa-owned-hash-ledger.js +14 -0
  19. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  20. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  21. package/dist/core/upstream-evidence-manifest.js +27 -13
  22. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  23. package/dist/migrations/ensure-tsconfig-local-includes.d.ts +1 -1
  24. package/dist/migrations/ensure-tsconfig-local-includes.js +1 -1
  25. package/expo/copy-overwrite/eslint.config.ts +9 -0
  26. package/expo/copy-overwrite/eslint.expo.ts +92 -4
  27. package/package.json +5 -2
  28. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  29. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  30. package/plugins/lisa/rules/reference/design-value-binding.md +4 -0
  31. package/plugins/lisa/scripts/automation-run-record.mjs +124 -4
  32. package/plugins/lisa/scripts/automation-status-claude-adapter.mjs +9 -1
  33. package/plugins/lisa/scripts/automation-status-codex-adapter.mjs +9 -1
  34. package/plugins/lisa/scripts/automation-status-run-history.mjs +34 -3
  35. package/plugins/lisa-agy/plugin.json +1 -1
  36. package/plugins/lisa-agy/scripts/automation-run-record.mjs +124 -4
  37. package/plugins/lisa-agy/scripts/automation-status-claude-adapter.mjs +9 -1
  38. package/plugins/lisa-agy/scripts/automation-status-codex-adapter.mjs +9 -1
  39. package/plugins/lisa-agy/scripts/automation-status-run-history.mjs +34 -3
  40. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  42. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  43. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-copilot/rules/reference/design-value-binding.md +4 -0
  47. package/plugins/lisa-copilot/scripts/automation-run-record.mjs +124 -4
  48. package/plugins/lisa-copilot/scripts/automation-status-claude-adapter.mjs +9 -1
  49. package/plugins/lisa-copilot/scripts/automation-status-codex-adapter.mjs +9 -1
  50. package/plugins/lisa-copilot/scripts/automation-status-run-history.mjs +34 -3
  51. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-cursor/rules/design-value-binding-reference.mdc +4 -0
  53. package/plugins/lisa-cursor/scripts/automation-run-record.mjs +124 -4
  54. package/plugins/lisa-cursor/scripts/automation-status-claude-adapter.mjs +9 -1
  55. package/plugins/lisa-cursor/scripts/automation-status-codex-adapter.mjs +9 -1
  56. package/plugins/lisa-cursor/scripts/automation-status-run-history.mjs +34 -3
  57. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  59. package/plugins/lisa-expo-agy/plugin.json +1 -1
  60. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  62. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  63. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  64. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  65. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  66. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  67. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  68. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  69. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  70. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  71. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  72. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  73. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  74. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  75. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  76. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  77. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  78. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  79. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  80. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  81. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  82. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  83. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  84. package/plugins/lisa-rails-agy/plugin.json +1 -1
  85. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  86. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  87. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  88. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  89. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  90. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  91. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  92. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  93. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  94. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  95. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  96. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  97. package/plugins/src/base/rules/reference/design-value-binding.md +4 -0
  98. package/plugins/src/base/scripts/automation-run-record.mjs +124 -4
  99. package/plugins/src/base/scripts/automation-status-claude-adapter.mjs +9 -1
  100. package/plugins/src/base/scripts/automation-status-codex-adapter.mjs +9 -1
  101. package/plugins/src/base/scripts/automation-status-run-history.mjs +34 -3
  102. package/scripts/lisa-mutation.mjs +13 -0
  103. package/typescript/copy-contents/.husky/pre-push +7 -1
  104. package/typescript/copy-overwrite/scripts/lisa-mutation.mjs +496 -122
  105. 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
- // Mutation-testing gate (StrykerJS)
6
- // -----------------------------------------------------------------------------
7
- // Opt-in, diff-only mutation-testing gate shared by the pre-push hook and CI.
8
- //
9
- // Behavior:
10
- // 1. Reads `mutation.gate.json`. If the gate is disabled (the default), it
11
- // prints a notice and exits 0 pushes and CI are never slowed down until
12
- // a project explicitly opts in.
13
- // 2. When enabled, it computes the source files changed on this branch
14
- // (vs the merge-base with the configured `since` ref) and runs Stryker on
15
- // ONLY those files. Mutation testing is slow, so a full-repo run is never
16
- // done by this gate.
17
- // 3. The mutation-score threshold itself lives in `stryker.conf.*`
18
- // (`thresholds.break`) Stryker exits non-zero when the score is below it,
19
- // which fails the gate.
20
- //
21
- // Configuration (`mutation.gate.json`, project-owned / create-only):
22
- // { "enabled": false, "since": "main" }
23
- //
24
- // Overridable via env: MUTATION_ENABLED=true|false, MUTATION_SINCE=<ref>.
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 { execFileSync, spawnSync } from "node:child_process";
63
+ import process from "node:process";
30
64
 
31
- const CWD = process.cwd();
65
+ import { invokedAsScript } from "./lib/invoked-as-script.mjs";
32
66
 
33
- const readGate = () => {
34
- const gatePath = path.join(CWD, "mutation.gate.json");
35
- if (!fs.existsSync(gatePath)) {
36
- return { enabled: false, since: "main" };
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
- return JSON.parse(fs.readFileSync(gatePath, "utf8"));
40
- } catch (err) {
41
- console.error(`⚠️ Could not parse mutation.gate.json: ${err.message}`);
42
- return { enabled: false, since: "main" };
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
- const envFlag = name => {
47
- const v = process.env[name];
48
- if (v === undefined) return undefined;
49
- return v === "true" || v === "1";
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
- const gate = readGate();
53
- const enabled = envFlag("MUTATION_ENABLED") ?? gate.enabled === true;
54
- const since = process.env.MUTATION_SINCE || gate.since || "main";
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
- if (!enabled) {
57
- console.log(
58
- '⚪ Mutation-testing gate disabled (mutation.gate.json: "enabled": false). Skipping.\n' +
59
- ' Flip "enabled": true (and tune thresholds.break in stryker.conf.json) to turn it on.'
60
- );
61
- process.exit(0);
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
- // --- Resolve the diff base (merge-base with the `since` ref) -----------------
65
- // stderr is ignored: failed merge-base/diff probes are expected (e.g. a missing
66
- // origin/<ref> candidate) and handled by the surrounding try/catch.
67
- const git = args =>
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: CWD,
321
+ cwd,
70
322
  encoding: "utf8",
71
323
  stdio: ["ignore", "pipe", "ignore"],
72
324
  }).trim();
73
325
 
74
- let base;
75
- try {
76
- // Prefer the remote ref when present (CI checks out detached); fall back to local.
77
- const candidates = [`origin/${since}`, since];
78
- let resolved = "";
79
- for (const ref of candidates) {
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) break;
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
- base = resolved;
88
- } catch {
89
- base = "";
90
- }
344
+ return "";
345
+ };
91
346
 
92
- if (!base) {
93
- console.log(
94
- `⚪ Mutation gate: could not resolve a merge-base against "${since}" ` +
95
- "(shallow clone or unknown ref). Skipping rather than mutating the whole repo."
96
- );
97
- process.exit(0);
98
- }
99
-
100
- // --- Compute changed, mutate-eligible source files ---------------------------
101
- const isMutable = f =>
102
- /\.(ts|tsx)$/.test(f) &&
103
- !/\.(spec|test)\.(ts|tsx)$/.test(f) &&
104
- !f.endsWith(".d.ts") &&
105
- !f.endsWith(".stories.tsx") &&
106
- (f.startsWith("src/") || f.startsWith("lib/"));
107
-
108
- let changed = [];
109
- try {
110
- changed = git(["diff", "--name-only", "--diff-filter=ACMR", `${base}...HEAD`])
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(f => f.trim())
113
- .filter(Boolean)
114
- .filter(isMutable)
115
- .filter(f => fs.existsSync(path.join(CWD, f)));
116
- } catch (err) {
117
- console.error(`⚠️ Could not compute changed files: ${err.message}`);
118
- process.exit(0);
119
- }
120
-
121
- if (changed.length === 0) {
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
- `⚪ Mutation gate: no changed source files vs ${since}. Nothing to mutate.`
510
+ `🧬 ${OUTCOMES.scoped} Stryker on ${scope.selected.length} of ` +
511
+ `${scope.changed} changed file(s), selected by ${declaration.source}:`
124
512
  );
125
- process.exit(0);
126
- }
127
-
128
- console.log(
129
- `🧬 Mutation gate: running Stryker on ${changed.length} changed file(s):`
130
- );
131
- for (const f of changed) console.log(` • ${f}`);
132
-
133
- // --- Run Stryker on just the changed files (diff-only) -----------------------
134
- const strykerBin = path.join(
135
- CWD,
136
- "node_modules",
137
- ".bin",
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",