@polymorphism-tech/morph-spec 8.42.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 +9 -1
- package/README.md +2 -2
- 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 +5 -4
- package/framework/MORPH.md +10 -9
- package/framework/agents.json +1 -1
- package/framework/docs/CLI.md +10 -3
- package/framework/evals/proof-rubrics/mutation-proof.md +43 -13
- 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/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/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 +8 -3
- 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 +9 -4
- 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/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/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/linux-runner.js +30 -3
- package/src/lib/tasks/test-runner.js +1 -1
- 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 +11 -4
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { dotnetBuildCommandFor, resolveTestBuildTargets } from '../tasks/test-runner.js';
|
|
2
|
+
|
|
3
|
+
const FILTER = /--filter(?:[ =:])(?:"([^"]*)"|'([^']*)'|(\S+))/;
|
|
4
|
+
const quote = (s) => `"${String(s).replace(/"/g, '\\"')}"`;
|
|
5
|
+
|
|
6
|
+
export function declaredFilter(command) {
|
|
7
|
+
const m = FILTER.exec(command);
|
|
8
|
+
return m ? (m[1] ?? m[2] ?? m[3]) : null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function withFilter(command, expression) {
|
|
12
|
+
const declared = declaredFilter(command);
|
|
13
|
+
if (declared === null) return `${command} --filter ${quote(expression)}`;
|
|
14
|
+
return command.replace(FILTER, () => `--filter ${quote(`(${declared})&(${expression})`)}`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function withTrx(command, { dir, name }) {
|
|
18
|
+
return `${command} --results-directory ${quote(dir)} --logger ${quote(`trx;LogFileName=${name}`)}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function withoutBuild(command) {
|
|
22
|
+
return /(?:^|\s)--no-build(?:\s|$)/.test(command) ? command : `${command} --no-build`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const CONFIGURATION = /(?:^|\s)(?:-c|--configuration)[ =:]?(?:"([^"]*)"|(\S+))/;
|
|
26
|
+
const VERBOSITY = /\s(?:-v|--verbosity)[ =:]?\S+/g;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The verify's build command per target. The test command's `-c` follows it, and
|
|
30
|
+
* verbosity is minimal: at `quiet` the `Project -> dll` line the binary proof reads is gone.
|
|
31
|
+
*/
|
|
32
|
+
export function dotnetBuildCommands(node, projectPath) {
|
|
33
|
+
const configuration = CONFIGURATION.exec(node.command ?? '');
|
|
34
|
+
const flag = configuration ? ` -c ${configuration[1] ?? configuration[2]}` : '';
|
|
35
|
+
return resolveTestBuildTargets(node)
|
|
36
|
+
.map((target) => dotnetBuildCommandFor(target, projectPath))
|
|
37
|
+
.filter(Boolean)
|
|
38
|
+
.map((command) => `${command.replace(VERBOSITY, '')} -v m${flag}`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const BUILT = /^\s*(?<project>\S.*?) -> (?<dll>.+\.dll)\s*$/gm;
|
|
42
|
+
|
|
43
|
+
export function parseBuiltDlls(output) {
|
|
44
|
+
const dlls = new Map();
|
|
45
|
+
for (const m of String(output).matchAll(BUILT)) dlls.set(m.groups.project, m.groups.dll.trim());
|
|
46
|
+
return dlls;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const VITEST_RUN = /(vitest(?:\.mjs|\.js)?)\s+run\b/;
|
|
50
|
+
|
|
51
|
+
export function relatedCommand(runner, command, files) {
|
|
52
|
+
const list = files.map(quote).join(' ');
|
|
53
|
+
if (runner === 'vitest' && VITEST_RUN.test(command)) {
|
|
54
|
+
return `${command.replace(/\s+run\b/, ' related')} ${list} --run`;
|
|
55
|
+
}
|
|
56
|
+
if (runner === 'jest' && /\bjest\b/.test(command) && !/[;&|]/.test(command)) {
|
|
57
|
+
return `${command} --findRelatedTests ${list}`;
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { isAbsolute, normalize, sep } from 'node:path';
|
|
2
|
+
|
|
3
|
+
export const MUTANT_ID = /^[A-Za-z][A-Za-z0-9_-]{0,31}$/;
|
|
4
|
+
export const MODES = ['schemata', 'rebuild'];
|
|
5
|
+
export const BACKSLASH_TOKEN = '{{BS}}';
|
|
6
|
+
|
|
7
|
+
const expandBackslash = (s) => (typeof s === 'string' ? s.split(BACKSLASH_TOKEN).join(String.fromCharCode(92)) : s);
|
|
8
|
+
|
|
9
|
+
export class ManifestError extends Error {
|
|
10
|
+
constructor(problems) {
|
|
11
|
+
super(`manifesto de mutantes inválido:\n${problems.map((p) => ` - ${p}`).join('\n')}`);
|
|
12
|
+
this.name = 'ManifestError';
|
|
13
|
+
this.problems = problems;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function eolOf(text) {
|
|
18
|
+
return text.includes('\r\n') ? '\r\n' : '\n';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const toEol = (s, eol) => s.replace(/\r?\n/g, eol);
|
|
22
|
+
|
|
23
|
+
function lineBounds(text, line) {
|
|
24
|
+
let start = 0;
|
|
25
|
+
for (let n = 1; n < line; n++) {
|
|
26
|
+
const nl = text.indexOf('\n', start);
|
|
27
|
+
if (nl === -1) return null;
|
|
28
|
+
start = nl + 1;
|
|
29
|
+
}
|
|
30
|
+
const nl = text.indexOf('\n', start);
|
|
31
|
+
return { start, end: nl === -1 ? text.length : nl };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Where `find` begins: it must start on `line` and occur there exactly once,
|
|
36
|
+
* so the `arquivo:linha` of the report is a fact the tool derived, not typed.
|
|
37
|
+
*/
|
|
38
|
+
export function locate(text, { line, find }) {
|
|
39
|
+
const bounds = lineBounds(text, line);
|
|
40
|
+
if (!bounds) return { error: `o arquivo tem menos de ${line} linhas` };
|
|
41
|
+
const needle = toEol(find, eolOf(text));
|
|
42
|
+
const at = text.indexOf(needle, bounds.start);
|
|
43
|
+
if (at === -1) return { error: `\`find\` não aparece a partir da linha ${line}` };
|
|
44
|
+
if (at > bounds.end) {
|
|
45
|
+
const first = text.slice(0, at).split('\n').length;
|
|
46
|
+
return { error: `\`find\` começa na linha ${first}, não na ${line}` };
|
|
47
|
+
}
|
|
48
|
+
const again = text.indexOf(needle, at + 1);
|
|
49
|
+
if (again !== -1 && again <= bounds.end) {
|
|
50
|
+
return { error: `\`find\` aparece mais de uma vez na linha ${line}: estenda-o até ficar único` };
|
|
51
|
+
}
|
|
52
|
+
return { start: at, end: at + needle.length, needle };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function validateManifest(manifest, { fileExists } = {}) {
|
|
56
|
+
const problems = [];
|
|
57
|
+
const list = manifest?.mutants;
|
|
58
|
+
if (!Array.isArray(list) || list.length === 0) {
|
|
59
|
+
throw new ManifestError(['`mutants` precisa ser um array com ao menos um mutante']);
|
|
60
|
+
}
|
|
61
|
+
const seen = new Set();
|
|
62
|
+
const mutants = list.map((raw, index) => {
|
|
63
|
+
const at = `mutants[${index}]${typeof raw?.id === 'string' ? ` (${raw.id})` : ''}`;
|
|
64
|
+
const m = raw ?? {};
|
|
65
|
+
if (typeof m.id !== 'string' || !MUTANT_ID.test(m.id)) problems.push(`${at}: \`id\` inválido (letra + letras/dígitos/_/-, até 32)`);
|
|
66
|
+
else if (seen.has(m.id)) problems.push(`${at}: \`id\` repetido`);
|
|
67
|
+
else seen.add(m.id);
|
|
68
|
+
if (typeof m.file !== 'string' || m.file === '') problems.push(`${at}: \`file\` ausente`);
|
|
69
|
+
else if (isAbsolute(m.file) || normalize(m.file).split(sep).includes('..')) problems.push(`${at}: \`file\` tem de ser relativo à raiz do projeto, sem \`..\``);
|
|
70
|
+
else if (fileExists && !fileExists(m.file)) problems.push(`${at}: \`file\` não existe: ${m.file}`);
|
|
71
|
+
if (!Number.isInteger(m.line) || m.line < 1) problems.push(`${at}: \`line\` tem de ser inteiro >= 1`);
|
|
72
|
+
if (typeof m.find !== 'string' || m.find === '') problems.push(`${at}: \`find\` ausente`);
|
|
73
|
+
if (typeof m.replace !== 'string') problems.push(`${at}: \`replace\` ausente (use string vazia para apagar)`);
|
|
74
|
+
else if (m.replace === m.find) problems.push(`${at}: \`replace\` igual a \`find\` não é mutação`);
|
|
75
|
+
if (m.mode !== undefined && !MODES.includes(m.mode)) problems.push(`${at}: \`mode\` tem de ser ${MODES.join(' | ')}`);
|
|
76
|
+
return {
|
|
77
|
+
id: m.id, file: typeof m.file === 'string' ? m.file.replace(/\\/g, '/') : m.file,
|
|
78
|
+
line: m.line, find: expandBackslash(m.find), replace: expandBackslash(m.replace), mode: m.mode ?? null,
|
|
79
|
+
note: typeof m.note === 'string' ? m.note : null,
|
|
80
|
+
};
|
|
81
|
+
});
|
|
82
|
+
if (problems.length > 0) throw new ManifestError(problems);
|
|
83
|
+
return mutants;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Resolves every mutant against the file text and refuses overlaps: two
|
|
88
|
+
* regions of one file inside each other cannot both be a switch in one build.
|
|
89
|
+
*/
|
|
90
|
+
export function resolveRegions(mutants, readText) {
|
|
91
|
+
const problems = [];
|
|
92
|
+
const byFile = new Map();
|
|
93
|
+
for (const m of mutants) {
|
|
94
|
+
const text = readText(m.file);
|
|
95
|
+
if (text === null) { problems.push(`${m.id}: não consegui ler ${m.file}`); continue; }
|
|
96
|
+
const at = locate(text, m);
|
|
97
|
+
if (at.error) { problems.push(`${m.id}: ${at.error}`); continue; }
|
|
98
|
+
const eol = eolOf(text);
|
|
99
|
+
const region = {
|
|
100
|
+
...m, start: at.start, end: at.end, eol,
|
|
101
|
+
findText: at.needle, replaceText: toEol(m.replace, eol),
|
|
102
|
+
};
|
|
103
|
+
if (!byFile.has(m.file)) byFile.set(m.file, []);
|
|
104
|
+
byFile.get(m.file).push(region);
|
|
105
|
+
}
|
|
106
|
+
for (const regions of byFile.values()) {
|
|
107
|
+
regions.sort((a, b) => a.start - b.start);
|
|
108
|
+
for (let i = 1; i < regions.length; i++) {
|
|
109
|
+
if (regions[i].start < regions[i - 1].end) {
|
|
110
|
+
problems.push(`${regions[i].id} e ${regions[i - 1].id} se sobrepõem em ${regions[i].file}: rode-os em manifestos separados`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (problems.length > 0) throw new ManifestError(problems);
|
|
115
|
+
return mutants.map((m) => [...byFile.values()].flat().find((r) => r.id === m.id));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function applyRegions(text, regions, produce) {
|
|
119
|
+
let out = '';
|
|
120
|
+
let cursor = 0;
|
|
121
|
+
const spans = new Map();
|
|
122
|
+
for (const region of [...regions].sort((a, b) => a.start - b.start)) {
|
|
123
|
+
out += text.slice(cursor, region.start);
|
|
124
|
+
const from = out.split('\n').length;
|
|
125
|
+
out += produce(region);
|
|
126
|
+
spans.set(region.id, { from, to: out.split('\n').length });
|
|
127
|
+
cursor = region.end;
|
|
128
|
+
}
|
|
129
|
+
return { text: out + text.slice(cursor), spans };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function diffOf(m, limit = 160) {
|
|
133
|
+
const one = (s) => {
|
|
134
|
+
const flat = s.replace(/\r?\n\s*/g, ' ').replace(/\|/g, '\\|');
|
|
135
|
+
return flat.length > limit ? `${flat.slice(0, limit)}…` : flat;
|
|
136
|
+
};
|
|
137
|
+
return `\`- ${one(m.find)}\` / \`+ ${one(m.replace)}\``;
|
|
138
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
import { isInside } from '../tasks/test-runner.js';
|
|
4
|
+
import { isDotnetSource } from '../tasks/dotnet-test-introspection.js';
|
|
5
|
+
import { resolveRegions, validateManifest } from './manifest.js';
|
|
6
|
+
|
|
7
|
+
const JS_RUNNERS = new Set(['vitest', 'jest', 'node-test']);
|
|
8
|
+
|
|
9
|
+
export function loadPlan({ root, planPath }) {
|
|
10
|
+
const manifest = JSON.parse(readFileSync(planPath, 'utf8').replace(/^/, ''));
|
|
11
|
+
const mutants = validateManifest(manifest, { fileExists: (f) => existsSync(join(root, f)) });
|
|
12
|
+
return resolveRegions(mutants, (f) => {
|
|
13
|
+
try { return readFileSync(join(root, f), 'utf8'); } catch { return null; }
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function pickNode(file, nodes, root) {
|
|
18
|
+
const abs = resolve(root, file);
|
|
19
|
+
if (isDotnetSource(file)) return nodes.find((n) => n.runner === 'dotnet') ?? null;
|
|
20
|
+
const owners = nodes
|
|
21
|
+
.filter((n) => JS_RUNNERS.has(n.runner) && isInside(abs, resolve(root, n.cwd || '.')))
|
|
22
|
+
.sort((a, b) => resolve(root, b.cwd || '.').length - resolve(root, a.cwd || '.').length);
|
|
23
|
+
return owners[0] ?? null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function groupByNode(regions, nodes, root) {
|
|
27
|
+
const groups = new Map();
|
|
28
|
+
const orphans = [];
|
|
29
|
+
for (const region of regions) {
|
|
30
|
+
const node = pickNode(region.file, nodes, root);
|
|
31
|
+
if (!node) { orphans.push(region); continue; }
|
|
32
|
+
if (!groups.has(node.id)) groups.set(node.id, { node, regions: [] });
|
|
33
|
+
groups.get(node.id).regions.push(region);
|
|
34
|
+
}
|
|
35
|
+
return { groups: [...groups.values()], orphans };
|
|
36
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
const cell = (s) => String(s ?? '').replace(/\r?\n/g, ' ').replace(/\|/g, '\\|');
|
|
2
|
+
const short = (md5) => (md5 ? md5.slice(0, 12) : '—');
|
|
3
|
+
const seconds = (ms) => `${(ms / 1000).toFixed(1)} s`;
|
|
4
|
+
|
|
5
|
+
function countCell(m) {
|
|
6
|
+
const scope = m.scope === 'subset' ? 'subconjunto' : 'suíte inteira';
|
|
7
|
+
switch (m.verdict) {
|
|
8
|
+
case 'killed': return m.scope === 'subset' ? `≥${m.killers} de ${m.executed} (${scope})` : `${m.killers} de ${m.executed ?? '?'} (${scope})`;
|
|
9
|
+
case 'survived': return `**0 de ${m.executed ?? '?'} (SOBREVIVEU, ${scope})**`;
|
|
10
|
+
case 'build-error': return '— (o mutante não compila: não é morte)';
|
|
11
|
+
default: return '— (inconclusivo: não conta como morte nem como sobrevivência)';
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function reasonCell(m) {
|
|
16
|
+
if (m.verdict === 'survived') return m.reason || 'nenhum teste falhou na suíte inteira, com o mutante compilado de verdade';
|
|
17
|
+
const how = m.demoted ? ` [rebuild: ${m.demoted}]` : '';
|
|
18
|
+
return `${m.reason || '—'}${how}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const BINARY = {
|
|
22
|
+
md5: 'DLL do rebuild final idêntica (md5) à DLL construída da árvore limpa antes de qualquer mutante',
|
|
23
|
+
rebuilt: 'DLL recompilada DEPOIS da restauração e sem a chave de schemata, mas com md5 diferente da baseline — o build não é determinístico neste projeto, então o conteúdo não se compara',
|
|
24
|
+
unavailable: 'sem etapa de build neste runner: nada a provar sobre binário',
|
|
25
|
+
unlocated: 'ATENÇÃO — o build não informou a DLL dos projetos mutados, então o binário final NÃO pôde ser provado',
|
|
26
|
+
mismatch: 'ATENÇÃO — a DLL do rebuild final NÃO bate com a baseline e ainda carrega a chave de schemata',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export function renderSection(result, { head = null, date = null, subset = null, note = null } = {}) {
|
|
30
|
+
const lines = ['## Mutações', ''];
|
|
31
|
+
const { method } = result;
|
|
32
|
+
const where = subset?.usable
|
|
33
|
+
? `${subset.classes.length} classe(s) de teste (${subset.files.changed?.length ?? 0} alterada(s) pela feature, ${subset.files.mentions?.length ?? 0} citando os tipos mutados)`
|
|
34
|
+
: (subset?.reason ?? 'nenhum');
|
|
35
|
+
lines.push(`**Método:** \`morph-spec mutate\`${head ? ` sobre ${head.slice(0, 8)}` : ''}${date ? ` em ${date}` : ''}.`);
|
|
36
|
+
lines.push(`- ${method.schemata} mutante(s) por chave em UM build (schemata), ${method.rebuild} por rebuild real${(method.jobs ?? 1) > 1 ? `; rodadas de escopo rápido em ${method.jobs} workers` : ''}.`);
|
|
37
|
+
lines.push(`- Escopo rápido: ${method.subset.live ? where : `desligado (${method.subset.note ?? subset?.reason ?? 'sem subconjunto'})`}.`);
|
|
38
|
+
lines.push('- Morto = falha nova (ausente na rodada-base do mesmo escopo) com o motivo abaixo; na suíte inteira, a falha também tem de se repetir. Sobrevivente de escopo rápido é SEMPRE refeito com o mutante compilado de verdade e a suíte inteira; "sobreviveu" só sai desse caminho.');
|
|
39
|
+
if (result.baseline.subset) lines.push(`- Rodada-base do subconjunto: ${result.baseline.subset.executed} executados, ${result.baseline.subset.failed} falhando.`);
|
|
40
|
+
if (result.baseline.full) lines.push(`- Rodada-base da suíte inteira: ${result.baseline.full.executed} executados, ${result.baseline.full.failed} falhando.`);
|
|
41
|
+
if (note) lines.push(`- ${note}`);
|
|
42
|
+
lines.push('');
|
|
43
|
+
lines.push('| # | Arquivo:linha | Mutação (diff) | md5 antes | md5 depois | Testes derrubados | Motivo da falha |');
|
|
44
|
+
lines.push('|---|---|---|---|---|---|---|');
|
|
45
|
+
for (const m of result.mutants) {
|
|
46
|
+
lines.push(`| ${m.id} | ${cell(`${m.file}:${m.line}`)} | ${m.diff} | ${short(m.md5Before)} | ${short(m.md5After)} | ${cell(countCell(m))} | ${cell(reasonCell(m))} |`);
|
|
47
|
+
}
|
|
48
|
+
lines.push('');
|
|
49
|
+
const r = result.restore;
|
|
50
|
+
const bad = r.files.filter((f) => !f.ok).map((f) => f.file);
|
|
51
|
+
lines.push(`**Reversão:** snapshot/restore de bytes, md5 de cada arquivo conferido${bad.length ? ` — FALHOU em ${bad.join(', ')}` : ' (todos iguais ao original)'}; sem \`git checkout\`. Escrita nova a cada restauração, então o arquivo é mais novo que qualquer binário de mutante e o build recompila.`);
|
|
52
|
+
lines.push(`**Binário:** ${BINARY[result.binary.proof]}.`);
|
|
53
|
+
lines.push(`**Árvore:** \`git status --porcelain\` ${r.treeClean ? 'idêntico ao de antes da rodada (vazio em src/)' : 'DIFERENTE do de antes da rodada — investigar antes de qualquer nota'}.`);
|
|
54
|
+
const sum = result.timings.reduce((acc, t) => acc + t.ms, 0);
|
|
55
|
+
const jobs = method.jobs ?? 1;
|
|
56
|
+
const wall = result.wallMs ?? sum;
|
|
57
|
+
lines.push(`**Tempo:** ${seconds(wall)} de relógio${jobs > 1 ? ` com ${jobs} workers` : ''}; soma das ${result.timings.length} etapas de build+teste: ${seconds(sum)}.`);
|
|
58
|
+
lines.push('');
|
|
59
|
+
return lines.join('\n');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function summarize(result) {
|
|
63
|
+
const tally = { killed: 0, survived: 0, 'build-error': 0, inconclusive: 0 };
|
|
64
|
+
for (const m of result.mutants) tally[m.verdict] = (tally[m.verdict] ?? 0) + 1;
|
|
65
|
+
return tally;
|
|
66
|
+
}
|
|
@@ -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
|
+
}
|