@codyswann/lisa 4.64.1 → 4.64.3

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 (118) hide show
  1. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  2. package/dist/core/lisa-owned-hash-ledger.js +24 -0
  3. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  4. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  5. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  6. package/dist/core/upstream-evidence-manifest.js +19 -17
  7. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  8. package/expo/create-only/stryker.conf.json +1 -0
  9. package/package.json +5 -5
  10. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  11. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  12. package/plugins/lisa/.codex-plugin/skills/lisa-design-intake/SKILL.md +1 -1
  13. package/plugins/lisa/.codex-plugin/skills/lisa-implement/SKILL.md +3 -1
  14. package/plugins/lisa/.codex-plugin/skills/lisa-quality-review/SKILL.md +2 -2
  15. package/plugins/lisa/.codex-plugin/skills/lisa-review-local/SKILL.md +9 -3
  16. package/plugins/lisa/.codex-plugin/skills/lisa-tdd-implementation/SKILL.md +2 -1
  17. package/plugins/lisa/.codex-plugin/skills/lisa-tracker-source-artifacts/SKILL.md +3 -1
  18. package/plugins/lisa/hooks/threshold-ratchet-compare.mjs +50 -8
  19. package/plugins/lisa/hooks/threshold-ratchet-families.mjs +24 -0
  20. package/plugins/lisa/rules/reference/design-source-of-truth.md +32 -10
  21. package/plugins/lisa/scripts/design-source-gate.mjs +35 -13
  22. package/plugins/lisa/skills/lisa-design-intake/SKILL.md +1 -1
  23. package/plugins/lisa/skills/lisa-implement/SKILL.md +3 -1
  24. package/plugins/lisa/skills/lisa-quality-review/SKILL.md +2 -2
  25. package/plugins/lisa/skills/lisa-review-local/SKILL.md +9 -3
  26. package/plugins/lisa/skills/lisa-tdd-implementation/SKILL.md +2 -1
  27. package/plugins/lisa/skills/lisa-tracker-source-artifacts/SKILL.md +3 -1
  28. package/plugins/lisa-agy/plugin.json +1 -1
  29. package/plugins/lisa-agy/scripts/design-source-gate.mjs +35 -13
  30. package/plugins/lisa-agy/skills/lisa-design-intake/SKILL.md +1 -1
  31. package/plugins/lisa-agy/skills/lisa-implement/SKILL.md +3 -1
  32. package/plugins/lisa-agy/skills/lisa-quality-review/SKILL.md +2 -2
  33. package/plugins/lisa-agy/skills/lisa-review-local/SKILL.md +9 -3
  34. package/plugins/lisa-agy/skills/lisa-tdd-implementation/SKILL.md +2 -1
  35. package/plugins/lisa-agy/skills/lisa-tracker-source-artifacts/SKILL.md +3 -1
  36. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  37. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  38. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  39. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  40. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-copilot/hooks/threshold-ratchet-compare.mjs +50 -8
  43. package/plugins/lisa-copilot/hooks/threshold-ratchet-families.mjs +24 -0
  44. package/plugins/lisa-copilot/rules/reference/design-source-of-truth.md +32 -10
  45. package/plugins/lisa-copilot/scripts/design-source-gate.mjs +35 -13
  46. package/plugins/lisa-copilot/skills/lisa-design-intake/SKILL.md +1 -1
  47. package/plugins/lisa-copilot/skills/lisa-implement/SKILL.md +3 -1
  48. package/plugins/lisa-copilot/skills/lisa-quality-review/SKILL.md +2 -2
  49. package/plugins/lisa-copilot/skills/lisa-review-local/SKILL.md +9 -3
  50. package/plugins/lisa-copilot/skills/lisa-tdd-implementation/SKILL.md +2 -1
  51. package/plugins/lisa-copilot/skills/lisa-tracker-source-artifacts/SKILL.md +3 -1
  52. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-cursor/hooks/threshold-ratchet-compare.mjs +50 -8
  54. package/plugins/lisa-cursor/hooks/threshold-ratchet-families.mjs +24 -0
  55. package/plugins/lisa-cursor/rules/design-source-of-truth-reference.mdc +32 -10
  56. package/plugins/lisa-cursor/scripts/design-source-gate.mjs +35 -13
  57. package/plugins/lisa-cursor/skills/lisa-design-intake/SKILL.md +1 -1
  58. package/plugins/lisa-cursor/skills/lisa-implement/SKILL.md +3 -1
  59. package/plugins/lisa-cursor/skills/lisa-quality-review/SKILL.md +2 -2
  60. package/plugins/lisa-cursor/skills/lisa-review-local/SKILL.md +9 -3
  61. package/plugins/lisa-cursor/skills/lisa-tdd-implementation/SKILL.md +2 -1
  62. package/plugins/lisa-cursor/skills/lisa-tracker-source-artifacts/SKILL.md +3 -1
  63. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  64. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  65. package/plugins/lisa-expo-agy/plugin.json +1 -1
  66. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  67. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  68. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  69. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  70. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  71. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  72. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  73. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  74. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  75. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  76. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  77. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  78. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  79. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  80. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  81. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  82. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  83. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  84. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  85. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  86. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  87. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  88. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  89. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  90. package/plugins/lisa-rails-agy/plugin.json +1 -1
  91. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  92. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  93. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  94. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  95. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  96. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  97. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  98. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  99. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  100. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  101. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  102. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  103. package/plugins/src/base/hooks/threshold-ratchet-compare.mjs +50 -8
  104. package/plugins/src/base/hooks/threshold-ratchet-families.mjs +24 -0
  105. package/plugins/src/base/rules/reference/design-source-of-truth.md +32 -10
  106. package/plugins/src/base/scripts/design-source-gate.mjs +35 -13
  107. package/plugins/src/base/skills/lisa-design-intake/SKILL.md +1 -1
  108. package/plugins/src/base/skills/lisa-implement/SKILL.md +3 -1
  109. package/plugins/src/base/skills/lisa-quality-review/SKILL.md +2 -2
  110. package/plugins/src/base/skills/lisa-review-local/SKILL.md +9 -3
  111. package/plugins/src/base/skills/lisa-tdd-implementation/SKILL.md +2 -1
  112. package/plugins/src/base/skills/lisa-tracker-source-artifacts/SKILL.md +3 -1
  113. package/rails/copy-overwrite/scripts/threshold-ratchet-compare.mjs +50 -8
  114. package/rails/copy-overwrite/scripts/threshold-ratchet-families.mjs +24 -0
  115. package/typescript/copy-overwrite/scripts/lisa-mutation.mjs +297 -25
  116. package/typescript/copy-overwrite/scripts/threshold-ratchet-compare.mjs +50 -8
  117. package/typescript/copy-overwrite/scripts/threshold-ratchet-families.mjs +24 -0
  118. package/typescript/create-only/stryker.conf.json +1 -0
@@ -21,6 +21,7 @@ import {
21
21
  extractStrykerConstraints,
22
22
  extractStrykerMutate,
23
23
  familyFor,
24
+ globRoot,
24
25
  parseJson,
25
26
  } from "./threshold-ratchet-families.mjs";
26
27
 
@@ -157,15 +158,56 @@ function compareStryker(relPath, base, current) {
157
158
  );
158
159
  const baseMutate = extractStrykerMutate(base);
159
160
  const currentMutate = extractStrykerMutate(current);
161
+ // Roots the gate already covered, and roots this change adds. An exclusion
162
+ // scoped to a newly added root narrows territory that was not being mutated
163
+ // a moment ago, so it cannot shrink the gate — the change is a net widening.
164
+ //
165
+ // Without this, repairing an INERT `mutate` list is unreachable without a
166
+ // human-approved ratchet exception: the repair necessarily adds source roots
167
+ // AND the test-file exclusions that belong with them, and every one of those
168
+ // exclusions read as a weakening. Measured on two repositories whose real
169
+ // effect was 0 -> 69 and 0 -> 75 files mutated (CodySwannGT/lisa#4243).
170
+ const baseRoots = [...baseMutate.positives].map(globRoot);
171
+ /**
172
+ * Whether an exclusion might touch any baseline positive.
173
+ *
174
+ * Both containment directions matter: excluding `src` removes a baseline
175
+ * rooted at `src/existing`, too. An empty prefix (wildcards, braces, classes
176
+ * or extglobs at the root) might cover everything. We grant an exemption
177
+ * only for provably disjoint territory, never from a guessed glob overlap.
178
+ * @param {string} root A rooted glob prefix
179
+ * @returns {boolean} True when overlap cannot be ruled out
180
+ */
181
+ const overlapsBase = root =>
182
+ baseRoots.some(
183
+ base =>
184
+ root === "" ||
185
+ base === "" ||
186
+ root === base ||
187
+ root.startsWith(`${base}/`) ||
188
+ base.startsWith(`${root}/`)
189
+ );
190
+ const addedRoots = [...currentMutate.positives]
191
+ .filter(positive => !baseMutate.positives.has(positive))
192
+ .map(globRoot)
193
+ .filter(root => root !== "");
194
+ /**
195
+ * Whether a root lies inside territory THIS change is adding.
196
+ * @param {string} root A rooted glob prefix
197
+ * @returns {boolean} True when an added root equals or contains it
198
+ */
199
+ const underAdded = root =>
200
+ addedRoots.some(added => root === added || root.startsWith(`${added}/`));
160
201
  for (const negation of currentMutate.negations) {
161
- if (!baseMutate.negations.has(negation)) {
162
- findings.push({
163
- file: relPath,
164
- key: `mutate ${negation}`,
165
- type: TYPE_EXEMPTION_ADDED,
166
- message: `${relPath}: new mutation-testing exclusion "${negation}" — excluding files from a gate is a weakening.`,
167
- });
168
- }
202
+ if (baseMutate.negations.has(negation)) continue;
203
+ const root = globRoot(negation);
204
+ if (!overlapsBase(root) && underAdded(root)) continue;
205
+ findings.push({
206
+ file: relPath,
207
+ key: `mutate ${negation}`,
208
+ type: TYPE_EXEMPTION_ADDED,
209
+ message: `${relPath}: new mutation-testing exclusion "${negation}" — excluding files from a gate is a weakening.`,
210
+ });
169
211
  }
170
212
  for (const positive of baseMutate.positives) {
171
213
  if (!currentMutate.positives.has(positive)) {
@@ -311,6 +311,30 @@ export function extractStrykerMutate(conf) {
311
311
  return { positives, negations };
312
312
  }
313
313
 
314
+ /**
315
+ * The literal directory prefix a glob is rooted at, before any wildcard.
316
+ *
317
+ * `lib/**\/*.spec.ts` and `!lib/**\/*.d.ts` are both rooted at `lib`. This is
318
+ * what lets the ratchet tell territory a change ADDED from territory it
319
+ * already covered: an exclusion inside a newly added root cannot shrink a
320
+ * gate, because nothing there was being mutated a moment ago.
321
+ * @param {string} glob A stryker mutate entry, with or without a leading `!`
322
+ * @returns {string} The rooted prefix, or "" when the glob starts with a wildcard
323
+ */
324
+ export function globRoot(glob) {
325
+ const bare = glob.startsWith("!") ? glob.slice(1) : glob;
326
+ // Backslash escapes and parent traversal make textual containment unsafe.
327
+ // An unknown prefix overlaps everything rather than granting an exemption.
328
+ if (bare.includes("\\") || bare.split("/").includes("..")) return "";
329
+ const rooted = [];
330
+ for (const segment of bare.split("/")) {
331
+ if (segment === "." || segment === "") continue;
332
+ if (["*", "?", "{", "[", "("].some(meta => segment.includes(meta))) break;
333
+ rooted.push(segment);
334
+ }
335
+ return rooted.join("/");
336
+ }
337
+
314
338
  /**
315
339
  * Parse one k6 threshold expression (`p(95)<1000`, `rate>=0.99`, …) into a
316
340
  * ratchet constraint. Upper bounds (`<`, `<=`) may only decrease; lower
@@ -47,9 +47,10 @@
47
47
  * `nothing-to-mutate`, in a block that states no mutant was generated and no
48
48
  * score was computed. Exit 0.
49
49
  * - **The mutate configuration selects nothing in this repository at all** —
50
- * a misconfigured gate, permanently inert, green forever. Reported as
51
- * `inert-mutate-config` and it FAILS, exit 1. Distinguishing the two costs
52
- * one `git ls-files`.
50
+ * report the missed source roots even while disabled. When enabled, derive
51
+ * tracked TypeScript roots unless sourceDirs was explicitly declared. If no
52
+ * viable selection results, report `inert-mutate-config` and FAIL, exit 1.
53
+ * A viable project selection is never widened by derivation.
53
54
  * - **Something changed that no mutation tool can reach** — a shell guard.
54
55
  * Reported as `uninstrumentable-language`, exit 0, because it is not a test
55
56
  * failure. It is separated from `nothing-to-mutate` because the two are
@@ -780,7 +781,7 @@ const declarationFromJson = (cwd, name) => {
780
781
  try {
781
782
  const conf = JSON.parse(fs.readFileSync(path.join(cwd, name), "utf8"));
782
783
  if (Array.isArray(conf.mutate) && conf.mutate.length > 0) {
783
- return { mutate: conf.mutate, source: name };
784
+ return { mutate: conf.mutate, source: name, explicit: true };
784
785
  }
785
786
  return {
786
787
  mutate: FALLBACK_MUTATE,
@@ -804,6 +805,9 @@ const declarationFromJson = (cwd, name) => {
804
805
  * @returns {{mutate: readonly string[], source: string}} Declaration and origin.
805
806
  */
806
807
  export const resolveMutateDeclaration = cwd => {
808
+ const declared = declarationFromSourceDirs(cwd);
809
+ if (declared) return declared;
810
+
807
811
  const found = JSON_CONFIG_NAMES.find(name =>
808
812
  fs.existsSync(path.join(cwd, name))
809
813
  );
@@ -820,6 +824,179 @@ export const resolveMutateDeclaration = cwd => {
820
824
  };
821
825
  };
822
826
 
827
+ /**
828
+ * Top-level directories that are never a project's own source.
829
+ *
830
+ * Deliberately short. Every entry here is a directory whose contents would be
831
+ * wrong to mutate rather than merely unusual to mutate, because a false
832
+ * entry in this list silently shrinks a derived gate and derivation only runs
833
+ * where the project's own config already selected nothing. `scripts` is
834
+ * absent on purpose: shipped guard scripts are exactly the code a mutation
835
+ * gate most wants, and excluding them is the defect CodySwannGT/lisa#3668
836
+ * was filed about.
837
+ * @type {readonly string[]}
838
+ */
839
+ /**
840
+ * Extensions derivation counts, and the ones `mutateFromSourceDirs` emits.
841
+ *
842
+ * Kept as one list because the two MUST agree: a root derived from files the
843
+ * generated patterns cannot select produces a suggestion that selects nothing.
844
+ * @type {readonly string[]}
845
+ */
846
+ export const DERIVABLE_EXTENSIONS = Object.freeze([".ts", ".tsx"]);
847
+
848
+ /**
849
+ * Filename markers that mean "not the project's own mutable source".
850
+ *
851
+ * Compared against the basename with its final extension removed, so
852
+ * `guard.spec.ts` yields `guard.spec` and matches `.spec`.
853
+ * @type {readonly string[]}
854
+ */
855
+ export const NON_SOURCE_MARKERS = Object.freeze([
856
+ ".spec",
857
+ ".test",
858
+ ".d",
859
+ ".stories",
860
+ ]);
861
+
862
+ export const NON_SOURCE_ROOTS = Object.freeze([
863
+ "__mocks__",
864
+ "__tests__",
865
+ "build",
866
+ "coverage",
867
+ "dist",
868
+ "e2e",
869
+ "fixtures",
870
+ "node_modules",
871
+ "out",
872
+ "test",
873
+ "tests",
874
+ "vendor",
875
+ ]);
876
+
877
+ /**
878
+ * Whether a tracked file is a project's own mutable source.
879
+ * @param {string} file - Repository-relative path.
880
+ * @returns {boolean} True when it is source rather than a test or artifact.
881
+ */
882
+ const isDerivableSource = file => {
883
+ const normalized = normalizePath(file);
884
+ // Deliberately NARROWER than `isStrykerParseable`. Derivation exists to feed
885
+ // `mutateFromSourceDirs`, which emits `.ts`/`.tsx` patterns only — counting a
886
+ // `.js` or `.vue` file here would report a root whose derived patterns then
887
+ // select nothing, which is a suggestion that cannot work and, worse, a
888
+ // non-zero root count that used to let an inert gate run on unchanged
889
+ // patterns. The two must agree on what counts, so they share this list.
890
+ if (!DERIVABLE_EXTENSIONS.includes(path.extname(normalized).toLowerCase())) {
891
+ return false;
892
+ }
893
+ // Suffix comparison rather than one alternating regex: the pattern that
894
+ // expresses this compactly (`(?:\.spec|\.test)\.[cm]?[jt]sx?$`) is exactly
895
+ // the shape the ReDoS rule flags, and this runs over every tracked path.
896
+ const base = normalized.slice(normalized.lastIndexOf("/") + 1);
897
+ const marker = base.slice(0, base.lastIndexOf("."));
898
+ if (NON_SOURCE_MARKERS.some(suffix => marker.endsWith(suffix))) return false;
899
+ const [root] = normalized.split("/");
900
+ return normalized.includes("/") && !NON_SOURCE_ROOTS.includes(root);
901
+ };
902
+
903
+ /**
904
+ * Source roots inferred from what the repository actually tracks.
905
+ *
906
+ * This exists because the scaffolded `mutate` list is a GUESS about layout —
907
+ * `src/**` for the TypeScript and Expo profiles — and a guess that misses
908
+ * produces a gate wired to nothing rather than an error. Derivation is only
909
+ * ever consulted when the project's own declaration already selects zero
910
+ * files, so it cannot quietly override a working configuration; it turns a
911
+ * dead end into a working gate and says so.
912
+ * @param {string} cwd - Project root.
913
+ * @returns {{root: string, files: number}[]} Roots with source, most first.
914
+ */
915
+ export const deriveSourceRoots = cwd => {
916
+ let tracked;
917
+ try {
918
+ tracked = git(cwd, ["ls-files"]).split("\n");
919
+ } catch {
920
+ // probe-direction: fail-closed — an unreadable index yields no roots, and
921
+ // no roots means no suggestion and no derived fallback, so an enabled gate
922
+ // with an inert list still fails rather than quietly running against a
923
+ // guess. The sibling `countMutateTargetsInRepo` deliberately goes the other
924
+ // way on the same failure, because there an unreadable index must not block
925
+ // a push over something unrelated to mutation testing.
926
+ return [];
927
+ }
928
+ const counts = new Map();
929
+ for (const file of tracked) {
930
+ if (!file || !isDerivableSource(file)) continue;
931
+ const [root] = normalizePath(file).split("/");
932
+ counts.set(root, (counts.get(root) ?? 0) + 1);
933
+ }
934
+ return [...counts.entries()]
935
+ .map(([root, files]) => ({ root, files }))
936
+ .sort((a, b) => b.files - a.files || a.root.localeCompare(b.root));
937
+ };
938
+
939
+ /**
940
+ * A mutate list covering the given directories, with the usual exclusions.
941
+ * @param {readonly string[]} dirs - Source directories, repo-relative.
942
+ * @returns {string[]} Stryker `mutate` patterns.
943
+ */
944
+ export const mutateFromSourceDirs = dirs => [
945
+ ...dirs.flatMap(dir => [`${dir}/**/*.ts`, `${dir}/**/*.tsx`]),
946
+ ...dirs.flatMap(dir => [
947
+ `!${dir}/**/*.spec.ts`,
948
+ `!${dir}/**/*.spec.tsx`,
949
+ `!${dir}/**/*.test.ts`,
950
+ `!${dir}/**/*.test.tsx`,
951
+ `!${dir}/**/*.d.ts`,
952
+ `!${dir}/**/*.stories.tsx`,
953
+ ]),
954
+ ];
955
+
956
+ /**
957
+ * The project's declared source directories, if it declares any.
958
+ *
959
+ * Reading this from `.lisa.config.json` rather than from the Stryker config
960
+ * is the point: `stryker.conf.json` ships create-only, so a layout the
961
+ * scaffold guessed wrong can never be corrected upstream for a repository
962
+ * that already exists. A config key can.
963
+ * @param {string} cwd - Project root.
964
+ * @returns {{mutate: readonly string[], source: string} | null} Declaration.
965
+ */
966
+ const declarationFromSourceDirs = cwd => {
967
+ const configPath = path.join(cwd, ".lisa.config.json");
968
+ if (!fs.existsSync(configPath)) return null;
969
+ let dirs;
970
+ try {
971
+ const conf = JSON.parse(fs.readFileSync(configPath, "utf8"));
972
+ dirs = conf?.quality?.mutation?.sourceDirs;
973
+ } catch {
974
+ // A malformed project config is not this gate's error to report; the
975
+ // Stryker config is still a usable declaration, so fall through to it.
976
+ return null;
977
+ }
978
+ if (!Array.isArray(dirs) || dirs.length === 0) return null;
979
+ // Trailing slashes trimmed by hand rather than with `/\/+$/`: this value
980
+ // comes from a config file, and a `+` anchored at the end is the shape the
981
+ // ReDoS rule flags on attacker-influenced input.
982
+ const trimTrailingSlashes = dir => {
983
+ let end = dir.length;
984
+ while (end > 0 && dir[end - 1] === "/") end -= 1;
985
+ return dir.slice(0, end);
986
+ };
987
+ const clean = dirs
988
+ .filter(dir => typeof dir === "string" && dir.length > 0)
989
+ .map(dir => trimTrailingSlashes(normalizePath(dir)));
990
+ if (clean.length === 0) return null;
991
+ return {
992
+ mutate: mutateFromSourceDirs(clean),
993
+ source: `.lisa.config.json quality.mutation.sourceDirs (${clean.join(", ")})`,
994
+ explicit: true,
995
+ sourceDirs: true,
996
+ overrideMutate: true,
997
+ };
998
+ };
999
+
823
1000
  /**
824
1001
  * Stryker's per-mutant budget when a config declares none, in milliseconds.
825
1002
  *
@@ -2457,12 +2634,13 @@ const runStrykerCaptured = (cwd, entry, env, deadlineMs) => {
2457
2634
  * reachable by accident — an empty diff selection is reported as
2458
2635
  * `nothing-to-mutate` and returns long before here.
2459
2636
  * @param {string} cwd - Project root.
2460
- * @param {readonly string[]} selected - Repository-relative paths, or empty for
2637
+ * @param {readonly string[]} selected - Paths or resolved patterns, or empty for
2461
2638
  * the project's own `mutate` patterns.
2639
+ * @param {boolean} wholeList - Keep test-runner diff scoping empty for --all.
2462
2640
  * @returns {{code: number, output: string|null}} Stryker's status, and its
2463
2641
  * output when this machine could keep a copy.
2464
2642
  */
2465
- const runStryker = (cwd, selected) => {
2643
+ const runStryker = (cwd, selected, wholeList = false) => {
2466
2644
  const scope = selected.join(",");
2467
2645
  const base = strykerEntry(cwd);
2468
2646
  const sandbox = runSandboxName(resolveSandboxRoot(cwd));
@@ -2485,7 +2663,9 @@ const runStryker = (cwd, selected) => {
2485
2663
  // narrow with it. A project that ignores it loses nothing, and a project
2486
2664
  // that reads it can only ever REMOVE suites — which removes kills and
2487
2665
  // lowers the score — so no value of this can turn a failing gate green.
2488
- MUTATION_SCOPE: scope,
2666
+ // Resolved patterns are not changed file paths. Whole-list runs must not
2667
+ // accidentally ask a test runner to narrow its suite to these globs.
2668
+ MUTATION_SCOPE: wholeList ? "" : scope,
2489
2669
  };
2490
2670
  // Reclaim before the run, never after — see `sweepSandboxes`. Here rather
2491
2671
  // than at either call site so the `--all` path cannot be given a different
@@ -2602,19 +2782,26 @@ export const runGate = (cwd = process.cwd(), argv = []) => {
2602
2782
  // as a defect; the behaviour is pinned by a test rather than by this note.
2603
2783
  const since = process.env.MUTATION_SINCE || gate.since || "main";
2604
2784
 
2605
- if (!enabled) {
2606
- console.log(
2607
- `⚪ ${OUTCOMES.disabled} — mutation.gate.json says "enabled": false. Skipping.\n` +
2608
- ' Flip "enabled": true (and tune thresholds.break in stryker.conf.json) to turn it on.'
2609
- );
2610
- return finish(OUTCOMES.disabled, 0);
2611
- }
2612
-
2613
- const declaration = resolveMutateDeclaration(cwd);
2785
+ // Resolved BEFORE the enabled check on purpose. A disabled gate used to
2786
+ // return here, so a repository whose patterns also selected nothing learned
2787
+ // neither fact — and disabled is the default, so the two hid each other for
2788
+ // as long as nobody turned the gate on. Detection is also the only thing
2789
+ // that reaches an ALREADY-scaffolded repository: `stryker.conf.json` ships
2790
+ // create-only, so a layout the scaffold guessed wrong can never be
2791
+ // corrected there from upstream (CodySwannGT/lisa#4243).
2792
+ let declaration = resolveMutateDeclaration(cwd);
2614
2793
  let patterns;
2615
2794
  try {
2616
2795
  patterns = compileMutatePatterns(declaration.mutate);
2617
2796
  } catch (error) {
2797
+ if (!enabled) {
2798
+ console.log(
2799
+ `⚪ ${OUTCOMES.disabled} — mutation.gate.json says "enabled": false. Skipping.\n` +
2800
+ ` Heads up: the \`mutate\` patterns in ${declaration.source} do not compile,\n` +
2801
+ " so this gate would fail immediately if it were switched on."
2802
+ );
2803
+ return finish(OUTCOMES.disabled, 0);
2804
+ }
2618
2805
  console.error(
2619
2806
  `❌ ${OUTCOMES.invalidMutatePattern}\n` +
2620
2807
  ` ${error instanceof Error ? error.message : String(error)}\n` +
@@ -2624,15 +2811,97 @@ export const runGate = (cwd = process.cwd(), argv = []) => {
2624
2811
  }
2625
2812
 
2626
2813
  if (countMutateTargetsInRepo(cwd, patterns) === 0) {
2627
- console.error(
2628
- `❌ ${OUTCOMES.inertConfig}\n` +
2629
- ` The mutate patterns from ${declaration.source} select NO tracked file\n` +
2630
- " in this repository, so this gate can never generate a mutant and would\n" +
2631
- " report success on every run forever. That is not a pass — it is a gate\n" +
2632
- " that is switched on and wired to nothing.\n" +
2633
- " Fix the `mutate` patterns in your Stryker config, or turn the gate off."
2814
+ // An inert scaffolded or project-written list needs the same repair.
2815
+ // Explicit sourceDirs are authoritative; otherwise derive tracked roots
2816
+ // only after proving the configured selection is empty, and report the
2817
+ // substitution. A viable project mutate list always retains its scope.
2818
+ const roots = deriveSourceRoots(cwd);
2819
+ const suggestion =
2820
+ roots.length > 0
2821
+ ? roots
2822
+ .map(
2823
+ entry =>
2824
+ `${entry.root}/ (${entry.files} file${entry.files === 1 ? "" : "s"})`
2825
+ )
2826
+ .join(", ")
2827
+ : null;
2828
+
2829
+ if (!enabled) {
2830
+ // A repository with no source at all is not "wired to nothing" in any
2831
+ // way worth warning about, so the extra notice fires only when there IS
2832
+ // source the patterns are missing.
2833
+ const detail = suggestion
2834
+ ? [
2835
+ `⚠️ AND the \`mutate\` patterns from ${declaration.source} select NO`,
2836
+ " tracked file. Before switching it on, declare the source directories;",
2837
+ " the candidate roots below show where tracked source exists.",
2838
+ ` Source in this repository looks like it lives in: ${suggestion}`,
2839
+ " Declare it under quality.mutation.sourceDirs in .lisa.config.json.",
2840
+ ].join("\n")
2841
+ : ' Flip "enabled": true (and tune thresholds.break in stryker.conf.json) to turn it on.';
2842
+ console.log(
2843
+ `⚪ ${OUTCOMES.disabled} — mutation.gate.json says "enabled": false. Skipping.\n${detail}`
2844
+ );
2845
+ return finish(OUTCOMES.disabled, 0);
2846
+ }
2847
+
2848
+ // Tracks whether a WORKING set of patterns was actually installed. The
2849
+ // failure below keys on this rather than on `roots.length`, so a
2850
+ // derivation that yields roots but no selectable file still fails closed
2851
+ // instead of letting the gate run on the inert patterns it started with.
2852
+ let substituted = false;
2853
+ if (!declaration.sourceDirs && roots.length > 0) {
2854
+ const derived = {
2855
+ mutate: mutateFromSourceDirs(roots.map(entry => entry.root)),
2856
+ source: `source roots derived from tracked files (${suggestion})`,
2857
+ explicit: false,
2858
+ overrideMutate: true,
2859
+ };
2860
+ const derivedPatterns = compileMutatePatterns(derived.mutate);
2861
+ if (countMutateTargetsInRepo(cwd, derivedPatterns) > 0) {
2862
+ substituted = true;
2863
+ console.log(
2864
+ `⚠️ ${declaration.source} select NO tracked file, and this project\n` +
2865
+ " declares no source directories. Deriving patterns from what the\n" +
2866
+ ` repository tracks: ${suggestion}\n` +
2867
+ " Declare quality.mutation.sourceDirs in .lisa.config.json to make\n" +
2868
+ " this explicit and stop the guessing."
2869
+ );
2870
+ declaration = derived;
2871
+ patterns = derivedPatterns;
2872
+ }
2873
+ }
2874
+
2875
+ if (!substituted) {
2876
+ const where = suggestion
2877
+ ? [
2878
+ ` Source in this repository looks like it lives in: ${suggestion}`,
2879
+ " Declare it under quality.mutation.sourceDirs in .lisa.config.json,",
2880
+ " or widen `mutate` in your Stryker config.",
2881
+ ].join("\n")
2882
+ : [
2883
+ " No candidate source directory could be derived either, so this",
2884
+ " repository may simply have no mutable source yet.",
2885
+ ].join("\n");
2886
+ console.error(
2887
+ [
2888
+ `❌ ${OUTCOMES.inertConfig}`,
2889
+ ` The mutate patterns from ${declaration.source} select NO tracked file`,
2890
+ " in this repository, so this gate can never generate a mutant and would",
2891
+ " report success on every run forever. That is not a pass — it is a gate",
2892
+ " that is switched on and wired to nothing.",
2893
+ where,
2894
+ " Fix the `mutate` patterns in your Stryker config, or turn the gate off.",
2895
+ ].join("\n")
2896
+ );
2897
+ return finish(OUTCOMES.inertConfig, 1);
2898
+ }
2899
+ } else if (!enabled) {
2900
+ console.log(
2901
+ `⚪ ${OUTCOMES.disabled} — mutation.gate.json says "enabled": false. Skipping.\n` +
2902
+ ' Flip "enabled": true (and tune thresholds.break in stryker.conf.json) to turn it on.'
2634
2903
  );
2635
- return finish(OUTCOMES.inertConfig, 1);
2904
+ return finish(OUTCOMES.disabled, 0);
2636
2905
  }
2637
2906
 
2638
2907
  const unparseable = selectUninstrumentableMutateTargets(cwd, patterns);
@@ -2662,7 +2931,10 @@ export const runGate = (cwd = process.cwd(), argv = []) => {
2662
2931
  `🧬 ${OUTCOMES.wholeList} — Stryker over every pattern in ` +
2663
2932
  `${declaration.source}, with no diff scoping.`
2664
2933
  );
2665
- const reported = reportRun(cwd, runStryker(cwd, []), null);
2934
+ // Stryker cannot read Lisa's sourceDirs or an in-memory derived list.
2935
+ // Preserve native config loading unless resolution replaced that list.
2936
+ const selected = declaration.overrideMutate ? declaration.mutate : [];
2937
+ const reported = reportRun(cwd, runStryker(cwd, selected, true), null);
2666
2938
  return finish(OUTCOMES.wholeList, reported.code, reported.measured);
2667
2939
  }
2668
2940
 
@@ -21,6 +21,7 @@ import {
21
21
  extractStrykerConstraints,
22
22
  extractStrykerMutate,
23
23
  familyFor,
24
+ globRoot,
24
25
  parseJson,
25
26
  } from "./threshold-ratchet-families.mjs";
26
27
 
@@ -157,15 +158,56 @@ function compareStryker(relPath, base, current) {
157
158
  );
158
159
  const baseMutate = extractStrykerMutate(base);
159
160
  const currentMutate = extractStrykerMutate(current);
161
+ // Roots the gate already covered, and roots this change adds. An exclusion
162
+ // scoped to a newly added root narrows territory that was not being mutated
163
+ // a moment ago, so it cannot shrink the gate — the change is a net widening.
164
+ //
165
+ // Without this, repairing an INERT `mutate` list is unreachable without a
166
+ // human-approved ratchet exception: the repair necessarily adds source roots
167
+ // AND the test-file exclusions that belong with them, and every one of those
168
+ // exclusions read as a weakening. Measured on two repositories whose real
169
+ // effect was 0 -> 69 and 0 -> 75 files mutated (CodySwannGT/lisa#4243).
170
+ const baseRoots = [...baseMutate.positives].map(globRoot);
171
+ /**
172
+ * Whether an exclusion might touch any baseline positive.
173
+ *
174
+ * Both containment directions matter: excluding `src` removes a baseline
175
+ * rooted at `src/existing`, too. An empty prefix (wildcards, braces, classes
176
+ * or extglobs at the root) might cover everything. We grant an exemption
177
+ * only for provably disjoint territory, never from a guessed glob overlap.
178
+ * @param {string} root A rooted glob prefix
179
+ * @returns {boolean} True when overlap cannot be ruled out
180
+ */
181
+ const overlapsBase = root =>
182
+ baseRoots.some(
183
+ base =>
184
+ root === "" ||
185
+ base === "" ||
186
+ root === base ||
187
+ root.startsWith(`${base}/`) ||
188
+ base.startsWith(`${root}/`)
189
+ );
190
+ const addedRoots = [...currentMutate.positives]
191
+ .filter(positive => !baseMutate.positives.has(positive))
192
+ .map(globRoot)
193
+ .filter(root => root !== "");
194
+ /**
195
+ * Whether a root lies inside territory THIS change is adding.
196
+ * @param {string} root A rooted glob prefix
197
+ * @returns {boolean} True when an added root equals or contains it
198
+ */
199
+ const underAdded = root =>
200
+ addedRoots.some(added => root === added || root.startsWith(`${added}/`));
160
201
  for (const negation of currentMutate.negations) {
161
- if (!baseMutate.negations.has(negation)) {
162
- findings.push({
163
- file: relPath,
164
- key: `mutate ${negation}`,
165
- type: TYPE_EXEMPTION_ADDED,
166
- message: `${relPath}: new mutation-testing exclusion "${negation}" — excluding files from a gate is a weakening.`,
167
- });
168
- }
202
+ if (baseMutate.negations.has(negation)) continue;
203
+ const root = globRoot(negation);
204
+ if (!overlapsBase(root) && underAdded(root)) continue;
205
+ findings.push({
206
+ file: relPath,
207
+ key: `mutate ${negation}`,
208
+ type: TYPE_EXEMPTION_ADDED,
209
+ message: `${relPath}: new mutation-testing exclusion "${negation}" — excluding files from a gate is a weakening.`,
210
+ });
169
211
  }
170
212
  for (const positive of baseMutate.positives) {
171
213
  if (!currentMutate.positives.has(positive)) {
@@ -311,6 +311,30 @@ export function extractStrykerMutate(conf) {
311
311
  return { positives, negations };
312
312
  }
313
313
 
314
+ /**
315
+ * The literal directory prefix a glob is rooted at, before any wildcard.
316
+ *
317
+ * `lib/**\/*.spec.ts` and `!lib/**\/*.d.ts` are both rooted at `lib`. This is
318
+ * what lets the ratchet tell territory a change ADDED from territory it
319
+ * already covered: an exclusion inside a newly added root cannot shrink a
320
+ * gate, because nothing there was being mutated a moment ago.
321
+ * @param {string} glob A stryker mutate entry, with or without a leading `!`
322
+ * @returns {string} The rooted prefix, or "" when the glob starts with a wildcard
323
+ */
324
+ export function globRoot(glob) {
325
+ const bare = glob.startsWith("!") ? glob.slice(1) : glob;
326
+ // Backslash escapes and parent traversal make textual containment unsafe.
327
+ // An unknown prefix overlaps everything rather than granting an exemption.
328
+ if (bare.includes("\\") || bare.split("/").includes("..")) return "";
329
+ const rooted = [];
330
+ for (const segment of bare.split("/")) {
331
+ if (segment === "." || segment === "") continue;
332
+ if (["*", "?", "{", "[", "("].some(meta => segment.includes(meta))) break;
333
+ rooted.push(segment);
334
+ }
335
+ return rooted.join("/");
336
+ }
337
+
314
338
  /**
315
339
  * Parse one k6 threshold expression (`p(95)<1000`, `rate>=0.99`, …) into a
316
340
  * ratchet constraint. Upper bounds (`<`, `<=`) may only decrease; lower
@@ -12,6 +12,7 @@
12
12
  "timeoutMS": 60000,
13
13
  "_dryRunTimeoutMinutesComment": "Whole-suite liveness bound, set by dryRunTimeoutMinutes, for the single un-mutated run that measures every test before any mutant is generated. Stryker's 5-minute default is the one that fails first: a suite needing 159s on an idle box has under 2x margin, and none at all under load, and when it blows there is no score to report — the gate simply dies. 20 minutes is generous on purpose; a genuinely hung dry run still fails, just later, and CI's own job timeout still caps it. Absolute BY NECESSITY: this bounds the run that PRODUCES the timing measurement, so there is nothing yet measured to scale it against. It cannot be made adaptive the way a per-mutant budget can. The claim it makes is deliberately weak — not \"your dry run finishes in this long\" but \"a dry run still going at this point is wedged rather than slow\" — which is what lets one number hold across hardware nobody here has seen. It fails only where a HEALTHY dry run genuinely exceeds it, which takes a pathological environment rather than a slow one (a saturated temp directory can make a single mkdtemp cost seconds). In that case the environment is the bug and raising this number hides it — which is why the gate now reports a blown budget as `dry-run-timeout` naming this value, rather than as a mutation score.",
14
14
  "dryRunTimeoutMinutes": 20,
15
+ "_mutateComment": "Initial source-layout guess. Lisa mutation runs report when these patterns select no tracked files and derive source roots when quality.mutation.sourceDirs is absent. Declare that key in .lisa.config.json to set authoritative roots; it takes precedence in diff and --all runs. This create-only file stays project-owned.",
15
16
  "mutate": [
16
17
  "src/**/*.ts",
17
18
  "src/**/*.tsx",