@payconductor/houndsman 0.13.0 → 0.16.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 (116) hide show
  1. package/README.md +6 -1
  2. package/package.json +1 -1
  3. package/src/cli/commands/compare-command.ts +112 -0
  4. package/src/cli/commands/context-command.ts +1 -0
  5. package/src/cli/commands/findings-command.ts +11 -1
  6. package/src/cli/commands/golden-command.ts +92 -0
  7. package/src/cli/commands/hunt-command.ts +105 -4
  8. package/src/cli/commands/review-command.ts +38 -0
  9. package/src/cli/commands/status-command.ts +3 -2
  10. package/src/cli/help.ts +6 -1
  11. package/src/cli/index.ts +5 -1
  12. package/src/cli/progress.ts +7 -2
  13. package/src/core/ai/ai-client.ts +38 -2
  14. package/src/core/ai/features/brief-enricher.ts +1 -1
  15. package/src/core/ai/features/business-analyzer.ts +61 -0
  16. package/src/core/ai/features/business-summarizer.ts +44 -0
  17. package/src/core/ai/index.ts +8 -0
  18. package/src/core/ai/prompts/brief.ts +33 -7
  19. package/src/core/ai/prompts/business.ts +76 -0
  20. package/src/core/ai/provider-capabilities.ts +12 -0
  21. package/src/core/ai/schemas.ts +70 -2
  22. package/src/core/ai/usage-recorder.ts +53 -1
  23. package/src/core/brief.ts +278 -16
  24. package/src/core/builtins.ts +108 -0
  25. package/src/core/business-summary.ts +583 -0
  26. package/src/core/commands/compare.ts +266 -0
  27. package/src/core/commands/context.ts +6 -2
  28. package/src/core/commands/findings.ts +38 -30
  29. package/src/core/commands/golden.ts +176 -0
  30. package/src/core/commands/review.ts +6 -0
  31. package/src/core/commands/scaffold.ts +29 -0
  32. package/src/core/commands/status.ts +9 -4
  33. package/src/core/compare.ts +310 -0
  34. package/src/core/concurrency.ts +30 -0
  35. package/src/core/config.ts +41 -3
  36. package/src/core/defaults.ts +50 -4
  37. package/src/core/domain/budget.ts +62 -4
  38. package/src/core/domain/classification.ts +12 -2
  39. package/src/core/engine/carryover.ts +4 -0
  40. package/src/core/engine/finalize-pipeline.ts +371 -0
  41. package/src/core/engine/prepare-pipeline.ts +1294 -0
  42. package/src/core/engine/review-context.ts +51 -0
  43. package/src/core/engine/review-options.ts +47 -0
  44. package/src/core/engine/review-scope.ts +14 -1
  45. package/src/core/engine/review-session.ts +11 -13
  46. package/src/core/engine/run-artifacts.ts +196 -0
  47. package/src/core/engine/run-format.ts +4 -0
  48. package/src/core/findings-state.ts +22 -2
  49. package/src/core/github-review.ts +5 -2
  50. package/src/core/golden.ts +346 -0
  51. package/src/core/hunt/hunt-runner.ts +621 -34
  52. package/src/core/hunt/index.ts +40 -1
  53. package/src/core/hunt/maki-intent-prompt.ts +50 -0
  54. package/src/core/hunt/maki-prompt.ts +298 -5
  55. package/src/core/hunt/maki-runner.ts +93 -16
  56. package/src/core/hunt/pool.ts +4 -26
  57. package/src/core/hunt/review-checks.ts +460 -0
  58. package/src/core/logging/index.ts +6 -1
  59. package/src/core/logging/progress.ts +85 -1
  60. package/src/core/orchestrator.ts +33 -1289
  61. package/src/core/paths.ts +6 -3
  62. package/src/core/pr-context.ts +2 -4
  63. package/src/core/prepare-report.ts +61 -0
  64. package/src/core/project-config.ts +18 -1
  65. package/src/core/propagation.ts +127 -0
  66. package/src/core/registry-check.ts +90 -27
  67. package/src/core/renderers/manifest-writer.ts +13 -0
  68. package/src/core/renderers/resume-renderer.ts +151 -11
  69. package/src/core/report-builder.ts +116 -278
  70. package/src/core/severity.ts +245 -0
  71. package/src/core/storage/file/file-storage.ts +7 -3
  72. package/src/core/storage/sqlite/mappers.ts +10 -0
  73. package/src/core/storage/sqlite/schema.ts +104 -23
  74. package/src/core/storage/sqlite/sqlite-storage.ts +22 -9
  75. package/src/core/storage/types.ts +17 -4
  76. package/src/core/storage/work-area.ts +59 -1
  77. package/src/core/types.ts +31 -0
  78. package/src/core/watchdog-registry.ts +21 -3
  79. package/src/index.ts +51 -4
  80. package/src/server/client/ArtifactsPanel.tsx +4 -2
  81. package/src/server/client/FindingCard.tsx +21 -1
  82. package/src/server/client/RunsSection.tsx +5 -0
  83. package/src/server/client/utils/constants.ts +21 -11
  84. package/src/server/client/utils/markdown.ts +1 -1
  85. package/src/server/client/utils/types.ts +14 -2
  86. package/src/server/run-repository.ts +35 -14
  87. package/src/server/styles.css +2 -0
  88. package/src/skill/OUTPUT-FORMAT.md +55 -10
  89. package/src/skill/README.md +19 -14
  90. package/src/skill/SETUP.md +1 -0
  91. package/src/skill/SKILL.md +13 -4
  92. package/src/skill/example-resume.md +43 -0
  93. package/src/skill/hunt.md +205 -58
  94. package/src/skill/train.md +3 -0
  95. package/src/tests/__snapshots__/baseline.test.ts.snap +208 -184
  96. package/src/tests/ai-core.test.ts +46 -0
  97. package/src/tests/ai-features.test.ts +95 -4
  98. package/src/tests/baseline.test.ts +29 -121
  99. package/src/tests/brief-scope.test.ts +250 -0
  100. package/src/tests/business-summary.test.ts +258 -0
  101. package/src/tests/compare.test.ts +256 -0
  102. package/src/tests/findings-state.test.ts +82 -0
  103. package/src/tests/fixtures/fake-model-provider.ts +2 -1
  104. package/src/tests/golden.test.ts +195 -0
  105. package/src/tests/hunt-maki.test.ts +710 -2
  106. package/src/tests/markdown.test.ts +3 -3
  107. package/src/tests/progress.test.ts +29 -1
  108. package/src/tests/propagation.test.ts +119 -0
  109. package/src/tests/review-resume.test.ts +60 -1
  110. package/src/tests/review-scope.test.ts +38 -29
  111. package/src/tests/review-scripts.test.ts +125 -218
  112. package/src/tests/server.test.ts +7 -4
  113. package/src/tests/severity.test.ts +235 -0
  114. package/src/tests/storage.test.ts +194 -9
  115. package/src/core/renderers/report-renderer.ts +0 -66
  116. package/src/skill/example-review.md +0 -66
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Modelo de intenção (`business-summary.json`) e política de severidade dos
3
+ * findings de lógica/domínio.
4
+ */
5
+
6
+ import { describe, expect, test } from "bun:test";
7
+ import type { AstAnalysis } from "../core/ast/types";
8
+ import { defaultRenderBrief } from "../core/brief";
9
+ import { GENERAL_WATCHDOG_ID, generalWatchdog } from "../core/builtins";
10
+ import {
11
+ buildBusinessBrief,
12
+ buildBusinessSummary,
13
+ parseBusinessSummary,
14
+ } from "../core/business-summary";
15
+ import { normalizeFinding } from "../core/report-builder";
16
+ import { findWatchdog } from "../core/watchdog-registry";
17
+ import { fakeHoundsmanConfig } from "./fixtures/fake-project";
18
+
19
+ const facts: AstAnalysis["facts"] = {
20
+ language: "typescript",
21
+ symbols: [],
22
+ imports: [],
23
+ exports: [],
24
+ decorators: [],
25
+ hasSyntaxErrors: false,
26
+ };
27
+
28
+ const delta = {
29
+ addedSymbols: [
30
+ {
31
+ name: "Contrato",
32
+ kind: "interface" as const,
33
+ exported: true,
34
+ startLine: 1,
35
+ endLine: 3,
36
+ },
37
+ {
38
+ name: "executar",
39
+ kind: "function" as const,
40
+ exported: true,
41
+ startLine: 5,
42
+ endLine: 9,
43
+ },
44
+ ],
45
+ removedSymbols: [],
46
+ modifiedSymbols: [],
47
+ changedSignatures: [],
48
+ addedImports: [],
49
+ removedImports: [],
50
+ changedExports: [],
51
+ };
52
+
53
+ const classification = {
54
+ files: {
55
+ "src/alpha/a.ts": { status: "modified" as const },
56
+ "src/beta/b.ts": { status: "modified" as const },
57
+ "src/alpha/old.ts": { status: "deleted" as const },
58
+ },
59
+ churn: {
60
+ "src/alpha/a.ts": { additions: 10, deletions: 2 },
61
+ "src/beta/b.ts": { additions: 1, deletions: 0 },
62
+ },
63
+ watchdogs: {
64
+ alpha: ["src/alpha/a.ts"],
65
+ beta: ["src/beta/b.ts"],
66
+ [GENERAL_WATCHDOG_ID]: ["src/alpha/a.ts", "src/beta/b.ts"],
67
+ },
68
+ };
69
+
70
+ const domains = [
71
+ { id: "alpha", name: "Alpha" },
72
+ { id: "beta", name: "Beta" },
73
+ { id: GENERAL_WATCHDOG_ID, name: "Lógica e domínio (fluxo)" },
74
+ ];
75
+
76
+ describe("buildBusinessSummary", () => {
77
+ test("deriva fluxos por domínio (sem o general) e contratos do AST", () => {
78
+ const summary = buildBusinessSummary({
79
+ classification,
80
+ domains,
81
+ ast: new Map([["src/alpha/a.ts", { facts, delta }]]),
82
+ headSha: "abc123",
83
+ context: { objective: "Entregar o split" },
84
+ });
85
+
86
+ expect(summary.source).toBe("deterministic");
87
+ expect(summary.intent).toBe("Entregar o split");
88
+ expect(summary.headSha).toBe("abc123");
89
+ expect(summary.flows.map((flow) => flow.name)).toEqual(["Alpha", "Beta"]);
90
+ expect(summary.flows[0]?.files).toEqual(["src/alpha/a.ts"]);
91
+ expect(summary.flows[0]?.expected).toContain("Contrato");
92
+ expect(summary.contracts).toEqual([
93
+ {
94
+ name: "Contrato",
95
+ sides: ["src/alpha/a.ts"],
96
+ expectation:
97
+ "Declaração `interface` nova — verificar se todos os lados do contrato a honram.",
98
+ },
99
+ ]);
100
+ });
101
+
102
+ test("sem contexto e sem AST, mantém o esqueleto vazio (nunca inventa)", () => {
103
+ const summary = buildBusinessSummary({ classification, domains });
104
+ expect(summary.intent).toBeUndefined();
105
+ expect(summary.contracts).toEqual([]);
106
+ expect(summary.invariants).toEqual([]);
107
+ expect(summary.flows).toHaveLength(2);
108
+ });
109
+ });
110
+
111
+ describe("parseBusinessSummary", () => {
112
+ test("normaliza listas e preserva a fonte", () => {
113
+ const summary = parseBusinessSummary(
114
+ {
115
+ source: "reviewer",
116
+ intent: " Entregar Y ",
117
+ flows: [{ name: "Renovação", trigger: "job", files: ["src/a.ts"] }],
118
+ invariants: ["split sempre presente"],
119
+ contracts: [{ name: "AutomaticPix", sides: ["asaas", "processor"] }],
120
+ states: [{ name: "authorization", transitions: ["ativa → cancelada"] }],
121
+ },
122
+ "deterministic",
123
+ );
124
+ expect(summary?.source).toBe("reviewer");
125
+ expect(summary?.intent).toBe("Entregar Y");
126
+ expect(summary?.flows[0]?.name).toBe("Renovação");
127
+ expect(summary?.invariants).toEqual(["split sempre presente"]);
128
+ });
129
+
130
+ test("rejeita documento sem conteúdo útil", () => {
131
+ expect(parseBusinessSummary({})).toBeNull();
132
+ expect(parseBusinessSummary(null)).toBeNull();
133
+ expect(parseBusinessSummary({ flows: [{ name: "" }] })).toBeNull();
134
+ });
135
+
136
+ test("corta itens acima do limite e ignora entradas inválidas", () => {
137
+ const summary = parseBusinessSummary({
138
+ intent: "x",
139
+ flows: [
140
+ { name: "ok" },
141
+ { name: "" },
142
+ { name: "outro", files: ["a", 1, "b"] },
143
+ ],
144
+ });
145
+ expect(summary?.flows.map((flow) => flow.name)).toEqual(["ok", "outro"]);
146
+ expect(summary?.flows[1]?.files).toEqual(["a", "b"]);
147
+ });
148
+ });
149
+
150
+ describe("buildBusinessBrief", () => {
151
+ test("aponta o output e inclui o schema e o modelo determinístico", () => {
152
+ const summary = buildBusinessSummary({ classification, domains });
153
+ const brief = buildBusinessBrief({
154
+ projectName: "Fake",
155
+ branch: "main",
156
+ headSha: "abc123",
157
+ outputPath: "/tmp/work/review/business-summary.json",
158
+ business: summary,
159
+ files: [
160
+ {
161
+ path: "src/alpha/a.ts",
162
+ status: "modified",
163
+ ranges: [{ start: 3, end: 9 }],
164
+ symbols: ["Contrato"],
165
+ },
166
+ ],
167
+ docs: ["AGENTS.md"],
168
+ context: { objective: "Entregar o split" },
169
+ });
170
+ expect(brief).toContain("/tmp/work/review/business-summary.json");
171
+ expect(brief).toContain('"invariants"');
172
+ expect(brief).toContain("Modelo determinístico");
173
+ expect(brief).toContain("src/alpha/a.ts");
174
+ expect(brief).toContain("AGENTS.md");
175
+ expect(brief).toContain('source: "reviewer"');
176
+ });
177
+ });
178
+
179
+ describe("política de severidade (lógica/domínio)", () => {
180
+ const base = {
181
+ file: "src/alpha/a.ts",
182
+ line: 3,
183
+ message: "gap",
184
+ action: "corrigir",
185
+ };
186
+
187
+ test("blocking sem cenário+evidência vira attention", () => {
188
+ const warnings: string[] = [];
189
+ const finding = normalizeFinding(
190
+ {
191
+ ...base,
192
+ rule: "Domínio: renovação perde o split",
193
+ severity: "blocking",
194
+ },
195
+ (message) => warnings.push(message),
196
+ );
197
+ expect(finding.basis).toBe("domain");
198
+ expect(finding.severity).toBe("attention");
199
+ expect(warnings).toHaveLength(1);
200
+ });
201
+
202
+ test("com cenário e evidência, blocking é preservado", () => {
203
+ const finding = normalizeFinding({
204
+ ...base,
205
+ rule: "Lógica: subscription sem authorize",
206
+ severity: "blocking",
207
+ scenario: "renovação de assinatura Pix",
208
+ evidence: "this.sendAutomaticPixPayment(data)",
209
+ });
210
+ expect(finding.basis).toBe("logic");
211
+ expect(finding.severity).toBe("blocking");
212
+ });
213
+
214
+ test("regra explícita mantém a severidade declarada", () => {
215
+ const finding = normalizeFinding({
216
+ ...base,
217
+ rule: "Nunca usar `any`.",
218
+ severity: "blocking",
219
+ });
220
+ expect(finding.basis).toBe("rule");
221
+ expect(finding.severity).toBe("blocking");
222
+ });
223
+ });
224
+
225
+ describe("watchdog general", () => {
226
+ test("resolvido como built-in mesmo sem config", () => {
227
+ const definition = findWatchdog(fakeHoundsmanConfig, GENERAL_WATCHDOG_ID);
228
+ expect(definition?.name).toContain("Lógica");
229
+ expect(definition?.allowEmergentFindings).toBe(true);
230
+ expect(definition?.fallback).toBe(false);
231
+ });
232
+
233
+ test("brief inclui intenção, diff e obrigação de output", () => {
234
+ const summary = buildBusinessSummary({ classification, domains });
235
+ const markdown = defaultRenderBrief(
236
+ {
237
+ watchdogId: GENERAL_WATCHDOG_ID,
238
+ files: ["src/alpha/a.ts"],
239
+ statuses: { "src/alpha/a.ts": { status: "modified" as const } },
240
+ deleted: [],
241
+ branch: "main",
242
+ outputPath: "/tmp/findings/general.json",
243
+ outputFormatPath: "OUTPUT-FORMAT.md",
244
+ docs: [],
245
+ business: summary,
246
+ businessSummaryPath: "/tmp/business-summary.json",
247
+ snippets: [{ file: "src/alpha/a.ts", snippet: "@@ -1 +1 @@\n-a\n+b" }],
248
+ flowReview: true,
249
+ },
250
+ generalWatchdog,
251
+ ).join("\n");
252
+ expect(markdown).toContain("## Modelo de intenção (negócio)");
253
+ expect(markdown).toContain("## Trechos alterados (diff)");
254
+ expect(markdown).toContain("## Obrigação de output (fluxo)");
255
+ expect(markdown).toContain("/tmp/business-summary.json");
256
+ expect(markdown).toContain("scenario");
257
+ });
258
+ });
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Comparação A/B e identidade das variantes.
3
+ *
4
+ * Cobre o slug da run (URL/backend de arquivos), o diff de findings entre dois
5
+ * snapshots e o parse de `--variant` no comando default.
6
+ */
7
+
8
+ import { describe, expect, test } from "bun:test";
9
+ import { mkdtempSync, rmSync } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { runCompare } from "../core/commands/compare";
13
+ import { parseReviewArgs } from "../core/commands/review";
14
+ import {
15
+ buildComparison,
16
+ type ComparisonFinding,
17
+ countBySeverity,
18
+ type ReviewSnapshot,
19
+ renderComparison,
20
+ } from "../core/compare";
21
+ import type { FindingsStateFile } from "../core/findings-state";
22
+ import { reviewSlug, sanitizeVariant } from "../core/report-builder";
23
+ import { buildRepoIdentity } from "../core/storage/repo-identity";
24
+ import { SQLiteStorageProvider } from "../core/storage/sqlite/sqlite-storage";
25
+
26
+ function finding(
27
+ fingerprint: string,
28
+ partial: Partial<ComparisonFinding> = {},
29
+ ): ComparisonFinding {
30
+ return {
31
+ fingerprint,
32
+ rule: "regra",
33
+ severity: "attention",
34
+ file: "src/a.ts",
35
+ line: 10,
36
+ message: "mensagem",
37
+ watchdogs: ["alpha"],
38
+ ...partial,
39
+ };
40
+ }
41
+
42
+ function snapshot(
43
+ label: string,
44
+ findings: ComparisonFinding[],
45
+ variant?: string,
46
+ ): ReviewSnapshot {
47
+ return {
48
+ label,
49
+ slug: reviewSlug("feature/x", variant),
50
+ branch: "feature/x",
51
+ variant,
52
+ headSha: "deadbeef",
53
+ findings,
54
+ bySeverity: countBySeverity(findings),
55
+ coverage: { watchdogs: 2, files: 3, overflow: 0 },
56
+ durationMs: 60_000,
57
+ };
58
+ }
59
+
60
+ describe("reviewSlug / sanitizeVariant", () => {
61
+ test("run principal usa o slug da branch", () => {
62
+ expect(reviewSlug("feature/add-webhook")).toBe("feature-add-webhook");
63
+ expect(reviewSlug("feature/x", " ")).toBe("feature-x");
64
+ });
65
+
66
+ test("variante ganha sufixo legível + hash estável", () => {
67
+ const baseline = reviewSlug("feature/x", "Baseline A");
68
+ const optimized = reviewSlug("feature/x", "Baseline B");
69
+ expect(baseline).toStartWith("feature-x--baseline-a-");
70
+ expect(baseline).toBe(reviewSlug("feature/x", "Baseline A"));
71
+ expect(baseline).not.toBe(optimized);
72
+ expect(sanitizeVariant(" A/B ")).toBe("a-b");
73
+ });
74
+ });
75
+
76
+ describe("buildComparison", () => {
77
+ const a = snapshot("A", [
78
+ finding("f1", { severity: "attention", file: "src/a.ts" }),
79
+ finding("f2", { severity: "suggestion", file: "src/b.ts", line: 2 }),
80
+ ]);
81
+ const b = snapshot(
82
+ "B",
83
+ [
84
+ finding("f1", { severity: "blocking", file: "src/a.ts" }),
85
+ finding("f3", { severity: "blocking", file: "src/c.ts", line: 5 }),
86
+ ],
87
+ "otimizado",
88
+ );
89
+
90
+ test("separa novos, sumidos e mudança de severidade", () => {
91
+ const comparison = buildComparison(a, b);
92
+ expect(comparison.onlyB.map((item) => item.fingerprint)).toEqual(["f3"]);
93
+ expect(comparison.onlyA.map((item) => item.fingerprint)).toEqual(["f2"]);
94
+ expect(comparison.common).toHaveLength(1);
95
+ expect(comparison.severityChanged).toHaveLength(1);
96
+ expect(comparison.severityChanged[0].a.severity).toBe("attention");
97
+ expect(comparison.severityChanged[0].b.severity).toBe("blocking");
98
+ });
99
+
100
+ test("conta por severidade", () => {
101
+ expect(a.bySeverity).toEqual({
102
+ blocking: 0,
103
+ attention: 1,
104
+ suggestion: 1,
105
+ nit: 0,
106
+ });
107
+ expect(b.bySeverity).toEqual({
108
+ blocking: 2,
109
+ attention: 0,
110
+ suggestion: 0,
111
+ nit: 0,
112
+ });
113
+ });
114
+
115
+ test("renderiza a tabela e as listas", () => {
116
+ const markdown = renderComparison(buildComparison(a, b));
117
+ expect(markdown).toContain("# Comparação A/B — `feature/x`");
118
+ expect(markdown).toContain("| Findings (presentes) | 2 | 2 | 0 |");
119
+ expect(markdown).toContain("## Só em B (novos) (1)");
120
+ expect(markdown).toContain("## Só em A (não apareceram em B) (1)");
121
+ expect(markdown).toContain("## Severidade mudou (1)");
122
+ expect(markdown).toContain("## Só em B");
123
+ });
124
+
125
+ test("expõe checklist, cobertura e custo das passadas extras do harness", () => {
126
+ const withHarness: ReviewSnapshot = {
127
+ ...snapshot("B", []),
128
+ harness: {
129
+ checksExpected: 8,
130
+ checksVerified: 6,
131
+ checksGaps: 2,
132
+ reviewedFiles: 11,
133
+ skippedFiles: 3,
134
+ undeclaredFiles: 4,
135
+ businessScore: 20,
136
+ extraCostUsd: 0.035,
137
+ secondOpinionFindings: 2,
138
+ },
139
+ };
140
+ const markdown = renderComparison(buildComparison(a, withHarness));
141
+ expect(markdown).toContain("| Checklist verificado | — | 6/8 | — |");
142
+ expect(markdown).toContain("| Arquivos sem declaração | — | 4 | — |");
143
+ expect(markdown).toContain("| Modelo de intenção (score) | — | 20 | — |");
144
+ expect(markdown).toContain("| Passadas extras (custo) | — | $0.0350 | — |");
145
+ expect(markdown).toContain("| Segunda opinião (findings) | — | 2 | — |");
146
+ });
147
+ });
148
+
149
+ describe("--variant", () => {
150
+ test("é lido pelo parse do comando default", () => {
151
+ const options = parseReviewArgs([
152
+ "--branch",
153
+ "feature/x",
154
+ "--variant",
155
+ " baseline ",
156
+ ]);
157
+ expect(options.variant).toBe("baseline");
158
+ });
159
+
160
+ describe("runCompare", () => {
161
+ test("lê as duas runs e grava o relatório na run B", () => {
162
+ const dir = mkdtempSync(join(tmpdir(), "houndsman-compare-"));
163
+ const storage = new SQLiteStorageProvider(join(dir, "houndsman.db"));
164
+ try {
165
+ const repo = buildRepoIdentity(null, dir);
166
+ const main = storage.getOrCreateReview({
167
+ repo,
168
+ branch: "feature/x",
169
+ slug: "feature-x",
170
+ });
171
+ const variant = storage.getOrCreateReview({
172
+ repo,
173
+ branch: "feature/x",
174
+ variant: "otimizado",
175
+ slug: "feature-x--otimizado-1a2b3c",
176
+ });
177
+ const state = (
178
+ findings: FindingsStateFile["findings"],
179
+ ): FindingsStateFile => ({
180
+ version: 2,
181
+ branch: "feature/x",
182
+ slug: "feature-x",
183
+ updatedAt: "2026-01-01T00:00:00.000Z",
184
+ order: Object.keys(findings),
185
+ findings,
186
+ });
187
+ const baseFinding = {
188
+ id: "ab12cd34ef",
189
+ fingerprint: "fp-1",
190
+ watchdogIds: ["alpha"],
191
+ rule: "regra",
192
+ severity: "attention" as const,
193
+ file: "src/a.ts",
194
+ line: 10,
195
+ message: "mensagem",
196
+ action: "ação",
197
+ anchor: null,
198
+ count: 1,
199
+ present: true,
200
+ decision: "pending" as const,
201
+ execState: "pending" as const,
202
+ createdAt: "2026-01-01T00:00:00.000Z",
203
+ updatedAt: "2026-01-01T00:00:00.000Z",
204
+ history: [],
205
+ };
206
+ storage.writeFindings(main.id, state({ ab12cd34ef: baseFinding }));
207
+ storage.writeFindings(
208
+ variant.id,
209
+ state({
210
+ ab12cd34ef: baseFinding,
211
+ ff00aa11bb: {
212
+ ...baseFinding,
213
+ id: "ff00aa11bb",
214
+ fingerprint: "fp-2",
215
+ rule: "outra",
216
+ },
217
+ }),
218
+ );
219
+ storage.writeArtifact(main.id, {
220
+ name: "manifest.json",
221
+ kind: "manifest",
222
+ content: JSON.stringify({
223
+ watchdogs: [{ files: ["src/a.ts"], overflow: 0 }],
224
+ }),
225
+ });
226
+ storage.writeArtifact(variant.id, {
227
+ name: "prepare-report.json",
228
+ kind: "prepare",
229
+ content: JSON.stringify({ durationMs: 1_000, stages: [] }),
230
+ });
231
+
232
+ const result = runCompare(storage, repo, {
233
+ branch: "feature/x",
234
+ variantB: "otimizado",
235
+ });
236
+ expect(result.b.id).toBe(variant.id);
237
+ expect(result.markdown).toContain("Só em B (novos) (1)");
238
+ expect(
239
+ storage.readArtifact(variant.id, result.artifacts.markdown)?.kind,
240
+ ).toBe("compare");
241
+ expect(
242
+ storage.readArtifact(variant.id, result.artifacts.json)?.content,
243
+ ).toContain("onlyB");
244
+ } finally {
245
+ storage.close();
246
+ rmSync(dir, { recursive: true, force: true });
247
+ }
248
+ });
249
+ });
250
+
251
+ test("vazio é rejeitado", () => {
252
+ expect(() => parseReviewArgs(["--variant", " "])).toThrow(
253
+ "--variant não pode ser vazio",
254
+ );
255
+ });
256
+ });
@@ -33,6 +33,7 @@ function merged(
33
33
  partial: Partial<MergedFinding> & { file: string; rule: string },
34
34
  ): MergedFinding {
35
35
  return {
36
+ ...partial,
36
37
  rule: partial.rule,
37
38
  severity: (partial.severity ?? "attention") as Severity,
38
39
  file: partial.file,
@@ -209,6 +210,87 @@ describe("buildStateFromFindings", () => {
209
210
  project.cleanup();
210
211
  }
211
212
  });
213
+
214
+ test("emergente sem impactClass mantém a severidade já triada", () => {
215
+ const project = makeProject({ "src/f.ts": DUPLICATED });
216
+ try {
217
+ const reader = fileReader(project.cwd);
218
+ const base = {
219
+ file: "src/f.ts",
220
+ rule: "Lógica: gap",
221
+ line: 2,
222
+ message: "gap",
223
+ basis: "logic" as const,
224
+ };
225
+ const first = buildStateFromFindings(
226
+ [merged({ ...base, severity: "blocking", scenario: "s", evidence: "x" })],
227
+ { branch: "main", readFile: reader },
228
+ );
229
+ const second = buildStateFromFindings(
230
+ [merged({ ...base, severity: "suggestion" })],
231
+ { branch: "main", readFile: reader, previous: first },
232
+ );
233
+ expect(Object.values(second.findings)[0].severity).toBe("blocking");
234
+ } finally {
235
+ project.cleanup();
236
+ }
237
+ });
238
+
239
+ test("emergente com impactClass acompanha o valor derivado", () => {
240
+ const project = makeProject({ "src/f.ts": DUPLICATED });
241
+ try {
242
+ const reader = fileReader(project.cwd);
243
+ const base = {
244
+ file: "src/f.ts",
245
+ rule: "Domínio: gap",
246
+ line: 2,
247
+ message: "gap",
248
+ basis: "domain" as const,
249
+ };
250
+ const first = buildStateFromFindings(
251
+ [merged({ ...base, severity: "suggestion", impactClass: "degradation" })],
252
+ { branch: "main", readFile: reader },
253
+ );
254
+ const second = buildStateFromFindings(
255
+ [
256
+ merged({
257
+ ...base,
258
+ severity: "attention",
259
+ impactClass: "contract-drift",
260
+ }),
261
+ ],
262
+ { branch: "main", readFile: reader, previous: first },
263
+ );
264
+ expect(Object.values(second.findings)[0].severity).toBe("attention");
265
+ } finally {
266
+ project.cleanup();
267
+ }
268
+ });
269
+
270
+ test("finding de regra sempre acompanha a severidade nova (config manda)", () => {
271
+ const project = makeProject({ "src/f.ts": DUPLICATED });
272
+ try {
273
+ const reader = fileReader(project.cwd);
274
+ const base = {
275
+ file: "src/f.ts",
276
+ rule: "Nunca usar any",
277
+ line: 2,
278
+ message: "any",
279
+ basis: "rule" as const,
280
+ };
281
+ const first = buildStateFromFindings(
282
+ [merged({ ...base, severity: "blocking" })],
283
+ { branch: "main", readFile: reader },
284
+ );
285
+ const second = buildStateFromFindings(
286
+ [merged({ ...base, severity: "attention" })],
287
+ { branch: "main", readFile: reader, previous: first },
288
+ );
289
+ expect(Object.values(second.findings)[0].severity).toBe("attention");
290
+ } finally {
291
+ project.cleanup();
292
+ }
293
+ });
212
294
  });
213
295
 
214
296
  describe("syncState", () => {
@@ -30,6 +30,7 @@ export class FakeModelProvider implements ModelProvider {
30
30
  constructor(
31
31
  private readonly handler: FakeHandler,
32
32
  modelId = "fake-model",
33
+ private readonly finishReason = "stop",
33
34
  ) {
34
35
  this.modelId = modelId;
35
36
  }
@@ -41,7 +42,7 @@ export class FakeModelProvider implements ModelProvider {
41
42
  return {
42
43
  output: output as T,
43
44
  usage: fakeUsage(),
44
- finishReason: "stop",
45
+ finishReason: this.finishReason,
45
46
  };
46
47
  }
47
48
  }