@polymorphism-tech/morph-spec 8.41.0 → 8.43.0
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/CLAUDE.md +14 -4
- package/README.md +3 -3
- package/bin/morph-spec.js +48 -2
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +6 -5
- package/framework/MORPH.md +10 -9
- package/framework/agents.json +7 -7
- package/framework/docs/CLI.md +10 -3
- package/framework/evals/proof-rubrics/mutation-proof.md +43 -13
- package/framework/hooks/README.md +1 -0
- package/framework/hooks/claude-code/core/chain.js +2 -0
- package/framework/hooks/claude-code/core/comment-guard.js +107 -0
- package/framework/hooks/claude-code/core/heavy-gate.js +72 -0
- package/framework/hooks/claude-code/core/heavy-release.js +34 -0
- package/framework/hooks/claude-code/core/worktree-context.js +21 -20
- package/framework/hooks/claude-code/core/worktree-create.js +4 -4
- package/framework/hooks/shared/agent-reap.js +89 -0
- package/framework/hooks/shared/comment-rules.js +171 -0
- package/{src/lib/validators/code-budget → framework/hooks/shared}/comment-scanner.js +2 -2
- package/{src/lib/ai → framework/hooks/shared}/comment-stripper.js +2 -1
- package/framework/hooks/shared/e2e-check.js +3 -1
- package/framework/hooks/shared/graph-refresh.js +1 -9
- package/framework/hooks/shared/heavy-command.js +128 -0
- package/framework/hooks/shared/heavy-config.js +65 -0
- package/framework/hooks/shared/heavy-holders.js +85 -0
- package/framework/hooks/shared/heavy-slots.js +207 -0
- package/framework/hooks/shared/process-alive.js +8 -0
- package/framework/hooks/shared/source-files.js +34 -0
- package/framework/hooks/shared/worktree-root.js +93 -0
- package/framework/schemas/config.schema.json +80 -0
- package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
- package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
- package/framework/skills/core/morph-apply/SKILL.md +11 -4
- package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
- package/framework/skills/core/morph-eval/SKILL.md +1 -1
- package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
- package/framework/skills/core/morph-implement/SKILL.md +12 -5
- package/framework/skills/core/morph-machine/SKILL.md +46 -0
- package/framework/skills/core/morph-media/SKILL.md +1 -1
- package/framework/skills/core/morph-plan/SKILL.md +1 -1
- package/framework/skills/core/morph-proposal/SKILL.md +1 -1
- package/framework/skills/core/morph-review/SKILL.md +45 -16
- package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/framework/skills/core/morph-uiux/SKILL.md +1 -1
- package/framework/standards/PROVENANCE.json +10 -10
- package/framework/standards/STANDARDS.json +1 -1
- package/framework/standards/architecture/code-comments.md +9 -2
- package/framework/standards/frontend/design-system/ai-video-generation.md +2 -0
- package/framework/standards/frontend/scroll-driven/frame-scrub.md +11 -1
- package/package.json +1 -1
- package/src/commands/e2e/e2e.js +27 -8
- package/src/commands/mcp/mcp-setup.js +10 -9
- package/src/commands/project/doctor-procs.js +162 -0
- package/src/commands/project/doctor.js +42 -4
- package/src/commands/project/heavy.js +61 -0
- package/src/commands/project/init.js +1 -1
- package/src/commands/project/update.js +4 -0
- package/src/commands/project/worktree-reap.js +34 -0
- package/src/commands/project/worktree.js +78 -27
- package/src/commands/validation/mutate.js +200 -0
- package/src/commands/validation/verify.js +26 -1
- package/src/dashboard/scanner/features-scanner.js +4 -4
- package/src/dashboard/scanner/project-api.js +4 -4
- package/src/lib/ai/code-audit.js +1 -1
- package/src/lib/ai/registry-audit.js +1 -1
- package/src/lib/detectors/claude-config-detector.js +30 -1
- package/src/lib/heavy/cleanup.js +70 -0
- package/src/lib/heavy/gate.js +96 -0
- package/src/lib/installers/mcp-installer.js +15 -2
- package/src/lib/mutation/adapters.js +90 -0
- package/src/lib/mutation/commands.js +60 -0
- package/src/lib/mutation/manifest.js +138 -0
- package/src/lib/mutation/plan.js +36 -0
- package/src/lib/mutation/report.js +66 -0
- package/src/lib/mutation/runner.js +351 -0
- package/src/lib/mutation/schemata.js +60 -0
- package/src/lib/mutation/snapshot.js +69 -0
- package/src/lib/mutation/subset.js +75 -0
- package/src/lib/mutation/trx.js +51 -0
- package/src/lib/procs/classify.js +91 -0
- package/src/lib/procs/list.js +124 -0
- package/src/lib/procs/mcp-config.js +103 -0
- package/src/lib/procs/prune.js +25 -0
- package/src/lib/procs/wslconfig.js +70 -0
- package/src/lib/projects/registry.js +6 -2
- package/src/lib/tasks/dotnet-test-introspection.js +1 -1
- package/src/lib/tasks/linux-runner.js +30 -3
- package/src/lib/tasks/test-runner.js +1 -1
- package/src/lib/validators/code-budget/code-budget-validator.js +4 -6
- package/src/lib/validators/code-budget/index.js +1 -1
- package/src/lib/validators/reuse/symbol-scanner.js +1 -28
- package/src/lib/validators/verify-runner.js +26 -8
- package/src/lib/worktree/agent-reaper.js +247 -0
- package/src/lib/worktree/dirs.js +3 -32
- package/src/lib/worktree/vscode-settings.js +113 -0
- package/src/lib/worktree/worktree-audit.js +49 -5
- package/src/lib/worktree/worktree-include.js +46 -0
- package/src/scripts/setup-infra.js +4 -0
- package/src/utils/hooks-installer.js +14 -6
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { basename, dirname, join, relative, resolve } from 'node:path';
|
|
3
|
+
import { applyRegions, diffOf } from './manifest.js';
|
|
4
|
+
import { blame, parseCompilerErrors, patchFile, switchClassName, switchSource, SWITCH_ENV } from './schemata.js';
|
|
5
|
+
import { md5Of, md5OfFile, openSnapshot, restoreSnapshot, trackGenerated } from './snapshot.js';
|
|
6
|
+
import { parseBuiltDlls } from './commands.js';
|
|
7
|
+
import { firstLines, methodOf } from './trx.js';
|
|
8
|
+
import { nearestProjectOf } from '../tasks/dotnet-test-introspection.js';
|
|
9
|
+
|
|
10
|
+
const posix = (p) => p.replace(/\\/g, '/');
|
|
11
|
+
const MAX_SCHEMATA_ATTEMPTS = 3;
|
|
12
|
+
const MAX_REPRODUCE = 5;
|
|
13
|
+
|
|
14
|
+
const envWithout = (env) => { const { [SWITCH_ENV]: _drop, ...rest } = env; return rest; };
|
|
15
|
+
const failedNames = (result) => new Set(result.failedTests.map((t) => t.name));
|
|
16
|
+
|
|
17
|
+
function newFailures(result, baseline) {
|
|
18
|
+
const known = baseline ? failedNames(baseline) : new Set();
|
|
19
|
+
return result.failedTests.filter((t) => !known.has(t.name));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function reasonOf(failures) {
|
|
23
|
+
const [first] = failures;
|
|
24
|
+
if (!first) return '';
|
|
25
|
+
const text = firstLines(first.message);
|
|
26
|
+
return text ? `${first.name}: ${text}` : first.name;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Judges one test result against the same scope's baseline: killed needs a
|
|
31
|
+
* failure the pristine run did not have; a run that executed nothing or died
|
|
32
|
+
* proves nothing either way.
|
|
33
|
+
*/
|
|
34
|
+
export function judge(result, baseline) {
|
|
35
|
+
if (result.outcome === 'crash') return { state: 'inconclusive', reason: result.note ?? 'a rodada não terminou' };
|
|
36
|
+
if (result.executed === 0) return { state: 'inconclusive', reason: 'nenhum teste foi executado neste escopo' };
|
|
37
|
+
if (result.outcome === 'red') {
|
|
38
|
+
const fresh = newFailures(result, baseline);
|
|
39
|
+
if (fresh.length > 0) return { state: 'killed', failures: fresh, reason: reasonOf(fresh) };
|
|
40
|
+
}
|
|
41
|
+
return { state: 'survived' };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const carriesSwitch = (dll) => readFileSync(dll).includes(Buffer.from(SWITCH_ENV, 'utf16le'));
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Equal bytes against the DLL built from the clean tree is the proof. A non-deterministic
|
|
48
|
+
* build can only show it was rebuilt AFTER the restore and carries no switch.
|
|
49
|
+
*/
|
|
50
|
+
export function proveBinary({ pristine, dlls, restoredAt, hashes }) {
|
|
51
|
+
const projects = [...pristine].map(([name, md5]) => {
|
|
52
|
+
const dll = dlls.get(name);
|
|
53
|
+
const final = hashes.get(name) ?? null;
|
|
54
|
+
const rebuilt = Boolean(dll) && statSync(dll).mtimeMs >= restoredAt - 50;
|
|
55
|
+
return { name, pristine: md5, final, equal: final === md5, rebuilt, switchLeft: Boolean(dll) && carriesSwitch(dll) };
|
|
56
|
+
});
|
|
57
|
+
if (projects.length === 0) return { proof: 'unlocated', projects };
|
|
58
|
+
if (projects.some((p) => p.switchLeft)) return { proof: 'mismatch', projects };
|
|
59
|
+
if (projects.every((p) => p.equal)) return { proof: 'md5', projects };
|
|
60
|
+
return { proof: projects.every((p) => p.rebuilt) ? 'rebuilt' : 'mismatch', projects };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function pool(items, size, worker) {
|
|
64
|
+
const queue = [...items];
|
|
65
|
+
await Promise.all(Array.from({ length: Math.min(size, queue.length) }, async () => {
|
|
66
|
+
while (queue.length > 0) await worker(queue.shift());
|
|
67
|
+
}));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function mergeBaselines(copies) {
|
|
71
|
+
const [first] = copies;
|
|
72
|
+
const broken = copies.find((c) => c.outcome === 'crash');
|
|
73
|
+
if (broken) return broken;
|
|
74
|
+
const seen = new Map();
|
|
75
|
+
for (const c of copies) for (const t of c.failedTests) if (!seen.has(t.name)) seen.set(t.name, t);
|
|
76
|
+
const failedTests = [...seen.values()];
|
|
77
|
+
return { ...first, outcome: failedTests.length > 0 ? 'red' : 'green', failed: failedTests.length, failedTests };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function runMutation(input) {
|
|
81
|
+
const { root, node, regions, workDir, runId, exec, adapter, gate, git, log = () => {}, options = {} } = input;
|
|
82
|
+
const timeoutMs = options.timeoutMs ?? node.timeoutMs ?? 900_000;
|
|
83
|
+
const startedAt = Date.now();
|
|
84
|
+
let workerCount = 1;
|
|
85
|
+
const timings = [];
|
|
86
|
+
const timed = async (label, fn) => {
|
|
87
|
+
const at = Date.now();
|
|
88
|
+
try { return await fn(); } finally { timings.push({ label, ms: Date.now() - at }); }
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const files = [...new Set(regions.map((r) => r.file))];
|
|
92
|
+
const originals = new Map(files.map((f) => [f, readFileSync(join(root, f), 'utf8')]));
|
|
93
|
+
const porcelainBefore = git.porcelain();
|
|
94
|
+
const snapshot = openSnapshot({ dir: join(workDir, 'snapshot'), root, files, runId });
|
|
95
|
+
const projectOf = (file) => {
|
|
96
|
+
const csproj = nearestProjectOf(join(root, file), root);
|
|
97
|
+
return csproj ? { rel: posix(relative(root, csproj)), dir: dirname(csproj), name: basename(csproj).replace(/\.[a-z]+proj$/i, '') } : null;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const results = new Map(regions.map((r) => [r.id, {
|
|
101
|
+
id: r.id, file: r.file, line: r.line, diff: diffOf(r), note: r.note,
|
|
102
|
+
md5Before: md5Of(readFileSync(join(root, r.file))),
|
|
103
|
+
md5After: md5Of(applyRegions(originals.get(r.file), [r], (x) => x.replaceText).text),
|
|
104
|
+
mode: r.mode === 'rebuild' || adapter.kind !== 'dotnet' || !/\.cs$/i.test(r.file) ? 'rebuild' : 'schemata',
|
|
105
|
+
demoted: null, verdict: null, scope: null, killers: 0, executed: null, reason: '', durationMs: 0,
|
|
106
|
+
}]));
|
|
107
|
+
const byId = new Map(regions.map((r) => [r.id, r]));
|
|
108
|
+
|
|
109
|
+
const build = async (label) => {
|
|
110
|
+
const dlls = new Map();
|
|
111
|
+
for (const command of adapter.buildCommands) {
|
|
112
|
+
const r = await timed(label, () => exec(command, { cwd: adapter.cwd, env: process.env, timeoutMs, label }));
|
|
113
|
+
const output = `${r.stdout}\n${r.stderr}`;
|
|
114
|
+
if (r.status !== 0) return { ok: false, output };
|
|
115
|
+
for (const [name, dll] of parseBuiltDlls(output)) dlls.set(name, dll);
|
|
116
|
+
}
|
|
117
|
+
return { ok: true, dlls };
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const dllHashes = (dlls) => new Map(
|
|
121
|
+
[...new Set(files.map((f) => projectOf(f)?.name).filter(Boolean))]
|
|
122
|
+
.filter((name) => dlls.has(name) && existsSync(dlls.get(name)))
|
|
123
|
+
.map((name) => [name, md5OfFile(dlls.get(name))]),
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
const run = (scope, label, env) => timed(label, () => adapter.runTests({ scope, label, env }));
|
|
127
|
+
|
|
128
|
+
const confirmKill = async (id, verdict, env) => {
|
|
129
|
+
if (!adapter.reproduce) return verdict;
|
|
130
|
+
const names = [...new Set(verdict.failures.map((f) => f.name))].slice(0, MAX_REPRODUCE);
|
|
131
|
+
const again = await timed(`${id}-reproduce`, () => adapter.reproduce({ names, label: `${id}-reproduce`, env }));
|
|
132
|
+
if (again.outcome === 'crash' || !again.executed) {
|
|
133
|
+
return { state: 'inconclusive', reason: `a falha de ${names[0]} não pôde ser confirmada na repetição (${again.note ?? 'nenhum teste executado'})` };
|
|
134
|
+
}
|
|
135
|
+
const wanted = new Set(names.map(methodOf));
|
|
136
|
+
const held = again.failedTests.filter((t) => wanted.has(methodOf(t.name)));
|
|
137
|
+
if (held.length > 0) return { ...verdict, reason: reasonOf(held) };
|
|
138
|
+
const whole = await timed(`${id}-reproduce-full`, () => adapter.runTests({ scope: 'full', label: `${id}-reproduce-full`, env }));
|
|
139
|
+
if (whole.outcome === 'crash' || !whole.executed) {
|
|
140
|
+
return { state: 'inconclusive', reason: `a falha de ${names[0]} não pôde ser confirmada na repetição (${whole.note ?? 'nenhum teste executado'})` };
|
|
141
|
+
}
|
|
142
|
+
const heldWhole = whole.failedTests.filter((t) => wanted.has(methodOf(t.name)));
|
|
143
|
+
if (heldWhole.length > 0) return { ...verdict, reason: reasonOf(heldWhole) };
|
|
144
|
+
const which = names.length === 1 ? names[0] : `${names.length} testes`;
|
|
145
|
+
return { state: 'survived', reason: `nenhum teste falhou de forma reproduzível: ${which} falhou uma vez e passou isolado e na suíte inteira repetida (instável, não prova o mutante)` };
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const record = (id, patch) => Object.assign(results.get(id), patch);
|
|
149
|
+
const applyOnDisk = (id) => {
|
|
150
|
+
const region = byId.get(id);
|
|
151
|
+
writeFileSync(join(root, region.file), applyRegions(originals.get(region.file), [region], (x) => x.replaceText).text);
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
let pristine = new Map();
|
|
155
|
+
let binary = { proof: 'unavailable', projects: [] };
|
|
156
|
+
let baselineSubset = null;
|
|
157
|
+
let baselineFull = null;
|
|
158
|
+
let subsetLive = adapter.hasSubset;
|
|
159
|
+
let subsetNote = null;
|
|
160
|
+
const needFullBaseline = [];
|
|
161
|
+
let restoreReport = [];
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
gate?.enter('mutate');
|
|
165
|
+
|
|
166
|
+
if (adapter.buildCommands.length > 0) {
|
|
167
|
+
restoreSnapshot(snapshot);
|
|
168
|
+
const first = await build('build-pristine');
|
|
169
|
+
if (!first.ok) throw Object.assign(new Error('a árvore não compila ANTES da mutação — nada foi mutado'), { code: 'baseline-build-failed', output: first.output });
|
|
170
|
+
pristine = dllHashes(first.dlls);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const compileErrorOf = (output, fallback) => firstLines(parseCompilerErrors(output).map((e) => `${e.code} ${e.message}`)[0] ?? fallback);
|
|
174
|
+
|
|
175
|
+
const screenLiteral = async (ids) => {
|
|
176
|
+
let remaining = ids;
|
|
177
|
+
for (let attempt = 0; attempt < MAX_SCHEMATA_ATTEMPTS && remaining.length > 0; attempt++) {
|
|
178
|
+
const patched = [];
|
|
179
|
+
for (const file of files) {
|
|
180
|
+
const own = remaining.map((id) => byId.get(id)).filter((r) => r.file === file);
|
|
181
|
+
if (own.length === 0) continue;
|
|
182
|
+
const { text, spans } = applyRegions(originals.get(file), own, (r) => r.replaceText);
|
|
183
|
+
writeFileSync(join(root, file), text);
|
|
184
|
+
patched.push({ abs: join(root, file), file, spans });
|
|
185
|
+
}
|
|
186
|
+
const b = await build('build-literal');
|
|
187
|
+
restoreSnapshot(snapshot);
|
|
188
|
+
if (b.ok) return remaining;
|
|
189
|
+
const { blamed, unmapped } = blame(parseCompilerErrors(b.output), patched);
|
|
190
|
+
const demote = unmapped.length > 0 || blamed.length === 0 ? [...remaining] : blamed;
|
|
191
|
+
const note = `a forma literal não compila (${compileErrorOf(b.output, 'erro de compilação')})`;
|
|
192
|
+
for (const id of demote) record(id, { mode: 'rebuild', demoted: note });
|
|
193
|
+
remaining = remaining.filter((id) => !demote.includes(id));
|
|
194
|
+
}
|
|
195
|
+
for (const id of remaining) record(id, { mode: 'rebuild', demoted: 'a triagem da forma literal não convergiu' });
|
|
196
|
+
return [];
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
let schemataIds = [...results.values()].filter((r) => r.mode === 'schemata').map((r) => r.id);
|
|
200
|
+
if (adapter.buildCommands.length > 0) schemataIds = await screenLiteral(schemataIds);
|
|
201
|
+
let schemataBuilt = false;
|
|
202
|
+
for (let attempt = 0; attempt < MAX_SCHEMATA_ATTEMPTS && schemataIds.length > 0; attempt++) {
|
|
203
|
+
const patched = [];
|
|
204
|
+
const classes = new Map();
|
|
205
|
+
for (const file of files) {
|
|
206
|
+
const own = schemataIds.map((id) => byId.get(id)).filter((r) => r.file === file);
|
|
207
|
+
if (own.length === 0) continue;
|
|
208
|
+
const project = projectOf(file);
|
|
209
|
+
const className = switchClassName(project.rel);
|
|
210
|
+
const { text, spans } = patchFile(originals.get(file), own, className);
|
|
211
|
+
writeFileSync(join(root, file), text);
|
|
212
|
+
patched.push({ abs: join(root, file), file, spans });
|
|
213
|
+
if (!classes.has(project.rel)) {
|
|
214
|
+
const generated = posix(relative(root, join(project.dir, `${className}.g.cs`)));
|
|
215
|
+
writeFileSync(join(root, generated), switchSource(className));
|
|
216
|
+
trackGenerated(snapshot, generated);
|
|
217
|
+
classes.set(project.rel, generated);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
const b = await build('build-schemata');
|
|
221
|
+
if (b.ok) { schemataBuilt = true; break; }
|
|
222
|
+
const { blamed, unmapped } = blame(parseCompilerErrors(b.output), patched);
|
|
223
|
+
restoreSnapshot(snapshot);
|
|
224
|
+
const note = `schemata recusou — ${compileErrorOf(b.output, 'o build de schemata falhou')}`;
|
|
225
|
+
const demote = unmapped.length > 0 || blamed.length === 0 ? [...schemataIds] : blamed;
|
|
226
|
+
for (const id of demote) record(id, { mode: 'rebuild', demoted: note });
|
|
227
|
+
schemataIds = schemataIds.filter((id) => !demote.includes(id));
|
|
228
|
+
}
|
|
229
|
+
if (!schemataBuilt) {
|
|
230
|
+
for (const id of schemataIds) record(id, { mode: 'rebuild', demoted: 'schemata recusou — o build não convergiu' });
|
|
231
|
+
schemataIds = [];
|
|
232
|
+
} else {
|
|
233
|
+
restoreSnapshot(snapshot);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const hasRebuild = [...results.values()].some((r) => r.mode === 'rebuild');
|
|
237
|
+
const jobs = Math.max(1, Math.min(options.jobs ?? 1, gate?.jobs ?? options.jobs ?? 1));
|
|
238
|
+
if (subsetLive && (schemataIds.length > 0 || hasRebuild)) {
|
|
239
|
+
const copies = schemataIds.length > 0 ? jobs : 1;
|
|
240
|
+
baselineSubset = mergeBaselines(await Promise.all(Array.from({ length: copies }, (_, i) => (
|
|
241
|
+
run('subset', i === 0 ? 'baseline-subset' : `baseline-subset-${i + 1}`, envWithout(process.env))))));
|
|
242
|
+
if (baselineSubset.outcome === 'crash' || baselineSubset.executed === 0) {
|
|
243
|
+
subsetLive = false;
|
|
244
|
+
subsetNote = baselineSubset.executed === 0 ? 'o filtro do subconjunto não selecionou teste algum' : (baselineSubset.note ?? 'a rodada-base do subconjunto não terminou');
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
if (!subsetLive && schemataIds.length > 0) baselineFull = await run('full', 'baseline-full', envWithout(process.env));
|
|
248
|
+
|
|
249
|
+
const scopeOf = subsetLive ? 'subset' : 'full';
|
|
250
|
+
const scopeBaseline = () => (subsetLive ? baselineSubset : baselineFull);
|
|
251
|
+
const routed = [];
|
|
252
|
+
const scopeJobs = subsetLive ? jobs : 1;
|
|
253
|
+
workerCount = schemataIds.length > 0 ? scopeJobs : 1;
|
|
254
|
+
const outcomes = new Map();
|
|
255
|
+
await pool(schemataIds, scopeJobs, async (id) => {
|
|
256
|
+
outcomes.set(id, await run(scopeOf, `${id}-${scopeOf}`, { ...process.env, [SWITCH_ENV]: id }));
|
|
257
|
+
});
|
|
258
|
+
for (const id of schemataIds) {
|
|
259
|
+
const result = outcomes.get(id);
|
|
260
|
+
const first = judge(result, scopeBaseline());
|
|
261
|
+
const verdict = first.state === 'killed' && scopeOf === 'full' ? await confirmKill(id, first, { ...process.env, [SWITCH_ENV]: id }) : first;
|
|
262
|
+
const base = { scope: scopeOf, executed: result.executed, durationMs: result.durationMs };
|
|
263
|
+
if (verdict.state === 'killed') {
|
|
264
|
+
record(id, { ...base, verdict: 'killed', killers: verdict.failures.length, reason: verdict.reason });
|
|
265
|
+
} else {
|
|
266
|
+
record(id, { ...base, verdict: null, reason: verdict.reason ?? '' });
|
|
267
|
+
routed.push({ id, needs: 'full' });
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
for (const r of results.values()) if (r.mode === 'rebuild') routed.push({ id: r.id, needs: subsetLive ? 'subset-then-full' : 'full' });
|
|
271
|
+
|
|
272
|
+
for (const { id, needs } of routed) {
|
|
273
|
+
applyOnDisk(id);
|
|
274
|
+
try {
|
|
275
|
+
if (adapter.buildCommands.length > 0) {
|
|
276
|
+
const b = await build(`build-${id}`);
|
|
277
|
+
if (!b.ok) {
|
|
278
|
+
const err = parseCompilerErrors(b.output)[0];
|
|
279
|
+
record(id, { verdict: 'build-error', reason: err ? `${err.code} ${err.message}` : 'o mutante não compila', scope: null });
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
if (needs === 'subset-then-full') {
|
|
284
|
+
const sub = await run('subset', `${id}-subset`, envWithout(process.env));
|
|
285
|
+
const verdict = judge(sub, baselineSubset);
|
|
286
|
+
if (verdict.state === 'killed') {
|
|
287
|
+
record(id, { verdict: 'killed', scope: 'subset', killers: verdict.failures.length, executed: sub.executed, reason: verdict.reason, durationMs: sub.durationMs });
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
const full = await run('full', `${id}-full`, envWithout(process.env));
|
|
292
|
+
const first = judge(full, baselineFull);
|
|
293
|
+
const verdict = first.state === 'killed' ? await confirmKill(id, first, envWithout(process.env)) : first;
|
|
294
|
+
const base = { scope: 'full', executed: full.executed, durationMs: full.durationMs };
|
|
295
|
+
if (verdict.state === 'inconclusive') record(id, { ...base, verdict: 'inconclusive', reason: verdict.reason });
|
|
296
|
+
else if (verdict.state === 'survived') record(id, { ...base, verdict: 'survived', reason: verdict.reason ?? '' });
|
|
297
|
+
else { record(id, { ...base, verdict: 'killed', killers: verdict.failures.length, reason: verdict.reason }); if (!baselineFull) needFullBaseline.push({ id, full }); }
|
|
298
|
+
} finally {
|
|
299
|
+
restoreSnapshot(snapshot);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const restoredAt = Date.now();
|
|
304
|
+
restoreReport = restoreSnapshot(snapshot);
|
|
305
|
+
if (adapter.buildCommands.length > 0) {
|
|
306
|
+
const last = await build('build-final');
|
|
307
|
+
if (!last.ok) throw Object.assign(new Error('o rebuild final da árvore restaurada falhou'), { code: 'final-build-failed', output: last.output });
|
|
308
|
+
binary = proveBinary({ pristine, dlls: last.dlls, restoredAt, hashes: dllHashes(last.dlls) });
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (needFullBaseline.length > 0) {
|
|
312
|
+
baselineFull = await run('full', 'baseline-full', envWithout(process.env));
|
|
313
|
+
for (const { id, full } of needFullBaseline) {
|
|
314
|
+
const verdict = judge(full, baselineFull);
|
|
315
|
+
if (verdict.state === 'killed') record(id, { killers: verdict.failures.length, reason: verdict.reason });
|
|
316
|
+
else record(id, { verdict: 'survived', killers: 0, reason: '' });
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
} finally {
|
|
320
|
+
restoreReport = restoreSnapshot(snapshot, { rewrite: false });
|
|
321
|
+
gate?.release();
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const porcelainAfter = git.porcelain();
|
|
325
|
+
const mutants = regions.map((r) => results.get(r.id));
|
|
326
|
+
return {
|
|
327
|
+
runId,
|
|
328
|
+
method: {
|
|
329
|
+
adapter: adapter.kind,
|
|
330
|
+
schemata: mutants.filter((m) => m.mode === 'schemata').length,
|
|
331
|
+
rebuild: mutants.filter((m) => m.mode === 'rebuild').length,
|
|
332
|
+
subset: { requested: adapter.hasSubset, live: subsetLive, note: subsetNote },
|
|
333
|
+
jobs: workerCount,
|
|
334
|
+
},
|
|
335
|
+
wallMs: Date.now() - startedAt,
|
|
336
|
+
mutants,
|
|
337
|
+
baseline: {
|
|
338
|
+
subset: baselineSubset ? { executed: baselineSubset.executed, failed: baselineSubset.failed } : null,
|
|
339
|
+
full: baselineFull ? { executed: baselineFull.executed, failed: baselineFull.failed } : null,
|
|
340
|
+
},
|
|
341
|
+
binary,
|
|
342
|
+
restore: {
|
|
343
|
+
files: restoreReport,
|
|
344
|
+
ok: restoreReport.every((f) => f.ok),
|
|
345
|
+
porcelainBefore,
|
|
346
|
+
porcelainAfter,
|
|
347
|
+
treeClean: porcelainBefore === porcelainAfter,
|
|
348
|
+
},
|
|
349
|
+
timings,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { applyRegions } from './manifest.js';
|
|
3
|
+
|
|
4
|
+
export const SWITCH_ENV = 'MORPH_MUTANT';
|
|
5
|
+
|
|
6
|
+
export function switchClassName(csprojRel) {
|
|
7
|
+
return `MorphMutationSwitch_${createHash('md5').update(csprojRel).digest('hex').slice(0, 6)}`;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function switchSource(className) {
|
|
11
|
+
return [
|
|
12
|
+
'// <auto-generated/>',
|
|
13
|
+
'#nullable enable',
|
|
14
|
+
'#pragma warning disable',
|
|
15
|
+
`internal static class ${className}`,
|
|
16
|
+
'{',
|
|
17
|
+
` private static readonly string? Active = System.Environment.GetEnvironmentVariable("${SWITCH_ENV}");`,
|
|
18
|
+
' internal static bool On(string id) => Active == id;',
|
|
19
|
+
'}',
|
|
20
|
+
'',
|
|
21
|
+
].join('\n');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const wrap = (className, region) =>
|
|
25
|
+
`(${className}.On("${region.id}") ? (${region.replaceText}) : (${region.findText}))`;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A C# file with every region behind its switch; the original text stays as the else branch.
|
|
29
|
+
* Each patched span comes back so a compiler error can be pinned on its mutant.
|
|
30
|
+
*/
|
|
31
|
+
export function patchFile(text, regions, className) {
|
|
32
|
+
return applyRegions(text, regions, (region) => wrap(className, region));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const MSBUILD_ERROR = /^(?<file>.+?)\((?<line>\d+),(?<col>\d+)\): error (?<code>[A-Z]+\d+): (?<msg>.*?)(?: \[.*\])?\s*$/gm;
|
|
36
|
+
|
|
37
|
+
export function parseCompilerErrors(output) {
|
|
38
|
+
return [...String(output).matchAll(MSBUILD_ERROR)].map((m) => ({
|
|
39
|
+
file: m.groups.file.trim(), line: Number(m.groups.line), code: m.groups.code, message: m.groups.msg.trim(),
|
|
40
|
+
}));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* An error inside a patched span names its mutant; one elsewhere in a patched file
|
|
45
|
+
* (an `out` variable the switch left unassigned) names every mutant of that file.
|
|
46
|
+
*/
|
|
47
|
+
export function blame(errors, patched) {
|
|
48
|
+
const blamed = new Set();
|
|
49
|
+
const unmapped = [];
|
|
50
|
+
for (const err of errors) {
|
|
51
|
+
const file = patched.find((p) => samePath(p.abs, err.file));
|
|
52
|
+
if (!file) { unmapped.push(err); continue; }
|
|
53
|
+
const inSpan = [...file.spans].find(([, s]) => err.line >= s.from && err.line <= s.to);
|
|
54
|
+
if (inSpan) blamed.add(inSpan[0]);
|
|
55
|
+
else for (const id of file.spans.keys()) blamed.add(id);
|
|
56
|
+
}
|
|
57
|
+
return { blamed: [...blamed], unmapped };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const samePath = (a, b) => a.replace(/\\/g, '/').toLowerCase() === b.replace(/\\/g, '/').toLowerCase();
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join, dirname } from 'node:path';
|
|
4
|
+
|
|
5
|
+
export const md5Of = (bytes) => createHash('md5').update(bytes).digest('hex');
|
|
6
|
+
export const md5OfFile = (abs) => md5Of(readFileSync(abs));
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Copies of the original bytes, kept outside the working tree, plus a journal
|
|
10
|
+
* that survives the process: a run killed halfway is undone by the next
|
|
11
|
+
* `--restore` from the journal alone.
|
|
12
|
+
*/
|
|
13
|
+
export function openSnapshot({ dir, root, files, runId }) {
|
|
14
|
+
mkdirSync(dir, { recursive: true });
|
|
15
|
+
const entries = files.map((file, index) => {
|
|
16
|
+
const abs = join(root, file);
|
|
17
|
+
const bytes = readFileSync(abs);
|
|
18
|
+
const backup = join(dir, `${index}.bak`);
|
|
19
|
+
writeFileSync(backup, bytes);
|
|
20
|
+
return { file, md5: md5Of(bytes), backup, created: false };
|
|
21
|
+
});
|
|
22
|
+
const snapshot = { runId, root, dir, entries, generated: [], state: 'open' };
|
|
23
|
+
writeJournal(snapshot);
|
|
24
|
+
return snapshot;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function trackGenerated(snapshot, file) {
|
|
28
|
+
if (!snapshot.generated.includes(file)) snapshot.generated.push(file);
|
|
29
|
+
writeJournal(snapshot);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function writeJournal(snapshot) {
|
|
33
|
+
writeFileSync(join(snapshot.dir, 'journal.json'), JSON.stringify(snapshot, null, 2));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function readJournal(dir) {
|
|
37
|
+
const path = join(dir, 'journal.json');
|
|
38
|
+
return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Writes the original bytes back — a fresh write, so the file is NEWER than
|
|
43
|
+
* any binary built from a mutant. Restoring the old mtime would let the build
|
|
44
|
+
* consider the mutant binary up to date and never recompile the source.
|
|
45
|
+
*/
|
|
46
|
+
export function restoreSnapshot(snapshot, { rewrite = true } = {}) {
|
|
47
|
+
const report = [];
|
|
48
|
+
for (const entry of snapshot.entries) {
|
|
49
|
+
const abs = join(snapshot.root, entry.file);
|
|
50
|
+
const before = existsSync(abs) ? md5OfFile(abs) : null;
|
|
51
|
+
if (rewrite || before !== entry.md5) {
|
|
52
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
53
|
+
writeFileSync(abs, readFileSync(entry.backup));
|
|
54
|
+
}
|
|
55
|
+
const after = md5OfFile(abs);
|
|
56
|
+
report.push({ file: entry.file, md5Original: entry.md5, md5Before: before, md5After: after, ok: after === entry.md5 });
|
|
57
|
+
}
|
|
58
|
+
for (const file of snapshot.generated) {
|
|
59
|
+
const abs = join(snapshot.root, file);
|
|
60
|
+
if (existsSync(abs)) unlinkSync(abs);
|
|
61
|
+
}
|
|
62
|
+
snapshot.state = report.every((r) => r.ok) ? 'restored' : 'restore-failed';
|
|
63
|
+
writeJournal(snapshot);
|
|
64
|
+
return report;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function discardSnapshot(snapshot) {
|
|
68
|
+
rmSync(snapshot.dir, { recursive: true, force: true });
|
|
69
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
3
|
+
import { csharpClasses } from '../tasks/dotnet-test-introspection.js';
|
|
4
|
+
import { relatedCommand } from './commands.js';
|
|
5
|
+
|
|
6
|
+
const SKIP_DIRS = new Set(['bin', 'obj', 'node_modules', '.git', 'worktrees', 'TestResults']);
|
|
7
|
+
const SAFE_CLASS = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
8
|
+
const MIN_TYPE_LENGTH = 4;
|
|
9
|
+
|
|
10
|
+
const posix = (p) => p.replace(/\\/g, '/');
|
|
11
|
+
|
|
12
|
+
function walkCs(dir, out = []) {
|
|
13
|
+
let entries = [];
|
|
14
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return out; }
|
|
15
|
+
for (const e of entries) {
|
|
16
|
+
if (e.isDirectory()) {
|
|
17
|
+
if (!SKIP_DIRS.has(e.name)) walkCs(join(dir, e.name), out);
|
|
18
|
+
} else if (e.name.endsWith('.cs')) {
|
|
19
|
+
out.push(join(dir, e.name));
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The tests to run FIRST, chosen by the tool: files the feature changed, and files naming a
|
|
29
|
+
* type the mutated files declare. A mutant the subset does not kill goes to the whole suite.
|
|
30
|
+
*/
|
|
31
|
+
export function dotnetSubset({ root, testProjectDirs, mutatedFiles, changed = [] }) {
|
|
32
|
+
const changedSet = new Set(changed.map(posix));
|
|
33
|
+
const mutatedTypes = new Set();
|
|
34
|
+
for (const file of mutatedFiles) {
|
|
35
|
+
for (const t of csharpClasses(resolve(root, file)).types) if (t.length >= MIN_TYPE_LENGTH) mutatedTypes.add(t);
|
|
36
|
+
}
|
|
37
|
+
const mention = mutatedTypes.size > 0
|
|
38
|
+
? new RegExp(`\\b(?:${[...mutatedTypes].map(escapeRe).join('|')})\\b`) : null;
|
|
39
|
+
|
|
40
|
+
const byChange = new Set();
|
|
41
|
+
const byMention = new Set();
|
|
42
|
+
const classes = new Set();
|
|
43
|
+
for (const dir of testProjectDirs) {
|
|
44
|
+
for (const abs of walkCs(resolve(root, dir))) {
|
|
45
|
+
const info = csharpClasses(abs);
|
|
46
|
+
if (!info.scannable || !info.hasTestAttribute || info.names.length === 0) continue;
|
|
47
|
+
const rel = posix(relative(root, abs));
|
|
48
|
+
const viaChange = changedSet.has(rel);
|
|
49
|
+
const viaMention = !viaChange && mention !== null && mention.test(readFileSync(abs, 'utf8'));
|
|
50
|
+
if (!viaChange && !viaMention) continue;
|
|
51
|
+
(viaChange ? byChange : byMention).add(rel);
|
|
52
|
+
for (const name of info.names) classes.add(name);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const unsafe = [...classes].find((c) => !SAFE_CLASS.test(c));
|
|
56
|
+
if (unsafe) return { usable: false, reason: `nome de classe "${unsafe}" não é seguro num filtro` };
|
|
57
|
+
if (classes.size === 0) return { usable: false, reason: 'nenhum arquivo de teste alterado pela feature nem citando os tipos mutados' };
|
|
58
|
+
return {
|
|
59
|
+
usable: true,
|
|
60
|
+
classes: [...classes].sort(),
|
|
61
|
+
filter: [...classes].sort().map((c) => `FullyQualifiedName~${c}`).join('|'),
|
|
62
|
+
files: { changed: [...byChange].sort(), mentions: [...byMention].sort() },
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function jsSubset({ runner, command, root, mutatedFiles }) {
|
|
67
|
+
const files = mutatedFiles.filter((f) => existsSync(resolve(root, f)) && statSync(resolve(root, f)).isFile());
|
|
68
|
+
const cmd = relatedCommand(runner, command, files);
|
|
69
|
+
if (cmd === null) return { usable: false, reason: `runner "${runner}" sem seleção por arquivo relacionado neste formato de comando` };
|
|
70
|
+
return { usable: true, command: cmd, files: { related: files } };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function testProjectDirsOf(root, csprojs) {
|
|
74
|
+
return [...new Set(csprojs.map((c) => posix(relative(root, dirname(resolve(root, c))))))];
|
|
75
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
const ENTITIES = { '<': '<', '>': '>', '&': '&', '"': '"', ''': "'" };
|
|
2
|
+
|
|
3
|
+
export function decodeXml(text) {
|
|
4
|
+
return text
|
|
5
|
+
.replace(/&(?:lt|gt|amp|quot|apos);/g, (m) => ENTITIES[m])
|
|
6
|
+
.replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCodePoint(parseInt(hex, 16)))
|
|
7
|
+
.replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(Number(dec)));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const attr = (attrs, name) => new RegExp(`\\b${name}="([^"]*)"`).exec(attrs)?.[1] ?? null;
|
|
11
|
+
const count = (attrs, name) => Number(attr(attrs, name) ?? 0);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* What a VSTest TRX says about a run: the counters, and for every failed test
|
|
15
|
+
* its name and the FIRST message line — the literal reason a mutation report
|
|
16
|
+
* quotes. Linear over the text: a full suite writes several MB.
|
|
17
|
+
*/
|
|
18
|
+
export function parseTrx(xml) {
|
|
19
|
+
const counters = /<Counters\b([^>]*)\/?>/.exec(xml)?.[1] ?? null;
|
|
20
|
+
const summary = /<ResultSummary\b([^>]*)>/.exec(xml)?.[1] ?? '';
|
|
21
|
+
const failedTests = [];
|
|
22
|
+
const tag = /<UnitTestResult\b([^>]*?)(\/?)>/g;
|
|
23
|
+
for (let m = tag.exec(xml); m; m = tag.exec(xml)) {
|
|
24
|
+
if (attr(m[1], 'outcome') !== 'Failed') continue;
|
|
25
|
+
let message = '';
|
|
26
|
+
if (m[2] !== '/') {
|
|
27
|
+
const close = xml.indexOf('</UnitTestResult>', tag.lastIndex);
|
|
28
|
+
const body = xml.slice(tag.lastIndex, close === -1 ? undefined : close);
|
|
29
|
+
const raw = /<Message>([\s\S]*?)<\/Message>/.exec(body)?.[1] ?? '';
|
|
30
|
+
message = decodeXml(raw.replace(/^<!\[CDATA\[|\]\]>$/g, '')).trim();
|
|
31
|
+
}
|
|
32
|
+
failedTests.push({ name: decodeXml(attr(m[1], 'testName') ?? '?'), message });
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
parsed: counters !== null,
|
|
36
|
+
total: counters ? count(counters, 'total') : 0,
|
|
37
|
+
executed: counters ? count(counters, 'executed') : 0,
|
|
38
|
+
passed: counters ? count(counters, 'passed') : 0,
|
|
39
|
+
failed: counters ? count(counters, 'failed') : failedTests.length,
|
|
40
|
+
aborted: counters ? count(counters, 'aborted') + count(counters, 'timeout') : 0,
|
|
41
|
+
runOutcome: attr(summary, 'outcome'),
|
|
42
|
+
failedTests,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const methodOf = (testName) => String(testName).replace(/\s*\(.*$/s, '').trim();
|
|
47
|
+
|
|
48
|
+
export function firstLines(message, max = 2, width = 240) {
|
|
49
|
+
const lines = String(message).split(/\r?\n/).map((l) => l.trim()).filter(Boolean).slice(0, max).join(' ⏎ ');
|
|
50
|
+
return lines.length > width ? `${lines.slice(0, width)}…` : lines;
|
|
51
|
+
}
|