@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.
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +24 -0
- package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
- package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +19 -17
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/expo/create-only/stryker.conf.json +1 -0
- package/package.json +5 -5
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-design-intake/SKILL.md +1 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-implement/SKILL.md +3 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-quality-review/SKILL.md +2 -2
- package/plugins/lisa/.codex-plugin/skills/lisa-review-local/SKILL.md +9 -3
- package/plugins/lisa/.codex-plugin/skills/lisa-tdd-implementation/SKILL.md +2 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-tracker-source-artifacts/SKILL.md +3 -1
- package/plugins/lisa/hooks/threshold-ratchet-compare.mjs +50 -8
- package/plugins/lisa/hooks/threshold-ratchet-families.mjs +24 -0
- package/plugins/lisa/rules/reference/design-source-of-truth.md +32 -10
- package/plugins/lisa/scripts/design-source-gate.mjs +35 -13
- package/plugins/lisa/skills/lisa-design-intake/SKILL.md +1 -1
- package/plugins/lisa/skills/lisa-implement/SKILL.md +3 -1
- package/plugins/lisa/skills/lisa-quality-review/SKILL.md +2 -2
- package/plugins/lisa/skills/lisa-review-local/SKILL.md +9 -3
- package/plugins/lisa/skills/lisa-tdd-implementation/SKILL.md +2 -1
- package/plugins/lisa/skills/lisa-tracker-source-artifacts/SKILL.md +3 -1
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-agy/scripts/design-source-gate.mjs +35 -13
- package/plugins/lisa-agy/skills/lisa-design-intake/SKILL.md +1 -1
- package/plugins/lisa-agy/skills/lisa-implement/SKILL.md +3 -1
- package/plugins/lisa-agy/skills/lisa-quality-review/SKILL.md +2 -2
- package/plugins/lisa-agy/skills/lisa-review-local/SKILL.md +9 -3
- package/plugins/lisa-agy/skills/lisa-tdd-implementation/SKILL.md +2 -1
- package/plugins/lisa-agy/skills/lisa-tracker-source-artifacts/SKILL.md +3 -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/hooks/threshold-ratchet-compare.mjs +50 -8
- package/plugins/lisa-copilot/hooks/threshold-ratchet-families.mjs +24 -0
- package/plugins/lisa-copilot/rules/reference/design-source-of-truth.md +32 -10
- package/plugins/lisa-copilot/scripts/design-source-gate.mjs +35 -13
- package/plugins/lisa-copilot/skills/lisa-design-intake/SKILL.md +1 -1
- package/plugins/lisa-copilot/skills/lisa-implement/SKILL.md +3 -1
- package/plugins/lisa-copilot/skills/lisa-quality-review/SKILL.md +2 -2
- package/plugins/lisa-copilot/skills/lisa-review-local/SKILL.md +9 -3
- package/plugins/lisa-copilot/skills/lisa-tdd-implementation/SKILL.md +2 -1
- package/plugins/lisa-copilot/skills/lisa-tracker-source-artifacts/SKILL.md +3 -1
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/hooks/threshold-ratchet-compare.mjs +50 -8
- package/plugins/lisa-cursor/hooks/threshold-ratchet-families.mjs +24 -0
- package/plugins/lisa-cursor/rules/design-source-of-truth-reference.mdc +32 -10
- package/plugins/lisa-cursor/scripts/design-source-gate.mjs +35 -13
- package/plugins/lisa-cursor/skills/lisa-design-intake/SKILL.md +1 -1
- package/plugins/lisa-cursor/skills/lisa-implement/SKILL.md +3 -1
- package/plugins/lisa-cursor/skills/lisa-quality-review/SKILL.md +2 -2
- package/plugins/lisa-cursor/skills/lisa-review-local/SKILL.md +9 -3
- package/plugins/lisa-cursor/skills/lisa-tdd-implementation/SKILL.md +2 -1
- package/plugins/lisa-cursor/skills/lisa-tracker-source-artifacts/SKILL.md +3 -1
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/src/base/hooks/threshold-ratchet-compare.mjs +50 -8
- package/plugins/src/base/hooks/threshold-ratchet-families.mjs +24 -0
- package/plugins/src/base/rules/reference/design-source-of-truth.md +32 -10
- package/plugins/src/base/scripts/design-source-gate.mjs +35 -13
- package/plugins/src/base/skills/lisa-design-intake/SKILL.md +1 -1
- package/plugins/src/base/skills/lisa-implement/SKILL.md +3 -1
- package/plugins/src/base/skills/lisa-quality-review/SKILL.md +2 -2
- package/plugins/src/base/skills/lisa-review-local/SKILL.md +9 -3
- package/plugins/src/base/skills/lisa-tdd-implementation/SKILL.md +2 -1
- package/plugins/src/base/skills/lisa-tracker-source-artifacts/SKILL.md +3 -1
- package/rails/copy-overwrite/scripts/threshold-ratchet-compare.mjs +50 -8
- package/rails/copy-overwrite/scripts/threshold-ratchet-families.mjs +24 -0
- package/typescript/copy-overwrite/scripts/lisa-mutation.mjs +297 -25
- package/typescript/copy-overwrite/scripts/threshold-ratchet-compare.mjs +50 -8
- package/typescript/copy-overwrite/scripts/threshold-ratchet-families.mjs +24 -0
- 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 (
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
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 -
|
|
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
|
-
|
|
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
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
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
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
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.
|
|
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
|
-
|
|
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 (
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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",
|