@polymorphism-tech/morph-spec 9.7.0 → 9.8.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 (54) hide show
  1. package/CLAUDE.md +1 -1
  2. package/README.md +2 -2
  3. package/bin/morph-spec.js +1 -1
  4. package/claude-plugin.json +1 -1
  5. package/docs/CHEATSHEET.md +1 -1
  6. package/docs/QUICKSTART.md +1 -1
  7. package/framework/agents.json +1 -1
  8. package/framework/docs/CLI.md +18 -9
  9. package/framework/evals/review-checklist.md +3 -3
  10. package/framework/hooks/claude-code/__pycache__/statusline.cpython-313.pyc +0 -0
  11. package/framework/hooks/claude-code/statusline.sh +11 -11
  12. package/framework/hooks/shared/heavy-slots.js +41 -4
  13. package/framework/schemas/config.schema.json +1 -1
  14. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  15. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  16. package/framework/skills/core/morph-apply/SKILL.md +2 -2
  17. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  18. package/framework/skills/core/morph-machine/SKILL.md +1 -1
  19. package/framework/skills/core/morph-media/SKILL.md +1 -1
  20. package/framework/skills/core/morph-plan/SKILL.md +2 -2
  21. package/framework/skills/core/morph-review/SKILL.md +1 -1
  22. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  23. package/framework/skills/core/morph-uiux/SKILL.md +1 -1
  24. package/framework/templates/.idea/morph-templates.xml +92 -0
  25. package/framework/templates/.vscode/morph-templates.code-snippets +186 -0
  26. package/framework/templates/IDE-SNIPPETS.md +262 -262
  27. package/framework/templates/code/dotnet/abstractions/api-endpoint.cs +7 -7
  28. package/framework/templates/code/dotnet/feature/request.cs.hbs +4 -4
  29. package/framework/templates/code/dotnet/feature/response.cs.hbs +4 -4
  30. package/framework/templates/code/sql/ai-agents/pgvector-embeddings-migration.sql.template +91 -91
  31. package/framework/templates/frontend/nextjs/client-component.tsx.hbs +26 -26
  32. package/framework/templates/frontend/nextjs/tsconfig.json.hbs +26 -26
  33. package/framework/templates/infrastructure/docker/Dockerfile.template +58 -58
  34. package/framework/templates/infrastructure/docker/dockerfile-api.dockerfile +38 -38
  35. package/framework/templates/infrastructure/docker/dockerfile-web.dockerfile +48 -48
  36. package/framework/templates/infrastructure/github/actions/docker-build-push/action.yml.hbs +45 -45
  37. package/framework/templates/infrastructure/github/actions/health-check/action.yml.hbs +27 -27
  38. package/framework/templates/infrastructure/github/workflows/docker-build-push.yml.hbs +59 -59
  39. package/framework/templates/infrastructure/github/workflows/dotnet-build.yml.hbs +39 -39
  40. package/framework/templates/state.template.json +17 -17
  41. package/framework/templates/ui/design-system.css +226 -226
  42. package/package.json +1 -1
  43. package/src/commands/project/worktree.js +24 -2
  44. package/src/commands/state/finish.js +43 -1
  45. package/src/commands/state/report-check.js +36 -1
  46. package/src/commands/validation/verify.js +3 -0
  47. package/src/dashboard/app/main.jsx +13 -13
  48. package/src/dashboard/vite.config.js +24 -24
  49. package/src/lib/heavy/gate.js +22 -1
  50. package/src/lib/review-reports.js +5 -3
  51. package/src/lib/tasks/test-nodes.js +24 -6
  52. package/src/lib/tasks/test-runner.js +40 -1
  53. package/src/lib/validators/verify-runner.js +50 -12
  54. package/src/utils/agents-installer.js +1 -1
@@ -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
+ });
@@ -1,7 +1,7 @@
1
1
  import { minFreeMemFor, readHeavyConfig } from '../../../framework/hooks/shared/heavy-config.js';
2
2
  import { heavyClass } from '../../../framework/hooks/shared/heavy-command.js';
3
3
  import {
4
- acquireHeavy, awaitFreeMemory, describeBusy, heavyDir, releaseHeavy, updateLease,
4
+ acquireHeavy, awaitFreeMemory, describeBusy, enterVerifySuite, heavyDir, isVerifySuite, releaseHeavy, updateLease,
5
5
  } from '../../../framework/hooks/shared/heavy-slots.js';
6
6
 
7
7
  export class HeavyBusyError extends Error {
@@ -33,6 +33,7 @@ export function createHeavyGate({
33
33
  let lease = null;
34
34
  let admittedGb = 0;
35
35
  let waitedMs = 0;
36
+ let suiteEntered = false;
36
37
  let exitHandler = null;
37
38
  let saved = null;
38
39
 
@@ -52,6 +53,7 @@ export function createHeavyGate({
52
53
  lease = null;
53
54
  restoreEnv();
54
55
  entered = false;
56
+ suiteEntered = false;
55
57
  };
56
58
 
57
59
  const raiseFloor = (kind) => {
@@ -65,12 +67,29 @@ export function createHeavyGate({
65
67
  admittedGb = floor;
66
68
  };
67
69
 
70
+ const awaitSuite = () => {
71
+ const result = enterVerifySuite({
72
+ dir: heavyDir(env),
73
+ lease,
74
+ slots: config.slots,
75
+ waitTimeoutMs: config.waitTimeoutSec * 1000,
76
+ maxHoldMs: config.maxHoldMinutes * 60_000,
77
+ log,
78
+ ...deps,
79
+ });
80
+ if (result.status === 'busy') throw new HeavyBusyError(result, config);
81
+ waitedMs += result.waitedMs;
82
+ suiteEntered = true;
83
+ };
84
+
68
85
  return {
69
86
  config,
70
87
  get waitedMs() { return waitedMs; },
71
88
 
72
89
  enter(node, kind = heavyClass(node)) {
90
+ const suite = isVerifySuite({ label, node });
73
91
  if (entered) {
92
+ if (suite && lease && !suiteEntered) awaitSuite();
74
93
  raiseFloor(kind);
75
94
  updateLease(lease, { node });
76
95
  return;
@@ -86,6 +105,7 @@ export function createHeavyGate({
86
105
  feature,
87
106
  node,
88
107
  kind,
108
+ suite,
89
109
  cwd: projectPath,
90
110
  minFreeMemGb,
91
111
  waitTimeoutMs: config.waitTimeoutSec * 1000,
@@ -96,6 +116,7 @@ export function createHeavyGate({
96
116
  });
97
117
  if (result.status === 'busy') throw new HeavyBusyError(result, config);
98
118
  lease = result.lease;
119
+ suiteEntered = suite;
99
120
  admittedGb = minFreeMemGb;
100
121
  waitedMs = result.waitedMs;
101
122
  }
@@ -53,8 +53,10 @@ const cleanId = (s) => String(s ?? '').replace(/[*`_]/g, '').trim().toUpperCase(
53
53
 
54
54
  const isSeparator = (cells) => cells.every((c) => /^:?-{2,}:?$/.test(c) || c === '');
55
55
 
56
- // A headed table counts only when its header names the `tipo` column: a status table a
57
- // confirmation lists under "Nenhum." is not a blocker table.
56
+ // A status table a confirmation lists under "Nenhum." is skipped; any other header off the
57
+ // contract still counts, so its rows land in `untyped` and pause the gate.
58
+ const isStatusHeader = (header) => /^(situacao|status)$/.test(normalize(header[1]));
59
+
58
60
  function tableRows(body, keepHeader = () => true) {
59
61
  const rows = [];
60
62
  let skipping = false;
@@ -77,7 +79,7 @@ function tableRows(body, keepHeader = () => true) {
77
79
  function parseBlockers(body) {
78
80
  const blockers = [];
79
81
  const untyped = [];
80
- for (const cells of tableRows(body, (header) => /^tipo$/i.test(header[1] ?? ''))) {
82
+ for (const cells of tableRows(body, (header) => !isStatusHeader(header))) {
81
83
  const row = { id: cleanId(cells[0]), type: normalize(cells[1]), where: cells[2] ?? '', evidence: cells[3] ?? '' };
82
84
  (BLOCKER_TYPES.includes(row.type) ? blockers : untyped).push(row);
83
85
  }
@@ -9,6 +9,14 @@ import { PACKAGE_LOCATIONS } from '../detectors/index.js';
9
9
  /** `none` yields an UNKNOWN count, never a fabricated zero. */
10
10
  export const TEST_RUNNERS = ['dotnet', 'vitest', 'jest', 'node-test', 'playwright', 'none', EVAL_RUNNER];
11
11
 
12
+ // A count is a baseline only where it stays still: dotnet/vitest/jest/playwright grow every
13
+ // slice, so their baseline only warned or blocked a deliberate deletion.
14
+ const BASELINE_RUNNERS = ['node-test', EVAL_RUNNER];
15
+
16
+ export function keepsBaseline(runner) {
17
+ return BASELINE_RUNNERS.includes(runner);
18
+ }
19
+
12
20
  export const TEST_NODE_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
13
21
 
14
22
  const FALLBACK_NODE_ID = 'default';
@@ -67,12 +75,13 @@ export function inferRunner(command) {
67
75
  }
68
76
 
69
77
  function makeNode(partial) {
78
+ const runner = partial.runner ?? inferRunner(partial.command);
70
79
  return {
71
80
  id: partial.id,
72
81
  command: partial.command ?? null,
73
82
  cwd: partial.cwd ?? '.',
74
- runner: partial.runner ?? inferRunner(partial.command),
75
- expectedCount: partial.expectedCount ?? null,
83
+ runner,
84
+ expectedCount: keepsBaseline(runner) ? partial.expectedCount ?? null : null,
76
85
  timeoutMs: partial.timeoutMs ?? null,
77
86
  build: partial.build ?? [],
78
87
  e2e: partial.e2e ?? false,
@@ -329,11 +338,11 @@ export function morphConfigPath(projectPath = '.') {
329
338
  * Refuses a non-integer count (null is UNKNOWN, never 0), an undeclared id, or a
330
339
  * declared node with no measurement. Every other config key survives.
331
340
  * @param {Array<{id: string, count: number|null}>} measurements
332
- * @returns {{ recorded: boolean, reason: string|null, updates: Array<{id: string, from: number|null, to: number}>, path: string }}
341
+ * @returns {{ recorded: boolean, reason: string|null, updates: Array<{id: string, from: number|null, to: number}>, removed: string[], path: string }}
333
342
  */
334
343
  export function recordTestBaselines(projectPath, measurements) {
335
344
  const path = morphConfigPath(projectPath);
336
- const refuse = (reason) => ({ recorded: false, reason, updates: [], path });
345
+ const refuse = (reason) => ({ recorded: false, reason, updates: [], removed: [], path });
337
346
 
338
347
  if (!existsSync(path)) return refuse('não existe .morph/config/config.json — não há onde gravar');
339
348
 
@@ -349,7 +358,10 @@ export function recordTestBaselines(projectPath, measurements) {
349
358
  return refuse('config.json não declara project.tests[] — não há entrada onde gravar a baseline');
350
359
  }
351
360
 
352
- const unreadable = measurements.filter((m) => !Number.isInteger(m.count)).map((m) => m.id);
361
+ const runnerOf = new Map(declared.filter(isPlainObject).map((e) => [e.id, e.runner ?? inferRunner(e.command)]));
362
+ const unreadable = measurements
363
+ .filter((m) => keepsBaseline(runnerOf.get(m.id)) && !Number.isInteger(m.count))
364
+ .map((m) => m.id);
353
365
  if (unreadable.length > 0) {
354
366
  return refuse(`contagem desconhecida em: ${unreadable.join(', ')} — desconhecido nunca vira 0`);
355
367
  }
@@ -370,8 +382,14 @@ export function recordTestBaselines(projectPath, measurements) {
370
382
  }
371
383
 
372
384
  const updates = [];
385
+ const removed = [];
373
386
  for (const entry of declared) {
374
387
  if (!entry || !byId.has(entry.id)) continue;
388
+ if (!keepsBaseline(runnerOf.get(entry.id))) {
389
+ if ('expectedCount' in entry) removed.push(entry.id);
390
+ delete entry.expectedCount;
391
+ continue;
392
+ }
375
393
  const to = byId.get(entry.id);
376
394
  updates.push({
377
395
  id: entry.id,
@@ -382,5 +400,5 @@ export function recordTestBaselines(projectPath, measurements) {
382
400
  }
383
401
 
384
402
  atomicWriteJson(path, config);
385
- return { recorded: true, reason: null, updates, path };
403
+ return { recorded: true, reason: null, updates, removed, path };
386
404
  }
@@ -8,7 +8,7 @@ import { getBuildSteps } from '../../../framework/hooks/shared/build-check.js';
8
8
  import { CONFIG_FILE_NAME } from '../../core/state/state-manager.js';
9
9
 
10
10
  // Per chained project: a fixed cap timed a long `a && b` chain out for being long, not broken.
11
- export const DEFAULT_TEST_TIMEOUT_PER_PROJECT = 120_000;
11
+ export const DEFAULT_TEST_TIMEOUT_PER_PROJECT = 1_800_000;
12
12
  const TEST_OUTPUT_MAX_BUFFER = 256 * 1024 * 1024;
13
13
 
14
14
  /**
@@ -424,6 +424,45 @@ export function parseTestCount(output, runner) {
424
424
  return extractor(stripAnsi(output));
425
425
  }
426
426
 
427
+ const FAILURE_LINES_MAX = 10;
428
+ const TAP_NOT_OK = /^(\s*)not ok \d+ - (.+?)\s*$/;
429
+ const TAP_DIRECTIVE = /\s#\s*(?:TODO|SKIP)\b/i;
430
+ const VITEST_FAIL = /^\s*FAIL\s+(.+?)\s*$/;
431
+ const VITEST_CROSS = /^\s*[×✗]\s+(.+?)(?:\s+\d+(?:\.\d+)?\s*m?s)?\s*$/;
432
+ // The duration suffix is required: it separates a test line from the summary's `failed: 1` / `Failed!`.
433
+ const DOTNET_FAILED = /^\s*(?:failed|com falha)\s+(\S.*?)\s*(?:\[[^\]]*\]|\([^)]*\))\s*$/i;
434
+
435
+ function tapLeafFailures(lines) {
436
+ const seen = [];
437
+ for (const line of lines) {
438
+ const match = TAP_NOT_OK.exec(line);
439
+ if (!match || TAP_DIRECTIVE.test(match[2])) continue;
440
+ const depth = match[1].length;
441
+ // TAP prints a subtest's result before its parent's: deeper unclaimed entries above are its children.
442
+ const children = seen.filter((e) => !e.claimed && e.depth > depth);
443
+ for (const child of children) child.claimed = true;
444
+ seen.push({ depth, name: match[2], leaf: children.length === 0, claimed: false });
445
+ }
446
+ return seen.filter((e) => e.leaf).map((e) => e.name);
447
+ }
448
+
449
+ function captures(lines, pattern) {
450
+ return lines.map((line) => pattern.exec(line)?.[1]).filter(Boolean);
451
+ }
452
+
453
+ /** Failed test names anywhere in the output (TAP leaves, vitest, dotnet), deduplicated, at most 10; [] when no form matches. */
454
+ export function failureLines(output) {
455
+ if (typeof output !== 'string' || !output) return [];
456
+ const lines = stripAnsi(output).split(/\r?\n/);
457
+ const vitestFiles = captures(lines, VITEST_FAIL);
458
+ const names = [
459
+ ...tapLeafFailures(lines),
460
+ ...(vitestFiles.length > 0 ? vitestFiles : captures(lines, VITEST_CROSS)),
461
+ ...captures(lines, DOTNET_FAILED),
462
+ ];
463
+ return [...new Set(names)].slice(0, FAILURE_LINES_MAX);
464
+ }
465
+
427
466
  /**
428
467
  * Asymmetric on purpose: only a drop fails (non-null `reason`); a rise only warns,
429
468
  * or every new test would redden the gate and teach deleting tests.
@@ -1,5 +1,5 @@
1
1
  import { execFileSync } from 'child_process';
2
- import { existsSync, readFileSync, rmSync } from 'fs';
2
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
3
3
  import { join, resolve } from 'path';
4
4
 
5
5
  import { runBuildCheck, getBuildSteps, isDotnetStack } from '../../../framework/hooks/shared/build-check.js';
@@ -9,7 +9,7 @@ import { composeProjectName, runE2eCheck } from '../../../framework/hooks/shared
9
9
  import { buildComposeEnv } from '../e2e/compose-env.js';
10
10
  import { e2eApplicability } from '../e2e/applicability.js';
11
11
  import {
12
- runTestSuite, parseTestCount, compareTestCount, resolveTestTimeout, discoverDotnetSolution,
12
+ runTestSuite, parseTestCount, compareTestCount, resolveTestTimeout, discoverDotnetSolution, failureLines,
13
13
  } from '../tasks/test-runner.js';
14
14
  import {
15
15
  resolveTestNodes, parseTestFilter, nodeMatchesFilter, recordTestBaselines, morphConfigPath,
@@ -24,8 +24,7 @@ import { runValidation } from './validation-runner.js';
24
24
  import { checkOutputsTraceability } from './traceability-check.js';
25
25
  import { createHeavyGate } from '../heavy/gate.js';
26
26
 
27
- // A cold dotnet build takes minutes, not seconds.
28
- const DEFAULT_BUILD_TIMEOUT = 120_000;
27
+ const DEFAULT_BUILD_TIMEOUT = 1_800_000;
29
28
 
30
29
  const VALIDATOR_FEEDBACK_LIMIT = 10;
31
30
 
@@ -364,7 +363,8 @@ async function runNodesInLinux({
364
363
  // No exit code is NOT a pass: the container died before this node reported.
365
364
  ? { passed: false, output: raw.output || '', reason: raw.reason, exitCode: null }
366
365
  : { passed: raw.exitCode === 0, output: raw.output || '', exitCode: raw.exitCode };
367
- return judgeRun({ node, result, durationMs: raw.durationMs ?? 0 });
366
+ const logPath = writeFailedRunLog(projectPath, node, result);
367
+ return judgeRun({ node, result, durationMs: raw.durationMs ?? 0, logPath });
368
368
  });
369
369
 
370
370
  return { runs, environment: suite.environment };
@@ -389,7 +389,9 @@ async function runOneTestNode({
389
389
  const result = await _runTestSuite(projectPath, {
390
390
  timeout, node, ...(childEnv ? { env: childEnv } : {}),
391
391
  });
392
- const entry = judgeRun({ node, result, durationMs: Date.now() - start });
392
+ const durationMs = Date.now() - start;
393
+ const logPath = writeFailedRunLog(projectPath, node, result);
394
+ const entry = judgeRun({ node, result, durationMs, logPath });
393
395
  if (!evalChannel) return entry;
394
396
 
395
397
  const judged = judgeEvalRun(entry, node, evalChannel.summaryPath);
@@ -397,6 +399,19 @@ async function runOneTestNode({
397
399
  return judged;
398
400
  }
399
401
 
402
+ // Fail-open: a log that cannot be written must not change the verdict.
403
+ function writeFailedRunLog(projectPath, node, result) {
404
+ if (result.passed || result.skipped || !result.output) return null;
405
+ const relPath = `.morph/logs/verify-tests-${String(node.id).replace(/[^\w.-]/g, '_')}.log`;
406
+ try {
407
+ mkdirSync(join(projectPath, '.morph', 'logs'), { recursive: true });
408
+ writeFileSync(join(projectPath, relPath), result.output);
409
+ return relPath;
410
+ } catch {
411
+ return null;
412
+ }
413
+ }
414
+
400
415
  function discardFile(path) {
401
416
  try { rmSync(path, { force: true }); } catch { /* the next run removes it first */ }
402
417
  }
@@ -476,13 +491,15 @@ function preDecideRun({ node, filter, projectPath = '.', refreshCache = false })
476
491
  }
477
492
 
478
493
  // The single count rule for host and container runs; a second copy makes them disagree.
479
- function judgeRun({ node, result, durationMs }) {
494
+ function judgeRun({ node, result, durationMs, logPath = null }) {
480
495
  const entry = emptyRunEntry(node);
481
496
 
482
497
  if (result.skipped) {
483
498
  return { ...entry, durationMs, reason: result.reason || 'no test command detected' };
484
499
  }
485
500
 
501
+ const logLine = logPath ? [`log completo: ${logPath}`] : [];
502
+
486
503
  // A truncated log tail reads like a passing run cut mid-sentence: lead with the timeout.
487
504
  if (result.timedOut) {
488
505
  const tail = tailLines(result.output, OUTPUT_TAIL_LINES);
@@ -492,7 +509,7 @@ function judgeRun({ node, result, durationMs }) {
492
509
  durationMs,
493
510
  timedOut: true,
494
511
  reason: result.message,
495
- output: tail ? `${result.message}\n${tail}` : result.message,
512
+ output: [result.message, ...logLine, tail].filter(Boolean).join('\n'),
496
513
  };
497
514
  }
498
515
 
@@ -511,18 +528,39 @@ function judgeRun({ node, result, durationMs }) {
511
528
  }
512
529
  if (verdict.verdict === 'drop') warnings.push(verdict.reason);
513
530
 
531
+ // Without a baseline, zero is the floor: a green run over no test is a suite that stopped running.
532
+ if (result.passed && count === 0 && node.runner !== EVAL_RUNNER && !Number.isInteger(node.expectedCount)) {
533
+ return { ...entry, status: 'fail', durationMs, count, warnings, reason: 'suíte verde sem nenhum teste executado', output: '' };
534
+ }
535
+
536
+ if (result.passed) {
537
+ return { ...entry, status: 'pass', durationMs, count, warnings, reason: result.reason ?? null, output: '' };
538
+ }
539
+
540
+ // The failing test sits thousands of lines above a TAP summary, never in the tail.
541
+ const failed = failureLines(result.output);
514
542
  return {
515
543
  ...entry,
516
- status: result.passed ? 'pass' : 'fail',
544
+ status: 'fail',
517
545
  durationMs,
518
546
  count,
519
547
  warnings,
520
548
  // `test-build-failed` / `stale-binary`: refused before any test ran.
521
- reason: result.reason ?? null,
522
- output: result.passed ? '' : tailLines(result.output, OUTPUT_TAIL_LINES),
549
+ reason: result.reason ?? failureSummary(failed),
550
+ output: [
551
+ ...failed.map((name) => `falhou: ${name}`),
552
+ ...logLine,
553
+ tailLines(result.output, OUTPUT_TAIL_LINES),
554
+ ].filter(Boolean).join('\n'),
523
555
  };
524
556
  }
525
557
 
558
+ function failureSummary(failed) {
559
+ if (failed.length === 0) return null;
560
+ const more = failed.length > 3 ? ` (+${failed.length - 3})` : '';
561
+ return `falhou: ${failed.slice(0, 3).join('; ')}${more}`;
562
+ }
563
+
526
564
  function acceptRecordedDrops(testsNode) {
527
565
  const runs = testsNode.runs.map(({ countDrop, ...run }) => (countDrop ? { ...run, status: 'pass', reason: null } : run));
528
566
  return rollUpTestRuns(runs, { command: testsNode.command, source: testsNode.source, environment: testsNode.environment });
@@ -615,7 +653,7 @@ function baselineTargetProblem(projectPath, testPlan, { skipTests }) {
615
653
  // exited 0 is the one red it accepts: lowering the number is how deleting tests passes.
616
654
  function recordBaselineFrom(projectPath, testsNode) {
617
655
  const runs = testsNode.runs || [];
618
- const refuse = (reason) => ({ recorded: false, reason, updates: [] });
656
+ const refuse = (reason) => ({ recorded: false, reason, updates: [], removed: [] });
619
657
 
620
658
  if (runs.length === 0) return refuse('nenhum nó de teste executou');
621
659
 
@@ -284,7 +284,7 @@ export const DISPATCH_CONTRACT = [
284
284
  'ANTES de encerrar — é o retorno autoritativo, não a sua mensagem final.',
285
285
  '2. ESTADO: NÃO toque em `plan.md`, `checks.json`, `feature.json` nem `state.json`: o estado é do orquestrador.',
286
286
  '3. TESTE: a prova é do orquestrador (`morph-spec verify {feature}`, depois do seu retorno), não sua. Escreva os testes pelo orçamento: ' +
287
- 'um por Cn `prova: teste`, variantes em tabela (`[Theory]`/`it.each`); `leitura` sem teste. NÃO rode teste a cada marco nem faça ciclo red→green: ' +
287
+ 'um por Cn `prova: teste`, na camada mais baixa que o prova (a de cima só para o que só existe lá), variantes em tabela (`[Theory]`/`it.each`); `leitura` sem teste. NÃO rode teste a cada marco nem faça ciclo red→green: ' +
288
288
  'escreva código e testes, compile, e rode SÓ o filtro dos seus testes UMA vez, em primeiro plano, antes do relatório ' +
289
289
  '(`vitest related <arquivos da fatia> --run`; `dotnet test --filter "FullyQualifiedName~<Classe>" --no-build` após o build); ' +
290
290
  'se falhar, corrija e rode só os que falharam. Teto: 3 rodadas (`verify --filter` conta, exit 3 não); ' +