@ticket-gate/engine 0.0.0 → 0.1.0-rc.1
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/LICENSE +202 -0
- package/README.md +82 -0
- package/bin/ticket-gate.mjs +261 -0
- package/checks/assertions-unguarded.mjs +31 -0
- package/checks/assertions.mjs +319 -0
- package/checks/cache-report.mjs +313 -0
- package/checks/docs-stale.mjs +69 -0
- package/checks/duplication.mjs +258 -0
- package/checks/ratchet-options.mjs +51 -0
- package/checks/surface.mjs +397 -0
- package/cli.mjs +379 -0
- package/commands-config.mjs +55 -0
- package/config.mjs +597 -0
- package/digest.mjs +195 -0
- package/env-name.mjs +24 -0
- package/failures.mjs +256 -0
- package/fu.mjs +458 -0
- package/graph/js.mjs +517 -0
- package/graph/load-time.mjs +309 -0
- package/graph/typescript.mjs +28 -0
- package/hooks/pre-tool-use.mjs +213 -0
- package/hooks/stop.mjs +495 -0
- package/is-main.mjs +15 -0
- package/kill-group.mjs +13 -0
- package/ledger.mjs +186 -0
- package/lock.d.mts +72 -0
- package/lock.mjs +329 -0
- package/move-proof.mjs +206 -0
- package/mutations/check.mjs +506 -0
- package/mutations/cli.mjs +361 -0
- package/mutations/exec.mjs +120 -0
- package/mutations/gate.mjs +148 -0
- package/mutations/harness.mjs +240 -0
- package/mutations/kinds.mjs +148 -0
- package/mutations/locate.mjs +71 -0
- package/mutations/project.mjs +23 -0
- package/mutations/registry.mjs +173 -0
- package/mutations/rotate.mjs +419 -0
- package/mutations/rotation-status.mjs +93 -0
- package/mutations/run.mjs +252 -0
- package/mutations/select.mjs +333 -0
- package/mutations/served.mjs +216 -0
- package/mutations/validate.mjs +86 -0
- package/mutations/verdict.mjs +159 -0
- package/nightly/cli.mjs +30 -0
- package/nightly/define.mjs +111 -0
- package/nightly/e2e-runs.mjs +73 -0
- package/nightly/gate.mjs +25 -0
- package/nightly/io.mjs +109 -0
- package/nightly/launchd.mjs +42 -0
- package/nightly/record.mjs +231 -0
- package/nightly/run.mjs +470 -0
- package/nightly/schedule.mjs +76 -0
- package/nightly/seed.mjs +78 -0
- package/nightly/status.mjs +189 -0
- package/nightly/suites.mjs +43 -0
- package/nightly-config.mjs +38 -0
- package/nightly-window.mjs +6 -0
- package/pack-hooks-all.mjs +15 -0
- package/pack-hooks.mjs +26 -0
- package/package.json +21 -1
- package/plan.mjs +272 -0
- package/precheck.mjs +90 -0
- package/project.mjs +21 -0
- package/ratchet.mjs +431 -0
- package/receipt.mjs +34 -0
- package/review-map.mjs +432 -0
- package/root.mjs +33 -0
- package/run.mjs +291 -0
- package/runners/playwright.mjs +132 -0
- package/score/adapters/stryker.mjs +111 -0
- package/score/base.mjs +82 -0
- package/score/cli.mjs +182 -0
- package/score/judge.mjs +107 -0
- package/score/lines.mjs +40 -0
- package/score/schema.mjs +68 -0
- package/score/survivors.mjs +103 -0
- package/score/targets.mjs +34 -0
- package/score-config.mjs +120 -0
- package/selection/check.mjs +45 -0
- package/selection/cli.mjs +267 -0
- package/selection/define.mjs +55 -0
- package/selection/for.mjs +235 -0
- package/selection/graphs.mjs +38 -0
- package/selection/project.mjs +11 -0
- package/selection/select.mjs +159 -0
- package/selection/units.mjs +646 -0
- package/selection/value-rules.mjs +142 -0
- package/selection-config.mjs +76 -0
- package/serve.mjs +142 -0
- package/summary.mjs +34 -0
- package/tree.mjs +211 -0
package/score/cli.mjs
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// The command line of the mutation score gate.
|
|
2
|
+
//
|
|
3
|
+
// <score command> --base=<ref> [--root=<dir>]
|
|
4
|
+
// [--report=<path> --base-report=<path>] [--files=<p,...>]
|
|
5
|
+
//
|
|
6
|
+
// Exit 0: pass (or nothing to mutate). 1: a file failed the rule. 2: the
|
|
7
|
+
// mutation tool, the report, the worktree or the install failed.
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { dirname, join, resolve } from 'node:path';
|
|
10
|
+
|
|
11
|
+
import { GateError, baseWorktree, git, gitOk, readReport } from './base.mjs';
|
|
12
|
+
import { baseToHeadLines, parseChangedLines } from './lines.mjs';
|
|
13
|
+
import {
|
|
14
|
+
judge,
|
|
15
|
+
needsForcedRerun,
|
|
16
|
+
regressedMutants,
|
|
17
|
+
scoreFile,
|
|
18
|
+
summaryLine,
|
|
19
|
+
warnings,
|
|
20
|
+
} from './judge.mjs';
|
|
21
|
+
import { renderSurvivors, reportFiles } from './survivors.mjs';
|
|
22
|
+
import { mutationTargets } from './targets.mjs';
|
|
23
|
+
|
|
24
|
+
/** Runs the gate; returns the exit code. `cfg` is the project config, `root` the default repo root. */
|
|
25
|
+
export function runScore(argv, cfg, root0) {
|
|
26
|
+
const opt = (name) =>
|
|
27
|
+
argv.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 3);
|
|
28
|
+
const root = resolve(opt('root') ?? root0);
|
|
29
|
+
const baseArg = opt('base');
|
|
30
|
+
const adapter = cfg.adapter;
|
|
31
|
+
if (!baseArg) {
|
|
32
|
+
console.error(
|
|
33
|
+
`mutation: FAIL --base=<ref> is required. Run: ${cfg.commands.score} --base=<BASE sha>`,
|
|
34
|
+
);
|
|
35
|
+
return 2;
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
const sha = git(root, [
|
|
39
|
+
'rev-parse',
|
|
40
|
+
'--verify',
|
|
41
|
+
`${baseArg}^{commit}`,
|
|
42
|
+
]).trim();
|
|
43
|
+
const sha7 = sha.slice(0, 7);
|
|
44
|
+
const paths = opt('files')
|
|
45
|
+
? opt('files').split(',')
|
|
46
|
+
: [
|
|
47
|
+
...git(root, ['diff', '--name-only', '--no-renames', sha]).split(
|
|
48
|
+
'\n',
|
|
49
|
+
),
|
|
50
|
+
...git(root, ['ls-files', '--others', '--exclude-standard']).split(
|
|
51
|
+
'\n',
|
|
52
|
+
),
|
|
53
|
+
].filter(Boolean);
|
|
54
|
+
const targets = mutationTargets(
|
|
55
|
+
paths,
|
|
56
|
+
(p) => existsSync(join(root, p)),
|
|
57
|
+
cfg,
|
|
58
|
+
);
|
|
59
|
+
if (!targets.length) {
|
|
60
|
+
console.log(`mutation: ${cfg.noTargets} vs ${sha7}`);
|
|
61
|
+
return 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let bad = 0;
|
|
65
|
+
for (const t of targets)
|
|
66
|
+
for (const d of adapter.disablesWithoutReason(
|
|
67
|
+
readFileSync(join(root, t), 'utf8'),
|
|
68
|
+
)) {
|
|
69
|
+
console.log(
|
|
70
|
+
`mutation: FAIL ${t}:${d.line}: Stryker disable without a reason`,
|
|
71
|
+
);
|
|
72
|
+
bad++;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const isNew = Object.fromEntries(
|
|
76
|
+
targets.map((t) => [t, !gitOk(root, ['cat-file', '-e', `${sha}:${t}`])]),
|
|
77
|
+
);
|
|
78
|
+
const existing = targets.filter((t) => !isNew[t]);
|
|
79
|
+
|
|
80
|
+
// `force`: the confirming run, which ignores the incremental file and re-tests every mutant.
|
|
81
|
+
const reportPath = opt('report') ?? join(root, adapter.reportPath);
|
|
82
|
+
const branchReport = (force) => {
|
|
83
|
+
if (!opt('report')) adapter.runBranch({ root, files: targets, force });
|
|
84
|
+
return readReport(reportPath, 'branch');
|
|
85
|
+
};
|
|
86
|
+
const baseReportOf = (force) => {
|
|
87
|
+
if (opt('base-report')) return readReport(opt('base-report'), 'base');
|
|
88
|
+
if (!existing.length) return null;
|
|
89
|
+
const dir = baseWorktree(root, sha, cfg);
|
|
90
|
+
const out = adapter.runBase({ root, dir, files: existing, force });
|
|
91
|
+
return readReport(out, 'base');
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const diffOf = (path) =>
|
|
95
|
+
git(root, ['diff', '-U0', '--no-renames', '--no-color', sha, '--', path]);
|
|
96
|
+
const verdictsOf = (report, baseReport) => {
|
|
97
|
+
const branchFiles = reportFiles(report, cfg.packageDir);
|
|
98
|
+
const baseFiles = reportFiles(baseReport, cfg.packageDir);
|
|
99
|
+
return targets.map((path) => {
|
|
100
|
+
const mutants = branchFiles[path]?.mutants ?? [];
|
|
101
|
+
const s = scoreFile(mutants);
|
|
102
|
+
const baseScore = baseFiles[path]
|
|
103
|
+
? scoreFile(baseFiles[path].mutants).score
|
|
104
|
+
: 100;
|
|
105
|
+
const diff = isNew[path] ? null : diffOf(path);
|
|
106
|
+
const changed = diff === null ? null : parseChangedLines(diff);
|
|
107
|
+
const onChanged = (status) =>
|
|
108
|
+
mutants.filter(
|
|
109
|
+
(m) =>
|
|
110
|
+
m.status === status &&
|
|
111
|
+
(changed === null || changed.has(m.location.start.line)),
|
|
112
|
+
).length;
|
|
113
|
+
const v = {
|
|
114
|
+
path,
|
|
115
|
+
isNew: isNew[path],
|
|
116
|
+
score: s.score,
|
|
117
|
+
baseScore,
|
|
118
|
+
changed,
|
|
119
|
+
noCoverageOnChanged: onChanged('NoCoverage'),
|
|
120
|
+
survivedOnChanged: onChanged('Survived'),
|
|
121
|
+
survivorsOnChanged: onChanged('Survived') + onChanged('NoCoverage'),
|
|
122
|
+
regressed:
|
|
123
|
+
diff === null
|
|
124
|
+
? []
|
|
125
|
+
: regressedMutants(
|
|
126
|
+
baseFiles[path]?.mutants ?? [],
|
|
127
|
+
mutants,
|
|
128
|
+
baseToHeadLines(diff),
|
|
129
|
+
),
|
|
130
|
+
};
|
|
131
|
+
return { ...v, reasons: judge(v, cfg), warnings: warnings(v) };
|
|
132
|
+
});
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
let report = branchReport(false);
|
|
136
|
+
let verdicts = verdictsOf(report, baseReportOf(false));
|
|
137
|
+
// A regressed mutant can be a stale incremental result (a static mutant credited
|
|
138
|
+
// to whichever test imported the module): confirm it on fresh runs before failing.
|
|
139
|
+
if (needsForcedRerun(verdicts, !opt('report'))) {
|
|
140
|
+
const n = verdicts.reduce((a, v) => a + v.regressed.length, 0);
|
|
141
|
+
console.log(
|
|
142
|
+
`mutation: ${n} mutant(s) killed on base survive on unchanged lines; confirming with --force (no incremental cache)`,
|
|
143
|
+
);
|
|
144
|
+
report = branchReport(true);
|
|
145
|
+
verdicts = verdictsOf(report, baseReportOf(true));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
for (const v of verdicts)
|
|
149
|
+
for (const r of v.reasons) {
|
|
150
|
+
console.log(`mutation: FAIL ${v.path}: ${r}`);
|
|
151
|
+
bad++;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const newDisables = targets.flatMap((t) =>
|
|
155
|
+
adapter.addedDisables(root, sha, t, isNew[t]),
|
|
156
|
+
);
|
|
157
|
+
const md = renderSurvivors({
|
|
158
|
+
base: sha,
|
|
159
|
+
verdicts,
|
|
160
|
+
report,
|
|
161
|
+
newDisables,
|
|
162
|
+
packageDir: cfg.packageDir,
|
|
163
|
+
});
|
|
164
|
+
const survivorsFile = join(root, cfg.survivors);
|
|
165
|
+
mkdirSync(dirname(survivorsFile), { recursive: true });
|
|
166
|
+
writeFileSync(survivorsFile, md);
|
|
167
|
+
process.stdout.write(md);
|
|
168
|
+
if (bad)
|
|
169
|
+
console.log(
|
|
170
|
+
`mutation: survivors are in ${cfg.survivors}; ${cfg.handoff}, then rerun: ${cfg.commands.score} --base=${sha7}`,
|
|
171
|
+
);
|
|
172
|
+
console.log(summaryLine(verdicts));
|
|
173
|
+
return bad ? 1 : 0;
|
|
174
|
+
} catch (e) {
|
|
175
|
+
if (e instanceof GateError) {
|
|
176
|
+
console.log(e.message);
|
|
177
|
+
return 2;
|
|
178
|
+
}
|
|
179
|
+
console.log(`mutation: FAIL ${e.stderr ?? e.message}`);
|
|
180
|
+
return 2;
|
|
181
|
+
}
|
|
182
|
+
}
|
package/score/judge.mjs
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Scores and verdicts of the mutation score gate. Pure: the rule thresholds
|
|
2
|
+
// arrive in `cfg`.
|
|
3
|
+
|
|
4
|
+
const KILLED = new Set(['Killed', 'Timeout']);
|
|
5
|
+
const OPEN = new Set(['Survived', 'NoCoverage']);
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Head mutants on unchanged lines that the base killed and the branch does not: a
|
|
9
|
+
* test was removed or weakened. A mutant is matched by its location (mapped from
|
|
10
|
+
* base to head), mutator and replacement; counts are compared per key, so a key the
|
|
11
|
+
* base already left open is not a regression. A changed line never matches: `toHead`
|
|
12
|
+
* maps untouched base lines only onto untouched head lines.
|
|
13
|
+
*/
|
|
14
|
+
export function regressedMutants(baseMutants, headMutants, toHead) {
|
|
15
|
+
const keyOf = (m, start, end) =>
|
|
16
|
+
`${start}:${m.location.start.column}-${end}:${m.location.end.column}/${m.mutatorName}/${m.replacement}`;
|
|
17
|
+
const base = new Map();
|
|
18
|
+
for (const m of baseMutants) {
|
|
19
|
+
const start = toHead(m.location.start.line);
|
|
20
|
+
const end = toHead(m.location.end.line);
|
|
21
|
+
if (start === null || end === null) continue;
|
|
22
|
+
const k = keyOf(m, start, end);
|
|
23
|
+
const c = base.get(k) ?? { killed: 0, open: 0 };
|
|
24
|
+
if (KILLED.has(m.status)) c.killed++;
|
|
25
|
+
else if (OPEN.has(m.status)) c.open++;
|
|
26
|
+
base.set(k, c);
|
|
27
|
+
}
|
|
28
|
+
const headOpen = new Map();
|
|
29
|
+
for (const m of headMutants) {
|
|
30
|
+
if (!OPEN.has(m.status)) continue;
|
|
31
|
+
const k = keyOf(m, m.location.start.line, m.location.end.line);
|
|
32
|
+
headOpen.set(k, [...(headOpen.get(k) ?? []), m]);
|
|
33
|
+
}
|
|
34
|
+
const out = [];
|
|
35
|
+
for (const [k, list] of headOpen) {
|
|
36
|
+
const c = base.get(k);
|
|
37
|
+
if (!c) continue;
|
|
38
|
+
const n = Math.min(c.killed, Math.max(0, list.length - c.open));
|
|
39
|
+
out.push(...list.slice(0, n));
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Killed + Timeout over Killed + Timeout + Survived + NoCoverage. */
|
|
45
|
+
export function scoreFile(mutants) {
|
|
46
|
+
const c = { Killed: 0, Timeout: 0, Survived: 0, NoCoverage: 0 };
|
|
47
|
+
for (const m of mutants) if (m.status in c) c[m.status]++;
|
|
48
|
+
const killed = c.Killed + c.Timeout;
|
|
49
|
+
const valid = killed + c.Survived + c.NoCoverage;
|
|
50
|
+
const score = valid === 0 ? 100 : Math.round((10000 * killed) / valid) / 100;
|
|
51
|
+
return {
|
|
52
|
+
killed,
|
|
53
|
+
survived: c.Survived,
|
|
54
|
+
noCoverage: c.NoCoverage,
|
|
55
|
+
valid,
|
|
56
|
+
score,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Failure reasons for one file; empty means pass. `cfg.newFileMin` is the floor for a new file. */
|
|
61
|
+
export function judge(v, cfg) {
|
|
62
|
+
const reasons = [];
|
|
63
|
+
if (v.isNew && v.score < cfg.newFileMin)
|
|
64
|
+
reasons.push(`new file score ${v.score.toFixed(2)} < ${cfg.newFileMin}`);
|
|
65
|
+
// An existing file fails only on a mutant that survives on a line this branch
|
|
66
|
+
// changed. A whole-file score below base is a warning (see `warnings`): it can
|
|
67
|
+
// come from lines nobody touched, and the nightly rotation re-checks them.
|
|
68
|
+
if (!v.isNew && (v.survivedOnChanged ?? 0) > 0)
|
|
69
|
+
reasons.push(`${v.survivedOnChanged} surviving mutant(s) on changed lines`);
|
|
70
|
+
if (!v.isNew && v.noCoverageOnChanged > 0)
|
|
71
|
+
reasons.push(
|
|
72
|
+
`${v.noCoverageOnChanged} no-coverage mutant(s) on changed lines`,
|
|
73
|
+
);
|
|
74
|
+
if (!v.isNew && (v.regressed?.length ?? 0) > 0)
|
|
75
|
+
reasons.push(
|
|
76
|
+
`${v.regressed.length} mutant(s) killed on base now survive on unchanged lines (a test was removed or weakened)`,
|
|
77
|
+
);
|
|
78
|
+
return reasons;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Non-failing notes for one file: the whole-file score fell below base with no
|
|
83
|
+
* regressed mutant (a killed base mutant on a line the branch removed, say).
|
|
84
|
+
*/
|
|
85
|
+
export function warnings(v) {
|
|
86
|
+
const out = [];
|
|
87
|
+
if (!v.isNew && v.score < v.baseScore && !(v.regressed?.length ?? 0))
|
|
88
|
+
out.push(
|
|
89
|
+
`score ${v.score.toFixed(2)} dropped below base ${v.baseScore} (whole file; not a failure, no mutant killed on base survives)`,
|
|
90
|
+
);
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** True when a regressed mutant should be confirmed by a run without the incremental cache. */
|
|
95
|
+
export function needsForcedRerun(verdicts, ranStryker) {
|
|
96
|
+
return ranStryker && verdicts.some((v) => (v.regressed?.length ?? 0) > 0);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function summaryLine(verdicts) {
|
|
100
|
+
const scores = verdicts.map((v) => v.score);
|
|
101
|
+
const min = scores.length ? Math.min(...scores) : 100;
|
|
102
|
+
const max = scores.length ? Math.max(...scores) : 100;
|
|
103
|
+
const n = verdicts.reduce((a, v) => a + v.survivorsOnChanged, 0);
|
|
104
|
+
const g = verdicts.reduce((a, v) => a + (v.regressed?.length ?? 0), 0);
|
|
105
|
+
const w = verdicts.reduce((a, v) => a + (v.warnings?.length ?? 0), 0);
|
|
106
|
+
return `mutation: ${verdicts.length} files, score ${min.toFixed(1)}..${max.toFixed(1)}, ${n} survivors on changed lines${g ? `, ${g} regressed on unchanged lines` : ''}${w ? `, ${w} warning(s): whole-file score below base` : ''}`;
|
|
107
|
+
}
|
package/score/lines.mjs
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Line arithmetic over a `git diff -U0` text. Pure.
|
|
2
|
+
|
|
3
|
+
/** New-side line numbers of a `git diff -U0` text. */
|
|
4
|
+
export function parseChangedLines(diffU0) {
|
|
5
|
+
const lines = new Set();
|
|
6
|
+
for (const m of diffU0.matchAll(
|
|
7
|
+
/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/gm,
|
|
8
|
+
)) {
|
|
9
|
+
const start = Number(m[1]);
|
|
10
|
+
const count = m[2] === undefined ? 1 : Number(m[2]);
|
|
11
|
+
for (let i = 0; i < count; i++) lines.add(start + i);
|
|
12
|
+
}
|
|
13
|
+
return lines;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Base line -> head line for the lines a `git diff -U0` left untouched; null for a
|
|
18
|
+
* base line the diff removed or rewrote.
|
|
19
|
+
*/
|
|
20
|
+
export function baseToHeadLines(diffU0) {
|
|
21
|
+
const hunks = [];
|
|
22
|
+
for (const m of diffU0.matchAll(
|
|
23
|
+
/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/gm,
|
|
24
|
+
))
|
|
25
|
+
hunks.push({
|
|
26
|
+
oldStart: Number(m[1]),
|
|
27
|
+
oldCount: m[2] === undefined ? 1 : Number(m[2]),
|
|
28
|
+
newCount: m[4] === undefined ? 1 : Number(m[4]),
|
|
29
|
+
});
|
|
30
|
+
return (line) => {
|
|
31
|
+
let delta = 0;
|
|
32
|
+
for (const h of hunks) {
|
|
33
|
+
// `-a,0` inserts after base line a; `-a,b` rewrites base lines a..a+b-1.
|
|
34
|
+
const last = h.oldCount === 0 ? h.oldStart : h.oldStart + h.oldCount - 1;
|
|
35
|
+
if (h.oldCount > 0 && line >= h.oldStart && line <= last) return null;
|
|
36
|
+
if (line > last) delta += h.newCount - h.oldCount;
|
|
37
|
+
}
|
|
38
|
+
return line + delta;
|
|
39
|
+
};
|
|
40
|
+
}
|
package/score/schema.mjs
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Shape check of a mutation-testing-report-schema report (majors 1 and 2).
|
|
2
|
+
// Only the fields the score rules read are checked; the first problem wins.
|
|
3
|
+
export const SCHEMA_VERSION = /^[12](?:\.(?:0|[1-9]\d*)){0,2}$/;
|
|
4
|
+
|
|
5
|
+
export const STATUSES = new Set([
|
|
6
|
+
'Killed',
|
|
7
|
+
'Survived',
|
|
8
|
+
'NoCoverage',
|
|
9
|
+
'CompileError',
|
|
10
|
+
'RuntimeError',
|
|
11
|
+
'Timeout',
|
|
12
|
+
'Ignored',
|
|
13
|
+
'Pending',
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
const isLine = (n) => Number.isInteger(n) && n >= 1;
|
|
17
|
+
const isObject = (v) =>
|
|
18
|
+
typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
19
|
+
const isStringArray = (v) =>
|
|
20
|
+
Array.isArray(v) && v.every((s) => typeof s === 'string');
|
|
21
|
+
|
|
22
|
+
function mutantProblem(m) {
|
|
23
|
+
if (!STATUSES.has(m?.status))
|
|
24
|
+
return `status ${JSON.stringify(m?.status)} is not a schema status`;
|
|
25
|
+
if (typeof m.mutatorName !== 'string') return 'mutatorName is not a string';
|
|
26
|
+
if (m.replacement !== undefined && typeof m.replacement !== 'string')
|
|
27
|
+
return 'replacement is not a string';
|
|
28
|
+
for (const end of ['start', 'end'])
|
|
29
|
+
for (const axis of ['line', 'column'])
|
|
30
|
+
if (!isLine(m.location?.[end]?.[axis]))
|
|
31
|
+
return `location.${end}.${axis} is not a positive integer`;
|
|
32
|
+
if (m.coveredBy !== undefined && !isStringArray(m.coveredBy))
|
|
33
|
+
return 'coveredBy is not an array of strings';
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The first way `report` departs from the schema, or null. */
|
|
38
|
+
export function schemaProblem(report) {
|
|
39
|
+
if (!isObject(report)) return 'is not a JSON object';
|
|
40
|
+
if (
|
|
41
|
+
typeof report.schemaVersion !== 'string' ||
|
|
42
|
+
!SCHEMA_VERSION.test(report.schemaVersion)
|
|
43
|
+
)
|
|
44
|
+
return `schemaVersion ${JSON.stringify(report.schemaVersion)} is not a supported mutation-testing-report-schema version (1.x or 2.x)`;
|
|
45
|
+
if (!isObject(report.files)) return 'files is missing';
|
|
46
|
+
for (const [path, file] of Object.entries(report.files)) {
|
|
47
|
+
const where = `files[${JSON.stringify(path)}]`;
|
|
48
|
+
if (!Array.isArray(file?.mutants))
|
|
49
|
+
return `${where}.mutants is not an array`;
|
|
50
|
+
for (const [i, m] of file.mutants.entries()) {
|
|
51
|
+
const p = mutantProblem(m);
|
|
52
|
+
if (p) return `${where}.mutants[${i}]: ${p}`;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (report.testFiles !== undefined) {
|
|
56
|
+
if (!isObject(report.testFiles)) return 'testFiles is not an object';
|
|
57
|
+
for (const [path, tf] of Object.entries(report.testFiles)) {
|
|
58
|
+
const ok =
|
|
59
|
+
Array.isArray(tf?.tests) &&
|
|
60
|
+
tf.tests.every(
|
|
61
|
+
(t) => typeof t?.id === 'string' && typeof t?.name === 'string',
|
|
62
|
+
);
|
|
63
|
+
if (!ok)
|
|
64
|
+
return `testFiles[${JSON.stringify(path)}].tests is not an array of { id, name }`;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// The survivors Markdown of the mutation score gate. Pure: the package directory
|
|
2
|
+
// the report keys are relative to arrives as `packageDir`.
|
|
3
|
+
import { isAbsolute } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { summaryLine } from './judge.mjs';
|
|
6
|
+
|
|
7
|
+
/** Report keys are relative to `packageDir` or absolute; make them repo paths. */
|
|
8
|
+
export function repoKey(key, packageDir) {
|
|
9
|
+
if (packageDir === '.') return key;
|
|
10
|
+
if (isAbsolute(key)) {
|
|
11
|
+
const i = key.indexOf(`${packageDir}/`);
|
|
12
|
+
return i === -1 ? key : key.slice(i);
|
|
13
|
+
}
|
|
14
|
+
return key.startsWith(`${packageDir}/`) ? key : `${packageDir}/${key}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Report files by repo path. */
|
|
18
|
+
export function reportFiles(report, packageDir) {
|
|
19
|
+
const out = {};
|
|
20
|
+
for (const [k, f] of Object.entries(report?.files ?? {}))
|
|
21
|
+
out[repoKey(k, packageDir)] = f;
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function slice(source, loc) {
|
|
26
|
+
const lines = source.split('\n');
|
|
27
|
+
const first = lines[loc.start.line - 1] ?? '';
|
|
28
|
+
if (loc.end.line === loc.start.line)
|
|
29
|
+
return first.slice(loc.start.column - 1, loc.end.column - 1);
|
|
30
|
+
return `${first.slice(loc.start.column - 1)} ...`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The Markdown of the survivors file. `verdicts` carry `path`,
|
|
35
|
+
* `isNew`, `score`, `baseScore`, `reasons`, `changed` (Set of lines, or null
|
|
36
|
+
* for every line); `report` is the branch Stryker report; `sources` optionally
|
|
37
|
+
* overrides a file's source text by repo path; `newDisables` is
|
|
38
|
+
* `[{ path, line, text }]`.
|
|
39
|
+
*/
|
|
40
|
+
export function renderSurvivors({
|
|
41
|
+
base,
|
|
42
|
+
verdicts,
|
|
43
|
+
report,
|
|
44
|
+
sources = {},
|
|
45
|
+
newDisables = [],
|
|
46
|
+
packageDir,
|
|
47
|
+
}) {
|
|
48
|
+
const files = reportFiles(report, packageDir);
|
|
49
|
+
const names = new Map();
|
|
50
|
+
for (const [file, t] of Object.entries(report?.testFiles ?? {}))
|
|
51
|
+
for (const test of t.tests ?? [])
|
|
52
|
+
names.set(test.id, `${file} > ${test.name}`);
|
|
53
|
+
|
|
54
|
+
const out = [`# Mutation survivors (base ${base.slice(0, 7)})`, ''];
|
|
55
|
+
out.push(summaryLine(verdicts));
|
|
56
|
+
for (const v of verdicts)
|
|
57
|
+
for (const r of v.reasons) out.push(`mutation: FAIL ${v.path}: ${r}`);
|
|
58
|
+
for (const v of verdicts)
|
|
59
|
+
for (const w of v.warnings ?? [])
|
|
60
|
+
out.push(`mutation: WARN ${v.path}: ${w}`);
|
|
61
|
+
for (const v of verdicts) {
|
|
62
|
+
out.push(
|
|
63
|
+
'',
|
|
64
|
+
`## ${v.path} score ${v.score} (${v.isNew ? 'new' : `base ${v.baseScore}`})`,
|
|
65
|
+
'',
|
|
66
|
+
);
|
|
67
|
+
const file = files[v.path];
|
|
68
|
+
const source = sources[v.path] ?? file?.source ?? '';
|
|
69
|
+
const open = (file?.mutants ?? [])
|
|
70
|
+
.filter((m) => m.status === 'Survived' || m.status === 'NoCoverage')
|
|
71
|
+
.sort(
|
|
72
|
+
(a, b) =>
|
|
73
|
+
a.location.start.line - b.location.start.line ||
|
|
74
|
+
a.location.start.column - b.location.start.column,
|
|
75
|
+
);
|
|
76
|
+
if (!open.length) out.push('- none');
|
|
77
|
+
const regressed = new Set(v.regressed ?? []);
|
|
78
|
+
for (const m of open) {
|
|
79
|
+
const { line, column } = m.location.start;
|
|
80
|
+
const changed = v.changed === null || v.changed?.has(line);
|
|
81
|
+
const tag = changed
|
|
82
|
+
? ' [changed]'
|
|
83
|
+
: regressed.has(m)
|
|
84
|
+
? ' [regressed]'
|
|
85
|
+
: '';
|
|
86
|
+
const tests = (m.coveredBy ?? [])
|
|
87
|
+
.map((id) => names.get(id))
|
|
88
|
+
.filter(Boolean);
|
|
89
|
+
const base_ = v.path.split('/').pop();
|
|
90
|
+
out.push(
|
|
91
|
+
`- \`${base_}:${line}:${column}/${m.mutatorName}\` ${m.status}${tag}`,
|
|
92
|
+
` - original: \`${slice(source, m.location)}\``,
|
|
93
|
+
` - mutated: \`${m.replacement}\``,
|
|
94
|
+
` - covered by: ${tests.length ? tests.map((t) => `\`${t}\``).join('; ') : 'none (no coverage)'}`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
out.push('', '## New disable comments', '');
|
|
99
|
+
if (!newDisables.length) out.push('- none');
|
|
100
|
+
for (const d of newDisables)
|
|
101
|
+
out.push(`- \`${d.path}:${d.line}\` ${d.text.trim()}`);
|
|
102
|
+
return `${out.join('\n')}\n`;
|
|
103
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Which changed paths the score gate mutates. Pure: the project's scope, extra
|
|
2
|
+
// and excluded patterns arrive in `cfg`.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The module a test file stands for: `foo.test.ts` or `foo.<case>.test.ts` -> the
|
|
6
|
+
* longest existing `foo[.<case>].ts` beside it; null for anything else.
|
|
7
|
+
*/
|
|
8
|
+
export function testedModule(path, exists) {
|
|
9
|
+
const m = /^(.*\/)([^/]+?)\.test\.ts$/.exec(path);
|
|
10
|
+
if (!m) return null;
|
|
11
|
+
const parts = m[2].split('.');
|
|
12
|
+
for (let n = parts.length; n >= 1; n--) {
|
|
13
|
+
const p = `${m[1]}${parts.slice(0, n).join('.')}.ts`;
|
|
14
|
+
if (exists(p)) return p;
|
|
15
|
+
}
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Changed paths that the gate mutates, sorted; deleted files drop out. A changed
|
|
21
|
+
* (or deleted) test file selects the module beside it, so a weakened test is judged
|
|
22
|
+
* by the regressed-mutant rule even when the module itself did not change.
|
|
23
|
+
* `cfg` carries `scope` (RegExp), `extra` (paths) and `excluded` (RegExp).
|
|
24
|
+
*/
|
|
25
|
+
export function mutationTargets(paths, exists, cfg) {
|
|
26
|
+
return [...new Set(paths)]
|
|
27
|
+
.map((p) => testedModule(p, exists) ?? p)
|
|
28
|
+
.filter((p) => p.endsWith('.ts'))
|
|
29
|
+
.filter((p) => cfg.scope.test(p) || cfg.extra.includes(p))
|
|
30
|
+
.filter((p) => !cfg.excluded.test(p))
|
|
31
|
+
.filter((p) => exists(p))
|
|
32
|
+
.filter((p, i, all) => all.indexOf(p) === i)
|
|
33
|
+
.sort();
|
|
34
|
+
}
|
package/score-config.mjs
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// The `score` key of gate.config.mjs: the project values the mutation score gate (./score) reads.
|
|
2
|
+
// Validated at load time; a bad value throws Error('gate.config: score.<key>: <what>').
|
|
3
|
+
|
|
4
|
+
export const SCORE_KEYS = Object.freeze([
|
|
5
|
+
'scope',
|
|
6
|
+
'extra',
|
|
7
|
+
'excluded',
|
|
8
|
+
'newFileMin',
|
|
9
|
+
'packageDir',
|
|
10
|
+
'adapter',
|
|
11
|
+
'baseInstall',
|
|
12
|
+
'survivors',
|
|
13
|
+
'worktreeDir',
|
|
14
|
+
'handoff',
|
|
15
|
+
'commands',
|
|
16
|
+
'noTargets',
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
/** The minimum score of a file that is new on the branch, unless `score.newFileMin` says otherwise. */
|
|
20
|
+
export const NEW_FILE_MIN = 80;
|
|
21
|
+
|
|
22
|
+
export const SCORE_DEFAULTS = Object.freeze({
|
|
23
|
+
extra: [],
|
|
24
|
+
excluded: /(?!)/,
|
|
25
|
+
newFileMin: NEW_FILE_MIN,
|
|
26
|
+
survivors: '.turbo/mutation-survivors.md',
|
|
27
|
+
worktreeDir: 'ticket-gate-score-base',
|
|
28
|
+
handoff: 'add or fix the tests that should kill them',
|
|
29
|
+
commands: { score: 'ticket-gate score' },
|
|
30
|
+
noTargets: 'no file in the score scope changed',
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const isObject = (v) => v && typeof v === 'object' && !Array.isArray(v);
|
|
34
|
+
const isStr = (v) => typeof v === 'string' && v !== '';
|
|
35
|
+
const isLine = (v) => isStr(v) && !/[\r\n]/.test(v);
|
|
36
|
+
const isStrList = (v) => Array.isArray(v) && v.every((s) => typeof s === 'string');
|
|
37
|
+
const segments = (p) => p.split(/[\\/]/);
|
|
38
|
+
const isRel = (p) =>
|
|
39
|
+
isStr(p) &&
|
|
40
|
+
!p.startsWith('/') &&
|
|
41
|
+
!/^[A-Za-z]:/.test(p) &&
|
|
42
|
+
!segments(p).includes('..');
|
|
43
|
+
const isSubDir = (p) =>
|
|
44
|
+
isRel(p) && p !== '.' && !segments(p).some((s) => s === '' || s === '.');
|
|
45
|
+
const isOneSegment = (p) => isStr(p) && p !== '.' && p !== '..' && !/[\\/]/.test(p);
|
|
46
|
+
|
|
47
|
+
/** Validates `score`; `fail(where, what)` throws. A key left out keeps its default (see `scoreOf`). */
|
|
48
|
+
export function validateScore(score, fail) {
|
|
49
|
+
if (!isObject(score)) fail('score', 'must be an object');
|
|
50
|
+
for (const k of Object.keys(score)) {
|
|
51
|
+
if (!SCORE_KEYS.includes(k))
|
|
52
|
+
fail(`score.${k}`, `unknown key (known: ${SCORE_KEYS.join(', ')})`);
|
|
53
|
+
}
|
|
54
|
+
for (const k of ['scope', 'excluded']) {
|
|
55
|
+
if (k === 'excluded' && score[k] === undefined) continue;
|
|
56
|
+
const re = score[k];
|
|
57
|
+
if (!(re instanceof RegExp) || re.global || re.sticky)
|
|
58
|
+
fail(`score.${k}`, 'must be a RegExp without the g or y flag');
|
|
59
|
+
}
|
|
60
|
+
if (score.extra !== undefined && !isStrList(score.extra))
|
|
61
|
+
fail('score.extra', 'must be an array of repo paths');
|
|
62
|
+
const min = score.newFileMin;
|
|
63
|
+
if (min !== undefined && !(typeof min === 'number' && min >= 0 && min <= 100))
|
|
64
|
+
fail('score.newFileMin', 'must be a number from 0 to 100');
|
|
65
|
+
if (!isSubDir(score.packageDir) && score.packageDir !== '.')
|
|
66
|
+
fail(
|
|
67
|
+
'score.packageDir',
|
|
68
|
+
'must be a relative sub-directory of the repo, or "." for a package at the repo root',
|
|
69
|
+
);
|
|
70
|
+
const a = score.adapter;
|
|
71
|
+
if (
|
|
72
|
+
!isObject(a) ||
|
|
73
|
+
!isStr(a.name) ||
|
|
74
|
+
!isStr(a.reportPath) ||
|
|
75
|
+
!['runBranch', 'runBase', 'disablesWithoutReason', 'addedDisables'].every(
|
|
76
|
+
(f) => typeof a[f] === 'function',
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
fail(
|
|
80
|
+
'score.adapter',
|
|
81
|
+
'needs a string name, a string reportPath and the functions runBranch, runBase, disablesWithoutReason, addedDisables',
|
|
82
|
+
);
|
|
83
|
+
const b = score.baseInstall;
|
|
84
|
+
if (
|
|
85
|
+
!isObject(b) ||
|
|
86
|
+
!isStr(b.cmd) ||
|
|
87
|
+
!isStrList(b.args) ||
|
|
88
|
+
!isStr(b.label) ||
|
|
89
|
+
!isStr(b.hint) ||
|
|
90
|
+
!isOneSegment(b.marker)
|
|
91
|
+
)
|
|
92
|
+
fail(
|
|
93
|
+
'score.baseInstall',
|
|
94
|
+
'needs a string cmd, a string-array args, strings label and hint, and a marker that is one file name',
|
|
95
|
+
);
|
|
96
|
+
if (score.survivors !== undefined && !(isRel(score.survivors) && score.survivors !== '.'))
|
|
97
|
+
fail('score.survivors', 'must be a relative file path');
|
|
98
|
+
if (score.worktreeDir !== undefined && !isOneSegment(score.worktreeDir))
|
|
99
|
+
fail('score.worktreeDir', 'must be a single directory name');
|
|
100
|
+
for (const k of ['handoff', 'noTargets']) {
|
|
101
|
+
if (score[k] !== undefined && !isLine(score[k]))
|
|
102
|
+
fail(`score.${k}`, 'must be a non-empty single-line string');
|
|
103
|
+
}
|
|
104
|
+
const c = score.commands;
|
|
105
|
+
if (c !== undefined) {
|
|
106
|
+
if (!isObject(c)) fail('score.commands', 'must be { score: string }');
|
|
107
|
+
for (const k of Object.keys(c)) {
|
|
108
|
+
if (k !== 'score') fail(`score.commands.${k}`, 'unknown key (known: score)');
|
|
109
|
+
}
|
|
110
|
+
if (c.score !== undefined && !isLine(c.score))
|
|
111
|
+
fail('score.commands.score', 'must be a non-empty single-line command string');
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The effective score config: defaults filled in, given values kept. */
|
|
116
|
+
export const scoreOf = (score) => ({
|
|
117
|
+
...SCORE_DEFAULTS,
|
|
118
|
+
...score,
|
|
119
|
+
commands: { ...SCORE_DEFAULTS.commands, ...score.commands },
|
|
120
|
+
});
|