@polymorphism-tech/morph-spec 8.42.0 → 8.44.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.
Files changed (101) hide show
  1. package/CLAUDE.md +15 -5
  2. package/README.md +2 -2
  3. package/bin/morph-spec.js +48 -2
  4. package/claude-plugin.json +1 -1
  5. package/docs/CHEATSHEET.md +1 -1
  6. package/docs/QUICKSTART.md +1 -1
  7. package/framework/CLAUDE.md +5 -4
  8. package/framework/MORPH.md +10 -9
  9. package/framework/agents.json +1 -1
  10. package/framework/docs/CLI.md +22 -10
  11. package/framework/evals/proof-rubrics/mutation-proof.md +43 -13
  12. package/framework/hooks/claude-code/core/heavy-gate.js +72 -0
  13. package/framework/hooks/claude-code/core/heavy-release.js +34 -0
  14. package/framework/hooks/claude-code/core/worktree-context.js +21 -20
  15. package/framework/hooks/claude-code/core/worktree-create.js +4 -4
  16. package/framework/hooks/claude-code/core/worktree-env-inject.js +1 -4
  17. package/framework/hooks/shared/agent-reap.js +89 -0
  18. package/framework/hooks/shared/e2e-check.js +3 -1
  19. package/framework/hooks/shared/graph-refresh.js +1 -9
  20. package/framework/hooks/shared/heavy-command.js +128 -0
  21. package/framework/hooks/shared/heavy-config.js +65 -0
  22. package/framework/hooks/shared/heavy-holders.js +85 -0
  23. package/framework/hooks/shared/heavy-slots.js +207 -0
  24. package/framework/hooks/shared/process-alive.js +8 -0
  25. package/framework/hooks/shared/worktree-root.js +93 -0
  26. package/framework/rules/ai-agents-standards.md +1 -0
  27. package/framework/schemas/config.schema.json +80 -0
  28. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  29. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  30. package/framework/skills/core/morph-apply/SKILL.md +8 -3
  31. package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
  32. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  33. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  34. package/framework/skills/core/morph-implement/SKILL.md +9 -4
  35. package/framework/skills/core/morph-machine/SKILL.md +46 -0
  36. package/framework/skills/core/morph-media/SKILL.md +1 -1
  37. package/framework/skills/core/morph-plan/SKILL.md +1 -1
  38. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  39. package/framework/skills/core/morph-review/SKILL.md +45 -16
  40. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  41. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  42. package/framework/skills/core/morph-uiux/SKILL.md +1 -1
  43. package/framework/standards/PROVENANCE.json +10 -10
  44. package/framework/standards/STANDARDS.json +45 -1
  45. package/framework/standards/ai-agents/tool-adapters.md +226 -0
  46. package/framework/standards/frontend/design-system/ai-video-generation.md +2 -0
  47. package/framework/standards/frontend/scroll-driven/frame-scrub.md +11 -1
  48. package/framework/standards/infrastructure/docker/local-compose-isolation.md +11 -1
  49. package/package.json +1 -1
  50. package/src/commands/e2e/e2e.js +27 -8
  51. package/src/commands/mcp/mcp-setup.js +10 -9
  52. package/src/commands/project/doctor-procs.js +162 -0
  53. package/src/commands/project/doctor.js +42 -4
  54. package/src/commands/project/heavy.js +61 -0
  55. package/src/commands/project/init.js +1 -1
  56. package/src/commands/project/update.js +4 -0
  57. package/src/commands/project/worktree-reap.js +34 -0
  58. package/src/commands/project/worktree.js +112 -43
  59. package/src/commands/state/approve.js +2 -1
  60. package/src/commands/state/finish.js +4 -0
  61. package/src/commands/state/gate-check.js +2 -1
  62. package/src/commands/validation/mutate.js +200 -0
  63. package/src/commands/validation/verify.js +39 -22
  64. package/src/dashboard/scanner/features-scanner.js +4 -4
  65. package/src/dashboard/scanner/project-api.js +4 -4
  66. package/src/lib/detectors/claude-config-detector.js +30 -1
  67. package/src/lib/e2e/compose-env.js +5 -2
  68. package/src/lib/git/evidence-staleness.js +39 -0
  69. package/src/lib/heavy/cleanup.js +70 -0
  70. package/src/lib/heavy/gate.js +96 -0
  71. package/src/lib/installers/mcp-installer.js +15 -2
  72. package/src/lib/mutation/adapters.js +90 -0
  73. package/src/lib/mutation/commands.js +60 -0
  74. package/src/lib/mutation/manifest.js +138 -0
  75. package/src/lib/mutation/plan.js +36 -0
  76. package/src/lib/mutation/report.js +66 -0
  77. package/src/lib/mutation/runner.js +351 -0
  78. package/src/lib/mutation/schemata.js +60 -0
  79. package/src/lib/mutation/snapshot.js +69 -0
  80. package/src/lib/mutation/subset.js +75 -0
  81. package/src/lib/mutation/trx.js +51 -0
  82. package/src/lib/procs/classify.js +91 -0
  83. package/src/lib/procs/list.js +124 -0
  84. package/src/lib/procs/mcp-config.js +103 -0
  85. package/src/lib/procs/prune.js +25 -0
  86. package/src/lib/procs/wslconfig.js +70 -0
  87. package/src/lib/projects/registry.js +6 -2
  88. package/src/lib/risk-detector.js +3 -3
  89. package/src/lib/tasks/eval-node.js +4 -34
  90. package/src/lib/tasks/linux-runner.js +30 -3
  91. package/src/lib/tasks/test-runner.js +1 -1
  92. package/src/lib/validators/contracts/contract-compliance-validator.js +4 -42
  93. package/src/lib/validators/verify-runner.js +26 -8
  94. package/src/lib/worktree/agent-reaper.js +247 -0
  95. package/src/lib/worktree/dirs.js +3 -32
  96. package/src/lib/worktree/env-allocator.js +35 -5
  97. package/src/lib/worktree/vscode-settings.js +113 -0
  98. package/src/lib/worktree/worktree-audit.js +49 -5
  99. package/src/lib/worktree/worktree-include.js +46 -0
  100. package/src/scripts/setup-infra.js +4 -0
  101. package/src/utils/hooks-installer.js +11 -4
@@ -11,6 +11,7 @@ import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'fs';
11
11
  import { execSync, spawn } from 'child_process';
12
12
  import { homedir } from 'os';
13
13
  import { installMcpServers } from '../../utils/claude-settings-manager.js';
14
+ import { mcpServerNames } from '../../utils/agents-installer.js';
14
15
 
15
16
  /**
16
17
  * Load the enhanced MCP registry (v3.0.0)
@@ -56,9 +57,11 @@ export function filterByStack(registry, stack, integrations = []) {
56
57
  * Match detected MCP servers against the registry
57
58
  * @param {Array} existingServers - From detectClaudeConfig().mcpServers
58
59
  * @param {Object} registry - MCP registry
60
+ * @param {string[]} [enabledPlugins] - From detectClaudeConfig().enabledPlugins. A plugin
61
+ * that ships the server already runs it, and writing it to `.mcp.json` too starts it twice.
59
62
  * @returns {Object} Map of mcpName → { installed: true, source }
60
63
  */
61
- export function getInstalledMcps(existingServers, registry) {
64
+ export function getInstalledMcps(existingServers, registry, enabledPlugins = []) {
62
65
  const installed = {};
63
66
  const registryNames = Object.keys(registry.mcps);
64
67
 
@@ -71,6 +74,15 @@ export function getInstalledMcps(existingServers, registry) {
71
74
  }
72
75
  }
73
76
 
77
+ const prefixes = enabledPlugins.map(id => `plugin_${String(id).split('@')[0].replace(/[^A-Za-z0-9_-]/g, '_')}_`);
78
+ for (const regName of registryNames) {
79
+ if (installed[regName]) continue;
80
+ const names = mcpServerNames(regName, registry.mcps[regName]);
81
+ if (names.some(n => prefixes.some(p => n.startsWith(p)))) {
82
+ installed[regName] = { installed: true, source: 'plugin' };
83
+ }
84
+ }
85
+
74
86
  return installed;
75
87
  }
76
88
 
@@ -597,12 +609,13 @@ export function generateSetupInstructions(name, mcpEntry) {
597
609
  * @param {Object} options
598
610
  * @param {boolean} options.autoOnly - Only handle auto-installable MCPs
599
611
  * @param {string[]} [options.integrations] - `project.integrations`, matched like the stack
612
+ * @param {string[]} [options.enabledPlugins] - `detectClaudeConfig().enabledPlugins`
600
613
  * @returns {Object} { autoInstallable, alreadyInstalled, needsManualSetup, notRelevant, prerequisitesMissing }
601
614
  */
602
615
  export function orchestrateMcpSetup(targetPath, stack, existingMcps, options = {}) {
603
616
  const registry = loadMcpRegistry();
604
617
  const relevant = filterByStack(registry, stack, options.integrations ?? []);
605
- const installed = getInstalledMcps(existingMcps || [], registry);
618
+ const installed = getInstalledMcps(existingMcps || [], registry, options.enabledPlugins ?? []);
606
619
 
607
620
  const autoInstallable = {};
608
621
  const alreadyInstalled = {};
@@ -0,0 +1,90 @@
1
+ import { existsSync, mkdirSync, readFileSync } from 'node:fs';
2
+ import { join, resolve } from 'node:path';
3
+ import { spawn } from 'node:child_process';
4
+ import { dotnetBuildCommands, withFilter, withTrx, withoutBuild } from './commands.js';
5
+ import { methodOf, parseTrx } from './trx.js';
6
+ import { stripAnsi } from '../tasks/test-runner.js';
7
+
8
+ const MAX_CAPTURE = 16 * 1024 * 1024;
9
+
10
+ export function spawnExec(command, { cwd, env, timeoutMs }) {
11
+ return new Promise((done) => {
12
+ const start = Date.now();
13
+ let stdout = '';
14
+ let stderr = '';
15
+ let timedOut = false;
16
+ const child = spawn(command, { cwd, env, shell: true, windowsHide: true });
17
+ child.stdout.setEncoding('utf8');
18
+ child.stderr.setEncoding('utf8');
19
+ child.stdout.on('data', (chunk) => { if (stdout.length < MAX_CAPTURE) stdout += chunk; });
20
+ child.stderr.on('data', (chunk) => { if (stderr.length < MAX_CAPTURE) stderr += chunk; });
21
+ const timer = setTimeout(() => { timedOut = true; child.kill(); }, timeoutMs);
22
+ const finish = (status) => { clearTimeout(timer); done({ status, stdout, stderr, durationMs: Date.now() - start, timedOut }); };
23
+ child.on('error', (err) => { stderr += String(err.message); finish(null); });
24
+ child.on('close', (status) => finish(status));
25
+ });
26
+ }
27
+
28
+ const FAIL_LINE = /^\s*(?:FAIL|✗|×|✖|not ok)\b|AssertionError|Error:|Expected/i;
29
+
30
+ function genericFailures(output) {
31
+ const lines = stripAnsi(output).split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
32
+ const first = lines.findIndex((l) => FAIL_LINE.test(l));
33
+ if (first === -1) return lines.slice(-3).map((l) => ({ name: l, message: '' }));
34
+ return [{ name: lines[first], message: lines.slice(first + 1, first + 3).join(' ⏎ ') }];
35
+ }
36
+
37
+ export function dotnetAdapter({ node, root, workDir, exec, subset, timeoutMs }) {
38
+ const cwd = resolve(root, node.cwd || '.');
39
+ const trxDir = join(workDir, 'trx');
40
+ mkdirSync(trxDir, { recursive: true });
41
+ const base = withoutBuild(node.command);
42
+ const filterOf = { subset: () => subset.filter, names: (filter) => filter };
43
+ const commandFor = (scope, label, filter) => withTrx(
44
+ scope === 'full' ? base : withFilter(base, filterOf[scope](filter)),
45
+ { dir: trxDir, name: `${label}.trx` },
46
+ );
47
+ const runTests = async ({ scope, label, env, filter }) => {
48
+ const trxPath = join(trxDir, `${label}.trx`);
49
+ const r = await exec(commandFor(scope, label, filter), { cwd, env, timeoutMs, label });
50
+ if (!existsSync(trxPath)) {
51
+ return { outcome: 'crash', executed: null, total: null, failed: 0, failedTests: [], durationMs: r.durationMs, note: r.timedOut ? `timeout de ${timeoutMs} ms` : `sem TRX (exit ${r.status})` };
52
+ }
53
+ const t = parseTrx(readFileSync(trxPath, 'utf8'));
54
+ if (!t.parsed || t.aborted > 0 || /abort/i.test(t.runOutcome ?? '')) {
55
+ return { outcome: 'crash', executed: t.executed, total: t.total, failed: t.failed, failedTests: t.failedTests, durationMs: r.durationMs, note: 'a rodada foi abortada (testhost caiu ou estourou o tempo)' };
56
+ }
57
+ return { outcome: t.failed > 0 ? 'red' : 'green', executed: t.executed, total: t.total, failed: t.failed, failedTests: t.failedTests, durationMs: r.durationMs };
58
+ };
59
+ return {
60
+ kind: 'dotnet',
61
+ hasSubset: Boolean(subset?.usable),
62
+ buildCommands: dotnetBuildCommands(node, root),
63
+ cwd,
64
+ runTests,
65
+ reproduce: ({ names, label, env }) => runTests({
66
+ scope: 'names',
67
+ label,
68
+ env,
69
+ filter: [...new Set(names.map(methodOf))].map((n) => `FullyQualifiedName~${n}`).join('|'),
70
+ }),
71
+ };
72
+ }
73
+
74
+ export function genericAdapter({ node, root, exec, subset, timeoutMs }) {
75
+ const cwd = resolve(root, node.cwd || '.');
76
+ return {
77
+ kind: node.runner,
78
+ hasSubset: Boolean(subset?.usable),
79
+ buildCommands: [],
80
+ cwd,
81
+ async runTests({ scope, label, env }) {
82
+ const command = scope === 'subset' ? subset.command : node.command;
83
+ const r = await exec(command, { cwd, env, timeoutMs, label });
84
+ if (r.timedOut) return { outcome: 'crash', executed: null, total: null, failed: 0, failedTests: [], durationMs: r.durationMs, note: `timeout de ${timeoutMs} ms` };
85
+ if (r.status === 0) return { outcome: 'green', executed: null, total: null, failed: 0, failedTests: [], durationMs: r.durationMs };
86
+ const failedTests = genericFailures(`${r.stdout}\n${r.stderr}`);
87
+ return { outcome: 'red', executed: null, total: null, failed: failedTests.length, failedTests, durationMs: r.durationMs };
88
+ },
89
+ };
90
+ }
@@ -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
+ }