@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.
Files changed (80) hide show
  1. package/README.md +2 -2
  2. package/bin/morph-spec.js +13 -5
  3. package/claude-plugin.json +1 -1
  4. package/docs/CHEATSHEET.md +1 -1
  5. package/docs/QUICKSTART.md +1 -1
  6. package/framework/agents.json +3 -3
  7. package/framework/docs/CLI.md +43 -11
  8. package/framework/evals/review-checklist.md +2 -2
  9. package/framework/hooks/claude-code/__pycache__/statusline.cpython-313.pyc +0 -0
  10. package/framework/hooks/claude-code/core/README.md +4 -0
  11. package/framework/hooks/claude-code/core/heavy-gate.js +5 -3
  12. package/framework/hooks/claude-code/core/test-budget.js +89 -0
  13. package/framework/hooks/claude-code/statusline.sh +11 -11
  14. package/framework/hooks/shared/e2e-check.js +0 -0
  15. package/framework/hooks/shared/feature-locator.js +7 -5
  16. package/framework/hooks/shared/heavy-command.js +72 -1
  17. package/framework/hooks/shared/heavy-config.js +28 -1
  18. package/framework/hooks/shared/heavy-slots.js +41 -5
  19. package/framework/hooks/shared/host-services.js +433 -0
  20. package/framework/schemas/config.schema.json +65 -2
  21. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  22. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  23. package/framework/skills/core/morph-apply/SKILL.md +1 -1
  24. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  25. package/framework/skills/core/morph-machine/SKILL.md +1 -1
  26. package/framework/skills/core/morph-media/SKILL.md +1 -1
  27. package/framework/skills/core/morph-plan/SKILL.md +3 -3
  28. package/framework/skills/core/morph-review/SKILL.md +3 -2
  29. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  30. package/framework/skills/core/morph-uiux/SKILL.md +1 -1
  31. package/framework/standards/STANDARDS.json +2 -1
  32. package/framework/standards/infrastructure/docker/local-compose-isolation.md +15 -1
  33. package/framework/templates/IDE-SNIPPETS.md +262 -262
  34. package/framework/templates/code/dotnet/abstractions/api-endpoint.cs +7 -7
  35. package/framework/templates/code/dotnet/feature/request.cs.hbs +4 -4
  36. package/framework/templates/code/dotnet/feature/response.cs.hbs +4 -4
  37. package/framework/templates/code/sql/ai-agents/pgvector-embeddings-migration.sql.template +91 -91
  38. package/framework/templates/frontend/nextjs/client-component.tsx.hbs +26 -26
  39. package/framework/templates/frontend/nextjs/tsconfig.json.hbs +26 -26
  40. package/framework/templates/infrastructure/docker/Dockerfile.template +58 -58
  41. package/framework/templates/infrastructure/docker/dockerfile-api.dockerfile +38 -38
  42. package/framework/templates/infrastructure/docker/dockerfile-web.dockerfile +48 -48
  43. package/framework/templates/infrastructure/github/actions/docker-build-push/action.yml.hbs +45 -45
  44. package/framework/templates/infrastructure/github/actions/health-check/action.yml.hbs +27 -27
  45. package/framework/templates/infrastructure/github/workflows/docker-build-push.yml.hbs +59 -59
  46. package/framework/templates/infrastructure/github/workflows/dotnet-build.yml.hbs +39 -39
  47. package/framework/templates/state.template.json +17 -17
  48. package/framework/templates/ui/design-system.css +226 -226
  49. package/package.json +1 -1
  50. package/src/commands/e2e/e2e.js +50 -24
  51. package/src/commands/observability/cost.js +12 -0
  52. package/src/commands/project/env.js +3 -3
  53. package/src/commands/project/heavy.js +10 -3
  54. package/src/commands/project/worktree.js +9 -4
  55. package/src/commands/state/advance.js +3 -0
  56. package/src/commands/state/create.js +3 -3
  57. package/src/commands/state/delete.js +1 -1
  58. package/src/commands/state/finish.js +20 -4
  59. package/src/commands/validation/mutate.js +2 -2
  60. package/src/commands/validation/plan-check.js +1 -0
  61. package/src/commands/validation/verify.js +3 -0
  62. package/src/dashboard/app/main.jsx +13 -13
  63. package/src/dashboard/vite.config.js +24 -24
  64. package/src/lib/e2e/compose-env.js +26 -3
  65. package/src/lib/e2e/stack-teardown.js +19 -4
  66. package/src/lib/heavy/gate.js +23 -4
  67. package/src/lib/heavy/live-stacks.js +64 -0
  68. package/src/lib/review-reports.js +21 -10
  69. package/src/lib/risk-detector.js +9 -1
  70. package/src/lib/tasks/test-runner.js +3 -0
  71. package/src/lib/telemetry/metrics.js +47 -3
  72. package/src/lib/validators/packages/package-validator.js +2 -2
  73. package/src/lib/worktree/docker-audit.js +7 -3
  74. package/src/lib/worktree/env-allocator.js +27 -5
  75. package/src/lib/worktree/ignored-triage.js +8 -0
  76. package/src/utils/agents-installer.js +4 -1
  77. package/src/utils/file-copier.js +26 -4
  78. package/src/utils/hooks-installer.js +4 -4
  79. package/framework/templates/.idea/morph-templates.xml +0 -92
  80. 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 memMark = report.freeGb < config.minFreeMemGb ? chalk.yellow('⚠') : chalk.green('✓');
33
- console.log(` ${memMark} RAM livre: ${report.freeGb.toFixed(1)} GB (mínimo ${config.minFreeMemGb} GB)`);
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. Volumes survive.
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: false });
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) out.e2e = { status: e2e.status, cwd: e2e.cwd, volumes: false };
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
- if (gitignoreAdded.length) {
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
- if (withoutLeftoverIgnore(cleanCwd, dirty.split(/\r?\n/).filter((l) => l.trim())).length) {
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
- const arch = runArchive(feature, { cwd: featureCwd, force: options.force, git: true });
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, { root, worktreePath, volumes: !options.keepVolumes });
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
- export function buildComposeEnv({ cwd, config, featureName, gitCommonDir }) {
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)) ignored.push(key);
43
- else env[key] = value;
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
 
@@ -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: config.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
+ }