@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.
Files changed (102) hide show
  1. package/CLAUDE.md +14 -4
  2. package/README.md +3 -3
  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 +6 -5
  8. package/framework/MORPH.md +10 -9
  9. package/framework/agents.json +7 -7
  10. package/framework/docs/CLI.md +10 -3
  11. package/framework/evals/proof-rubrics/mutation-proof.md +43 -13
  12. package/framework/hooks/README.md +1 -0
  13. package/framework/hooks/claude-code/core/chain.js +2 -0
  14. package/framework/hooks/claude-code/core/comment-guard.js +107 -0
  15. package/framework/hooks/claude-code/core/heavy-gate.js +72 -0
  16. package/framework/hooks/claude-code/core/heavy-release.js +34 -0
  17. package/framework/hooks/claude-code/core/worktree-context.js +21 -20
  18. package/framework/hooks/claude-code/core/worktree-create.js +4 -4
  19. package/framework/hooks/shared/agent-reap.js +89 -0
  20. package/framework/hooks/shared/comment-rules.js +171 -0
  21. package/{src/lib/validators/code-budget → framework/hooks/shared}/comment-scanner.js +2 -2
  22. package/{src/lib/ai → framework/hooks/shared}/comment-stripper.js +2 -1
  23. package/framework/hooks/shared/e2e-check.js +3 -1
  24. package/framework/hooks/shared/graph-refresh.js +1 -9
  25. package/framework/hooks/shared/heavy-command.js +128 -0
  26. package/framework/hooks/shared/heavy-config.js +65 -0
  27. package/framework/hooks/shared/heavy-holders.js +85 -0
  28. package/framework/hooks/shared/heavy-slots.js +207 -0
  29. package/framework/hooks/shared/process-alive.js +8 -0
  30. package/framework/hooks/shared/source-files.js +34 -0
  31. package/framework/hooks/shared/worktree-root.js +93 -0
  32. package/framework/schemas/config.schema.json +80 -0
  33. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  34. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  35. package/framework/skills/core/morph-apply/SKILL.md +11 -4
  36. package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
  37. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  38. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  39. package/framework/skills/core/morph-implement/SKILL.md +12 -5
  40. package/framework/skills/core/morph-machine/SKILL.md +46 -0
  41. package/framework/skills/core/morph-media/SKILL.md +1 -1
  42. package/framework/skills/core/morph-plan/SKILL.md +1 -1
  43. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  44. package/framework/skills/core/morph-review/SKILL.md +45 -16
  45. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  46. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  47. package/framework/skills/core/morph-uiux/SKILL.md +1 -1
  48. package/framework/standards/PROVENANCE.json +10 -10
  49. package/framework/standards/STANDARDS.json +1 -1
  50. package/framework/standards/architecture/code-comments.md +9 -2
  51. package/framework/standards/frontend/design-system/ai-video-generation.md +2 -0
  52. package/framework/standards/frontend/scroll-driven/frame-scrub.md +11 -1
  53. package/package.json +1 -1
  54. package/src/commands/e2e/e2e.js +27 -8
  55. package/src/commands/mcp/mcp-setup.js +10 -9
  56. package/src/commands/project/doctor-procs.js +162 -0
  57. package/src/commands/project/doctor.js +42 -4
  58. package/src/commands/project/heavy.js +61 -0
  59. package/src/commands/project/init.js +1 -1
  60. package/src/commands/project/update.js +4 -0
  61. package/src/commands/project/worktree-reap.js +34 -0
  62. package/src/commands/project/worktree.js +78 -27
  63. package/src/commands/validation/mutate.js +200 -0
  64. package/src/commands/validation/verify.js +26 -1
  65. package/src/dashboard/scanner/features-scanner.js +4 -4
  66. package/src/dashboard/scanner/project-api.js +4 -4
  67. package/src/lib/ai/code-audit.js +1 -1
  68. package/src/lib/ai/registry-audit.js +1 -1
  69. package/src/lib/detectors/claude-config-detector.js +30 -1
  70. package/src/lib/heavy/cleanup.js +70 -0
  71. package/src/lib/heavy/gate.js +96 -0
  72. package/src/lib/installers/mcp-installer.js +15 -2
  73. package/src/lib/mutation/adapters.js +90 -0
  74. package/src/lib/mutation/commands.js +60 -0
  75. package/src/lib/mutation/manifest.js +138 -0
  76. package/src/lib/mutation/plan.js +36 -0
  77. package/src/lib/mutation/report.js +66 -0
  78. package/src/lib/mutation/runner.js +351 -0
  79. package/src/lib/mutation/schemata.js +60 -0
  80. package/src/lib/mutation/snapshot.js +69 -0
  81. package/src/lib/mutation/subset.js +75 -0
  82. package/src/lib/mutation/trx.js +51 -0
  83. package/src/lib/procs/classify.js +91 -0
  84. package/src/lib/procs/list.js +124 -0
  85. package/src/lib/procs/mcp-config.js +103 -0
  86. package/src/lib/procs/prune.js +25 -0
  87. package/src/lib/procs/wslconfig.js +70 -0
  88. package/src/lib/projects/registry.js +6 -2
  89. package/src/lib/tasks/dotnet-test-introspection.js +1 -1
  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/code-budget/code-budget-validator.js +4 -6
  93. package/src/lib/validators/code-budget/index.js +1 -1
  94. package/src/lib/validators/reuse/symbol-scanner.js +1 -28
  95. package/src/lib/validators/verify-runner.js +26 -8
  96. package/src/lib/worktree/agent-reaper.js +247 -0
  97. package/src/lib/worktree/dirs.js +3 -32
  98. package/src/lib/worktree/vscode-settings.js +113 -0
  99. package/src/lib/worktree/worktree-audit.js +49 -5
  100. package/src/lib/worktree/worktree-include.js +46 -0
  101. package/src/scripts/setup-infra.js +4 -0
  102. package/src/utils/hooks-installer.js +14 -6
@@ -11,21 +11,50 @@ import { existsSync, readFileSync, readdirSync } from 'fs';
11
11
  import { join } from 'path';
12
12
  import { homedir } from 'os';
13
13
 
14
+ /**
15
+ * Plugin ids (`name@marketplace`) that are switched ON. User settings first, then
16
+ * the project's; a later file overrides an earlier one, and `false` turns one off.
17
+ *
18
+ * @param {string} targetPath - Root path of the project
19
+ * @param {string} [home] - Home directory holding `.claude/settings.json`
20
+ * @returns {string[]}
21
+ */
22
+ export function readEnabledPlugins(targetPath, home = homedir()) {
23
+ const merged = new Map();
24
+ const files = [
25
+ join(home, '.claude', 'settings.json'),
26
+ join(targetPath, '.claude', 'settings.json'),
27
+ join(targetPath, '.claude', 'settings.local.json'),
28
+ ];
29
+ for (const file of files) {
30
+ if (!existsSync(file)) continue;
31
+ try {
32
+ const map = JSON.parse(readFileSync(file, 'utf8').replace(/^/, '')).enabledPlugins;
33
+ if (!map || typeof map !== 'object') continue;
34
+ for (const [id, on] of Object.entries(map)) merged.set(id, on === true);
35
+ } catch { /* invalid JSON */ }
36
+ }
37
+ return [...merged].filter(([, on]) => on).map(([id]) => id);
38
+ }
39
+
14
40
  /**
15
41
  * Detect Claude Code configuration for a project.
16
42
  *
17
43
  * @param {string} targetPath - Root path of the project
18
- * @returns {{ plugins: string[], mcpServers: Array<{name: string, config: Object}>, customSkills: string[], customCommands: string[], hooks: string[] }}
44
+ * @returns {{ plugins: string[], enabledPlugins: string[], mcpServers: Array<{name: string, config: Object}>, customSkills: string[], customCommands: string[], hooks: string[] }}
19
45
  */
20
46
  export function detectClaudeConfig(targetPath) {
21
47
  const result = {
22
48
  plugins: [],
49
+ enabledPlugins: [],
23
50
  mcpServers: [],
24
51
  customSkills: [],
25
52
  customCommands: [],
26
53
  hooks: [],
27
54
  };
28
55
 
56
+ result.enabledPlugins = readEnabledPlugins(targetPath);
57
+
29
58
  const mcpJsonNames = new Set();
30
59
 
31
60
  try {
@@ -0,0 +1,70 @@
1
+ import { readdirSync } from 'node:fs';
2
+ import { getBuildSteps } from '../../../framework/hooks/shared/build-check.js';
3
+ import { listProcesses, defaultProcExec } from '../procs/list.js';
4
+ import { readProjectConfig } from '../../../framework/hooks/shared/heavy-config.js';
5
+ import { heavyDir, runExclusive } from '../../../framework/hooks/shared/heavy-slots.js';
6
+
7
+ const DOTNET_ACTIVE = /\bdotnet(?:\.exe)?["']?\s+(?:build|test|publish|pack|msbuild|restore|run|watch|vstest)\b/i;
8
+ const IDLE_WORKER = /\/nodemode:\d|vbcscompiler|razorserver/i;
9
+ const SHUTDOWN_SCOPES = new Set(['feature', 'e2e-down', 'prune']);
10
+ const SHUTDOWN_TIMEOUT_MS = 30_000;
11
+ const TAIL_CHARS = 400;
12
+
13
+ function hasDotnet(projectPath) {
14
+ const config = readProjectConfig(projectPath);
15
+ const stack = config?.project?.stack;
16
+ try {
17
+ if (stack && getBuildSteps(stack, { cwd: projectPath }).some((s) => /\bdotnet\b/.test(s.command))) return true;
18
+ } catch { /* stack sem passo de build */ }
19
+ try {
20
+ return readdirSync(projectPath).some((n) => /\.(sln|slnx|csproj)$/i.test(n));
21
+ } catch {
22
+ return false;
23
+ }
24
+ }
25
+
26
+ export function dotnetBusy(procs) {
27
+ return procs.some((p) => {
28
+ if (p.name === 'testhost') return true;
29
+ const cmd = p.cmd ?? '';
30
+ if (p.name === 'vbcscompiler' || IDLE_WORKER.test(cmd)) return false;
31
+ if (p.name === 'msbuild') return true;
32
+ return p.name === 'dotnet' && DOTNET_ACTIVE.test(cmd);
33
+ });
34
+ }
35
+
36
+ export function afterHeavyRun({ projectPath, config, scope, env = process.env, deps = {} }) {
37
+ const {
38
+ exec = defaultProcExec, list = listProcesses, dotnet = hasDotnet, exclusive = runExclusive,
39
+ } = deps;
40
+
41
+ if (!config.enabled) return { status: 'skipped', reason: 'semáforo desligado: sem ele nada prova que outra sessão não está compilando' };
42
+ if (!config.shutdownBuildServer) return { status: 'skipped', reason: 'desligado em heavy.shutdownBuildServer' };
43
+ if (!SHUTDOWN_SCOPES.has(scope)) {
44
+ return { status: 'skipped', reason: 'escopo de task: o servidor de compilação segue quente para a próxima task' };
45
+ }
46
+ if (!dotnet(projectPath)) return { status: 'skipped', reason: 'projeto sem .NET' };
47
+
48
+ const guarded = exclusive({
49
+ dir: heavyDir(env), label: 'build-server-shutdown', maxHoldMs: config.maxHoldMinutes * 60_000, env,
50
+ }, () => {
51
+ const listing = list();
52
+ if (listing.procs === null) return { status: 'skipped', reason: `não consegui listar processos (${listing.reason})` };
53
+ if (dotnetBusy(listing.procs)) return { status: 'skipped', reason: 'há dotnet build/test vivo fora do semáforo' };
54
+
55
+ const res = exec('dotnet', ['build-server', 'shutdown'], { timeout: SHUTDOWN_TIMEOUT_MS });
56
+ if (res.missing) return { status: 'skipped', reason: 'dotnet indisponível no PATH' };
57
+ if (res.code !== 0) {
58
+ return { status: 'failed', reason: `dotnet build-server shutdown saiu ${res.code}`, output: String(res.output ?? '').slice(-TAIL_CHARS) };
59
+ }
60
+ return { status: 'ran' };
61
+ });
62
+
63
+ if (!guarded.ran) {
64
+ const why = guarded.reason === 'holders'
65
+ ? 'outra sessão segura slot do semáforo pesado'
66
+ : `semáforo indisponível (${guarded.reason})`;
67
+ return { status: 'skipped', reason: why };
68
+ }
69
+ return guarded.result;
70
+ }
@@ -0,0 +1,96 @@
1
+ import { readHeavyConfig } from '../../../framework/hooks/shared/heavy-config.js';
2
+ import {
3
+ acquireHeavy, describeBusy, heavyDir, releaseHeavy, updateLease,
4
+ } from '../../../framework/hooks/shared/heavy-slots.js';
5
+
6
+ export class HeavyBusyError extends Error {
7
+ constructor(result, config) {
8
+ super(HeavyBusyError.message(result, config));
9
+ this.name = 'HeavyBusyError';
10
+ this.code = 'heavy-busy';
11
+ this.reason = result.reason;
12
+ this.holders = result.holders;
13
+ this.freeGb = result.freeGb;
14
+ this.waitedMs = result.waitedMs;
15
+ }
16
+
17
+ static message(result, config) {
18
+ return describeBusy(result, config);
19
+ }
20
+ }
21
+
22
+ const NODE_REUSE_ENV = 'MSBUILDDISABLENODEREUSE';
23
+ const LEASE_ENV = 'MORPH_HEAVY_LEASE';
24
+
25
+ export function createHeavyGate({
26
+ projectPath, label = 'verify', feature = null, config = readHeavyConfig(projectPath),
27
+ env = process.env, deps = {}, log = (m) => process.stderr.write(`${m}\n`),
28
+ } = {}) {
29
+ let entered = false;
30
+ let lease = null;
31
+ let waitedMs = 0;
32
+ let exitHandler = null;
33
+ let saved = null;
34
+
35
+ const restoreEnv = () => {
36
+ if (!saved) return;
37
+ for (const [key, value] of Object.entries(saved)) {
38
+ if (value === undefined) delete env[key];
39
+ else env[key] = value;
40
+ }
41
+ saved = null;
42
+ };
43
+
44
+ const release = () => {
45
+ if (exitHandler) process.removeListener('exit', exitHandler);
46
+ exitHandler = null;
47
+ releaseHeavy(lease);
48
+ lease = null;
49
+ restoreEnv();
50
+ entered = false;
51
+ };
52
+
53
+ return {
54
+ config,
55
+ get waitedMs() { return waitedMs; },
56
+
57
+ enter(node) {
58
+ if (entered) {
59
+ updateLease(lease, { node });
60
+ return;
61
+ }
62
+ for (const warning of config.warnings ?? []) log(`heavy: ${warning}`);
63
+
64
+ if (config.enabled) {
65
+ const result = acquireHeavy({
66
+ dir: heavyDir(env),
67
+ slots: config.slots,
68
+ label,
69
+ feature,
70
+ node,
71
+ cwd: projectPath,
72
+ minFreeMemGb: config.minFreeMemGb,
73
+ waitTimeoutMs: config.waitTimeoutSec * 1000,
74
+ maxHoldMs: config.maxHoldMinutes * 60_000,
75
+ env,
76
+ log,
77
+ ...deps,
78
+ });
79
+ if (result.status === 'busy') throw new HeavyBusyError(result, config);
80
+ lease = result.lease;
81
+ waitedMs = result.waitedMs;
82
+ }
83
+
84
+ saved = { [NODE_REUSE_ENV]: env[NODE_REUSE_ENV], [LEASE_ENV]: env[LEASE_ENV] };
85
+ if (config.disableNodeReuse) env[NODE_REUSE_ENV] = '1';
86
+ if (lease) env[LEASE_ENV] = String(process.pid);
87
+ entered = true;
88
+ if (lease) {
89
+ exitHandler = release;
90
+ process.once('exit', exitHandler);
91
+ }
92
+ },
93
+
94
+ release,
95
+ };
96
+ }
@@ -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
+ }