@polymorphism-tech/morph-spec 9.1.0 → 9.3.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 (104) hide show
  1. package/CLAUDE.md +14 -78
  2. package/README.md +2 -2
  3. package/bin/morph-spec.js +14 -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/CLAUDE.md +4 -3
  8. package/framework/MORPH.md +7 -10
  9. package/framework/agents.json +15 -12
  10. package/framework/commands/morph-status.md +2 -2
  11. package/framework/docs/CLI.md +24 -16
  12. package/framework/evals/review-checklist.md +15 -2
  13. package/framework/hooks/README.md +8 -44
  14. package/framework/hooks/claude-code/__pycache__/statusline.cpython-313.pyc +0 -0
  15. package/framework/hooks/claude-code/core/state-sync.js +1 -1
  16. package/framework/hooks/claude-code/core/worktree-context.js +23 -0
  17. package/framework/hooks/claude-code/statusline.py +3 -2
  18. package/framework/hooks/claude-code/statusline.sh +11 -11
  19. package/framework/hooks/shared/gate-event.js +18 -0
  20. package/framework/hooks/shared/next-step.js +107 -0
  21. package/framework/hooks/shared/slice-progress.js +18 -5
  22. package/framework/hooks/shared/state-writer.js +6 -1
  23. package/framework/mcp/registry.json +31 -1
  24. package/framework/rules/nextjs-standards.md +1 -1
  25. package/framework/schemas/feature.schema.json +1 -0
  26. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  27. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  28. package/framework/skills/core/morph-apply/SKILL.md +6 -4
  29. package/framework/skills/core/morph-hotfix/SKILL.md +3 -3
  30. package/framework/skills/core/morph-machine/SKILL.md +1 -1
  31. package/framework/skills/core/morph-media/SKILL.md +1 -1
  32. package/framework/skills/core/morph-plan/SKILL.md +8 -6
  33. package/framework/skills/core/morph-review/SKILL.md +9 -6
  34. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  35. package/framework/skills/core/morph-uiux/SKILL.md +2 -1
  36. package/framework/standards/STANDARDS.json +2 -2
  37. package/framework/standards/STANDARDS.meta.json +1 -1
  38. package/framework/standards/ai-agents/setup.md +2 -0
  39. package/framework/standards/backend/dotnet/testing.md +10 -4
  40. package/framework/standards/frontend/nextjs/testing.md +10 -6
  41. package/framework/standards/integration/mcp/mcp-tools.md +3 -1
  42. package/framework/templates/.idea/morph-templates.xml +92 -0
  43. package/framework/templates/.vscode/morph-templates.code-snippets +186 -0
  44. package/framework/templates/IDE-SNIPPETS.md +262 -262
  45. package/framework/templates/code/dotnet/abstractions/api-endpoint.cs +7 -7
  46. package/framework/templates/code/dotnet/feature/request.cs.hbs +4 -4
  47. package/framework/templates/code/dotnet/feature/response.cs.hbs +4 -4
  48. package/framework/templates/code/sql/ai-agents/pgvector-embeddings-migration.sql.template +91 -91
  49. package/framework/templates/docs/plan.md +3 -1
  50. package/framework/templates/frontend/nextjs/client-component.tsx.hbs +26 -26
  51. package/framework/templates/frontend/nextjs/feature-form.tsx.hbs +56 -56
  52. package/framework/templates/frontend/nextjs/tsconfig.json.hbs +26 -26
  53. package/framework/templates/infrastructure/docker/Dockerfile.template +58 -58
  54. package/framework/templates/infrastructure/docker/dockerfile-api.dockerfile +38 -38
  55. package/framework/templates/infrastructure/docker/dockerfile-web.dockerfile +48 -48
  56. package/framework/templates/infrastructure/github/actions/docker-build-push/action.yml.hbs +45 -45
  57. package/framework/templates/infrastructure/github/actions/health-check/action.yml.hbs +27 -27
  58. package/framework/templates/infrastructure/github/workflows/docker-build-push.yml.hbs +59 -59
  59. package/framework/templates/infrastructure/github/workflows/dotnet-build.yml.hbs +39 -39
  60. package/framework/templates/meta-prompts/fusion/fusion-agent.md +76 -76
  61. package/framework/templates/meta-prompts/fusion/fusion-aggregator.md +100 -100
  62. package/framework/templates/state.template.json +17 -17
  63. package/framework/templates/ui/design-system.css +226 -226
  64. package/package.json +1 -1
  65. package/src/commands/observability/cost.js +12 -0
  66. package/src/commands/project/status.js +6 -68
  67. package/src/commands/state/advance.js +1 -1
  68. package/src/commands/state/approve.js +2 -2
  69. package/src/commands/state/gate-check.js +2 -2
  70. package/src/commands/state/gate-decision.js +4 -1
  71. package/src/commands/state/next.js +24 -0
  72. package/src/commands/state/report-check.js +3 -1
  73. package/src/commands/validation/mutate.js +9 -6
  74. package/src/commands/validation/plan-check.js +4 -2
  75. package/src/commands/validation/tests-audit.js +7 -0
  76. package/src/commands/validation/validate-feature.js +4 -2
  77. package/src/commands/validation/verify.js +21 -16
  78. package/src/core/state/state-manager.js +4 -0
  79. package/src/dashboard/app/main.jsx +13 -13
  80. package/src/dashboard/app/pages/FeatureDetail.jsx +6 -2
  81. package/src/dashboard/scanner/features-scanner.js +5 -2
  82. package/src/dashboard/vite.config.js +24 -24
  83. package/src/lib/ci/workflow-scaffold.js +12 -0
  84. package/src/lib/e2e/applicability.js +37 -0
  85. package/src/lib/generators/handoff-generator.js +11 -25
  86. package/src/lib/mutation/adapters.js +6 -17
  87. package/src/lib/mutation/runner.js +23 -75
  88. package/src/lib/mutation/trx.js +0 -1
  89. package/src/lib/next-step.js +10 -0
  90. package/src/lib/plan/checks.js +29 -2
  91. package/src/lib/plan/plan-parser.js +6 -2
  92. package/src/lib/review-reports.js +6 -3
  93. package/src/lib/risk-detector.js +20 -7
  94. package/src/lib/stack/stack-scan.js +13 -1
  95. package/src/lib/telemetry/metrics.js +46 -0
  96. package/src/lib/tests-audit/audit.js +96 -0
  97. package/src/lib/validators/code-budget/code-budget-validator.js +1 -1
  98. package/src/lib/validators/validation-runner.js +6 -38
  99. package/src/lib/validators/verify-runner.js +37 -22
  100. package/src/utils/agents-installer.js +24 -1
  101. package/framework/hooks/git/commit-msg/conventional-commits.sh +0 -33
  102. package/framework/hooks/git/pre-commit/agents.sh +0 -25
  103. package/framework/hooks/git/pre-commit/orchestrator.sh +0 -64
  104. package/framework/hooks/git/pre-push/run-tests.sh +0 -44
@@ -4,12 +4,13 @@ import { applyRegions, diffOf } from './manifest.js';
4
4
  import { blame, parseCompilerErrors, patchFile, switchClassName, switchSource, SWITCH_ENV } from './schemata.js';
5
5
  import { md5Of, md5OfFile, openSnapshot, restoreSnapshot, trackGenerated } from './snapshot.js';
6
6
  import { parseBuiltDlls } from './commands.js';
7
- import { firstLines, methodOf } from './trx.js';
7
+ import { firstLines } from './trx.js';
8
8
  import { nearestProjectOf } from './dotnet-introspection.js';
9
9
 
10
10
  const posix = (p) => p.replace(/\\/g, '/');
11
11
  const MAX_SCHEMATA_ATTEMPTS = 3;
12
- const MAX_REPRODUCE = 5;
12
+
13
+ export const noSubsetReason = (why) => `sem subconjunto utilizável (${why ?? 'nenhum teste da feature nem que cite os tipos mutados'}): a mutação não roda a suíte inteira — prove o cenário por leitura`;
13
14
 
14
15
  const envWithout = (env) => { const { [SWITCH_ENV]: _drop, ...rest } = env; return rest; };
15
16
  const failedNames = (result) => new Set(result.failedTests.map((t) => t.name));
@@ -123,29 +124,15 @@ export async function runMutation(input) {
123
124
  .map((name) => [name, md5OfFile(dlls.get(name))]),
124
125
  );
125
126
 
126
- const run = (scope, label, env) => timed(label, () => adapter.runTests({ scope, label, env }));
127
-
128
- const confirmKill = async (id, verdict, env) => {
129
- if (!adapter.reproduce) return verdict;
130
- const names = [...new Set(verdict.failures.map((f) => f.name))].slice(0, MAX_REPRODUCE);
131
- const again = await timed(`${id}-reproduce`, () => adapter.reproduce({ names, label: `${id}-reproduce`, env }));
132
- if (again.outcome === 'crash' || !again.executed) {
133
- return { state: 'inconclusive', reason: `a falha de ${names[0]} não pôde ser confirmada na repetição (${again.note ?? 'nenhum teste executado'})` };
134
- }
135
- const wanted = new Set(names.map(methodOf));
136
- const held = again.failedTests.filter((t) => wanted.has(methodOf(t.name)));
137
- if (held.length > 0) return { ...verdict, reason: reasonOf(held) };
138
- const whole = await timed(`${id}-reproduce-full`, () => adapter.runTests({ scope: 'full', label: `${id}-reproduce-full`, env }));
139
- if (whole.outcome === 'crash' || !whole.executed) {
140
- return { state: 'inconclusive', reason: `a falha de ${names[0]} não pôde ser confirmada na repetição (${whole.note ?? 'nenhum teste executado'})` };
141
- }
142
- const heldWhole = whole.failedTests.filter((t) => wanted.has(methodOf(t.name)));
143
- if (heldWhole.length > 0) return { ...verdict, reason: reasonOf(heldWhole) };
144
- const which = names.length === 1 ? names[0] : `${names.length} testes`;
145
- return { state: 'survived', reason: `nenhum teste falhou de forma reproduzível: ${which} falhou uma vez e passou isolado e na suíte inteira repetida (instável, não prova o mutante)` };
146
- };
127
+ const run = (label, env) => timed(label, () => adapter.runTests({ label, env }));
147
128
 
148
129
  const record = (id, patch) => Object.assign(results.get(id), patch);
130
+ const recordSubset = (id, result) => {
131
+ const verdict = judge(result, baselineSubset);
132
+ const base = { scope: 'subset', executed: result.executed, durationMs: result.durationMs };
133
+ if (verdict.state === 'killed') record(id, { ...base, verdict: 'killed', killers: verdict.failures.length, reason: verdict.reason });
134
+ else record(id, { ...base, verdict: verdict.state, reason: verdict.reason ?? '' });
135
+ };
149
136
  const applyOnDisk = (id) => {
150
137
  const region = byId.get(id);
151
138
  writeFileSync(join(root, region.file), applyRegions(originals.get(region.file), [region], (x) => x.replaceText).text);
@@ -154,10 +141,8 @@ export async function runMutation(input) {
154
141
  let pristine = new Map();
155
142
  let binary = { proof: 'unavailable', projects: [] };
156
143
  let baselineSubset = null;
157
- let baselineFull = null;
158
144
  let subsetLive = adapter.hasSubset;
159
145
  let subsetNote = null;
160
- const needFullBaseline = [];
161
146
  let restoreReport = [];
162
147
 
163
148
  try {
@@ -238,40 +223,26 @@ export async function runMutation(input) {
238
223
  if (subsetLive && (schemataIds.length > 0 || hasRebuild)) {
239
224
  const copies = schemataIds.length > 0 ? jobs : 1;
240
225
  baselineSubset = mergeBaselines(await Promise.all(Array.from({ length: copies }, (_, i) => (
241
- run('subset', i === 0 ? 'baseline-subset' : `baseline-subset-${i + 1}`, envWithout(process.env))))));
226
+ run(i === 0 ? 'baseline-subset' : `baseline-subset-${i + 1}`, envWithout(process.env))))));
242
227
  if (baselineSubset.outcome === 'crash' || baselineSubset.executed === 0) {
243
228
  subsetLive = false;
244
229
  subsetNote = baselineSubset.executed === 0 ? 'o filtro do subconjunto não selecionou teste algum' : (baselineSubset.note ?? 'a rodada-base do subconjunto não terminou');
245
230
  }
246
231
  }
247
- if (!subsetLive && schemataIds.length > 0) baselineFull = await run('full', 'baseline-full', envWithout(process.env));
248
-
249
- const scopeOf = subsetLive ? 'subset' : 'full';
250
- const scopeBaseline = () => (subsetLive ? baselineSubset : baselineFull);
251
232
  const routed = [];
252
- const scopeJobs = subsetLive ? jobs : 1;
253
- workerCount = schemataIds.length > 0 ? scopeJobs : 1;
254
- const outcomes = new Map();
255
- await pool(schemataIds, scopeJobs, async (id) => {
256
- outcomes.set(id, await run(scopeOf, `${id}-${scopeOf}`, { ...process.env, [SWITCH_ENV]: id }));
257
- });
258
- for (const id of schemataIds) {
259
- const result = outcomes.get(id);
260
- const first = judge(result, scopeBaseline());
261
- const verdict = first.state === 'killed' && scopeOf === 'full' ? await confirmKill(id, first, { ...process.env, [SWITCH_ENV]: id }) : first;
262
- const base = { scope: scopeOf, executed: result.executed, durationMs: result.durationMs };
263
- if (verdict.state === 'killed') {
264
- record(id, { ...base, verdict: 'killed', killers: verdict.failures.length, reason: verdict.reason });
265
- } else if (scopeOf === 'subset' && verdict.state === 'survived') {
266
- record(id, { ...base, verdict: 'survived', reason: '' });
267
- } else {
268
- record(id, { ...base, verdict: null, reason: verdict.reason ?? '' });
269
- routed.push({ id, needs: 'full' });
270
- }
233
+ workerCount = subsetLive && schemataIds.length > 0 ? jobs : 1;
234
+ if (subsetLive) {
235
+ const outcomes = new Map();
236
+ await pool(schemataIds, jobs, async (id) => {
237
+ outcomes.set(id, await run(`${id}-subset`, { ...process.env, [SWITCH_ENV]: id }));
238
+ });
239
+ for (const id of schemataIds) recordSubset(id, outcomes.get(id));
240
+ for (const r of results.values()) if (r.mode === 'rebuild') routed.push(r.id);
241
+ } else {
242
+ for (const r of results.values()) record(r.id, { verdict: 'inconclusive', scope: null, reason: noSubsetReason(subsetNote) });
271
243
  }
272
- for (const r of results.values()) if (r.mode === 'rebuild') routed.push({ id: r.id, needs: subsetLive ? 'subset' : 'full' });
273
244
 
274
- for (const { id, needs } of routed) {
245
+ for (const id of routed) {
275
246
  applyOnDisk(id);
276
247
  try {
277
248
  if (adapter.buildCommands.length > 0) {
@@ -282,21 +253,7 @@ export async function runMutation(input) {
282
253
  continue;
283
254
  }
284
255
  }
285
- if (needs === 'subset') {
286
- const sub = await run('subset', `${id}-subset`, envWithout(process.env));
287
- const verdict = judge(sub, baselineSubset);
288
- const base = { scope: 'subset', executed: sub.executed, durationMs: sub.durationMs };
289
- if (verdict.state === 'killed') record(id, { ...base, verdict: 'killed', killers: verdict.failures.length, reason: verdict.reason });
290
- else record(id, { ...base, verdict: verdict.state, reason: verdict.reason ?? '' });
291
- continue;
292
- }
293
- const full = await run('full', `${id}-full`, envWithout(process.env));
294
- const first = judge(full, baselineFull);
295
- const verdict = first.state === 'killed' ? await confirmKill(id, first, envWithout(process.env)) : first;
296
- const base = { scope: 'full', executed: full.executed, durationMs: full.durationMs };
297
- if (verdict.state === 'inconclusive') record(id, { ...base, verdict: 'inconclusive', reason: verdict.reason });
298
- else if (verdict.state === 'survived') record(id, { ...base, verdict: 'survived', reason: verdict.reason ?? '' });
299
- else { record(id, { ...base, verdict: 'killed', killers: verdict.failures.length, reason: verdict.reason }); if (!baselineFull) needFullBaseline.push({ id, full }); }
256
+ recordSubset(id, await run(`${id}-subset`, envWithout(process.env)));
300
257
  } finally {
301
258
  restoreSnapshot(snapshot);
302
259
  }
@@ -310,14 +267,6 @@ export async function runMutation(input) {
310
267
  binary = proveBinary({ pristine, dlls: last.dlls, restoredAt, hashes: dllHashes(last.dlls) });
311
268
  }
312
269
 
313
- if (needFullBaseline.length > 0) {
314
- baselineFull = await run('full', 'baseline-full', envWithout(process.env));
315
- for (const { id, full } of needFullBaseline) {
316
- const verdict = judge(full, baselineFull);
317
- if (verdict.state === 'killed') record(id, { killers: verdict.failures.length, reason: verdict.reason });
318
- else record(id, { verdict: 'survived', killers: 0, reason: '' });
319
- }
320
- }
321
270
  } finally {
322
271
  restoreReport = restoreSnapshot(snapshot, { rewrite: false });
323
272
  gate?.release();
@@ -338,7 +287,6 @@ export async function runMutation(input) {
338
287
  mutants,
339
288
  baseline: {
340
289
  subset: baselineSubset ? { executed: baselineSubset.executed, failed: baselineSubset.failed } : null,
341
- full: baselineFull ? { executed: baselineFull.executed, failed: baselineFull.failed } : null,
342
290
  },
343
291
  binary,
344
292
  restore: {
@@ -43,7 +43,6 @@ export function parseTrx(xml) {
43
43
  };
44
44
  }
45
45
 
46
- export const methodOf = (testName) => String(testName).replace(/\s*\(.*$/s, '').trim();
47
46
 
48
47
  export function firstLines(message, max = 2, width = 240) {
49
48
  const lines = String(message).split(/\r?\n/).map((l) => l.trim()).filter(Boolean).slice(0, max).join(' ⏎ ');
@@ -0,0 +1,10 @@
1
+ import { collectNextSignals, decideNext } from '../../framework/hooks/shared/next-step.js';
2
+ import { treeMovedSince } from './git/evidence-staleness.js';
3
+
4
+ /** The CLI collector: the hook-readable facts plus verify staleness (git). Null without feature.json. */
5
+ export function nextStepFor(featureName, tree) {
6
+ const signals = collectNextSignals(featureName, tree);
7
+ if (!signals) return null;
8
+ signals.verificationStale = treeMovedSince(tree, signals.verification?.sha);
9
+ return { ...decideNext(signals), phase: signals.phase, failStreak: signals.failStreak };
10
+ }
@@ -3,8 +3,9 @@ import { existsSync, readFileSync } from 'fs';
3
3
  import { getAbsoluteOutputPath } from '../../core/paths/output-schema.js';
4
4
  import { frozenBody } from '../../../framework/hooks/shared/errata.js';
5
5
  import { readAgentsJson } from '../specs/screen-checklist.js';
6
+ import { getFeature } from '../../../framework/hooks/shared/state-reader.js';
6
7
  import { findArtifactTerms } from './artifact-vocabulary.js';
7
- import { PROOF_KINDS, REUSE_VERDICTS, ADR_SCOPES, SECTIONS, CONFLICTS_HEADING } from './plan-parser.js';
8
+ import { PROOF_KINDS, RISK_KINDS, REUSE_VERDICTS, ADR_SCOPES, SECTIONS, CONFLICTS_HEADING } from './plan-parser.js';
8
9
 
9
10
  export const CHECKS_VERSION = 1;
10
11
  export const MAX_SLICE_FILES = 8;
@@ -83,7 +84,19 @@ export function parallelGroups(slices) {
83
84
  return groups;
84
85
  }
85
86
 
86
- export function validatePlan(plan, { personas = null, surfaces = null } = {}) {
87
+ // A frozen body already extracted under older rules cannot gain a `risco:` line, so it is not demanded.
88
+ export function planRuleOptions(cwd, featureName) {
89
+ return {
90
+ workType: getFeature(featureName, cwd)?.workType ?? null,
91
+ riskRules: readPlanChecks(cwd, featureName).status !== 'fresh',
92
+ };
93
+ }
94
+
95
+ const foldRisk = (s) => s.normalize('NFD').replace(/\p{M}/gu, '').replace(/`/g, '').toLowerCase().trim();
96
+ const RISK_BY_FOLD = new Map(RISK_KINDS.map((r) => [foldRisk(r), r]));
97
+ const REGRESSION_WORK_TYPES = ['bug', 'hotfix'];
98
+
99
+ export function validatePlan(plan, { personas = null, surfaces = null, workType = null, riskRules = true } = {}) {
87
100
  const errors = [];
88
101
  const warnings = [];
89
102
  const at = (line) => (line ? ` (linha ${line})` : '');
@@ -114,12 +127,26 @@ export function validatePlan(plan, { personas = null, surfaces = null } = {}) {
114
127
  } else if (!PROOF_KINDS.includes(c.proof.toLowerCase())) {
115
128
  errors.push(`${c.id}: prova "${c.proof}" fora de ${PROOF_KINDS.join(' | ')}${at(c.line)}`);
116
129
  }
130
+ const proof = c.proof?.toLowerCase();
131
+ const risk = c.risk === null ? null : RISK_BY_FOLD.get(foldRisk(c.risk)) ?? false;
132
+ if (risk === false) {
133
+ errors.push(`${c.id}: risco "${c.risk}" fora de ${RISK_KINDS.join(' | ')}${at(c.line)}`);
134
+ } else if (riskRules && proof === 'teste' && risk === null) {
135
+ errors.push(`${c.id}: "prova: teste" sem "risco:" (${RISK_KINDS.join(' | ')}) — sem risco, a prova é "leitura" ou "e2e"${at(c.line)}`);
136
+ } else if (risk && proof === 'leitura') {
137
+ warnings.push(`${c.id}: risco "${risk}" provado só por leitura${at(c.line)}`);
138
+ }
117
139
  const terms = findArtifactTerms(c.text);
118
140
  if (terms.length > 0 && !c.artifactWhy) {
119
141
  errors.push(`${c.id} descreve artefato ("${terms.join(', ')}") sem "> Por que artefato:" — reescreva como efeito observável ou justifique${at(c.line)}`);
120
142
  }
121
143
  }
122
144
 
145
+ const isRegression = (c) => ['teste', 'e2e'].includes(c.proof?.toLowerCase()) && c.risk !== null && RISK_BY_FOLD.get(foldRisk(c.risk)) === 'regressão';
146
+ if (riskRules && REGRESSION_WORK_TYPES.includes(workType) && plan.scenarios.length > 0 && !plan.scenarios.some(isRegression)) {
147
+ errors.push(`${workType} sem cenário de regressão — o sintoma do bug real vira um Cn com "prova: teste" e "risco: regressão"`);
148
+ }
149
+
123
150
  const scenarioIds = new Set(plan.scenarios.map((c) => c.id));
124
151
  const sliceIds = new Set(plan.slices.map((s) => s.id));
125
152
  const covered = new Set();
@@ -2,7 +2,8 @@ import { tableCells, isTableLine, SEPARATOR_ROW_RE } from '../specs/gfm-table.js
2
2
  import { ARTIFACT_MARKER_RE } from './artifact-vocabulary.js';
3
3
  import { markdownLines } from '../../../framework/hooks/shared/errata.js';
4
4
 
5
- export const PROOF_KINDS = ['teste', 'e2e', 'eval', 'manual'];
5
+ export const PROOF_KINDS = ['teste', 'e2e', 'eval', 'manual', 'leitura'];
6
+ export const RISK_KINDS = ['regra', 'dinheiro', 'contrato', 'concorrência', 'dado', 'regressão'];
6
7
  export const REUSE_VERDICTS = ['REUSAR', 'ESTENDER', 'CRIAR'];
7
8
  export const ADR_SCOPES = ['feature', 'projeto'];
8
9
 
@@ -36,6 +37,7 @@ const SCENARIO_RE = new RegExp(String.raw`^[-*]\s+\*\*(C\d+)\*\*${DASH}(.+)$`);
36
37
  const ADR_RE = new RegExp(String.raw`^###\s+(ADR-\d+)${DASH}(.+?)\s*$`);
37
38
  const SLICE_RE = new RegExp(String.raw`^###\s+(F\d+)${DASH}(.+?)\s*$`);
38
39
  const PROOF_RE = /^\s*prova\s*:\s*(.*?)\s*$/i;
40
+ const RISK_RE = /^\s*risco\s*:\s*(.*?)\s*$/i;
39
41
  const WHY_RE = new RegExp(String.raw`^\s*>\s*` + ARTIFACT_MARKER_RE.source + String.raw`\s*(.*?)\s*$`, 'i');
40
42
  const SCOPE_RE = /^\s*\*\*Alcance:\*\*\s*(.*?)\s*$/i;
41
43
  const SLICE_LINE_RE = /^\s*[-*]\s+([\p{L}]+)\s*:\s*(.*?)\s*$/u;
@@ -69,7 +71,7 @@ function parseScenarios(lines) {
69
71
  if (line.fenced) { current = null; continue; }
70
72
  const m = SCENARIO_RE.exec(line.raw);
71
73
  if (m) {
72
- current = { id: m[1], text: m[2].trim(), proof: null, artifactWhy: null, line: line.n };
74
+ current = { id: m[1], text: m[2].trim(), proof: null, risk: null, artifactWhy: null, line: line.n };
73
75
  scenarios.push(current);
74
76
  continue;
75
77
  }
@@ -79,6 +81,8 @@ function parseScenarios(lines) {
79
81
  if (why) { current.artifactWhy = why[1] || ''; continue; }
80
82
  const proof = PROOF_RE.exec(line.raw);
81
83
  if (proof && current.proof === null) { current.proof = proof[1]; continue; }
84
+ const risk = RISK_RE.exec(line.raw);
85
+ if (risk && current.risk === null) { current.risk = risk[1]; continue; }
82
86
  if (/^\s*>/.test(line.raw)) continue;
83
87
  current.text = `${current.text} ${line.raw.trim()}`;
84
88
  }
@@ -27,10 +27,11 @@ const CHECKS_SECTION_RE = sectionRe('Checks');
27
27
  const MEDIA_SECTION_RE = /^##\s+M[íi]dia\s+gerada\s*$/m;
28
28
  const SCREEN_CHECKLIST_SECTION_RE = sectionRe('Checklist\\s+de\\s+tela');
29
29
 
30
- /** Hotfix: só o verificador, escrito pelo orquestrador sem dispatch. */
30
+ /** Hotfix: só o verificador, escrito pelo orquestrador sem dispatch. Chore: só o revisor (review leve). */
31
31
  export function requiredReviewRoles({ workType, securityRequired = false } = {}) {
32
32
  if (workType === 'hotfix') return ['verifier'];
33
- return securityRequired ? ['verifier', 'reviewer', 'security'] : ['verifier', 'reviewer'];
33
+ const base = workType === 'chore' ? ['reviewer'] : ['verifier', 'reviewer'];
34
+ return securityRequired ? [...base, 'security'] : base;
34
35
  }
35
36
 
36
37
  function sectionBodies(text, re) {
@@ -172,7 +173,9 @@ export function reviewSignal(reports, { required, checkIds }) {
172
173
  superseded: r.superseded,
173
174
  };
174
175
  }
175
- out.checks = checkIds === null
176
+ out.checks = !required.includes('verifier')
177
+ ? { status: 'not-required', missing: [], notProven: [] }
178
+ : checkIds === null
176
179
  ? { status: 'no-checks', missing: [], notProven: [] }
177
180
  : { status: 'ok', total: checkIds.length, ...checksCoverage(checkIds, reports.verifier.checks) };
178
181
  return out;
@@ -11,6 +11,7 @@ const E2E_SKIP_PATTERNS = [
11
11
  [/docker is not available/i, 'environment'],
12
12
  [/^requested$/i, 'requested'],
13
13
  [/does not containerize/i, 'project'],
14
+ [/^not applicable:/i, 'not-applicable'],
14
15
  ];
15
16
 
16
17
  // `unknown` (razão vazia) pausa de propósito: dispensa sem motivo é o no-op silencioso que a regra elimina.
@@ -99,6 +100,7 @@ export function reviewPause(signal) {
99
100
  if (r.verdict !== 'SHIP') return `veredito do ${role} é ${r.verdict}`;
100
101
  }
101
102
  const checks = signal?.checks;
103
+ if (checks?.status === 'not-required') return null;
102
104
  if (!checks || checks.status !== 'ok') {
103
105
  return 'sem `2-plan/checks.json` legível — a cobertura dos cenários pelo verificador não é conferível';
104
106
  }
@@ -156,23 +158,25 @@ export function shouldPauseGate(config, signals) {
156
158
  const trust = config?.trust ?? 'auto';
157
159
 
158
160
  if (trust === 'manual') {
159
- return { pause: true, reason: 'trust mode is manual' };
161
+ return { pause: true, signal: 'trust-manual', reason: 'trust mode is manual' };
160
162
  }
161
163
 
162
164
  if (FORCED_PAUSE_WORK_TYPES.includes(signals.workType)) {
163
165
  return {
164
166
  pause: true,
167
+ signal: 'hotfix',
165
168
  reason: 'hotfix: aprovação humana obrigatória no ship (política de tipo — ignora trust:auto)',
166
169
  };
167
170
  }
168
171
 
169
172
  if (signals.designSystemDivergence) {
170
- return { pause: true, reason: 'design-system divergence detected' };
173
+ return { pause: true, signal: 'design-system', reason: 'design-system divergence detected' };
171
174
  }
172
175
 
173
176
  if (Array.isArray(signals.standardsViolations) && signals.standardsViolations.length > 0) {
174
177
  return {
175
178
  pause: true,
179
+ signal: 'standards',
176
180
  reason: `standards violations detected: ${signals.standardsViolations.join(', ')}`,
177
181
  };
178
182
  }
@@ -181,6 +185,7 @@ export function shouldPauseGate(config, signals) {
181
185
  if (signals.verificationStatus === 'fail') {
182
186
  return {
183
187
  pause: true,
188
+ signal: 'verify-red',
184
189
  reason: 'último morph-spec verify está VERMELHO — rode verify e corrija antes de aprovar o gate',
185
190
  };
186
191
  }
@@ -189,6 +194,7 @@ export function shouldPauseGate(config, signals) {
189
194
  if (signals.gate === 'review' && !signals.verificationStatus) {
190
195
  return {
191
196
  pause: true,
197
+ signal: 'verify-missing',
192
198
  reason: 'nenhum `morph-spec verify` carimbado nesta feature — rode `morph-spec verify {feature}` antes do ship',
193
199
  };
194
200
  }
@@ -198,7 +204,7 @@ export function shouldPauseGate(config, signals) {
198
204
  if (signals.gate === 'review' && signals.e2eStatus === 'skip') {
199
205
  const klass = classifyE2eSkip(signals.e2eReason);
200
206
  if (E2E_SKIP_CLASSES_THAT_PAUSE.includes(klass)) {
201
- return { pause: true, reason: e2ePauseReason(klass, signals.e2eReason) };
207
+ return { pause: true, signal: 'e2e-skip', reason: e2ePauseReason(klass, signals.e2eReason) };
202
208
  }
203
209
  }
204
210
 
@@ -206,13 +212,14 @@ export function shouldPauseGate(config, signals) {
206
212
  if (signals.gate === 'review' && signals.verificationStale === true) {
207
213
  return {
208
214
  pause: true,
215
+ signal: 'verify-stale',
209
216
  reason: 'verification stamp desatualizado (o código mudou depois do commit do stamp) — rode `morph-spec verify` de novo antes do ship',
210
217
  };
211
218
  }
212
219
 
213
220
  if (signals.gate === 'review') {
214
221
  const reason = reviewPause(signals.reviewReports);
215
- if (reason) return { pause: true, reason };
222
+ if (reason) return { pause: true, signal: 'review-reports', reason };
216
223
  }
217
224
 
218
225
  // Prompt e modelo não são lidos por build nem por teste. Depois dos relatórios de review de propósito:
@@ -227,6 +234,7 @@ export function shouldPauseGate(config, signals) {
227
234
  if (signals.evalsStatus === null || signals.evalsStatus === undefined) {
228
235
  return {
229
236
  pause: true,
237
+ signal: 'prompt-model-no-eval',
230
238
  reason: `mudou prompt/modelo (${where}) e o projeto não declara nó de eval`
231
239
  + ' — nenhum build ou teste unitário fica vermelho por um prompt trocado;'
232
240
  + ' declare um `runner: "evals"` em project.tests[] ou registre a dispensa com `morph-spec gate-decision`',
@@ -235,6 +243,7 @@ export function shouldPauseGate(config, signals) {
235
243
  if (signals.evalsStatus === 'skip') {
236
244
  return {
237
245
  pause: true,
246
+ signal: 'prompt-model-eval-skip',
238
247
  reason: `mudou prompt/modelo (${where}) e o eval não foi executado neste verify`
239
248
  + ' — rode `morph-spec verify {feature}` (escopo de feature) antes do ship',
240
249
  };
@@ -248,6 +257,7 @@ export function shouldPauseGate(config, signals) {
248
257
  && signals.mediaClaims.section !== true) {
249
258
  return {
250
259
  pause: true,
260
+ signal: 'media-claims',
251
261
  reason: '`4-review/verifier.md` sem a seção `## Mídia gerada` — as quatro afirmações de mídia'
252
262
  + ' (custo, revisão humana, marcação, hot path) não foram provadas'
253
263
  + ' (evals/review-checklist.md, "Verificador")',
@@ -257,7 +267,7 @@ export function shouldPauseGate(config, signals) {
257
267
  // Sem o checklist de tela completo, paginação, ordenação, filtro e os três estados não viram cenário.
258
268
  if (signals.gate === 'uiux') {
259
269
  const reason = uiuxChecklistPause(signals.screenChecklist);
260
- if (reason) return { pause: true, reason };
270
+ if (reason) return { pause: true, signal: 'uiux-checklist', reason };
261
271
  }
262
272
 
263
273
  // Cobra-se só o item devido (`uncovered`): tela toda "não se aplica", design legado ou ausente não devem nada.
@@ -266,6 +276,7 @@ export function shouldPauseGate(config, signals) {
266
276
  if (section !== true) {
267
277
  return {
268
278
  pause: true,
279
+ signal: 'screen-checklist',
269
280
  reason: `\`4-review/verifier.md\` sem a seção \`## Checklist de tela\` — o design decidiu ${firstOf(uncovered)}`
270
281
  + ' e o verificador não registrou a conferência contra a tela entregue'
271
282
  + ' (evals/review-checklist.md, "Contrato de tela")',
@@ -273,6 +284,7 @@ export function shouldPauseGate(config, signals) {
273
284
  }
274
285
  return {
275
286
  pause: true,
287
+ signal: 'screen-checklist',
276
288
  reason: `a seção \`## Checklist de tela\` não confere ${firstOf(uncovered)}`
277
289
  + ' — uma linha por item decidido de cada tela, mesmo quando não verificável',
278
290
  };
@@ -280,17 +292,18 @@ export function shouldPauseGate(config, signals) {
280
292
 
281
293
  if (signals.gate === 'plan') {
282
294
  const reason = planChecksPause(signals.planChecks);
283
- if (reason) return { pause: true, reason };
295
+ if (reason) return { pause: true, signal: 'plan-checks', reason };
284
296
  }
285
297
 
286
298
  if (typeof signals.sliceCount === 'number' && signals.sliceCount > MAX_SLICES) {
287
299
  return {
288
300
  pause: true,
301
+ signal: 'slice-count',
289
302
  reason: `${signals.sliceCount} fatias, acima de ${MAX_SLICES} — o plano é grande demais para uma feature`,
290
303
  };
291
304
  }
292
305
 
293
- return { pause: false, reason: '' };
306
+ return { pause: false, signal: null, reason: '' };
294
307
  }
295
308
 
296
309
  export function getRiskSignals(signals) {
@@ -99,7 +99,19 @@ function auditDotnet(files, read) {
99
99
  const otherDb = has(/^(Microsoft\.EntityFrameworkCore\.SqlServer|Pomelo\.EntityFrameworkCore\.MySql|MySql\.|MySqlConnector|Microsoft\.Data\.SqlClient|System\.Data\.SqlClient)/);
100
100
  rows.push({ layer: 'ORM', detected: ef.length > 0 ? 'EF Core' : (packages.has('Dapper') ? 'Dapper' : 'ausente'), expected: 'EF Core', ok: ef.length > 0 });
101
101
  rows.push({ layer: 'Banco', detected: npgsql.length > 0 ? 'Postgres (Npgsql)' : (otherDb.join(', ') || 'ausente'), expected: 'Neon (Postgres)', ok: npgsql.length > 0 && otherDb.length === 0 });
102
- rows.push({ layer: 'IA', detected: has(/^Microsoft\.(Agents\.AI|Extensions\.AI)/).join(', ') || 'ausente', expected: 'MAF quando houver agentes', ok: true });
102
+ const semanticKernel = has(/^Microsoft\.SemanticKernel/);
103
+ const kernelFunction = grepFiles(source, read, /\[KernelFunction\b/);
104
+ const hostedAgents = has(/^Azure\.AI\.(Projects|Agents\.Persistent)\b/);
105
+ const floatingAi = has(/^(Microsoft\.Agents\.AI|Microsoft\.Extensions\.AI|OpenAI$|Google\.GenAI$|ModelContextProtocol)/)
106
+ .filter((p) => /\*|^[[(]/.test(packages.get(p) ?? ''));
107
+ rows.push({ layer: 'IA', detected: has(/^Microsoft\.(Agents\.AI|Extensions\.AI|SemanticKernel)/).join(', ') || 'ausente', expected: 'MAF quando houver agentes', ok: semanticKernel.length === 0 && kernelFunction.length === 0 && hostedAgents.length === 0 });
108
+ const antiPatterns = '`morph-spec standards --show ai-agents-setup#7-what-not-to-do-anti-patterns`';
109
+ if (semanticKernel.length > 0 || kernelFunction.length > 0) {
110
+ add('Alto', `Semantic Kernel em vez de MAF (${semanticKernel.join(', ') || '`[KernelFunction]`'})`, [...(semanticKernel.length > 0 ? projects : []), ...kernelFunction],
111
+ `\`Microsoft.Agents.AI\` (\`agent.RunAsync()\`, tool por \`AIFunctionFactory.Create()\`); ${antiPatterns}`);
112
+ }
113
+ if (hostedAgents.length > 0) add('Alto', `Serviço de agente hospedado (${hostedAgents.join(', ')})`, projects, `modelo direto por \`IChatClient\`; ${antiPatterns}`);
114
+ for (const p of floatingAi) add('Médio', `Versão flutuante de SDK de IA (${p} ${packages.get(p)})`, [], 'versão exata, a do pin (`morph-spec doctor --ai`)');
103
115
  rows.push({ layer: 'Jobs', detected: has(/^Hangfire/).length > 0 ? 'Hangfire' : 'ausente', expected: 'Hangfire só com razão real', ok: true });
104
116
  if (packages.has('Dapper') && ef.length === 0) add('Bloqueante', 'ORM diferente (Dapper puro sem EF Core)', projects, 'migrar o acesso a dados para EF Core');
105
117
  if (otherDb.length > 0) add('Bloqueante', `Banco diferente (${otherDb.join(', ')})`, projects, 'migrar para Neon (Postgres, Npgsql)');
@@ -4,6 +4,7 @@
4
4
  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
+ import { GATE_EVENT, GATE_STEPS } from '../../../framework/hooks/shared/gate-event.js';
7
8
 
8
9
  export const RERUN_WINDOW_MS = 10 * 60 * 1000;
9
10
 
@@ -122,6 +123,43 @@ function reuseStateOf(e) {
122
123
  return { state: 'absent', reason: null };
123
124
  }
124
125
 
126
+ const newGateSlot = () => ({ decisions: 0, paused: 0, autoPassed: 0, approvedAfterPause: 0, reworkedAfterPause: 0, unapproved: 0, bySignal: {} });
127
+
128
+ // A pause stays open until the next approve or a decision that no longer pauses on the same signal:
129
+ // re-running gate-check over the same unchanged pause is not rework.
130
+ function trackGate(gates, open, e) {
131
+ if (!e.gate || !GATE_STEPS.includes(e.step)) return;
132
+ const slot = gates[e.gate] ||= newGateSlot();
133
+ const key = `${e.feature || ''}\u0000${e.gate}`;
134
+ const pending = open.get(key);
135
+ const bySignal = (signal) => slot.bySignal[signal || 'desconhecido'] ||= { paused: 0, approvedAfterPause: 0, reworkedAfterPause: 0 };
136
+ const credit = (field) => {
137
+ slot[field] += 1;
138
+ bySignal(pending.signal)[field] += 1;
139
+ };
140
+
141
+ if (e.step === 'unapprove') {
142
+ slot.unapproved += 1;
143
+ return;
144
+ }
145
+ if (e.step === 'approve') {
146
+ if (pending) credit('approvedAfterPause');
147
+ open.delete(key);
148
+ return;
149
+ }
150
+ slot.decisions += 1;
151
+ if (pending && e.pause && e.signal === pending.signal) return;
152
+ if (pending) credit(e.source === 'manual' && !e.pause ? 'approvedAfterPause' : 'reworkedAfterPause');
153
+ if (e.pause) {
154
+ slot.paused += 1;
155
+ bySignal(e.signal).paused += 1;
156
+ open.set(key, { signal: e.signal });
157
+ } else {
158
+ slot.autoPassed += 1;
159
+ open.delete(key);
160
+ }
161
+ }
162
+
125
163
  /** The returned shape is documented in framework/docs/CLI.md, "cost --metrics". */
126
164
  export function computeMetrics(events, opts = {}) {
127
165
  const top = Number.isInteger(opts.top) && opts.top > 0 ? opts.top : 10;
@@ -140,6 +178,8 @@ export function computeMetrics(events, opts = {}) {
140
178
  const lastFingerprint = new Map();
141
179
  const unchangedRepeats = {};
142
180
  const reuse = { runs: 0, byTree: {}, skipReasons: {} };
181
+ const gates = {};
182
+ const openPauses = new Map();
143
183
  let v2 = 0;
144
184
  let legacy = 0;
145
185
  let turnsObserved = false;
@@ -166,6 +206,11 @@ export function computeMetrics(events, opts = {}) {
166
206
  continue;
167
207
  }
168
208
 
209
+ if (e.event === GATE_EVENT) {
210
+ trackGate(gates, openPauses, e);
211
+ continue;
212
+ }
213
+
169
214
  if (e.event === 'Verify') {
170
215
  if (e.fingerprint && e.persisted !== false) {
171
216
  if (lastFingerprint.get(fkey) === e.fingerprint) {
@@ -291,6 +336,7 @@ export function computeMetrics(events, opts = {}) {
291
336
  },
292
337
  dotnetFullSuite: fullSuite,
293
338
  reuse,
339
+ gates,
294
340
  features: featureRows,
295
341
  };
296
342
  }
@@ -0,0 +1,96 @@
1
+ import { execFileSync } from 'child_process';
2
+ import { readFileSync } from 'fs';
3
+ import { basename, join } from 'path';
4
+ import { isTestPath } from '../graph/sanitize.js';
5
+
6
+ export const PROSE_MIN_HITS = 3;
7
+ export const FAMILY_MIN_SIZE = 4;
8
+
9
+ const TEST_FILE = /\.(cs|[cm]?[jt]sx?)$/i;
10
+ const CS_CASE = /\[\s*\w*(Fact|Theory)\b/g;
11
+ const JS_CASE = /^\s*(?:it|test)(?:\.each\s*\([\s\S]*?\))?\s*\(\s*['"`]/gm;
12
+ const READS_DOC = /(ReadAll\w*|readFileSync)\s*\([^)]*\.(md|txt)\b/;
13
+ const PROSE_ASSERT = /(?:Assert\.(?:Contains|DoesNotContain)\(\s*"|\.(?:toContain|toMatch)\(\s*['"`])([^"'`]{20,})/g;
14
+ const CONSTANT_ASSERT = /Assert\.Equal\(\s*"[^"]*"\s*,\s*[A-Z]\w*(?:\.[A-Z]\w*)+\s*\)|expect\(\s*[A-Z][A-Z0-9_]+(?:\.[A-Z0-9_]+)*\s*\)\.toBe\(\s*['"`]/g;
15
+ const CS_METHOD = /\[\s*\w*Fact\b[^\]]*\][\s\S]*?(?:void|Task)\s+(\w+)\s*\(/g;
16
+ const LAYER = /^(\w+?)(Handler|Endpoints?|EndpointIntegration|Integration)Tests$/;
17
+
18
+ function trackedTestFiles(root) {
19
+ try {
20
+ return execFileSync('git', ['ls-files', '-z'], { cwd: root, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 256 * 1024 * 1024 })
21
+ .toString().split('\0').filter((f) => f && TEST_FILE.test(f) && isTestPath(f));
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ const lineOf = (text, index) => text.slice(0, index).split('\n').length;
28
+ const countOf = (text, re) => (text.match(re) ?? []).length;
29
+
30
+ function factFamilies(text) {
31
+ const byStem = new Map();
32
+ for (const m of text.matchAll(CS_METHOD)) {
33
+ const name = m[1];
34
+ const cut = name.lastIndexOf('_');
35
+ if (cut <= 0) continue;
36
+ const stem = name.slice(0, cut);
37
+ byStem.set(stem, [...(byStem.get(stem) ?? []), name]);
38
+ }
39
+ return [...byStem].filter(([, names]) => names.length >= FAMILY_MIN_SIZE).map(([stem, names]) => ({ stem, size: names.length }));
40
+ }
41
+
42
+ /** Read-only heuristics over the tracked test files: candidates to delete or merge, never verdicts. */
43
+ export function auditTests(root) {
44
+ const files = trackedTestFiles(root);
45
+ if (files === null) return { ok: false, reason: 'git ls-files não respondeu — a auditoria lê só arquivos rastreados' };
46
+
47
+ const perFile = [];
48
+ const prose = [];
49
+ const constants = [];
50
+ const families = [];
51
+ const layers = new Map();
52
+ for (const file of files) {
53
+ let text;
54
+ try { text = readFileSync(join(root, file), 'utf8'); } catch { continue; }
55
+ const cases = /\.cs$/i.test(file) ? countOf(text, CS_CASE) : countOf(text, JS_CASE);
56
+ if (cases === 0) continue;
57
+ perFile.push({ file, cases });
58
+
59
+ const proseHits = [...text.matchAll(PROSE_ASSERT)].filter((m) => /\s/.test(m[1]));
60
+ if (READS_DOC.test(text) || proseHits.length >= PROSE_MIN_HITS) {
61
+ prose.push({ file, readsDoc: READS_DOC.test(text), literalAsserts: proseHits.length });
62
+ }
63
+ for (const m of text.matchAll(CONSTANT_ASSERT)) constants.push({ file, line: lineOf(text, m.index) });
64
+ for (const family of factFamilies(text)) families.push({ file, ...family });
65
+
66
+ const layer = LAYER.exec(basename(file).replace(/\.\w+$/, ''));
67
+ if (layer) layers.set(layer[1], [...(layers.get(layer[1]) ?? []), file]);
68
+ }
69
+
70
+ perFile.sort((a, b) => b.cases - a.cases);
71
+ return {
72
+ ok: true,
73
+ files: perFile.length,
74
+ cases: perFile.reduce((n, f) => n + f.cases, 0),
75
+ largest: perFile.slice(0, 10),
76
+ prose,
77
+ constants,
78
+ families,
79
+ layers: [...layers].filter(([, f]) => f.length > 1).map(([subject, f]) => ({ subject, files: f })),
80
+ };
81
+ }
82
+
83
+ export function renderTestsAudit(result) {
84
+ if (!result.ok) return `tests audit: ${result.reason}`;
85
+ const out = [`${result.cases} casos em ${result.files} arquivos de teste`, ''];
86
+ const section = (title, rows) => {
87
+ if (rows.length === 0) return;
88
+ out.push(`${title} (${rows.length})`, ...rows.map((r) => ` ${r}`), '');
89
+ };
90
+ section('Maiores arquivos', result.largest.map((f) => `${f.cases} ${f.file}`));
91
+ section('Fixam texto ou leem doc — candidatos a apagar', result.prose.map((p) => `${p.file}${p.readsDoc ? ' (lê .md/.txt)' : ''}${p.literalAsserts ? ` — ${p.literalAsserts} asserts de frase` : ''}`));
92
+ section('Constante contra o próprio literal — candidatos a apagar', result.constants.map((c) => `${c.file}:${c.line}`));
93
+ section(`Famílias de [Fact] (≥ ${FAMILY_MIN_SIZE} com o mesmo prefixo) — candidatas a [Theory]`, result.families.map((f) => `${f.file} — ${f.stem}_* ×${f.size}`));
94
+ section('Mesmo assunto em mais de uma camada — mantenha a mais barata que morde', result.layers.map((l) => `${l.subject}: ${l.files.join(', ')}`));
95
+ return out.join('\n').trimEnd();
96
+ }