@polymorphism-tech/morph-spec 9.5.0 → 9.7.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/README.md +2 -2
- package/bin/morph-spec.js +13 -5
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/agents.json +3 -3
- package/framework/docs/CLI.md +43 -11
- package/framework/evals/review-checklist.md +2 -2
- package/framework/hooks/claude-code/__pycache__/statusline.cpython-313.pyc +0 -0
- package/framework/hooks/claude-code/core/README.md +4 -0
- package/framework/hooks/claude-code/core/heavy-gate.js +5 -3
- package/framework/hooks/claude-code/core/test-budget.js +89 -0
- package/framework/hooks/claude-code/statusline.sh +11 -11
- package/framework/hooks/shared/e2e-check.js +0 -0
- package/framework/hooks/shared/feature-locator.js +7 -5
- package/framework/hooks/shared/heavy-command.js +72 -1
- package/framework/hooks/shared/heavy-config.js +28 -1
- package/framework/hooks/shared/heavy-slots.js +41 -5
- package/framework/hooks/shared/host-services.js +433 -0
- package/framework/schemas/config.schema.json +65 -2
- 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 +1 -1
- package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
- package/framework/skills/core/morph-machine/SKILL.md +1 -1
- package/framework/skills/core/morph-media/SKILL.md +1 -1
- package/framework/skills/core/morph-plan/SKILL.md +3 -3
- package/framework/skills/core/morph-review/SKILL.md +3 -2
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/framework/skills/core/morph-uiux/SKILL.md +1 -1
- package/framework/standards/STANDARDS.json +2 -1
- package/framework/standards/infrastructure/docker/local-compose-isolation.md +15 -1
- package/framework/templates/IDE-SNIPPETS.md +262 -262
- package/framework/templates/code/dotnet/abstractions/api-endpoint.cs +7 -7
- package/framework/templates/code/dotnet/feature/request.cs.hbs +4 -4
- package/framework/templates/code/dotnet/feature/response.cs.hbs +4 -4
- package/framework/templates/code/sql/ai-agents/pgvector-embeddings-migration.sql.template +91 -91
- package/framework/templates/frontend/nextjs/client-component.tsx.hbs +26 -26
- package/framework/templates/frontend/nextjs/tsconfig.json.hbs +26 -26
- package/framework/templates/infrastructure/docker/Dockerfile.template +58 -58
- package/framework/templates/infrastructure/docker/dockerfile-api.dockerfile +38 -38
- package/framework/templates/infrastructure/docker/dockerfile-web.dockerfile +48 -48
- package/framework/templates/infrastructure/github/actions/docker-build-push/action.yml.hbs +45 -45
- package/framework/templates/infrastructure/github/actions/health-check/action.yml.hbs +27 -27
- package/framework/templates/infrastructure/github/workflows/docker-build-push.yml.hbs +59 -59
- package/framework/templates/infrastructure/github/workflows/dotnet-build.yml.hbs +39 -39
- package/framework/templates/state.template.json +17 -17
- package/framework/templates/ui/design-system.css +226 -226
- package/package.json +1 -1
- package/src/commands/e2e/e2e.js +50 -24
- package/src/commands/observability/cost.js +12 -0
- package/src/commands/project/env.js +3 -3
- package/src/commands/project/heavy.js +10 -3
- package/src/commands/project/worktree.js +9 -4
- package/src/commands/state/advance.js +3 -0
- package/src/commands/state/create.js +3 -3
- package/src/commands/state/delete.js +1 -1
- package/src/commands/state/finish.js +20 -4
- package/src/commands/validation/mutate.js +2 -2
- package/src/commands/validation/plan-check.js +1 -0
- package/src/commands/validation/verify.js +3 -0
- package/src/dashboard/app/main.jsx +13 -13
- package/src/dashboard/vite.config.js +24 -24
- package/src/lib/e2e/compose-env.js +26 -3
- package/src/lib/e2e/stack-teardown.js +19 -4
- package/src/lib/heavy/gate.js +23 -4
- package/src/lib/heavy/live-stacks.js +64 -0
- package/src/lib/review-reports.js +21 -10
- package/src/lib/risk-detector.js +9 -1
- package/src/lib/tasks/test-runner.js +3 -0
- package/src/lib/telemetry/metrics.js +47 -3
- package/src/lib/validators/packages/package-validator.js +2 -2
- package/src/lib/worktree/docker-audit.js +7 -3
- package/src/lib/worktree/env-allocator.js +27 -5
- package/src/lib/worktree/ignored-triage.js +8 -0
- package/src/utils/agents-installer.js +4 -1
- package/src/utils/file-copier.js +26 -4
- package/src/utils/hooks-installer.js +4 -4
- package/framework/templates/.idea/morph-templates.xml +0 -92
- package/framework/templates/.vscode/morph-templates.code-snippets +0 -186
|
@@ -108,6 +108,18 @@ function printMetricsBody(m) {
|
|
|
108
108
|
console.log(` ${klass.padEnd(12)} ${fmtDuration(t.measuredMs / 1000).padStart(8)} ${t.runs} execução(ões)${unmeasured}`);
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
const tov = m.testOutsideVerify;
|
|
112
|
+
const timed = (t) => `${String(t.runs).padStart(4)}× ${fmtDuration(t.measuredMs / 1000).padStart(8)}`
|
|
113
|
+
+ (t.unmeasured ? chalk.gray(` (${t.unmeasured} sem duração)`) : '');
|
|
114
|
+
console.log('\n' + chalk.white.bold('Teste fora do verify (sub-agentes)')
|
|
115
|
+
+ chalk.gray(` · de ${tov.total.runs} testes+verifies, ${fmtDuration(tov.total.measuredMs / 1000)} cronometrados`
|
|
116
|
+
+ (tov.total.unmeasured ? `, ${tov.total.unmeasured} sem duração` : '')));
|
|
117
|
+
console.log(` sub-agente rodando verify cheio ${timed(tov.subagentVerify)}`);
|
|
118
|
+
console.log(` implementador, 3ª rodada em diante ${timed(tov.implementerRound3Plus)}`);
|
|
119
|
+
console.log(` agente de review rodando teste ${timed(tov.reviewTests)}`);
|
|
120
|
+
console.log(chalk.gray(` ${tov.reviewTests.byPersona.runs}× pela persona morph-* (o que o hook test-budget reconhece);`
|
|
121
|
+
+ ' o resto veio de um nome de dispatch (ver-, rev-, gate3-…)'));
|
|
122
|
+
|
|
111
123
|
console.log('\n' + chalk.white.bold('Rerun do mesmo comando em ≤10 min (mesma feature; o filtro de saída não conta)'));
|
|
112
124
|
console.log(` total: ${pct(m.reruns.rate)} repetidas (${m.reruns.reruns}/${m.reruns.runs}) · ${pct(m.reruns.burstRate)} em rajada — testes, builds e verifies`);
|
|
113
125
|
for (const [kind, r] of Object.entries(m.rerunsByKind).sort((a, b) => b[1].runs - a[1].runs)) {
|
|
@@ -35,7 +35,7 @@ function gitCommonDir(cwd) {
|
|
|
35
35
|
const shq = (v) => `'${String(v).replace(/'/g, "'\\''")}'`;
|
|
36
36
|
|
|
37
37
|
/** The env map for a key, or `{ error }`. */
|
|
38
|
-
export function resolveWorktreeEnv(featureArg, cwd = process.cwd()) {
|
|
38
|
+
export function resolveWorktreeEnv(featureArg, cwd = process.cwd(), { withCredentials = true } = {}) {
|
|
39
39
|
const key = featureArg || envKeyFromBranch(currentBranch(cwd));
|
|
40
40
|
if (!key) {
|
|
41
41
|
return { error: 'no-feature', message: 'Nenhuma feature: passe o nome ou rode de uma branch morph/{f} / morph-task/{n}.' };
|
|
@@ -45,7 +45,7 @@ export function resolveWorktreeEnv(featureArg, cwd = process.cwd()) {
|
|
|
45
45
|
return { error: 'not-a-git-repo', message: 'Fora de um repositório git não há bloco de portas.' };
|
|
46
46
|
}
|
|
47
47
|
const root = resolvePrimaryRoot(cwd);
|
|
48
|
-
const env = buildComposeEnv({ cwd: root, config: readConfig(root), featureName: key, gitCommonDir: common });
|
|
48
|
+
const env = buildComposeEnv({ cwd: root, config: readConfig(root), featureName: key, gitCommonDir: common, withCredentials });
|
|
49
49
|
return { key, env };
|
|
50
50
|
}
|
|
51
51
|
|
|
@@ -57,7 +57,7 @@ export function render(env, format) {
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
export function envCommand(featureArg, options = {}) {
|
|
60
|
-
const result = resolveWorktreeEnv(featureArg || null, process.cwd());
|
|
60
|
+
const result = resolveWorktreeEnv(featureArg || null, process.cwd(), { withCredentials: Boolean(options.withSecrets) });
|
|
61
61
|
if (result.error) {
|
|
62
62
|
if (options.json) console.log(JSON.stringify({ error: result.error, message: result.message }));
|
|
63
63
|
else console.error(chalk.red(`✗ ${result.message}`));
|
|
@@ -5,6 +5,8 @@ import { readHeavyConfig } from '../../../framework/hooks/shared/heavy-config.js
|
|
|
5
5
|
import { createHeavyGate, HeavyBusyError } from '../../lib/heavy/gate.js';
|
|
6
6
|
import { describeHolder, heavyDir, heavyStatus } from '../../../framework/hooks/shared/heavy-slots.js';
|
|
7
7
|
import { resolveSpawn } from '../../../framework/hooks/shared/spawn-command.js';
|
|
8
|
+
import { heavyClass, heavyKind } from '../../../framework/hooks/shared/heavy-command.js';
|
|
9
|
+
import { memoryBusyHint } from '../../lib/heavy/live-stacks.js';
|
|
8
10
|
|
|
9
11
|
const EXIT_BUSY = 3;
|
|
10
12
|
|
|
@@ -15,6 +17,7 @@ export function heavyStatusCommand(options = {}) {
|
|
|
15
17
|
enabled: config.enabled,
|
|
16
18
|
slots: config.slots,
|
|
17
19
|
minFreeMemGb: config.minFreeMemGb,
|
|
20
|
+
minFreeMemGbByKind: config.minFreeMemGbByKind,
|
|
18
21
|
dir: status.dir,
|
|
19
22
|
freeGb: Number(status.freeGb.toFixed(2)),
|
|
20
23
|
holders: status.holders.map(({ pid, label, feature, node, cwd, since, leaseId }) => ({ pid, label, feature, node, cwd, since, ...(leaseId ? { leaseId } : {}) })),
|
|
@@ -29,8 +32,10 @@ export function heavyStatusCommand(options = {}) {
|
|
|
29
32
|
logger.header('Slots de nós pesados (build, testes, e2e)');
|
|
30
33
|
if (!config.enabled) console.log(chalk.yellow(' ⚠ semáforo desligado (heavy.enabled: false ou MORPH_HEAVY=off)'));
|
|
31
34
|
console.log(chalk.gray(` diretório: ${report.dir}`));
|
|
32
|
-
const
|
|
33
|
-
|
|
35
|
+
const floors = config.minFreeMemGbByKind;
|
|
36
|
+
const memMark = report.freeGb < Math.max(...Object.values(floors)) ? chalk.yellow('⚠') : chalk.green('✓');
|
|
37
|
+
const floorText = Object.entries(floors).map(([kind, gb]) => `${kind} ${gb}`).join(' · ');
|
|
38
|
+
console.log(` ${memMark} RAM livre: ${report.freeGb.toFixed(1)} GB (mínimo em GB: ${floorText})`);
|
|
34
39
|
console.log(` slots em uso: ${status.holders.length}/${config.slots}`);
|
|
35
40
|
for (const h of status.holders) console.log(chalk.gray(` • ${describeHolder(h)}${h.cwd ? ` — ${h.cwd}` : ''}`));
|
|
36
41
|
if (status.exclusive) console.log(chalk.yellow(` ⚠ limpeza do servidor de build em curso (${describeHolder(status.exclusive)})`));
|
|
@@ -42,10 +47,12 @@ export function heavyRunCommand(command, options = {}) {
|
|
|
42
47
|
const [file, ...args] = command;
|
|
43
48
|
const gate = createHeavyGate({ projectPath: process.cwd(), label: options.label ?? 'run' });
|
|
44
49
|
try {
|
|
45
|
-
gate.enter(file);
|
|
50
|
+
gate.enter(file, heavyClass(heavyKind(command.join(' '))));
|
|
46
51
|
} catch (err) {
|
|
47
52
|
if (!(err instanceof HeavyBusyError)) throw err;
|
|
48
53
|
console.error(chalk.yellow(`\n⏳ ${file} não rodou: ${err.message}. Não é vermelho — a fila já esperou o prazo dela: não repita em laço — siga sem esta rodada e avise o usuário (\`morph-spec heavy status\` mostra quem segura os slots).\n`));
|
|
54
|
+
const hint = memoryBusyHint(err, process.cwd());
|
|
55
|
+
if (hint) console.error(chalk.yellow(`${hint}\n`));
|
|
49
56
|
process.exit(EXIT_BUSY);
|
|
50
57
|
}
|
|
51
58
|
|
|
@@ -3,7 +3,7 @@ import { existsSync, mkdirSync, symlinkSync, readFileSync, writeFileSync, lstatS
|
|
|
3
3
|
import { join, resolve, dirname, basename } from 'path';
|
|
4
4
|
import chalk from 'chalk';
|
|
5
5
|
import { detectDefaultBranch } from '../../lib/git/default-branch.js';
|
|
6
|
-
import { triageIgnoredPaths } from '../../lib/worktree/ignored-triage.js';
|
|
6
|
+
import { isTestArtifact, triageIgnoredPaths } from '../../lib/worktree/ignored-triage.js';
|
|
7
7
|
import {
|
|
8
8
|
finishBackupDir,
|
|
9
9
|
writeFeatureScopeBackup,
|
|
@@ -710,6 +710,7 @@ export function primaryRootUnexplainedDirt(root, feature, opts = {}) {
|
|
|
710
710
|
if (!record || record.length < 4) return false;
|
|
711
711
|
const p = record.slice(3).replace(/\\/g, '/');
|
|
712
712
|
if (opts.exemptOwnScope && p.startsWith(ownScope)) return false;
|
|
713
|
+
if (record.startsWith('?? ') && isTestArtifact(p)) return false;
|
|
713
714
|
return !isLiveSiblingWorktreeRegistration(p, root, feature);
|
|
714
715
|
});
|
|
715
716
|
return { ok: true, dirt };
|
|
@@ -1012,11 +1013,13 @@ export async function runWorktreeRemove(featureArg, options = {}) {
|
|
|
1012
1013
|
// Read BEFORE the teardown: the entry is gone from `git worktree list` afterwards.
|
|
1013
1014
|
const envKey = envKeyForWorktree(worktreePath, root);
|
|
1014
1015
|
|
|
1015
|
-
// Compose stack down BEFORE the tree: a bind-mounted container holds the dir on Windows.
|
|
1016
|
+
// Compose stack down BEFORE the tree: a bind-mounted container holds the dir on Windows.
|
|
1017
|
+
// A `morph/{f}` volume survives for the finish that reclaims it; a `morph-task/*` one has no later owner.
|
|
1018
|
+
const volumes = /^morph-task\//.test(branch ?? '');
|
|
1016
1019
|
let e2e = null;
|
|
1017
1020
|
try {
|
|
1018
1021
|
const { tearDownFeatureStack } = await import('../../lib/e2e/stack-teardown.js');
|
|
1019
|
-
e2e = tearDownFeatureStack(envKey || feature || basename(worktreePath), { root, worktreePath, volumes:
|
|
1022
|
+
e2e = tearDownFeatureStack(envKey || feature || basename(worktreePath), { root, worktreePath, volumes, _exec: options._exec });
|
|
1020
1023
|
} catch { e2e = null; }
|
|
1021
1024
|
|
|
1022
1025
|
// The stamp this removal's backup is written under — protected from the
|
|
@@ -1043,7 +1046,9 @@ export async function runWorktreeRemove(featureArg, options = {}) {
|
|
|
1043
1046
|
...(feature ? { feature } : {}),
|
|
1044
1047
|
};
|
|
1045
1048
|
if (teardown.unlinked.length) out.unlinked = teardown.unlinked;
|
|
1046
|
-
if (e2e?.attempted
|
|
1049
|
+
if (e2e?.attempted || e2e?.hostServices) {
|
|
1050
|
+
out.e2e = { status: e2e.status, cwd: e2e.cwd, volumes, ...(e2e.hostServices ? { hostServices: e2e.hostServices } : {}) };
|
|
1051
|
+
}
|
|
1047
1052
|
if (teardown.worktreeRemoved) {
|
|
1048
1053
|
if (teardown.discarded.length) out.discardedIgnored = teardown.discarded;
|
|
1049
1054
|
if (teardown.replicable.length) out.replicatedInRoot = teardown.replicable;
|
|
@@ -76,6 +76,9 @@ function resolveApprovedBy(options) {
|
|
|
76
76
|
|
|
77
77
|
export function reportDotnetIgnores(added) {
|
|
78
78
|
if (added.length) console.log(chalk.gray(` .gitignore: ${added.join(' ')} adicionados (projeto .NET) — commite antes do primeiro build.`));
|
|
79
|
+
for (const { entry, file } of added.skipped || []) {
|
|
80
|
+
console.log(chalk.yellow(` .gitignore: ${entry} não adicionado — esconderia fonte versionada (${file}); ignore a saída de build pelo caminho do projeto.`));
|
|
81
|
+
}
|
|
79
82
|
}
|
|
80
83
|
|
|
81
84
|
function reportDesignSeed({ mockups, designSystem }) {
|
|
@@ -9,6 +9,7 @@ import { runWorktreeSetup, materializeState, isGitRepo, branchExists, buildBranc
|
|
|
9
9
|
import { detectDefaultBranch } from '../../lib/git/default-branch.js';
|
|
10
10
|
import { writePlanSkeleton } from '../../lib/plan/plan-template.js';
|
|
11
11
|
import { seedDotnetBuildIgnores } from '../../lib/stack/dotnet-build-ignores.js';
|
|
12
|
+
import { reportDotnetIgnores } from './advance.js';
|
|
12
13
|
|
|
13
14
|
// Branches from the DEFAULT branch, never HEAD, so it never inherits another feature's commits.
|
|
14
15
|
// `checkout: false` still CREATES the branch, which `finish` depends on. Fail-open: any git error
|
|
@@ -221,6 +222,7 @@ export async function createCommand(featureName, options = {}) {
|
|
|
221
222
|
if (branchResult) out.branch = branchResult;
|
|
222
223
|
if (planSkeleton) out.planSkeleton = planSkeleton;
|
|
223
224
|
if (gitignoreAdded.length) out.gitignoreAdded = gitignoreAdded;
|
|
225
|
+
if (gitignoreAdded.skipped) out.gitignoreSkipped = gitignoreAdded.skipped;
|
|
224
226
|
// Under --json the move goes to stderr, so it survives `... --json > out.json`.
|
|
225
227
|
if (branchResult?.movedFrom) {
|
|
226
228
|
console.error(chalk.yellow(branchMoveWarning(branchResult).join('\n')));
|
|
@@ -234,9 +236,7 @@ export async function createCommand(featureName, options = {}) {
|
|
|
234
236
|
console.log(chalk.gray(` .morph/features/${featureName}/feature.json (canonical shape)`));
|
|
235
237
|
console.log(chalk.gray(` status: ${finalFeature.status} | createdAt: ${finalFeature.createdAt}`));
|
|
236
238
|
console.log(chalk.gray(` scaffolded: ${wt.scaffold.join(', ')}`));
|
|
237
|
-
|
|
238
|
-
console.log(chalk.gray(` .gitignore: ${gitignoreAdded.join(' ')} adicionados (projeto .NET)`));
|
|
239
|
-
}
|
|
239
|
+
reportDotnetIgnores(gitignoreAdded);
|
|
240
240
|
if (planSkeleton?.written) {
|
|
241
241
|
console.log(chalk.gray(` 2-plan/plan.md: esqueleto escrito (gramática: morph-spec plan-check --help)`));
|
|
242
242
|
}
|
|
@@ -109,7 +109,7 @@ export async function deleteCommand(featureName, options = {}) {
|
|
|
109
109
|
const root = resolvePrimaryRoot(cwd);
|
|
110
110
|
// The stack comes down BEFORE the worktree, where its compose file and bind mount live. Fail-open.
|
|
111
111
|
try {
|
|
112
|
-
tearDownFeatureStack(featureName, { root, worktreePath: owner.tree, volumes: true });
|
|
112
|
+
tearDownFeatureStack(featureName, { root, worktreePath: owner.tree, volumes: true, images: 'local', _exec: options._exec });
|
|
113
113
|
} catch { /* fail-open */ }
|
|
114
114
|
const teardown = await teardownWorktree(owner.tree, {
|
|
115
115
|
root,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { execFileSync } from 'child_process';
|
|
2
|
-
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
|
2
|
+
import { existsSync, readFileSync, rmSync, writeFileSync } from 'fs';
|
|
3
3
|
import { join, resolve } from 'path';
|
|
4
4
|
import {
|
|
5
5
|
findWorktreeForFeature,
|
|
@@ -23,6 +23,7 @@ import { findReparsePoints } from '../../../framework/hooks/shared/reparse-scan.
|
|
|
23
23
|
import { runArchive } from './archive.js';
|
|
24
24
|
import { dropLeftoverIgnore, withoutLeftoverIgnore } from '../../../framework/hooks/shared/graph-freshness.js';
|
|
25
25
|
import { newBackupStamp, enforceBackupRetention, inspectFinishBackups } from '../../lib/worktree/finish-backup.js';
|
|
26
|
+
import { isTestArtifact } from '../../lib/worktree/ignored-triage.js';
|
|
26
27
|
|
|
27
28
|
const PR_TYPE_BY_WORK_TYPE = { feature: 'feat', bug: 'fix', hotfix: 'fix', chore: 'chore' };
|
|
28
29
|
|
|
@@ -289,7 +290,9 @@ export async function finishCommand(feature, options = {}) {
|
|
|
289
290
|
emit({ finished: false, error: 'status-failed', feature });
|
|
290
291
|
process.exit(1);
|
|
291
292
|
}
|
|
292
|
-
|
|
293
|
+
const dirtLines = withoutLeftoverIgnore(cleanCwd, dirty.split(/\r?\n/).filter((l) => l.trim()));
|
|
294
|
+
const testArtifacts = untrackedTestArtifacts(dirtLines);
|
|
295
|
+
if (dirtLines.length > testArtifacts.length) {
|
|
293
296
|
emit({ finished: false, error: isWorktreeFlow ? 'worktree-dirty' : 'working-dir-dirty', hint: 'commit seu trabalho na branch antes do finish', feature });
|
|
294
297
|
process.exit(1);
|
|
295
298
|
}
|
|
@@ -338,7 +341,10 @@ export async function finishCommand(feature, options = {}) {
|
|
|
338
341
|
|
|
339
342
|
// Every refusal is behind us: the leftover of a killed `graph refresh` goes now, never before.
|
|
340
343
|
dropLeftoverIgnore(cleanCwd);
|
|
341
|
-
|
|
344
|
+
if (isWorktreeFlow) {
|
|
345
|
+
for (const p of testArtifacts) rmSync(join(cleanCwd, p), { recursive: true, force: true });
|
|
346
|
+
}
|
|
347
|
+
const arch =runArchive(feature, { cwd: featureCwd, force: options.force, git: true });
|
|
342
348
|
if (!arch.ok) {
|
|
343
349
|
emit({ finished: false, error: `archive-${arch.error}`, feature });
|
|
344
350
|
process.exit(1);
|
|
@@ -794,6 +800,14 @@ function currentBranch(cwd) {
|
|
|
794
800
|
}
|
|
795
801
|
}
|
|
796
802
|
|
|
803
|
+
/** Untracked Playwright/test output (`?? test-results/`): never work, so it neither blocks nor survives the teardown. */
|
|
804
|
+
function untrackedTestArtifacts(lines) {
|
|
805
|
+
return lines
|
|
806
|
+
.filter((l) => l.startsWith('?? '))
|
|
807
|
+
.map((l) => l.slice(3).replace(/^"|"$/g, '').replace(/\/$/, ''))
|
|
808
|
+
.filter((p) => isTestArtifact(p));
|
|
809
|
+
}
|
|
810
|
+
|
|
797
811
|
function gitStatusPorcelain(cwd) {
|
|
798
812
|
try {
|
|
799
813
|
return execFileSync('git', ['status', '--porcelain'], { cwd, stdio: 'pipe' }).toString();
|
|
@@ -887,7 +901,9 @@ function openPrFor(branch, cwd) {
|
|
|
887
901
|
// Never throws: a stack that refuses to come down must not fail a finish that already merged.
|
|
888
902
|
function reclaimStack(feature, { root, worktreePath, options }) {
|
|
889
903
|
try {
|
|
890
|
-
return tearDownFeatureStack(feature, {
|
|
904
|
+
return tearDownFeatureStack(feature, {
|
|
905
|
+
root, worktreePath, volumes: !options.keepVolumes, images: 'local', _exec: options._exec,
|
|
906
|
+
});
|
|
891
907
|
} catch {
|
|
892
908
|
return { attempted: false, cwd: worktreePath || root, status: 'fail', stampDeleted: false };
|
|
893
909
|
}
|
|
@@ -113,13 +113,13 @@ function subsetFor({ node, group, root }) {
|
|
|
113
113
|
|
|
114
114
|
function e2eManualProtocol(feature, node) {
|
|
115
115
|
const f = feature ?? '<feature>';
|
|
116
|
-
const round = `eval "$(morph-spec env ${f})" && morph-spec e2e up ${f} && morph-spec heavy run -- ${node.command}`;
|
|
116
|
+
const round = `eval "$(morph-spec env ${f} --with-secrets)" && morph-spec e2e up ${f} && morph-spec heavy run -- ${node.command}`;
|
|
117
117
|
return 'mutate só recompila localmente, e a suíte que alcança esse código testa o container do compose: o mutante sairia sobrevivente em falso. ' +
|
|
118
118
|
`Prove à mão pelo nó "${node.id}", cada passo numa linha só e encadeado por \`&&\` (nunca \`;\`: um passo que falha não pode deixar a suíte rodar sobre o código original; ` +
|
|
119
119
|
'e o env da feature se perde entre chamadas de Bash): ' +
|
|
120
120
|
`(1) base verde antes do 1º mutante: \`${round}\`; ` +
|
|
121
121
|
`(2) por mutante: edite a linha && \`${round}\`; ` +
|
|
122
|
-
`(3) restaure, confirme com \`git diff --quiet -- <arquivo>\` e suba a stack de novo (\`eval "$(morph-spec env ${f})" && morph-spec e2e up ${f}\`). ` +
|
|
122
|
+
`(3) restaure, confirme com \`git diff --quiet -- <arquivo>\` e suba a stack de novo (\`eval "$(morph-spec env ${f} --with-secrets)" && morph-spec e2e up ${f}\`). ` +
|
|
123
123
|
'Registre cada mutante em 4-review (expect, resultado, teste que o matou).';
|
|
124
124
|
}
|
|
125
125
|
|
|
@@ -57,6 +57,7 @@ export function planCheckCommand(featureName, options = {}) {
|
|
|
57
57
|
const gitignoreAdded = result.written ? seedDotnetBuildIgnores(process.cwd(), { featureName }) : [];
|
|
58
58
|
if (options.json) {
|
|
59
59
|
if (gitignoreAdded.length) result.gitignoreAdded = gitignoreAdded;
|
|
60
|
+
if (gitignoreAdded.skipped) result.gitignoreSkipped = gitignoreAdded.skipped;
|
|
60
61
|
console.log(JSON.stringify(result, null, 2));
|
|
61
62
|
process.exit(result.exit);
|
|
62
63
|
}
|
|
@@ -11,6 +11,7 @@ import { buildVerifyEvent, recordVerifyEvent, findRepeatedVerify } from '../../l
|
|
|
11
11
|
import { treeFingerprint } from '../../lib/git/tree-fingerprint.js';
|
|
12
12
|
import { HeavyBusyError } from '../../lib/heavy/gate.js';
|
|
13
13
|
import { afterHeavyRun } from '../../lib/heavy/cleanup.js';
|
|
14
|
+
import { memoryBusyHint } from '../../lib/heavy/live-stacks.js';
|
|
14
15
|
import { readHeavyConfig } from '../../../framework/hooks/shared/heavy-config.js';
|
|
15
16
|
import { runCiVerify, CiUsageError, reusableLocalStamp } from '../../lib/ci/ci-verify.js';
|
|
16
17
|
|
|
@@ -146,6 +147,8 @@ async function runLocalVerify(featureName, options) {
|
|
|
146
147
|
}, null, 2));
|
|
147
148
|
}
|
|
148
149
|
console.error(chalk.yellow(`\n⏳ verify não rodou: ${err.message}. Não é vermelho — a fila já esperou o prazo dela: não repita em laço — siga sem esta rodada e avise o usuário (\`morph-spec heavy status\` mostra quem segura os slots).\n`));
|
|
150
|
+
const hint = memoryBusyHint(err, process.cwd());
|
|
151
|
+
if (hint) console.error(chalk.yellow(`${hint}\n`));
|
|
149
152
|
process.exit(3);
|
|
150
153
|
}
|
|
151
154
|
throw err;
|
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import { StrictMode } from 'react';
|
|
2
|
-
import { createRoot } from 'react-dom/client';
|
|
3
|
-
import { BrowserRouter } from 'react-router-dom';
|
|
4
|
-
import App from './App.jsx';
|
|
5
|
-
import './styles/index.css';
|
|
6
|
-
|
|
7
|
-
createRoot(document.getElementById('root')).render(
|
|
8
|
-
<StrictMode>
|
|
9
|
-
<BrowserRouter>
|
|
10
|
-
<App />
|
|
11
|
-
</BrowserRouter>
|
|
12
|
-
</StrictMode>
|
|
13
|
-
);
|
|
1
|
+
import { StrictMode } from 'react';
|
|
2
|
+
import { createRoot } from 'react-dom/client';
|
|
3
|
+
import { BrowserRouter } from 'react-router-dom';
|
|
4
|
+
import App from './App.jsx';
|
|
5
|
+
import './styles/index.css';
|
|
6
|
+
|
|
7
|
+
createRoot(document.getElementById('root')).render(
|
|
8
|
+
<StrictMode>
|
|
9
|
+
<BrowserRouter>
|
|
10
|
+
<App />
|
|
11
|
+
</BrowserRouter>
|
|
12
|
+
</StrictMode>
|
|
13
|
+
);
|
|
@@ -1,24 +1,24 @@
|
|
|
1
|
-
import { defineConfig } from 'vite';
|
|
2
|
-
import react from '@vitejs/plugin-react';
|
|
3
|
-
import tailwindcss from '@tailwindcss/vite';
|
|
4
|
-
import morphDataPlugin from './vite-plugin-morph-data.js';
|
|
5
|
-
import { fileURLToPath } from 'url';
|
|
6
|
-
import { dirname, resolve } from 'path';
|
|
7
|
-
|
|
8
|
-
const __filename = fileURLToPath(import.meta.url);
|
|
9
|
-
const __dirname = dirname(__filename);
|
|
10
|
-
|
|
11
|
-
const frameworkDir = resolve(__dirname, '..', '..', 'framework');
|
|
12
|
-
const projectDir = process.env.MORPH_PROJECT_DIR || process.cwd();
|
|
13
|
-
|
|
14
|
-
export default defineConfig({
|
|
15
|
-
root: __dirname,
|
|
16
|
-
plugins: [
|
|
17
|
-
react(),
|
|
18
|
-
tailwindcss(),
|
|
19
|
-
morphDataPlugin(frameworkDir, projectDir),
|
|
20
|
-
],
|
|
21
|
-
server: {
|
|
22
|
-
port: 3200,
|
|
23
|
-
},
|
|
24
|
-
});
|
|
1
|
+
import { defineConfig } from 'vite';
|
|
2
|
+
import react from '@vitejs/plugin-react';
|
|
3
|
+
import tailwindcss from '@tailwindcss/vite';
|
|
4
|
+
import morphDataPlugin from './vite-plugin-morph-data.js';
|
|
5
|
+
import { fileURLToPath } from 'url';
|
|
6
|
+
import { dirname, resolve } from 'path';
|
|
7
|
+
|
|
8
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
9
|
+
const __dirname = dirname(__filename);
|
|
10
|
+
|
|
11
|
+
const frameworkDir = resolve(__dirname, '..', '..', 'framework');
|
|
12
|
+
const projectDir = process.env.MORPH_PROJECT_DIR || process.cwd();
|
|
13
|
+
|
|
14
|
+
export default defineConfig({
|
|
15
|
+
root: __dirname,
|
|
16
|
+
plugins: [
|
|
17
|
+
react(),
|
|
18
|
+
tailwindcss(),
|
|
19
|
+
morphDataPlugin(frameworkDir, projectDir),
|
|
20
|
+
],
|
|
21
|
+
server: {
|
|
22
|
+
port: 3200,
|
|
23
|
+
},
|
|
24
|
+
});
|
|
@@ -11,7 +11,9 @@ import { allocateEnvBlock, portLayout, resolveFleetConfig, resolvePortAliases }
|
|
|
11
11
|
// Ports come from `portLayout()`, the same function that renders `.morph/worktree.env`. The credentials
|
|
12
12
|
// file loads after the layout but cannot outrank it: a `.env.e2e` copied from a sibling worktree carries
|
|
13
13
|
// that worktree's block. Ignored keys are announced on stderr; stdout carries `morph-spec env` output.
|
|
14
|
-
|
|
14
|
+
// `withCredentials: false` keeps envFile values off stdout (`morph-spec env` lands in transcripts);
|
|
15
|
+
// the withheld names go to stderr.
|
|
16
|
+
export function buildComposeEnv({ cwd, config, featureName, gitCommonDir, withCredentials = true }) {
|
|
15
17
|
const env = {};
|
|
16
18
|
let layoutKeys = null;
|
|
17
19
|
|
|
@@ -38,16 +40,37 @@ export function buildComposeEnv({ cwd, config, featureName, gitCommonDir }) {
|
|
|
38
40
|
const envFile = config?.e2e?.auth?.envFile;
|
|
39
41
|
if (envFile) {
|
|
40
42
|
const ignored = [];
|
|
43
|
+
const withheld = [];
|
|
44
|
+
const foreignPorts = [];
|
|
45
|
+
const layoutPorts = layoutKeys ? new Set([...layoutKeys].map((key) => env[key])) : null;
|
|
41
46
|
for (const [key, value] of Object.entries(loadEnvFile(join(cwd, envFile)))) {
|
|
42
|
-
if (layoutKeys?.has(key))
|
|
43
|
-
|
|
47
|
+
if (layoutKeys?.has(key)) {
|
|
48
|
+
ignored.push(key);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (layoutPorts && /(^|_)PORT$/.test(key) && !layoutPorts.has(value)) foreignPorts.push(`${key}=${value}`);
|
|
52
|
+
if (withCredentials) env[key] = value;
|
|
53
|
+
else withheld.push(key);
|
|
44
54
|
}
|
|
45
55
|
if (ignored.length) reportIgnoredLayoutKeys(envFile, ignored, env);
|
|
56
|
+
if (foreignPorts.length) reportForeignPorts(envFile, foreignPorts);
|
|
57
|
+
if (withheld.length) {
|
|
58
|
+
console.warn(chalk.yellow(
|
|
59
|
+
` ⚠ ${envFile}: ${withheld.join(', ')} omitida(s) — credenciais não vão para o stdout; use --with-secrets dentro de eval "$(…)".`,
|
|
60
|
+
));
|
|
61
|
+
}
|
|
46
62
|
}
|
|
47
63
|
|
|
48
64
|
return env;
|
|
49
65
|
}
|
|
50
66
|
|
|
67
|
+
function reportForeignPorts(envFile, foreignPorts) {
|
|
68
|
+
console.warn(chalk.yellow(
|
|
69
|
+
` ⚠ ${envFile}: ${foreignPorts.join(', ')} não é porta do bloco desta worktree. `
|
|
70
|
+
+ 'Se nomeia uma das portas do layout, declare-a em e2e.portEnvNames (ex.: { "postgres": "PG_PORT" }) e o bloco passa a prevalecer.',
|
|
71
|
+
));
|
|
72
|
+
}
|
|
73
|
+
|
|
51
74
|
function reportIgnoredLayoutKeys(envFile, ignored, env) {
|
|
52
75
|
const shown = ignored.map((key) => `${key}=${env[key]}`).join(', ');
|
|
53
76
|
console.warn(chalk.yellow(
|
|
@@ -13,12 +13,18 @@ import {
|
|
|
13
13
|
resolveComposeFile,
|
|
14
14
|
tearDownStack,
|
|
15
15
|
} from '../../../framework/hooks/shared/e2e-check.js';
|
|
16
|
+
import { stopHostServices } from '../../../framework/hooks/shared/host-services.js';
|
|
16
17
|
import { buildComposeEnv } from './compose-env.js';
|
|
17
18
|
|
|
18
|
-
export function tearDownFeatureStack(feature, { root, worktreePath = null, volumes = true, _exec = defaultExec } = {}) {
|
|
19
|
+
export function tearDownFeatureStack(feature, { root, worktreePath = null, volumes = true, images, _exec = defaultExec } = {}) {
|
|
19
20
|
const cwd = worktreePath && existsSync(worktreePath) ? worktreePath : root;
|
|
20
21
|
const projectName = composeProjectName(feature);
|
|
21
22
|
const gitCommonDir = resolveGitCommonDir(root);
|
|
23
|
+
// Before the compose lookup: a worktree already gone still leaves its host processes running.
|
|
24
|
+
const stopped = stopHostServicesSafely(gitCommonDir, projectName);
|
|
25
|
+
const hostServices = stopped.killed.length + stopped.skipped.length + stopped.warnings.length > 0
|
|
26
|
+
? { hostServices: stopped }
|
|
27
|
+
: {};
|
|
22
28
|
|
|
23
29
|
try {
|
|
24
30
|
const config = readConfig(cwd) ?? readConfig(root);
|
|
@@ -33,25 +39,34 @@ export function tearDownFeatureStack(feature, { root, worktreePath = null, volum
|
|
|
33
39
|
|
|
34
40
|
if (!composeFile) {
|
|
35
41
|
// Nothing to bring down, but a stamp left behind would tell the next `up` a stack it cannot see is fresh.
|
|
36
|
-
return { attempted: false, cwd, status: 'skip', stampDeleted: deleteStamp(gitCommonDir, projectName) };
|
|
42
|
+
return { attempted: false, cwd, status: 'skip', stampDeleted: deleteStamp(gitCommonDir, projectName), ...hostServices };
|
|
37
43
|
}
|
|
38
44
|
|
|
39
45
|
const env = {
|
|
40
46
|
COMPOSE_PROJECT_NAME: projectName,
|
|
41
47
|
...buildComposeEnv({ cwd: composeCwd, config, featureName: feature, gitCommonDir }),
|
|
42
48
|
};
|
|
43
|
-
const result = tearDownStack(composeCwd, { composeFile, projectName, env, volumes }, _exec);
|
|
49
|
+
const result = tearDownStack(composeCwd, { composeFile, projectName, env, volumes, images }, _exec);
|
|
44
50
|
|
|
45
51
|
return {
|
|
46
52
|
attempted: true,
|
|
47
53
|
cwd: composeCwd,
|
|
48
54
|
status: result.status,
|
|
49
55
|
stampDeleted: deleteStamp(gitCommonDir, projectName),
|
|
56
|
+
...hostServices,
|
|
50
57
|
...(result.status === 'fail' && result.output ? { output: result.output } : {}),
|
|
51
58
|
};
|
|
52
59
|
} catch {
|
|
53
60
|
// The stamp must not outlive the stack it described, even when the teardown itself blew up.
|
|
54
|
-
return { attempted: false, cwd, status: 'fail', stampDeleted: deleteStamp(gitCommonDir, projectName) };
|
|
61
|
+
return { attempted: false, cwd, status: 'fail', stampDeleted: deleteStamp(gitCommonDir, projectName), ...hostServices };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function stopHostServicesSafely(gitCommonDir, projectName) {
|
|
66
|
+
try {
|
|
67
|
+
return stopHostServices(gitCommonDir, projectName);
|
|
68
|
+
} catch (err) {
|
|
69
|
+
return { killed: [], skipped: [], warnings: [`host services not stopped: ${err?.message ?? err}`] };
|
|
55
70
|
}
|
|
56
71
|
}
|
|
57
72
|
|
package/src/lib/heavy/gate.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { readHeavyConfig } from '../../../framework/hooks/shared/heavy-config.js';
|
|
1
|
+
import { minFreeMemFor, readHeavyConfig } from '../../../framework/hooks/shared/heavy-config.js';
|
|
2
|
+
import { heavyClass } from '../../../framework/hooks/shared/heavy-command.js';
|
|
2
3
|
import {
|
|
3
|
-
acquireHeavy, describeBusy, heavyDir, releaseHeavy, updateLease,
|
|
4
|
+
acquireHeavy, awaitFreeMemory, describeBusy, heavyDir, releaseHeavy, updateLease,
|
|
4
5
|
} from '../../../framework/hooks/shared/heavy-slots.js';
|
|
5
6
|
|
|
6
7
|
export class HeavyBusyError extends Error {
|
|
@@ -11,6 +12,8 @@ export class HeavyBusyError extends Error {
|
|
|
11
12
|
this.reason = result.reason;
|
|
12
13
|
this.holders = result.holders;
|
|
13
14
|
this.freeGb = result.freeGb;
|
|
15
|
+
this.minFreeMemGb = result.minFreeMemGb ?? null;
|
|
16
|
+
this.kind = result.kind ?? null;
|
|
14
17
|
this.waitedMs = result.waitedMs;
|
|
15
18
|
}
|
|
16
19
|
|
|
@@ -28,6 +31,7 @@ export function createHeavyGate({
|
|
|
28
31
|
} = {}) {
|
|
29
32
|
let entered = false;
|
|
30
33
|
let lease = null;
|
|
34
|
+
let admittedGb = 0;
|
|
31
35
|
let waitedMs = 0;
|
|
32
36
|
let exitHandler = null;
|
|
33
37
|
let saved = null;
|
|
@@ -50,26 +54,40 @@ export function createHeavyGate({
|
|
|
50
54
|
entered = false;
|
|
51
55
|
};
|
|
52
56
|
|
|
57
|
+
const raiseFloor = (kind) => {
|
|
58
|
+
const floor = minFreeMemFor(config, kind);
|
|
59
|
+
if (!lease || floor <= admittedGb) return;
|
|
60
|
+
const result = awaitFreeMemory({
|
|
61
|
+
minFreeMemGb: floor, waitTimeoutMs: config.waitTimeoutSec * 1000, kind, log, ...deps,
|
|
62
|
+
});
|
|
63
|
+
if (result.status === 'busy') throw new HeavyBusyError(result, config);
|
|
64
|
+
waitedMs += result.waitedMs;
|
|
65
|
+
admittedGb = floor;
|
|
66
|
+
};
|
|
67
|
+
|
|
53
68
|
return {
|
|
54
69
|
config,
|
|
55
70
|
get waitedMs() { return waitedMs; },
|
|
56
71
|
|
|
57
|
-
enter(node) {
|
|
72
|
+
enter(node, kind = heavyClass(node)) {
|
|
58
73
|
if (entered) {
|
|
74
|
+
raiseFloor(kind);
|
|
59
75
|
updateLease(lease, { node });
|
|
60
76
|
return;
|
|
61
77
|
}
|
|
62
78
|
for (const warning of config.warnings ?? []) log(`heavy: ${warning}`);
|
|
63
79
|
|
|
64
80
|
if (config.enabled) {
|
|
81
|
+
const minFreeMemGb = minFreeMemFor(config, kind);
|
|
65
82
|
const result = acquireHeavy({
|
|
66
83
|
dir: heavyDir(env),
|
|
67
84
|
slots: config.slots,
|
|
68
85
|
label,
|
|
69
86
|
feature,
|
|
70
87
|
node,
|
|
88
|
+
kind,
|
|
71
89
|
cwd: projectPath,
|
|
72
|
-
minFreeMemGb
|
|
90
|
+
minFreeMemGb,
|
|
73
91
|
waitTimeoutMs: config.waitTimeoutSec * 1000,
|
|
74
92
|
maxHoldMs: config.maxHoldMinutes * 60_000,
|
|
75
93
|
env,
|
|
@@ -78,6 +96,7 @@ export function createHeavyGate({
|
|
|
78
96
|
});
|
|
79
97
|
if (result.status === 'busy') throw new HeavyBusyError(result, config);
|
|
80
98
|
lease = result.lease;
|
|
99
|
+
admittedGb = minFreeMemGb;
|
|
81
100
|
waitedMs = result.waitedMs;
|
|
82
101
|
}
|
|
83
102
|
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { readdirSync } from 'node:fs';
|
|
2
|
+
import { basename, dirname, join } from 'node:path';
|
|
3
|
+
import { composeProjectName, defaultExec } from '../../../framework/hooks/shared/e2e-check.js';
|
|
4
|
+
import {
|
|
5
|
+
MORPH_PROJECT_PREFIX, parseDockerJson, resolveGitCommonDir, stackBelongsTo,
|
|
6
|
+
} from '../worktree/docker-audit.js';
|
|
7
|
+
|
|
8
|
+
// Runs right after a full queue wait: a hung daemon must not add another one.
|
|
9
|
+
const LS_TIMEOUT_MS = 10_000;
|
|
10
|
+
|
|
11
|
+
// Stacks of every worktree live under the main checkout, and so does `.morph/archive`.
|
|
12
|
+
export function repoMainRoot(cwd) {
|
|
13
|
+
const common = resolveGitCommonDir(cwd);
|
|
14
|
+
return common && basename(common) === '.git' ? dirname(common) : cwd;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function dirNames(dir) {
|
|
18
|
+
try {
|
|
19
|
+
return readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
|
|
20
|
+
} catch {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function liveE2eStacks(cwd, { exec = defaultExec, root = repoMainRoot(cwd) } = {}) {
|
|
26
|
+
try {
|
|
27
|
+
const ls = exec('docker', ['compose', 'ls', '--format', 'json'], { cwd: root, timeout: LS_TIMEOUT_MS });
|
|
28
|
+
if (ls.code !== 0) return [];
|
|
29
|
+
const projects = parseDockerJson(ls.output);
|
|
30
|
+
if (!projects) return [];
|
|
31
|
+
|
|
32
|
+
const active = dirNames(join(root, '.morph', 'features'));
|
|
33
|
+
const owners = new Map(active.map((f) => [composeProjectName(f), { feature: f, state: 'active' }]));
|
|
34
|
+
for (const f of dirNames(join(root, '.morph', 'archive'))) {
|
|
35
|
+
const project = composeProjectName(f);
|
|
36
|
+
if (!owners.has(project)) owners.set(project, { feature: f, state: 'archived' });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return projects
|
|
40
|
+
.filter((p) => String(p?.Name ?? '').startsWith(MORPH_PROJECT_PREFIX) && stackBelongsTo(p, root))
|
|
41
|
+
.map((p) => {
|
|
42
|
+
const project = String(p.Name);
|
|
43
|
+
return { project, status: p.Status ?? null, ...(owners.get(project) ?? { feature: null, state: 'unknown' }) };
|
|
44
|
+
});
|
|
45
|
+
} catch {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function stackLine(s) {
|
|
51
|
+
if (s.state === 'archived') return ` • ${s.project}: feature ${s.feature} já arquivada — \`morph-spec e2e down ${s.feature}\` devolve a RAM`;
|
|
52
|
+
if (s.state === 'unknown') return ` • ${s.project}: nenhuma feature deste repo responde por ela — \`docker compose -p ${s.project} down\``;
|
|
53
|
+
return ` • ${s.project}: feature ${s.feature} em andamento`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function describeLiveStacks(stacks) {
|
|
57
|
+
if (!stacks.length) return '';
|
|
58
|
+
return `stacks e2e de pé neste repo (seguram RAM):\n${stacks.map(stackLine).join('\n')}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function memoryBusyHint(err, cwd, deps = {}) {
|
|
62
|
+
if (err?.reason !== 'memory') return '';
|
|
63
|
+
return describeLiveStacks(liveE2eStacks(cwd, deps));
|
|
64
|
+
}
|