@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,200 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { join, resolve } from 'node:path';
|
|
5
|
+
import { resolveTestBuildTargets } from '../../lib/tasks/test-runner.js';
|
|
6
|
+
import { dotnetTestMode } from '../../lib/tasks/dotnet-test-introspection.js';
|
|
7
|
+
import { dotnetBuildCommands } from '../../lib/mutation/commands.js';
|
|
8
|
+
import { resolveTestNodes } from '../../lib/tasks/test-nodes.js';
|
|
9
|
+
import { changedPathsAgainst, detectDefaultBranch, getHeadSha } from '../../lib/git/default-branch.js';
|
|
10
|
+
import { createHeavyGate, HeavyBusyError } from '../../lib/heavy/gate.js';
|
|
11
|
+
import { readHeavyConfig } from '../../../framework/hooks/shared/heavy-config.js';
|
|
12
|
+
import { resolveGitCommonDir } from '../../../framework/hooks/shared/git-paths.js';
|
|
13
|
+
import { BACKSLASH_TOKEN, ManifestError } from '../../lib/mutation/manifest.js';
|
|
14
|
+
import { groupByNode, loadPlan } from '../../lib/mutation/plan.js';
|
|
15
|
+
import { dotnetSubset, jsSubset, testProjectDirsOf } from '../../lib/mutation/subset.js';
|
|
16
|
+
import { dotnetAdapter, genericAdapter, spawnExec } from '../../lib/mutation/adapters.js';
|
|
17
|
+
import { runMutation } from '../../lib/mutation/runner.js';
|
|
18
|
+
import { renderSection, summarize } from '../../lib/mutation/report.js';
|
|
19
|
+
import { readJournal, restoreSnapshot } from '../../lib/mutation/snapshot.js';
|
|
20
|
+
|
|
21
|
+
const EXIT = { ok: 0, failed: 1, usage: 2, busy: 3, unsafe: 4 };
|
|
22
|
+
const KEEP_RUNS = 2;
|
|
23
|
+
const DEFAULT_JOBS = 2;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* One heavy slot per worker: the first waits like any heavy node, the others
|
|
27
|
+
* are taken only if free right now. The run uses as many workers as it got,
|
|
28
|
+
* so a busy machine degrades to sequential instead of queueing.
|
|
29
|
+
*/
|
|
30
|
+
export function workerGate({ root, feature, jobs }) {
|
|
31
|
+
const config = readHeavyConfig(root);
|
|
32
|
+
const gates = [createHeavyGate({ projectPath: root, label: 'mutate', feature, config })];
|
|
33
|
+
const quiet = { ...config, waitTimeoutSec: 0, warnings: [] };
|
|
34
|
+
for (let i = 1; i < jobs; i++) {
|
|
35
|
+
const { MORPH_HEAVY_LEASE: _inherited, ...env } = process.env;
|
|
36
|
+
gates.push(createHeavyGate({ projectPath: root, label: 'mutate', feature, config: quiet, env }));
|
|
37
|
+
}
|
|
38
|
+
let acquired = 0;
|
|
39
|
+
return {
|
|
40
|
+
get jobs() { return acquired; },
|
|
41
|
+
enter(node) {
|
|
42
|
+
gates[0].enter(node);
|
|
43
|
+
acquired = 1;
|
|
44
|
+
for (const gate of gates.slice(1)) {
|
|
45
|
+
try { gate.enter(node); acquired += 1; } catch (err) {
|
|
46
|
+
if (!(err instanceof HeavyBusyError)) throw err;
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
release() {
|
|
52
|
+
for (const gate of [...gates].reverse()) gate.release();
|
|
53
|
+
acquired = 0;
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const gitOut = (cwd, args) => execFileSync('git', args, { cwd, stdio: 'pipe', encoding: 'utf8', windowsHide: true });
|
|
59
|
+
|
|
60
|
+
function workRoot(root) {
|
|
61
|
+
const common = resolveGitCommonDir(root);
|
|
62
|
+
return common ? join(common, 'morph-spec', 'mutation') : join(root, '.morph', 'tmp', 'mutation');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function pruneOldRuns(base, keep) {
|
|
66
|
+
if (!existsSync(base)) return;
|
|
67
|
+
const runs = readdirSync(base).sort();
|
|
68
|
+
for (const name of runs.slice(0, Math.max(0, runs.length - keep))) {
|
|
69
|
+
const done = ['g0', 'g1', 'g2', 'g3'].every((g) => {
|
|
70
|
+
const journal = readJournal(join(base, name, g, 'snapshot'));
|
|
71
|
+
return journal === null || journal.state === 'restored';
|
|
72
|
+
});
|
|
73
|
+
if (done) rmSync(join(base, name), { recursive: true, force: true });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function fail(message, code) {
|
|
78
|
+
console.error(chalk.red(message));
|
|
79
|
+
process.exitCode = code;
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function restoreLatest(root, out = console) {
|
|
84
|
+
const base = workRoot(root);
|
|
85
|
+
if (!existsSync(base)) { out.log('nada a restaurar: nenhuma rodada de mutação registrada'); return []; }
|
|
86
|
+
const restored = [];
|
|
87
|
+
for (const run of readdirSync(base).sort()) {
|
|
88
|
+
for (const group of readdirSync(join(base, run))) {
|
|
89
|
+
const journal = readJournal(join(base, run, group, 'snapshot'));
|
|
90
|
+
if (!journal || journal.state === 'restored') continue;
|
|
91
|
+
const report = restoreSnapshot(journal);
|
|
92
|
+
restored.push({ run, group, files: report });
|
|
93
|
+
out.log(`${run}/${group}: ${report.every((r) => r.ok) ? 'restaurado' : 'FALHOU'} — ${report.map((r) => `${r.file} ${r.ok ? 'ok' : 'md5 diverge'}`).join(', ')}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (restored.length === 0) out.log('nada a restaurar: toda rodada registrada já foi restaurada');
|
|
97
|
+
return restored;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function subsetFor({ node, group, root, options }) {
|
|
101
|
+
if (options.subset === false) return { usable: false, reason: '--no-subset' };
|
|
102
|
+
const mutatedFiles = group.regions.map((r) => r.file);
|
|
103
|
+
if (node.runner === 'dotnet') {
|
|
104
|
+
const base = detectDefaultBranch(root);
|
|
105
|
+
const changed = base ? changedPathsAgainst(base, root) : [];
|
|
106
|
+
return dotnetSubset({ root, testProjectDirs: testProjectDirsOf(root, resolveTestBuildTargets(node)), mutatedFiles, changed });
|
|
107
|
+
}
|
|
108
|
+
return jsSubset({ runner: node.runner, command: node.command, root, mutatedFiles });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function mutateCommand(feature, options = {}) {
|
|
112
|
+
const root = resolve(options.cwd ?? process.cwd());
|
|
113
|
+
if (options.restore) { restoreLatest(root); return null; }
|
|
114
|
+
|
|
115
|
+
const planPath = resolve(root, options.plan ?? join('.morph', 'features', feature ?? '', '4-review', 'mutants.json'));
|
|
116
|
+
if (!existsSync(planPath)) return fail(`manifesto não encontrado: ${planPath}\nEscreva-o com a ferramenta Write, se você a tem; só com Bash, heredoc de delimitador entre aspas simples, com a barra invertida do código como ${BACKSLASH_TOKEN} — formato em .morph/framework/CLI.md, seção mutate.`, EXIT.usage);
|
|
117
|
+
|
|
118
|
+
let regions;
|
|
119
|
+
try {
|
|
120
|
+
regions = loadPlan({ root, planPath });
|
|
121
|
+
} catch (err) {
|
|
122
|
+
if (err instanceof ManifestError) return fail(err.message, EXIT.usage);
|
|
123
|
+
if (err instanceof SyntaxError) return fail(`${err.message}\nSe o manifesto tem barra invertida, o transporte do Bash pode tê-la alterado: escreva cada uma como ${BACKSLASH_TOKEN}, que a ferramenta expande.`, EXIT.usage);
|
|
124
|
+
throw err;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const { nodes } = resolveTestNodes(root);
|
|
128
|
+
const { groups, orphans } = groupByNode(regions, nodes, root);
|
|
129
|
+
if (orphans.length > 0) {
|
|
130
|
+
return fail(`nenhuma suíte declarada roda: ${orphans.map((o) => `${o.id} (${o.file})`).join(', ')}\nDeclare o nó em project.tests[] (.morph/config/config.json).`, EXIT.usage);
|
|
131
|
+
}
|
|
132
|
+
const mtp = groups.find((g) => g.node.runner === 'dotnet' && dotnetTestMode(resolve(root, g.node.cwd || '.')) === 'mtp');
|
|
133
|
+
if (mtp) return fail(`a suíte "${mtp.node.id}" roda no Microsoft.Testing.Platform (global.json): o TRX do VSTest, de onde o veredito e o motivo saem, não existe nela. Suporte a MTP ainda não há — rode a mutação com a suíte inteira à mão.`, EXIT.usage);
|
|
134
|
+
const uncompilable = groups.find((g) => g.node.runner === 'dotnet' && dotnetBuildCommands(g.node, root).length === 0);
|
|
135
|
+
if (uncompilable) return fail(`a suíte "${uncompilable.node.id}" não aponta nenhum .csproj (solution, ou nada): sem saber o que compilar, todo mutante rodaria sobre o binário antigo e sairia "sobrevivente". Declare o campo build no nó (config.json, project.tests[]).`, EXIT.usage);
|
|
136
|
+
const preDecided = groups.find((g) => g.node.status);
|
|
137
|
+
if (preDecided) return fail(`a suíte "${preDecided.node.id}" já vem decidida como ${preDecided.node.status}: ${preDecided.node.reason ?? ''}`, EXIT.usage);
|
|
138
|
+
|
|
139
|
+
if (!options.allowDirty) {
|
|
140
|
+
const dirty = gitOut(root, ['status', '--porcelain', '--', ...new Set(regions.map((r) => r.file))]).trim();
|
|
141
|
+
if (dirty) return fail(`há alteração não commitada nos arquivos a mutar:\n${dirty}\nCommite o fix ANTES de mutar (ou use --allow-dirty sabendo que o snapshot é o único caminho de volta).`, EXIT.usage);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const runId = `mut-${Date.now().toString(36)}`;
|
|
145
|
+
const base = workRoot(root);
|
|
146
|
+
pruneOldRuns(base, KEEP_RUNS);
|
|
147
|
+
const git = { porcelain: () => gitOut(root, ['status', '--porcelain', '--', '.', ':(exclude).morph']), head: () => getHeadSha(root) };
|
|
148
|
+
const jobs = Math.max(1, Number.isInteger(options.jobs) ? options.jobs : DEFAULT_JOBS);
|
|
149
|
+
const gate = workerGate({ root, feature: feature ?? null, jobs });
|
|
150
|
+
const exec = options.exec ?? spawnExec;
|
|
151
|
+
|
|
152
|
+
const merged = { wallMs: 0, mutants: [], timings: [], restore: { files: [], ok: true, treeClean: true }, binary: { proof: 'unavailable', projects: [] }, baseline: {}, method: { schemata: 0, rebuild: 0, subset: {} } };
|
|
153
|
+
const subsets = [];
|
|
154
|
+
try {
|
|
155
|
+
for (const [index, group] of groups.entries()) {
|
|
156
|
+
const workDir = join(base, runId, `g${index}`);
|
|
157
|
+
mkdirSync(workDir, { recursive: true });
|
|
158
|
+
const subset = subsetFor({ node: group.node, group, root, options });
|
|
159
|
+
subsets.push(subset);
|
|
160
|
+
const timeoutMs = options.timeoutMs ?? group.node.timeoutMs ?? 900_000;
|
|
161
|
+
const make = group.node.runner === 'dotnet' ? dotnetAdapter : genericAdapter;
|
|
162
|
+
const adapter = make({ node: group.node, root, workDir, exec, subset, timeoutMs });
|
|
163
|
+
const result = await runMutation({ root, node: group.node, regions: group.regions, workDir, runId, exec, adapter, gate, git, options: { timeoutMs, jobs }, log: (m) => options.json || console.error(chalk.gray(m)) });
|
|
164
|
+
merged.mutants.push(...result.mutants);
|
|
165
|
+
merged.timings.push(...result.timings);
|
|
166
|
+
merged.wallMs += result.wallMs;
|
|
167
|
+
merged.restore.files.push(...result.restore.files);
|
|
168
|
+
merged.restore.ok &&= result.restore.ok;
|
|
169
|
+
merged.restore.treeClean &&= result.restore.treeClean;
|
|
170
|
+
merged.restore.porcelainAfter = result.restore.porcelainAfter;
|
|
171
|
+
if (result.binary.proof !== 'unavailable') merged.binary = ['mismatch', 'unlocated'].includes(merged.binary.proof) ? merged.binary : result.binary;
|
|
172
|
+
merged.baseline = { ...merged.baseline, ...Object.fromEntries(Object.entries(result.baseline).filter(([, v]) => v)) };
|
|
173
|
+
merged.method.schemata += result.method.schemata;
|
|
174
|
+
merged.method.rebuild += result.method.rebuild;
|
|
175
|
+
merged.method.subset = result.method.subset;
|
|
176
|
+
merged.method.jobs = Math.max(merged.method.jobs ?? 1, result.method.jobs);
|
|
177
|
+
}
|
|
178
|
+
} catch (err) {
|
|
179
|
+
if (err instanceof HeavyBusyError) return fail(`suíte não rodou: ${err.message}. Tente de novo quando liberar (morph-spec heavy status).`, EXIT.busy);
|
|
180
|
+
if (err.code === 'baseline-build-failed') return fail(`${err.message}\n${String(err.output ?? '').split('\n').slice(-12).join('\n')}`, EXIT.failed);
|
|
181
|
+
throw err;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const order = new Map(regions.map((r, i) => [r.id, i]));
|
|
185
|
+
merged.mutants.sort((a, b) => order.get(a.id) - order.get(b.id));
|
|
186
|
+
const section = renderSection(merged, { head: git.head(), date: new Date().toISOString().slice(0, 10), subset: subsets[0] ?? null });
|
|
187
|
+
const outPath = resolve(root, options.out ?? join('.morph', 'features', feature ?? '_', '4-review', 'mutation-section.md'));
|
|
188
|
+
mkdirSync(join(outPath, '..'), { recursive: true });
|
|
189
|
+
writeFileSync(outPath, section);
|
|
190
|
+
|
|
191
|
+
const tally = summarize(merged);
|
|
192
|
+
if (options.json) console.log(JSON.stringify({ ...merged, tally, section: outPath }, null, 2));
|
|
193
|
+
else {
|
|
194
|
+
console.log(section);
|
|
195
|
+
console.log(chalk.gray(`seção gravada em ${outPath}`));
|
|
196
|
+
console.log(`${chalk.green(`${tally.killed} mortos`)} · ${tally.survived ? chalk.yellow(`${tally.survived} sobreviveram`) : '0 sobreviveram'} · ${tally['build-error']} não compilaram · ${tally.inconclusive} inconclusivos`);
|
|
197
|
+
}
|
|
198
|
+
if (!merged.restore.ok || ['mismatch', 'unlocated'].includes(merged.binary.proof) || !merged.restore.treeClean) process.exitCode = EXIT.unsafe;
|
|
199
|
+
return { ...merged, tally, section: outPath };
|
|
200
|
+
}
|
|
@@ -9,6 +9,9 @@ import { getHeadSha } from '../../lib/git/default-branch.js';
|
|
|
9
9
|
import { EVAL_RUNNER } from '../../lib/tasks/eval-node.js';
|
|
10
10
|
import { buildVerifyEvent, recordVerifyEvent, findRepeatedVerify } from '../../lib/telemetry/verify-event.js';
|
|
11
11
|
import { treeFingerprint } from '../../lib/git/tree-fingerprint.js';
|
|
12
|
+
import { HeavyBusyError } from '../../lib/heavy/gate.js';
|
|
13
|
+
import { afterHeavyRun } from '../../lib/heavy/cleanup.js';
|
|
14
|
+
import { readHeavyConfig } from '../../../framework/hooks/shared/heavy-config.js';
|
|
12
15
|
|
|
13
16
|
/** ✓ pass · ✗ fail · – skip */
|
|
14
17
|
function statusIcon(status) {
|
|
@@ -252,9 +255,27 @@ export async function verifyCommand(featureName, taskId, options = {}) {
|
|
|
252
255
|
console.error(chalk.red(`\n❌ ${err.message}\n`));
|
|
253
256
|
process.exit(2);
|
|
254
257
|
}
|
|
258
|
+
// Ocupado não é veredito: nada foi verificado, então não há stamp nem
|
|
259
|
+
// evento, e o exit 3 diz "tente de novo", nunca "vermelho".
|
|
260
|
+
if (err instanceof HeavyBusyError) {
|
|
261
|
+
if (options.json) {
|
|
262
|
+
const holders = err.holders.map(({ pid, label, feature, node, since }) => ({ pid, label, feature, node, since }));
|
|
263
|
+
console.log(JSON.stringify({
|
|
264
|
+
status: 'busy', persisted: false, busy: { reason: err.reason, holders, freeGb: err.freeGb, waitedMs: err.waitedMs },
|
|
265
|
+
}, null, 2));
|
|
266
|
+
}
|
|
267
|
+
console.error(chalk.yellow(`\n⏳ verify não rodou: ${err.message}. Não é vermelho — tente de novo quando liberar (\`morph-spec heavy status\`).\n`));
|
|
268
|
+
process.exit(3);
|
|
269
|
+
}
|
|
255
270
|
throw err;
|
|
256
271
|
}
|
|
257
272
|
|
|
273
|
+
const cleanup = afterHeavyRun({
|
|
274
|
+
projectPath: process.cwd(),
|
|
275
|
+
config: readHeavyConfig(process.cwd()),
|
|
276
|
+
scope: taskId ? 'task' : 'feature',
|
|
277
|
+
});
|
|
278
|
+
|
|
258
279
|
const scopeKey = taskId ? `taskVerifications.${taskId}` : 'verifications.feature';
|
|
259
280
|
// `--record-baseline` is config maintenance, not gate evidence: it measures
|
|
260
281
|
// the suite in order to write `expectedCount`. Persisting a `verification`
|
|
@@ -343,10 +364,14 @@ export async function verifyCommand(featureName, taskId, options = {}) {
|
|
|
343
364
|
}
|
|
344
365
|
|
|
345
366
|
if (options.json) {
|
|
346
|
-
const output = { ...result, persisted: persist, ...(persist ? {} : { reason }), repeatOf };
|
|
367
|
+
const output = { ...result, persisted: persist, ...(persist ? {} : { reason }), repeatOf, cleanup };
|
|
347
368
|
console.log(JSON.stringify(output, null, 2));
|
|
348
369
|
} else {
|
|
349
370
|
console.log(formatVerifyResult(result));
|
|
371
|
+
if (cleanup.status !== 'skipped') {
|
|
372
|
+
const mark = cleanup.status === 'ran' ? chalk.green('✓') : chalk.yellow('⚠');
|
|
373
|
+
console.log(chalk.gray(`${mark} dotnet build-server shutdown: ${cleanup.status}${cleanup.reason ? ` (${cleanup.reason})` : ''}\n`));
|
|
374
|
+
}
|
|
350
375
|
if (repeatOf) {
|
|
351
376
|
console.log(chalk.yellow(`⚠️ Mesma árvore do verify de feature de ${repeatOf.ts} (${repeatOf.status}): nada mudou fora de .morph/ desde então. `
|
|
352
377
|
+ 'Esta rodada não prova nada novo — reuse o resultado anterior, a menos que o ambiente (Docker, banco, serviço externo) tenha mudado.\n'));
|
|
@@ -26,7 +26,7 @@ import { GATE_PREREQ } from '../../core/state/gate-order.js';
|
|
|
26
26
|
import { validateTasksJson } from '../../lib/tasks/tasks-json-parser.js';
|
|
27
27
|
import { evaluatorVerdictFromText } from '../../lib/evaluator-report.js';
|
|
28
28
|
import { resolveFeatureTree } from '../../lib/worktree/feature-truth.js';
|
|
29
|
-
import {
|
|
29
|
+
import { containerDirs } from '../../../framework/hooks/shared/worktree-root.js';
|
|
30
30
|
import { canonicalPath, isInside, readMorphConfig } from '../../lib/tasks/test-runner.js';
|
|
31
31
|
import { isFinished } from './feature-status.js';
|
|
32
32
|
|
|
@@ -79,9 +79,9 @@ function collectLocations(projectDir) {
|
|
|
79
79
|
for (const name of subdirs(rootFeatures)) {
|
|
80
80
|
if (existsSync(join(rootFeatures, name, 'feature.json'))) slot(name).root = join(rootFeatures, name);
|
|
81
81
|
}
|
|
82
|
-
for (const
|
|
83
|
-
for (const wt of subdirs(
|
|
84
|
-
const wtFeatures = join(
|
|
82
|
+
for (const container of containerDirs(projectDir)) {
|
|
83
|
+
for (const wt of subdirs(container)) {
|
|
84
|
+
const wtFeatures = join(container, wt, '.morph', 'features');
|
|
85
85
|
for (const name of subdirs(wtFeatures)) {
|
|
86
86
|
if (existsSync(join(wtFeatures, name, 'feature.json'))) slot(name).worktrees.push(join(wtFeatures, name));
|
|
87
87
|
}
|
|
@@ -12,7 +12,7 @@ import { join } from 'path';
|
|
|
12
12
|
import { existsSync, readdirSync } from 'fs';
|
|
13
13
|
import { scanFeatures, readFeatureDetail, readFeatureFile } from './features-scanner.js';
|
|
14
14
|
import { createEventStore, buildFeed, lastActivityByFeature, ALL_KINDS, DEFAULT_KINDS } from './activity-scanner.js';
|
|
15
|
-
import {
|
|
15
|
+
import { containerDirs } from '../../../framework/hooks/shared/worktree-root.js';
|
|
16
16
|
import { isFinished } from './feature-status.js';
|
|
17
17
|
import { resolveFeatureTree } from '../../lib/worktree/feature-truth.js';
|
|
18
18
|
|
|
@@ -51,11 +51,11 @@ export function isMorphDataReload(path) {
|
|
|
51
51
|
*/
|
|
52
52
|
export function watchTargets(projectDir) {
|
|
53
53
|
const morphDirs = [join(projectDir, '.morph')];
|
|
54
|
-
for (const
|
|
54
|
+
for (const container of containerDirs(projectDir)) {
|
|
55
55
|
let entries = [];
|
|
56
|
-
try { entries = readdirSync(
|
|
56
|
+
try { entries = readdirSync(container, { withFileTypes: true }); } catch { continue; }
|
|
57
57
|
for (const e of entries) {
|
|
58
|
-
if (e.isDirectory()) morphDirs.push(join(
|
|
58
|
+
if (e.isDirectory()) morphDirs.push(join(container, e.name, '.morph'));
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
61
|
const targets = [];
|
|
@@ -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
|
+
}
|