@polymorphism-tech/morph-spec 9.6.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 (50) hide show
  1. package/CLAUDE.md +1 -1
  2. package/README.md +2 -2
  3. package/bin/morph-spec.js +13 -6
  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 +3 -3
  8. package/framework/docs/CLI.md +53 -16
  9. package/framework/evals/review-checklist.md +5 -5
  10. package/framework/hooks/claude-code/core/README.md +4 -0
  11. package/framework/hooks/claude-code/core/test-budget.js +89 -0
  12. package/framework/hooks/shared/e2e-check.js +0 -0
  13. package/framework/hooks/shared/heavy-command.js +60 -1
  14. package/framework/hooks/shared/heavy-slots.js +41 -4
  15. package/framework/hooks/shared/host-services.js +433 -0
  16. package/framework/schemas/config.schema.json +54 -2
  17. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  18. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  19. package/framework/skills/core/morph-apply/SKILL.md +2 -2
  20. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  21. package/framework/skills/core/morph-machine/SKILL.md +1 -1
  22. package/framework/skills/core/morph-media/SKILL.md +1 -1
  23. package/framework/skills/core/morph-plan/SKILL.md +3 -3
  24. package/framework/skills/core/morph-review/SKILL.md +3 -2
  25. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  26. package/framework/skills/core/morph-uiux/SKILL.md +1 -1
  27. package/framework/standards/STANDARDS.json +2 -1
  28. package/framework/standards/infrastructure/docker/local-compose-isolation.md +14 -1
  29. package/package.json +1 -1
  30. package/src/commands/e2e/e2e.js +47 -24
  31. package/src/commands/observability/cost.js +12 -0
  32. package/src/commands/project/worktree.js +31 -5
  33. package/src/commands/state/advance.js +3 -0
  34. package/src/commands/state/create.js +3 -3
  35. package/src/commands/state/delete.js +1 -1
  36. package/src/commands/state/finish.js +46 -2
  37. package/src/commands/state/report-check.js +36 -1
  38. package/src/commands/validation/plan-check.js +1 -0
  39. package/src/commands/validation/verify.js +3 -0
  40. package/src/lib/e2e/stack-teardown.js +19 -4
  41. package/src/lib/heavy/gate.js +22 -1
  42. package/src/lib/review-reports.js +23 -10
  43. package/src/lib/tasks/test-nodes.js +24 -6
  44. package/src/lib/tasks/test-runner.js +43 -1
  45. package/src/lib/telemetry/metrics.js +47 -3
  46. package/src/lib/validators/packages/package-validator.js +2 -2
  47. package/src/lib/validators/verify-runner.js +50 -12
  48. package/src/utils/agents-installer.js +5 -2
  49. package/src/utils/file-copier.js +26 -4
  50. package/src/utils/hooks-installer.js +4 -4
@@ -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,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
  }
@@ -21,7 +21,8 @@ export const reviewFileName = (role) => OUTPUT_SCHEMA[REVIEW_ROLES[role].output]
21
21
  // Append-only: uma reavaliação acrescenta ao fim, e vale o ÚLTIMO token/seção.
22
22
  const VERDICT_RE = /\*\*Verdict:\*\*\s*([A-Za-z]+)/g;
23
23
  const REVIEWED_SHA_RE = /\*\*Reviewed-SHA:\*\*\s*`?([0-9a-f]{7,40})/gi;
24
- const sectionRe = (title) => new RegExp(`^##\\s+${title}\\s*$`, 'gim');
24
+ // A confirmation round nests its sections one level down (`###`); both levels count.
25
+ const sectionRe = (title) => new RegExp(`^(#{2,3})\\s+${title}\\s*$`, 'gim');
25
26
  const BLOCKERS_SECTION_RE = sectionRe('Bloqueadores');
26
27
  const CHECKS_SECTION_RE = sectionRe('Checks');
27
28
  const MEDIA_SECTION_RE = /^##\s+M[íi]dia\s+gerada\s*$/m;
@@ -37,7 +38,7 @@ export function requiredReviewRoles({ workType, securityRequired = false } = {})
37
38
  function sectionBodies(text, re) {
38
39
  return [...text.matchAll(re)].map((m) => {
39
40
  const rest = text.slice(m.index + m[0].length);
40
- const next = rest.search(/^#{1,2}\s/m);
41
+ const next = rest.search(new RegExp(`^#{1,${m[1].length}}\\s`, 'm'));
41
42
  return next === -1 ? rest : rest.slice(0, next);
42
43
  });
43
44
  }
@@ -50,13 +51,26 @@ function lastSectionBody(text, re) {
50
51
  const normalize = (s) => String(s ?? '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/\s+/g, ' ').trim();
51
52
  const cleanId = (s) => String(s ?? '').replace(/[*`_]/g, '').trim().toUpperCase();
52
53
 
53
- function tableRows(body) {
54
+ const isSeparator = (cells) => cells.every((c) => /^:?-{2,}:?$/.test(c) || c === '');
55
+
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
+
60
+ function tableRows(body, keepHeader = () => true) {
54
61
  const rows = [];
55
- for (const line of body.split(/\r?\n/)) {
56
- if (!/^\s*\|/.test(line)) continue;
62
+ let skipping = false;
63
+ const lines = body.split(/\r?\n/);
64
+ for (const [i, line] of lines.entries()) {
65
+ if (!/^\s*\|/.test(line)) { skipping = false; continue; }
57
66
  const cells = line.trim().replace(/^\||\|$/g, '').split('|').map((c) => c.trim());
58
- if (cells.every((c) => /^:?-{2,}:?$/.test(c) || c === '')) continue;
59
- rows.push(cells);
67
+ if (isSeparator(cells)) continue;
68
+ const next = lines[i + 1]?.trim().replace(/^\||\|$/g, '').split('|').map((c) => c.trim());
69
+ if (next && /^\s*\|/.test(lines[i + 1]) && isSeparator(next) && !skipping) {
70
+ skipping = !keepHeader(cells);
71
+ continue;
72
+ }
73
+ if (!skipping) rows.push(cells);
60
74
  }
61
75
  return rows;
62
76
  }
@@ -65,8 +79,7 @@ function tableRows(body) {
65
79
  function parseBlockers(body) {
66
80
  const blockers = [];
67
81
  const untyped = [];
68
- for (const cells of tableRows(body)) {
69
- if (/^#$|^id$/i.test(cells[0]) && /^tipo$/i.test(cells[1] ?? '')) continue;
82
+ for (const cells of tableRows(body, (header) => !isStatusHeader(header))) {
70
83
  const row = { id: cleanId(cells[0]), type: normalize(cells[1]), where: cells[2] ?? '', evidence: cells[3] ?? '' };
71
84
  (BLOCKER_TYPES.includes(row.type) ? blockers : untyped).push(row);
72
85
  }
@@ -78,7 +91,7 @@ const STATUS_BY_KEY = Object.fromEntries(CHECK_STATUSES.map((s) => [normalize(s)
78
91
  // Vale a última linha de cada Cn em todas as seções: uma confirmação traz só os Cn que mudou.
79
92
  function parseChecks(bodies) {
80
93
  const byId = new Map();
81
- for (const cells of bodies.flatMap(tableRows)) {
94
+ for (const cells of bodies.flatMap((body) => tableRows(body))) {
82
95
  const id = cleanId(cells[0]);
83
96
  if (!/^C\d+$/.test(id)) continue;
84
97
  const raw = String(cells[1] ?? '').replace(/[*`]/g, '').trim();
@@ -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,8 @@ 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
+ const TEST_OUTPUT_MAX_BUFFER = 256 * 1024 * 1024;
12
13
 
13
14
  /**
14
15
  * Fallback test command when the project declared no nodes; `projectCount` scales the timeout.
@@ -423,6 +424,45 @@ export function parseTestCount(output, runner) {
423
424
  return extractor(stripAnsi(output));
424
425
  }
425
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
+
426
466
  /**
427
467
  * Asymmetric on purpose: only a drop fails (non-null `reason`); a rise only warns,
428
468
  * or every new test would redden the gate and teach deleting tests.
@@ -496,6 +536,8 @@ export function runTestSuite(projectPath = '.', opts = {}) {
496
536
  timeout,
497
537
  encoding: 'utf8',
498
538
  stdio: 'pipe',
539
+ // Node's 1 MiB default kills a large suite mid-run (ENOBUFS), which then reads as a red suite.
540
+ maxBuffer: TEST_OUTPUT_MAX_BUFFER,
499
541
  ...(env ? { env } : {}),
500
542
  });
501
543
  return { passed: true, output: output ?? '', exitCode: 0, timeoutMs: timeout };
@@ -5,6 +5,7 @@ import { ingestedBytes } from './telemetry-reader.js';
5
5
  import { producerOf } from '../../../framework/hooks/shared/command-tokens.js';
6
6
  import { isFailedToolCall } from '../../../framework/hooks/shared/telemetry-logger.js';
7
7
  import { GATE_EVENT, GATE_STEPS } from '../../../framework/hooks/shared/gate-event.js';
8
+ import { QUEUE_FULL_EXIT, REVIEW_AGENT_TYPES, morphVerifyCall, testRunKind } from '../../../framework/hooks/shared/heavy-command.js';
8
9
 
9
10
  export const RERUN_WINDOW_MS = 10 * 60 * 1000;
10
11
 
@@ -81,13 +82,53 @@ function newCounters() {
81
82
  };
82
83
  }
83
84
 
84
- function addTime(counters, klass, durationMs) {
85
- const slot = counters.timeByClass[klass] ||= { runs: 0, measuredMs: 0, unmeasured: 0 };
85
+ const newTimeSlot = () => ({ runs: 0, measuredMs: 0, unmeasured: 0 });
86
+
87
+ function addRun(slot, durationMs) {
86
88
  slot.runs += 1;
87
89
  if (Number.isFinite(durationMs)) slot.measuredMs += durationMs;
88
90
  else slot.unmeasured += 1;
89
91
  }
90
92
 
93
+ // `agent_type` carries the dispatch name when one is given (`ver-c`, `gate3-motor`), not the persona,
94
+ // so the metric also reads Gate 3 names; `byPersona` is the part the test-budget hook recognizes.
95
+ const REVIEW_NAME = /^(?:ver|rev|sec|g3|gate3|eval|review|reviewer|security)\d*-/;
96
+ const isReviewAgent = (type) => REVIEW_AGENT_TYPES.has(type) || type === 'morph-evaluator' || REVIEW_NAME.test(String(type ?? ''));
97
+
98
+ // Same split as the test-budget hook: review first, then an unfiltered verify, then the round count.
99
+ // A run refused by the full heavy queue ran no test.
100
+ function testOutsideVerify(bashEvents) {
101
+ const out = {
102
+ total: newTimeSlot(),
103
+ subagentVerify: newTimeSlot(),
104
+ implementerRound3Plus: newTimeSlot(),
105
+ reviewTests: { ...newTimeSlot(), byPersona: newTimeSlot() },
106
+ };
107
+ const byAgent = new Map();
108
+ for (const e of bashEvents) {
109
+ if (e.event === 'PostToolUseFailure' && e.exitCode === QUEUE_FULL_EXIT) continue;
110
+ const verify = morphVerifyCall(e.command);
111
+ if (!verify && !testRunKind(e.command)) continue;
112
+ addRun(out.total, e.durationMs);
113
+ if (!e.agentId) continue;
114
+ if (isReviewAgent(e.agentType)) {
115
+ addRun(out.reviewTests, e.durationMs);
116
+ if (REVIEW_AGENT_TYPES.has(e.agentType)) addRun(out.reviewTests.byPersona, e.durationMs);
117
+ continue;
118
+ }
119
+ if (!byAgent.has(e.agentId)) byAgent.set(e.agentId, []);
120
+ byAgent.get(e.agentId).push({ e, unfilteredVerify: Boolean(verify && !verify.filtered) });
121
+ }
122
+ for (const runs of byAgent.values()) {
123
+ runs.sort((a, b) => String(a.e.ts ?? '').localeCompare(String(b.e.ts ?? '')));
124
+ runs.forEach(({ e, unfilteredVerify }, round) => {
125
+ if (unfilteredVerify) addRun(out.subagentVerify, e.durationMs);
126
+ else if (round >= 2) addRun(out.implementerRound3Plus, e.durationMs);
127
+ });
128
+ }
129
+ return out;
130
+ }
131
+
91
132
  function finishCounters(c, turnsObserved) {
92
133
  return {
93
134
  toolCalls: c.toolCalls,
@@ -180,6 +221,7 @@ export function computeMetrics(events, opts = {}) {
180
221
  const reuse = { runs: 0, byTree: {}, skipReasons: {} };
181
222
  const gates = {};
182
223
  const openPauses = new Map();
224
+ const testBash = [];
183
225
  let v2 = 0;
184
226
  let legacy = 0;
185
227
  let turnsObserved = false;
@@ -257,10 +299,11 @@ export function computeMetrics(events, opts = {}) {
257
299
  }
258
300
 
259
301
  if (e.tool !== 'Bash' && e.tool !== 'PowerShell') continue;
302
+ if (e.tool === 'Bash' && typeof e.command === 'string') testBash.push(e);
260
303
  const { kind, klass } = classifyCommand(e.command);
261
304
  const git = gitCalls(e.command);
262
305
  for (const c of sinks) {
263
- addTime(c, klass, e.durationMs);
306
+ addRun(c.timeByClass[klass] ||= newTimeSlot(), e.durationMs);
264
307
  c.git.status += git.status;
265
308
  c.git.diff += git.diff;
266
309
  }
@@ -335,6 +378,7 @@ export function computeMetrics(events, opts = {}) {
335
378
  unchangedRepeats,
336
379
  },
337
380
  dotnetFullSuite: fullSuite,
381
+ testOutsideVerify: testOutsideVerify(testBash),
338
382
  reuse,
339
383
  gates,
340
384
  features: featureRows,
@@ -77,7 +77,7 @@ export class PackageValidator {
77
77
  }
78
78
 
79
79
  return {
80
- status: results.length === 0 ? 'ok' : 'error',
80
+ status: allIssues.some(i => i.level === 'error') ? 'error' : 'ok',
81
81
  totalFiles: csprojFiles.length,
82
82
  filesWithIssues: results.length,
83
83
  // The runner's contract: without these counts the findings read as `errors: 0`.
@@ -301,7 +301,7 @@ export async function validatePackages(projectPath = '.', options = {}) {
301
301
  validator.formatResults(results);
302
302
  }
303
303
 
304
- if (options.autoFix && results.status !== 'ok') {
304
+ if (options.autoFix && results.results?.length) {
305
305
  const fixedCount = await validator.autoFix(results);
306
306
  console.log(chalk.green(`\n✅ Auto-fixed ${fixedCount} issue(s)`));
307
307
  }
@@ -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,8 +284,11 @@ 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: ' +
288
- 'escreva código e testes, compile, e rode SÓ o filtro dos seus testes UMA vez, em primeiro plano, antes do relatório; se falhar, corrija e rode só os que falharam. ' +
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
+ 'escreva código e testes, compile, e rode SÓ o filtro dos seus testes UMA vez, em primeiro plano, antes do relatório ' +
289
+ '(`vitest related <arquivos da fatia> --run`; `dotnet test --filter "FullyQualifiedName~<Classe>" --no-build` após o build); ' +
290
+ 'se falhar, corrija e rode só os que falharam. Teto: 3 rodadas (`verify --filter` conta, exit 3 não); ' +
291
+ 'o hook nega a 4ª e o `verify` sem `--filter`: grave `PARCIAL` com o que ainda falha e devolva. ' +
289
292
  'Grave a saída em arquivo (`… > .morph/logs/test-last.log 2>&1`, sem nada depois) e leia o arquivo — um pipe troca o exit do teste pelo do último comando. ' +
290
293
  'Em batch paralelo SEM isolamento, só compile. Com `isolation: "worktree"`, o mesmo vale no seu worktree, e o último ato é o commit na branch dele.',
291
294
  '4. SOBREVIVÊNCIA: o limite de turnos corta sem aviso: commite `chore(F{n}): wip …` a cada marco (um arquivo ou uma fatia escrita), não só ao fim — ' +
@@ -1,4 +1,5 @@
1
1
  import fs from 'fs-extra';
2
+ import { execFileSync } from 'child_process';
2
3
  import { join, dirname, resolve } from 'path';
3
4
  import { fileURLToPath } from 'url';
4
5
  import { WORKTREE_DIR_NAMES } from '../lib/worktree/dirs.js';
@@ -140,18 +141,39 @@ const ignoredDir = (line) => line.trim()
140
141
  .replace(/^\[(\w)(\w)\]/, (_, a, b) => (a.toLowerCase() === b.toLowerCase() ? a : `[${a}${b}]`))
141
142
  .toLowerCase();
142
143
 
143
- /** Never rewrites an existing line. @returns {string[]} the entries added */
144
+ function trackedUnder(projectPath, dirs) {
145
+ let files;
146
+ try {
147
+ files = execFileSync('git', ['ls-files', '-z'], { cwd: projectPath, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).split('\0');
148
+ } catch { return new Map(); }
149
+ const found = new Map();
150
+ for (const file of files) {
151
+ const segments = file.toLowerCase().split('/').slice(0, -1);
152
+ for (const dir of dirs) if (!found.has(dir) && segments.includes(dir)) found.set(dir, file);
153
+ }
154
+ return found;
155
+ }
156
+
157
+ /**
158
+ * Never rewrites an existing line. Build output is never versioned, so a tracked file
159
+ * under a `bin`/`obj` segment is source that entry would hide: the entry is skipped.
160
+ * @returns {string[]} the entries added; `.skipped` lists `{ entry, file }` when any was skipped
161
+ */
144
162
  export function ensureDotnetBuildIgnores(projectPath) {
145
163
  const gitignorePath = join(projectPath, '.gitignore');
146
164
  const content = fs.existsSync(gitignorePath) ? fs.readFileSync(gitignorePath, 'utf8') : '';
147
165
  const present = new Set(content.split(/\r?\n/).filter((l) => !l.trim().startsWith('#')).map(ignoredDir));
148
- const missing = DOTNET_BUILD_DIRS.filter((dir) => !present.has(dir)).map((dir) => `${dir}/`);
149
- if (!missing.length) return [];
166
+ const absent = DOTNET_BUILD_DIRS.filter((dir) => !present.has(dir));
167
+ const tracked = absent.length ? trackedUnder(projectPath, absent) : new Map();
168
+ const missing = absent.filter((dir) => !tracked.has(dir)).map((dir) => `${dir}/`);
169
+ const skipped = [...tracked].map(([dir, file]) => ({ entry: `${dir}/`, file }));
170
+ const result = (added) => (skipped.length ? Object.assign(added, { skipped }) : added);
171
+ if (!missing.length) return result([]);
150
172
  const eol = content.includes('\r\n') ? '\r\n' : '\n';
151
173
  const lead = content && !content.endsWith('\n') ? eol : '';
152
174
  const block = [...(content ? [''] : []), '# .NET build output', ...missing, ''].join(eol);
153
175
  fs.writeFileSync(gitignorePath, content + lead + block);
154
- return missing;
176
+ return result(missing);
155
177
  }
156
178
 
157
179
  // Without the LF pin, Windows autocrlf marks every rewritten feature.json dirty