@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.
- package/CLAUDE.md +15 -5
- 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 +22 -10
- 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/claude-code/core/worktree-env-inject.js +1 -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/rules/ai-agents-standards.md +1 -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/STANDARDS.json +45 -1
- package/framework/standards/ai-agents/tool-adapters.md +226 -0
- package/framework/standards/frontend/design-system/ai-video-generation.md +2 -0
- package/framework/standards/frontend/scroll-driven/frame-scrub.md +11 -1
- package/framework/standards/infrastructure/docker/local-compose-isolation.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 +112 -43
- package/src/commands/state/approve.js +2 -1
- package/src/commands/state/finish.js +4 -0
- package/src/commands/state/gate-check.js +2 -1
- package/src/commands/validation/mutate.js +200 -0
- package/src/commands/validation/verify.js +39 -22
- 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/e2e/compose-env.js +5 -2
- package/src/lib/git/evidence-staleness.js +39 -0
- 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/risk-detector.js +3 -3
- package/src/lib/tasks/eval-node.js +4 -34
- package/src/lib/tasks/linux-runner.js +30 -3
- package/src/lib/tasks/test-runner.js +1 -1
- package/src/lib/validators/contracts/contract-compliance-validator.js +4 -42
- 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/env-allocator.js +35 -5
- 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) {
|
|
@@ -126,6 +129,13 @@ function decidePersistence({ cwd, featureName, noPersist }) {
|
|
|
126
129
|
return { persist: false, reason: samePath(root, cwd) ? 'owner-tree-elsewhere' : 'unregistered-worktree' };
|
|
127
130
|
}
|
|
128
131
|
|
|
132
|
+
function baselineStampBlock(baseline) {
|
|
133
|
+
if (!baseline?.recorded) return baseline?.reason ? `record-baseline: ${baseline.reason}` : 'record-baseline';
|
|
134
|
+
const fell = baseline.updates.filter((u) => u.from !== null && u.to < u.from);
|
|
135
|
+
if (fell.length === 0) return null;
|
|
136
|
+
return `record-baseline: a contagem caiu em ${fell.map((u) => `${u.id} (${u.from} → ${u.to})`).join(', ')}`;
|
|
137
|
+
}
|
|
138
|
+
|
|
129
139
|
/**
|
|
130
140
|
* The per-node status map, in the shape `feature.schema.json` fixes for BOTH
|
|
131
141
|
* writers of it — the per-scope history entry (`verifications.*`) and the
|
|
@@ -252,34 +262,37 @@ export async function verifyCommand(featureName, taskId, options = {}) {
|
|
|
252
262
|
console.error(chalk.red(`\n❌ ${err.message}\n`));
|
|
253
263
|
process.exit(2);
|
|
254
264
|
}
|
|
265
|
+
// Ocupado não é veredito: nada foi verificado, então não há stamp nem
|
|
266
|
+
// evento, e o exit 3 diz "tente de novo", nunca "vermelho".
|
|
267
|
+
if (err instanceof HeavyBusyError) {
|
|
268
|
+
if (options.json) {
|
|
269
|
+
const holders = err.holders.map(({ pid, label, feature, node, since }) => ({ pid, label, feature, node, since }));
|
|
270
|
+
console.log(JSON.stringify({
|
|
271
|
+
status: 'busy', persisted: false, busy: { reason: err.reason, holders, freeGb: err.freeGb, waitedMs: err.waitedMs },
|
|
272
|
+
}, null, 2));
|
|
273
|
+
}
|
|
274
|
+
console.error(chalk.yellow(`\n⏳ verify não rodou: ${err.message}. Não é vermelho — tente de novo quando liberar (\`morph-spec heavy status\`).\n`));
|
|
275
|
+
process.exit(3);
|
|
276
|
+
}
|
|
255
277
|
throw err;
|
|
256
278
|
}
|
|
257
279
|
|
|
280
|
+
const cleanup = afterHeavyRun({
|
|
281
|
+
projectPath: process.cwd(),
|
|
282
|
+
config: readHeavyConfig(process.cwd()),
|
|
283
|
+
scope: taskId ? 'task' : 'feature',
|
|
284
|
+
});
|
|
285
|
+
|
|
258
286
|
const scopeKey = taskId ? `taskVerifications.${taskId}` : 'verifications.feature';
|
|
259
|
-
//
|
|
260
|
-
//
|
|
261
|
-
//
|
|
262
|
-
// asks for — the same class of confusion as a hand-typed baseline.
|
|
263
|
-
//
|
|
264
|
-
// `--filter` at FEATURE scope is the other side of the same hole (#87): the
|
|
265
|
-
// run is narrowed to a subset of nodes, but the stamp it would write carries
|
|
266
|
-
// `scope: 'feature'` — the one scope risk-detector.js trusts unconditionally.
|
|
267
|
-
// A partial run must not stand in for the proof a gate asks for, same as
|
|
268
|
-
// `--record-baseline` above. `--skip-tests` and `--skip-build` are the same
|
|
269
|
-
// partial run through another door (#113): a skipped node never reddens the
|
|
270
|
-
// rollup, so the stamp said `pass` over a suite (or a build) that never ran,
|
|
271
|
-
// and no gate rule reads a skipped `tests`/`build`. Task scope is unaffected:
|
|
272
|
-
// that stamp already says `scope: 'task:{id}'` (which pauses the ship gate on
|
|
273
|
-
// its own), and `taskVerifications` never claimed to cover more than the
|
|
274
|
-
// task. Checked AFTER `--no-persist` (via the `!options.noPersist` guard) so
|
|
275
|
-
// an explicit `--no-persist` keeps its own reason instead of being reported
|
|
276
|
-
// as one of these — the flags are all "don't write", but decidePersistence()
|
|
277
|
-
// already owns the former's reason.
|
|
287
|
+
// A run that measured only part of the suite must not stand in for the proof a gate asks for:
|
|
288
|
+
// --filter and --skip-* at feature scope, or a --record-baseline that recorded nothing. Task scope
|
|
289
|
+
// is unaffected (its stamp already says scope 'task:{id}'); --no-persist keeps its own reason.
|
|
278
290
|
const partialFeatureRun = (!options.noPersist && !taskId)
|
|
279
291
|
? PARTIAL_RUN_FLAGS.find(([flag]) => options[flag])?.[1] ?? null
|
|
280
292
|
: null;
|
|
281
|
-
const
|
|
282
|
-
|
|
293
|
+
const baselineBlock = options.recordBaseline ? baselineStampBlock(result.baseline) : null;
|
|
294
|
+
const { persist, reason } = baselineBlock
|
|
295
|
+
? { persist: false, reason: baselineBlock }
|
|
283
296
|
: partialFeatureRun
|
|
284
297
|
? { persist: false, reason: partialFeatureRun }
|
|
285
298
|
: decidePersistence({ cwd: process.cwd(), featureName, noPersist: options.noPersist });
|
|
@@ -343,10 +356,14 @@ export async function verifyCommand(featureName, taskId, options = {}) {
|
|
|
343
356
|
}
|
|
344
357
|
|
|
345
358
|
if (options.json) {
|
|
346
|
-
const output = { ...result, persisted: persist, ...(persist ? {} : { reason }), repeatOf };
|
|
359
|
+
const output = { ...result, persisted: persist, ...(persist ? {} : { reason }), repeatOf, cleanup };
|
|
347
360
|
console.log(JSON.stringify(output, null, 2));
|
|
348
361
|
} else {
|
|
349
362
|
console.log(formatVerifyResult(result));
|
|
363
|
+
if (cleanup.status !== 'skipped') {
|
|
364
|
+
const mark = cleanup.status === 'ran' ? chalk.green('✓') : chalk.yellow('⚠');
|
|
365
|
+
console.log(chalk.gray(`${mark} dotnet build-server shutdown: ${cleanup.status}${cleanup.reason ? ` (${cleanup.reason})` : ''}\n`));
|
|
366
|
+
}
|
|
350
367
|
if (repeatOf) {
|
|
351
368
|
console.log(chalk.yellow(`⚠️ Mesma árvore do verify de feature de ${repeatOf.ts} (${repeatOf.status}): nada mudou fora de .morph/ desde então. `
|
|
352
369
|
+ '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 {
|
|
@@ -23,7 +23,7 @@ import { join } from 'path';
|
|
|
23
23
|
import chalk from 'chalk';
|
|
24
24
|
|
|
25
25
|
import { loadEnvFile } from '../../../framework/hooks/shared/e2e-check.js';
|
|
26
|
-
import { allocateEnvBlock, portLayout, resolveFleetConfig } from '../worktree/env-allocator.js';
|
|
26
|
+
import { allocateEnvBlock, portLayout, resolveFleetConfig, resolvePortAliases } from '../worktree/env-allocator.js';
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
29
|
* Build the environment a `docker compose` invocation should carry.
|
|
@@ -69,8 +69,11 @@ export function buildComposeEnv({ cwd, config, featureName, gitCommonDir }) {
|
|
|
69
69
|
const { basePort, blockSize } = resolveFleetConfig(cwd);
|
|
70
70
|
const block = allocateEnvBlock(featureName, { gitCommonDir, basePort, blockSize });
|
|
71
71
|
|
|
72
|
+
const { aliases, problems } = resolvePortAliases(config);
|
|
73
|
+
for (const problem of problems) console.warn(chalk.yellow(` ⚠ ${problem} — ignorado`));
|
|
74
|
+
|
|
72
75
|
const layout = {
|
|
73
|
-
...portLayout(block, { stack: config?.project?.stack ?? '', feature: featureName }),
|
|
76
|
+
...portLayout(block, { stack: config?.project?.stack ?? '', feature: featureName, aliases }),
|
|
74
77
|
PLAYWRIGHT_BASE_URL: `http://localhost:${block.base}`,
|
|
75
78
|
};
|
|
76
79
|
Object.assign(env, layout);
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { execFileSync } from 'child_process';
|
|
2
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
3
|
+
import { getHeadSha } from './default-branch.js';
|
|
4
|
+
|
|
5
|
+
const MORPH_DIR = '.morph/';
|
|
6
|
+
const CONFIG_PATH = '.morph/config/config.json';
|
|
7
|
+
|
|
8
|
+
const gitOut = (cwd, args) => execFileSync('git', args, { cwd, stdio: 'pipe', maxBuffer: 64 * 1024 * 1024 }).toString();
|
|
9
|
+
|
|
10
|
+
function withoutBaseline(text) {
|
|
11
|
+
const config = JSON.parse(text);
|
|
12
|
+
if (Array.isArray(config?.project?.tests)) {
|
|
13
|
+
for (const node of config.project.tests) delete node?.expectedCount;
|
|
14
|
+
}
|
|
15
|
+
return config;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function configMoved(cwd, sha, head) {
|
|
19
|
+
try {
|
|
20
|
+
const before = withoutBaseline(gitOut(cwd, ['show', `${sha}:${CONFIG_PATH}`]));
|
|
21
|
+
const after = withoutBaseline(gitOut(cwd, ['show', `${head}:${CONFIG_PATH}`]));
|
|
22
|
+
return !isDeepStrictEqual(before, after);
|
|
23
|
+
} catch {
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// `.morph/` is bookkeeping, except the config: it names the test commands, and only its
|
|
29
|
+
// expectedCount is something a baseline run writes.
|
|
30
|
+
export function treeMovedSince(cwd, sha, head = getHeadSha(cwd)) {
|
|
31
|
+
if (!sha || !head || sha === head) return false;
|
|
32
|
+
let changed;
|
|
33
|
+
try {
|
|
34
|
+
changed = gitOut(cwd, ['diff', '--name-only', '--no-renames', '-z', sha, head]).split('\0').filter(Boolean);
|
|
35
|
+
} catch {
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
return changed.some((path) => !path.startsWith(MORPH_DIR) || (path === CONFIG_PATH && configMoved(cwd, sha, head)));
|
|
39
|
+
}
|
|
@@ -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
|
+
}
|