@payconductor/houndsman 0.12.0 → 0.14.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 (119) hide show
  1. package/README.md +23 -2
  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/doctor-command.ts +22 -0
  6. package/src/cli/commands/findings-command.ts +11 -1
  7. package/src/cli/commands/hunt-command.ts +479 -0
  8. package/src/cli/commands/review-command.ts +58 -2
  9. package/src/cli/commands/status-command.ts +3 -2
  10. package/src/cli/help.ts +20 -3
  11. package/src/cli/index.ts +20 -3
  12. package/src/cli/progress.ts +50 -0
  13. package/src/core/ai/ai-client.ts +38 -2
  14. package/src/core/ai/ai-sdk-model-provider.ts +39 -0
  15. package/src/core/ai/features/brief-enricher.ts +1 -1
  16. package/src/core/ai/features/business-analyzer.ts +61 -0
  17. package/src/core/ai/features/business-summarizer.ts +44 -0
  18. package/src/core/ai/index.ts +14 -1
  19. package/src/core/ai/prompts/brief.ts +33 -7
  20. package/src/core/ai/prompts/business.ts +76 -0
  21. package/src/core/ai/provider-capabilities.ts +12 -0
  22. package/src/core/ai/schemas.ts +70 -2
  23. package/src/core/ai/types.ts +2 -0
  24. package/src/core/ai/usage-recorder.ts +53 -1
  25. package/src/core/brief.ts +246 -12
  26. package/src/core/builtins.ts +108 -0
  27. package/src/core/business-summary.ts +583 -0
  28. package/src/core/commands/compare.ts +233 -0
  29. package/src/core/commands/context.ts +6 -2
  30. package/src/core/commands/findings.ts +38 -30
  31. package/src/core/commands/review.ts +6 -0
  32. package/src/core/commands/scaffold.ts +38 -0
  33. package/src/core/commands/status.ts +9 -4
  34. package/src/core/compare.ts +243 -0
  35. package/src/core/concurrency.ts +30 -0
  36. package/src/core/config.ts +57 -1
  37. package/src/core/defaults.ts +63 -2
  38. package/src/core/domain/budget.ts +62 -4
  39. package/src/core/domain/classification.ts +12 -2
  40. package/src/core/engine/carryover.ts +4 -0
  41. package/src/core/engine/finalize-pipeline.ts +347 -0
  42. package/src/core/engine/prepare-pipeline.ts +1294 -0
  43. package/src/core/engine/review-context.ts +51 -0
  44. package/src/core/engine/review-options.ts +47 -0
  45. package/src/core/engine/review-scope.ts +106 -26
  46. package/src/core/engine/review-session.ts +18 -16
  47. package/src/core/engine/review-workspace.ts +14 -0
  48. package/src/core/engine/run-artifacts.ts +196 -0
  49. package/src/core/engine/run-format.ts +4 -0
  50. package/src/core/env.ts +14 -0
  51. package/src/core/findings-state.ts +13 -1
  52. package/src/core/github-review.ts +5 -2
  53. package/src/core/hunt/hunt-runner.ts +418 -0
  54. package/src/core/hunt/index.ts +61 -0
  55. package/src/core/hunt/maki-install.ts +331 -0
  56. package/src/core/hunt/maki-intent-prompt.ts +50 -0
  57. package/src/core/hunt/maki-model.ts +101 -0
  58. package/src/core/hunt/maki-prompt.ts +83 -0
  59. package/src/core/hunt/maki-release.ts +104 -0
  60. package/src/core/hunt/maki-runner.ts +276 -0
  61. package/src/core/hunt/pool.ts +8 -0
  62. package/src/core/logging/index.ts +7 -0
  63. package/src/core/logging/logger.ts +19 -0
  64. package/src/core/logging/progress.ts +366 -0
  65. package/src/core/orchestrator.ts +33 -1180
  66. package/src/core/paths.ts +6 -3
  67. package/src/core/pr-context.ts +2 -4
  68. package/src/core/prepare-report.ts +61 -0
  69. package/src/core/project-config.ts +46 -1
  70. package/src/core/propagation.ts +127 -0
  71. package/src/core/registry-check.ts +60 -21
  72. package/src/core/renderers/manifest-writer.ts +13 -0
  73. package/src/core/renderers/resume-renderer.ts +151 -11
  74. package/src/core/report-builder.ts +108 -278
  75. package/src/core/storage/file/file-storage.ts +7 -3
  76. package/src/core/storage/sqlite/mappers.ts +10 -0
  77. package/src/core/storage/sqlite/schema.ts +104 -23
  78. package/src/core/storage/sqlite/sqlite-storage.ts +22 -9
  79. package/src/core/storage/types.ts +17 -4
  80. package/src/core/storage/work-area.ts +59 -1
  81. package/src/core/types.ts +31 -0
  82. package/src/core/watchdog-registry.ts +21 -3
  83. package/src/index.ts +78 -4
  84. package/src/server/client/ArtifactsPanel.tsx +4 -2
  85. package/src/server/client/FindingCard.tsx +21 -1
  86. package/src/server/client/RunsSection.tsx +5 -0
  87. package/src/server/client/utils/constants.ts +21 -11
  88. package/src/server/client/utils/markdown.ts +1 -1
  89. package/src/server/client/utils/types.ts +14 -2
  90. package/src/server/run-repository.ts +35 -14
  91. package/src/server/styles.css +2 -0
  92. package/src/skill/OUTPUT-FORMAT.md +36 -10
  93. package/src/skill/README.md +30 -14
  94. package/src/skill/SETUP.md +22 -1
  95. package/src/skill/SKILL.md +17 -4
  96. package/src/skill/example-resume.md +43 -0
  97. package/src/skill/hunt.md +181 -58
  98. package/src/tests/__snapshots__/baseline.test.ts.snap +206 -178
  99. package/src/tests/ai-core.test.ts +84 -1
  100. package/src/tests/ai-features.test.ts +95 -4
  101. package/src/tests/baseline.test.ts +29 -121
  102. package/src/tests/brief-scope.test.ts +250 -0
  103. package/src/tests/business-summary.test.ts +258 -0
  104. package/src/tests/cli.test.ts +1 -0
  105. package/src/tests/compare.test.ts +233 -0
  106. package/src/tests/fixtures/fake-model-provider.ts +2 -1
  107. package/src/tests/hunt-cli.test.ts +185 -0
  108. package/src/tests/hunt-install.test.ts +153 -0
  109. package/src/tests/hunt-maki.test.ts +525 -0
  110. package/src/tests/markdown.test.ts +3 -3
  111. package/src/tests/progress.test.ts +145 -0
  112. package/src/tests/propagation.test.ts +119 -0
  113. package/src/tests/review-resume.test.ts +60 -1
  114. package/src/tests/review-scope.test.ts +90 -19
  115. package/src/tests/review-scripts.test.ts +106 -218
  116. package/src/tests/server.test.ts +7 -4
  117. package/src/tests/storage.test.ts +194 -9
  118. package/src/core/renderers/report-renderer.ts +0 -66
  119. package/src/skill/example-review.md +0 -66
@@ -7,12 +7,25 @@
7
7
  * `RESUME_MAX_CHARS`.
8
8
  */
9
9
 
10
+ import { type BusinessSummary, renderBusinessLines } from "../business-summary";
11
+ import type { JevCandidate } from "../jev";
10
12
  import type { ContextSummary, MergedFinding } from "../report-builder";
11
13
  import { systemClock } from "../services/clock";
12
14
  import type { TechnicalSummary } from "../technical-summary";
13
15
 
14
- /** Teto de caracteres do artefato (inclui cabeçalho e quebras de linha). */
15
- export const RESUME_MAX_CHARS = 5000;
16
+ /**
17
+ * Teto de caracteres do artefato (inclui cabeçalho e quebras de linha).
18
+ *
19
+ * As tabelas e a conclusão têm orçamento reservado — só o texto narrativo
20
+ * (negócio/técnico) é cortado quando o resumo cresce.
21
+ */
22
+ export const RESUME_MAX_CHARS = 8000;
23
+
24
+ /** Fração do orçamento narrativo destinada ao resumo de negócio. */
25
+ const BUSINESS_SHARE = 0.6;
26
+
27
+ /** Piso do orçamento narrativo mesmo em PRs com muitos domínios. */
28
+ const MIN_NARRATIVE_CHARS = 1200;
16
29
 
17
30
  /** Dados de classificação usados no resumo. */
18
31
  export type ResumeClassification = {
@@ -20,15 +33,34 @@ export type ResumeClassification = {
20
33
  watchdogs: Record<string, string[]>;
21
34
  };
22
35
 
36
+ /** Cobertura de um domínio (watchdog) na revisão. */
37
+ export type ResumeDomain = {
38
+ id: string;
39
+ name: string;
40
+ findings: number;
41
+ reviewedFiles: number;
42
+ expectedFiles: number;
43
+ /** Arquivos do escopo declarados em `skipped` (não revisados por decisão). */
44
+ skippedFiles?: number;
45
+ };
46
+
23
47
  export type ReviewResumeInput = {
24
48
  branch: string;
25
49
  /** `YYYY-MM-DD`; default: data corrente do sistema. */
26
50
  date?: string;
27
51
  context?: ContextSummary | null;
52
+ /** Modelo de intenção da PR (negócio). */
53
+ business?: BusinessSummary | null;
28
54
  classification: ResumeClassification;
29
55
  findings: readonly MergedFinding[];
30
56
  /** Pontos técnicos estruturais (não achados). */
31
57
  technical?: TechnicalSummary | null;
58
+ /** Cobertura por domínio (arquivos revisados × esperados). */
59
+ domains?: readonly ResumeDomain[];
60
+ /** Candidatos da pré-análise que nenhum watchdog confirmou. */
61
+ candidates?: readonly JevCandidate[];
62
+ /** Aviso opcional no topo (ex.: revisão incremental). */
63
+ note?: string;
32
64
  };
33
65
 
34
66
  const STATUS_BY_SEVERITY: Record<"blocking" | "attention" | "clean", string> = {
@@ -47,8 +79,28 @@ function plural(count: number, singular: string, plural: string): string {
47
79
  return `${count} ${count === 1 ? singular : plural}`;
48
80
  }
49
81
 
50
- /** Resumo de negócio: contexto da PR; sem ele, o alcance da branch. */
82
+ /** Resumo de negócio: modelo de intenção; sem ele, contexto da PR/branch. */
51
83
  function businessSection(input: ReviewResumeInput): string[] {
84
+ const lines = ["## Resumo de negócio", ""];
85
+ const business = input.business;
86
+
87
+ if (
88
+ business &&
89
+ (business.intent || business.flows.length > 0 || business.invariants.length > 0)
90
+ ) {
91
+ if (business.intent) lines.push(business.intent, "");
92
+ else if (input.context?.objective) lines.push(input.context.objective, "");
93
+ const rendered = renderBusinessLines(business).filter(
94
+ (line) => !line.startsWith("**Intenção:**"),
95
+ );
96
+ while (rendered[0] === "") rendered.shift();
97
+ if (rendered.length > 0) lines.push(...rendered, "");
98
+ if (input.context?.motivation) {
99
+ lines.push(`**Por quê:** ${truncate(input.context.motivation, 480)}`, "");
100
+ }
101
+ return lines;
102
+ }
103
+
52
104
  const context = input.context;
53
105
  const hasContext =
54
106
  context?.available === true &&
@@ -57,7 +109,6 @@ function businessSection(input: ReviewResumeInput): string[] {
57
109
  context.motivation ||
58
110
  (context.changeSummary && context.changeSummary.length > 0),
59
111
  );
60
- const lines = ["## Resumo de negócio", ""];
61
112
 
62
113
  if (!hasContext) {
63
114
  const domainCount = Object.keys(input.classification.watchdogs).length;
@@ -107,9 +158,78 @@ function clamp(markdown: string, max: number): string {
107
158
  return `${(cut > 0 ? head.slice(0, cut) : head).trimEnd()}\n…`;
108
159
  }
109
160
 
161
+ /** Conclusão com os totais por severidade. */
162
+ function conclusionSection(findings: readonly MergedFinding[]): string[] {
163
+ const counts = {
164
+ blocking: findings.filter((f) => f.severity === "blocking").length,
165
+ attention: findings.filter((f) => f.severity === "attention").length,
166
+ suggestion: findings.filter((f) => f.severity === "suggestion").length,
167
+ nit: findings.filter((f) => f.severity === "nit").length,
168
+ };
169
+ return [
170
+ "## Conclusão",
171
+ "",
172
+ `**Resumo:** ${counts.blocking} bloqueantes · ${counts.attention} atenção · ${counts.suggestion} sugestões · ${counts.nit} nits.`,
173
+ "",
174
+ ];
175
+ }
176
+
177
+ /** Cobertura por domínio: revisados, declarados fora do recorte e lacunas. */
178
+ function coverageSection(domains: readonly ResumeDomain[]): string[] {
179
+ if (domains.length === 0) return [];
180
+ const lines = [
181
+ "## Cobertura por domínio",
182
+ "",
183
+ "| Domínio | Achados | Revisados | Fora do recorte | Faltam |",
184
+ "|---|---|---|---|---|",
185
+ ];
186
+ for (const domain of domains) {
187
+ const skipped = domain.skippedFiles ?? 0;
188
+ const missing = Math.max(
189
+ 0,
190
+ domain.expectedFiles - domain.reviewedFiles - skipped,
191
+ );
192
+ lines.push(
193
+ `| ${domain.name} | ${domain.findings} | ${domain.reviewedFiles}/${domain.expectedFiles} | ${skipped > 0 ? skipped : "—"} | ${missing > 0 ? missing : "—"} |`,
194
+ );
195
+ }
196
+ lines.push(
197
+ "",
198
+ "> `Faltam` = arquivos do escopo sem revisão e sem declaração em `skipped`.",
199
+ "",
200
+ );
201
+ return lines;
202
+ }
203
+
204
+ /** Candidatos da pré-análise que nenhum watchdog confirmou (não bloqueiam). */
205
+ function candidatesSection(
206
+ candidates: readonly JevCandidate[],
207
+ findings: readonly MergedFinding[],
208
+ ): string[] {
209
+ if (candidates.length === 0) return [];
210
+ const confirmed = new Set(findings.map((f) => `${f.file}|${f.rule}`));
211
+ const pending = candidates.filter((c) => !confirmed.has(`${c.file}|${c.rule}`));
212
+ if (pending.length === 0) return [];
213
+ const lines = [
214
+ `## Candidatos não verificados (${pending.length})`,
215
+ "",
216
+ "> Indícios da pré-análise que nenhum watchdog confirmou. Não bloqueiam.",
217
+ "",
218
+ ];
219
+ for (const candidate of pending.slice(0, 8)) {
220
+ lines.push(
221
+ `- \`${candidate.file}\` · ${candidate.severity} · ${truncate(candidate.rule, 160)}`,
222
+ );
223
+ }
224
+ if (pending.length > 8) lines.push(`- … +${pending.length - 8}`);
225
+ lines.push("");
226
+ return lines;
227
+ }
228
+
110
229
  /**
111
230
  * Renderiza o resumo final. Determinístico e sem chamadas externas: o texto
112
- * nasce do contexto já disponível e dos pontos técnicos persistidos.
231
+ * nasce do contexto já disponível, do modelo de intenção, dos pontos técnicos
232
+ * e da cobertura por domínio. `review-resume.md` é o único artefato humano.
113
233
  */
114
234
  export function renderReviewResume(input: ReviewResumeInput): string {
115
235
  const blockers = input.findings.filter((f) => f.severity === "blocking").length;
@@ -121,14 +241,34 @@ export function renderReviewResume(input: ReviewResumeInput): string {
121
241
  ? STATUS_BY_SEVERITY.attention
122
242
  : STATUS_BY_SEVERITY.clean;
123
243
 
124
- const lines = [
244
+ const header = [
125
245
  `# Resumo da revisão — ${input.branch}`,
126
246
  "",
127
247
  `**Data:** ${input.date ?? systemClock().slice(0, 10)} · **Status:** ${status}`,
128
- "",
129
- ...businessSection(input),
130
- ...technicalSection(input.technical),
131
- ];
248
+ ...(input.note?.trim() ? ["", input.note.trim()] : []),
249
+ ].join("\n");
250
+
251
+ const coverage = coverageSection(input.domains ?? []).join("\n");
252
+ const candidates = candidatesSection(input.candidates ?? [], input.findings).join(
253
+ "\n",
254
+ );
255
+ const conclusion = conclusionSection(input.findings).join("\n");
256
+
257
+ // Tabelas e conclusão primeiro: o que sobra é o orçamento do texto narrativo.
258
+ const reserved = [header, coverage, candidates, conclusion]
259
+ .filter((part) => part.length > 0)
260
+ .join("\n\n").length;
261
+ const budget = Math.max(MIN_NARRATIVE_CHARS, RESUME_MAX_CHARS - reserved);
262
+ const businessMax = Math.floor(budget * BUSINESS_SHARE);
263
+ const business = clamp(businessSection(input).join("\n"), businessMax);
264
+ const technical = clamp(
265
+ technicalSection(input.technical).join("\n"),
266
+ budget - businessMax,
267
+ );
132
268
 
133
- return clamp(`${lines.join("\n").trimEnd()}\n`, RESUME_MAX_CHARS);
269
+ const body = [header, business, technical, coverage, candidates, conclusion]
270
+ .map((part) => part.trim())
271
+ .filter((part) => part.length > 0)
272
+ .join("\n\n");
273
+ return `${body}\n`;
134
274
  }
@@ -1,20 +1,16 @@
1
1
  /**
2
- * Consolidação dos relatórios JSON dos watchdogs em um markdown (biblioteca).
2
+ * Relatórios JSON dos watchdogs: tipos, validação, normalização e dedupe.
3
3
  *
4
- * Entrypoint: `cli/review.ts` (via `core/orchestrator.ts`). Deduplica achados
5
- * por (file, line, rule), agrupa por severidade e renderiza a tabela de cobertura.
4
+ * Entrypoint: `cli/review.ts` (via `core/orchestrator.ts`). A renderização do
5
+ * resumo final vive em `core/renderers/resume-renderer.ts`.
6
6
  *
7
7
  * Schema dos watchdogs: asset `OUTPUT-FORMAT.md` (embarcado em `src/skill/`).
8
8
  */
9
9
 
10
- import type { JevCandidate } from "./jev";
11
- import { systemClock } from "./services/clock";
12
- import { SEVERITY_EMOJI, SEVERITY_ORDER, type Severity, severityRank } from "./types";
10
+ import { SEVERITY_ORDER, type Severity, severityRank } from "./types";
13
11
 
14
- /** Resolve o nome legível de um watchdog (default: identidade). */
15
- export type WatchdogNameResolver = (watchdogId: string) => string;
16
-
17
- const identityName: WatchdogNameResolver = (id) => id;
12
+ /** Origem do finding: regra explícita, consistência lógica ou domínio. */
13
+ export type FindingBasis = "rule" | "logic" | "domain";
18
14
 
19
15
  export type Finding = {
20
16
  /** Id curto do finding (preenchido no `--finalize`; usado pela triagem). */
@@ -26,6 +22,14 @@ export type Finding = {
26
22
  message: string;
27
23
  action: string;
28
24
  evidence?: string;
25
+ /** Gatilho concreto que expõe o gap (obrigatório para `blocking` fora de regra). */
26
+ scenario?: string;
27
+ /** Consequência observável do gap. */
28
+ impact?: string;
29
+ /** Confiança do revisor (0–1). */
30
+ confidence?: number;
31
+ /** Origem do achado; inferida pelo prefixo `Lógica:`/`Domínio:` quando ausente. */
32
+ basis?: FindingBasis;
29
33
  /**
30
34
  * Commit em que o achado foi observado (interno). Preenchido nos carry-over
31
35
  * do review incremental — preserva a referência para a verificação (verify).
@@ -92,6 +96,39 @@ export function sanitizeBranch(branch: string): string {
92
96
  .toLowerCase();
93
97
  }
94
98
 
99
+ /** Normaliza o rótulo de uma variante A/B (`baseline` → `baseline`). */
100
+ export function sanitizeVariant(variant: string): string {
101
+ return variant
102
+ .trim()
103
+ .replace(/[^a-zA-Z0-9._-]+/g, "-")
104
+ .replace(/^-+|-+$/g, "")
105
+ .toLowerCase()
106
+ .slice(0, 32);
107
+ }
108
+
109
+ /** Hash curto e estável (FNV-1a) para desambiguar slugs de variantes. */
110
+ function shortHash(value: string): string {
111
+ let hash = 2166136261;
112
+ for (let index = 0; index < value.length; index++) {
113
+ hash ^= value.charCodeAt(index);
114
+ hash = Math.imul(hash, 16777619);
115
+ }
116
+ return (hash >>> 0).toString(16).padStart(6, "0").slice(0, 6);
117
+ }
118
+
119
+ /**
120
+ * Slug da run (diretório do backend de arquivos e URL da UI).
121
+ *
122
+ * Run principal: slug da branch. Variante A/B: `branch--variante-<hash>` — o
123
+ * hash garante unicidade mesmo quando dois rótulos sanitizam para o mesmo texto.
124
+ */
125
+ export function reviewSlug(branch: string, variant?: string): string {
126
+ const base = sanitizeBranch(branch);
127
+ const label = variant?.trim() ? sanitizeVariant(variant) : "";
128
+ if (!label) return base;
129
+ return `${base}--${label}-${shortHash(`${branch}\u0000${variant?.trim() ?? ""}`)}`;
130
+ }
131
+
95
132
  function isFinding(value: unknown): value is Finding {
96
133
  if (typeof value !== "object" || value === null) return false;
97
134
  const f = value as Record<string, unknown>;
@@ -102,10 +139,54 @@ function isFinding(value: unknown): value is Finding {
102
139
  typeof f.file === "string" &&
103
140
  typeof f.message === "string" &&
104
141
  typeof f.action === "string" &&
105
- (f.line === undefined || typeof f.line === "number")
142
+ (f.line === undefined || typeof f.line === "number") &&
143
+ (f.scenario === undefined || typeof f.scenario === "string") &&
144
+ (f.impact === undefined || typeof f.impact === "string") &&
145
+ (f.confidence === undefined || typeof f.confidence === "number") &&
146
+ (f.basis === undefined ||
147
+ f.basis === "rule" ||
148
+ f.basis === "logic" ||
149
+ f.basis === "domain")
106
150
  );
107
151
  }
108
152
 
153
+ /** Infere a origem do finding pelo prefixo reservado da regra. */
154
+ export function inferBasis(rule: string): FindingBasis {
155
+ const text = rule.trim().toLowerCase();
156
+ if (text.startsWith("lógica:") || text.startsWith("logica:")) return "logic";
157
+ if (text.startsWith("domínio:") || text.startsWith("dominio:")) return "domain";
158
+ return "rule";
159
+ }
160
+
161
+ /**
162
+ * Normaliza um finding e aplica a política de severidade: fora de regra,
163
+ * `blocking` exige `scenario` + `evidence`; sem os dois, rebaixa para
164
+ * `attention`. A regra explícita mantém a severidade declarada pelo watchdog.
165
+ */
166
+ export function normalizeFinding(
167
+ finding: Finding,
168
+ onWarn: (message: string) => void = () => {},
169
+ ): Finding {
170
+ const basis = finding.basis ?? inferBasis(finding.rule);
171
+ const confidence =
172
+ finding.confidence === undefined
173
+ ? undefined
174
+ : Math.min(1, Math.max(0, finding.confidence));
175
+ const normalized: Finding = { ...finding, basis, confidence };
176
+ if (basis !== "rule" && normalized.severity === "blocking") {
177
+ const proven = Boolean(
178
+ normalized.scenario?.trim() && normalized.evidence?.trim(),
179
+ );
180
+ if (!proven) {
181
+ normalized.severity = "attention";
182
+ onWarn(
183
+ `[report-builder] ${finding.file}: finding ${basis} sem cenário+evidência rebaixado para attention.`,
184
+ );
185
+ }
186
+ }
187
+ return normalized;
188
+ }
189
+
109
190
  /** Valida e normaliza um relatório. Retorna null se inválido. */
110
191
  export function parseWatchdogReport(
111
192
  raw: unknown,
@@ -126,13 +207,14 @@ export function parseWatchdogReport(
126
207
  return null;
127
208
  }
128
209
 
129
- const findings = obj.findings.filter((f) => {
130
- if (!isFinding(f)) {
210
+ const findings: Finding[] = [];
211
+ for (const item of obj.findings) {
212
+ if (!isFinding(item)) {
131
213
  onError(`[report-builder] ${source}: finding inválido ignorado`);
132
- return false;
214
+ continue;
133
215
  }
134
- return true;
135
- });
216
+ findings.push(normalizeFinding(item, onError));
217
+ }
136
218
 
137
219
  return {
138
220
  watchdogId: obj.watchdogId,
@@ -198,6 +280,16 @@ export function dedupeFindings(reports: WatchdogReport[]): MergedFinding[] {
198
280
  existing.action = finding.action;
199
281
  existing.evidence = finding.evidence ?? existing.evidence;
200
282
  }
283
+ // Cenário/impacto/confiança nunca se perdem no merge.
284
+ existing.scenario = existing.scenario ?? finding.scenario;
285
+ existing.impact = existing.impact ?? finding.impact;
286
+ if (finding.confidence !== undefined) {
287
+ existing.confidence = Math.max(
288
+ existing.confidence ?? 0,
289
+ finding.confidence,
290
+ );
291
+ }
292
+ existing.basis = existing.basis ?? finding.basis;
201
293
  continue;
202
294
  }
203
295
  map.set(key, { ...finding, watchdogs: [report.watchdogId] });
@@ -210,265 +302,3 @@ export function dedupeFindings(reports: WatchdogReport[]): MergedFinding[] {
210
302
  return (a.line ?? 0) - (b.line ?? 0);
211
303
  });
212
304
  }
213
-
214
- function watchdogName(nameOf: WatchdogNameResolver, id: string): string {
215
- return nameOf(id);
216
- }
217
-
218
- function renderFinding(finding: MergedFinding, nameOf: WatchdogNameResolver): string[] {
219
- const location = finding.line ? `${finding.file}:${finding.line}` : finding.file;
220
- const watchdogs = finding.watchdogs.map((r) => watchdogName(nameOf, r)).join(", ");
221
- const id = finding.id ? ` · \`${finding.id}\`` : "";
222
- const lines = [
223
- `- **${finding.rule}** — \`${location}\` · _${watchdogs}_${id}`,
224
- ` ${finding.message}`,
225
- ` **Ação:** ${finding.action}`,
226
- ];
227
- if (finding.evidence) {
228
- lines.push(` **Evidência:** \`${finding.evidence}\``);
229
- }
230
- lines.push("");
231
- return lines;
232
- }
233
-
234
- function renderCoverage(
235
- reports: WatchdogReport[],
236
- coverage: { classified?: Record<string, string[]> },
237
- nameOf: WatchdogNameResolver,
238
- ): string[] {
239
- const lines = [
240
- "## Cobertura",
241
- "",
242
- "| Watchdog | Arquivos revisados | Findings |",
243
- "|---|---|---|",
244
- ];
245
- for (const report of reports) {
246
- const expected = coverage.classified?.[report.watchdogId];
247
- const reviewed = new Set(report.reviewedFiles);
248
- const pending = expected ? expected.filter((f) => !reviewed.has(f)) : [];
249
- const reviewedLabel =
250
- expected && pending.length > 0
251
- ? `${report.reviewedFiles.length}/${expected.length} (faltam ${pending.length})`
252
- : `${report.reviewedFiles.length}`;
253
- lines.push(
254
- `| ${watchdogName(nameOf, report.watchdogId)} | ${reviewedLabel} | ${report.findings.length} |`,
255
- );
256
- }
257
- lines.push("");
258
- return lines;
259
- }
260
-
261
- /** Candidatos Jev não confirmados por nenhum watchdog — revisão manual, não bloqueiam. */
262
- function renderUnconfirmedCandidates(
263
- candidates: JevCandidate[],
264
- merged: MergedFinding[],
265
- ): string[] {
266
- const confirmed = new Set(merged.map((f) => `${f.file}|${f.rule}`));
267
- const pending = candidates.filter((c) => !confirmed.has(`${c.file}|${c.rule}`));
268
- if (pending.length === 0) return [];
269
- const lines = [
270
- "## 🔎 Candidatos não verificados (Jev)",
271
- "",
272
- "> Indícios automáticos que nenhum watchdog confirmou. Revise manualmente — não bloqueiam.",
273
- "",
274
- ];
275
- for (const c of pending) {
276
- lines.push(
277
- `- \`${c.file}\` · **${c.severity}** · confiança ${c.confidence.toFixed(2)} · _${c.watchdogId}_`,
278
- ` ${c.rule}`,
279
- "",
280
- );
281
- }
282
- return lines;
283
- }
284
-
285
- function renderConclusion(counts: Record<Severity, number>): string[] {
286
- const status =
287
- counts.blocking > 0
288
- ? "❌ Mudanças Necessárias"
289
- : counts.attention > 0
290
- ? "⚠️ Aprovado com Ressalvas"
291
- : "✅ Aprovado";
292
- return [
293
- "## Conclusão",
294
- "",
295
- `**Status:** ${status}`,
296
- `**Resumo:** ${counts.blocking} violações bloqueantes, ${counts.attention} pontos de atenção, ${counts.suggestion} sugestões e ${counts.nit} nits.`,
297
- "",
298
- ];
299
- }
300
-
301
- /**
302
- * Agrupa os achados por arquivo (navegabilidade em PRs grandes) e renderiza cada
303
- * bloco. O `merged` já vem ordenado por severidade/arquivo/linha.
304
- */
305
- function renderFindingsByFile(
306
- findings: readonly MergedFinding[],
307
- nameOf: WatchdogNameResolver,
308
- ): string[] {
309
- const byFile = new Map<string, MergedFinding[]>();
310
- for (const finding of findings) {
311
- const list = byFile.get(finding.file);
312
- if (list) list.push(finding);
313
- else byFile.set(finding.file, [finding]);
314
- }
315
-
316
- const lines: string[] = [];
317
- for (const [file, items] of byFile) {
318
- lines.push(`### \`${file}\``, "");
319
- for (const item of items) lines.push(...renderFinding(item, nameOf));
320
- }
321
- return lines;
322
- }
323
-
324
- /** Seções por severidade (❌ > ⚠️ > 💡 > 🔎), com achados agrupados por arquivo. */
325
- function renderFindingsSections(
326
- merged: readonly MergedFinding[],
327
- nameOf: WatchdogNameResolver,
328
- ): string[] {
329
- const lines: string[] = [];
330
- for (const severity of SEVERITY_ORDER) {
331
- const items = merged.filter((f) => f.severity === severity);
332
- if (items.length === 0) continue;
333
- lines.push(`## ${SEVERITY_EMOJI[severity]} ${severity}`, "");
334
- lines.push(...renderFindingsByFile(items, nameOf));
335
- }
336
- return lines;
337
- }
338
-
339
- /** Relatório de domínio gravado à parte (um por watchdog com achados). */
340
- export type DomainReport = {
341
- id: string;
342
- name: string;
343
- /** Nome do arquivo do relatório (relativo ao diretório de artefatos). */
344
- file: string;
345
- findings: number;
346
- reviewedFiles: number;
347
- expectedFiles: number;
348
- };
349
-
350
- /** Índice dos relatórios por domínio (seção do `review.md` consolidado). */
351
- function renderDomainIndex(domains: readonly DomainReport[]): string[] {
352
- if (domains.length === 0) return [];
353
- const lines = [
354
- "## Relatórios por domínio",
355
- "",
356
- "| Watchdog | Achados | Revisados | Relatório |",
357
- "|---|---|---|---|",
358
- ];
359
- for (const domain of domains) {
360
- lines.push(
361
- `| ${domain.name} | ${domain.findings} | ${domain.reviewedFiles}/${domain.expectedFiles} | [\`${domain.file}\`](${domain.file}) |`,
362
- );
363
- }
364
- lines.push("");
365
- return lines;
366
- }
367
-
368
- /**
369
- * Relatório isolado de um watchdog — mantém o `review.md` enxuto em PRs grandes.
370
- */
371
- export function renderDomainReport(
372
- branch: string,
373
- report: WatchdogReport,
374
- merged: MergedFinding[],
375
- expectedFiles: number,
376
- context?: ContextSummary | null,
377
- nameOf: WatchdogNameResolver = identityName,
378
- ): string {
379
- const now = systemClock().slice(0, 10);
380
- const name = watchdogName(nameOf, report.watchdogId);
381
- const lines: string[] = [
382
- `# Review — ${name} (\`${report.watchdogId}\`) — ${branch}`,
383
- "",
384
- `**Data:** ${now}`,
385
- `**Arquivos revisados:** ${report.reviewedFiles.length}/${expectedFiles}`,
386
- `**Achados:** ${merged.length}`,
387
- "",
388
- ];
389
-
390
- if (context?.available) lines.push(...renderContext(context));
391
-
392
- lines.push(...renderFindingsSections(merged, nameOf));
393
- if (merged.length === 0) lines.push("Nenhum achado. ✅", "");
394
-
395
- if (report.skipped.length > 0) {
396
- lines.push(
397
- "## Não revisados",
398
- "",
399
- ...report.skipped.map((item) => `- \`${item}\``),
400
- "",
401
- );
402
- }
403
-
404
- return lines.join("\n");
405
- }
406
-
407
- /** Seção de contexto da PR (Objetivo / Motivação / Resumo das alterações). */
408
- function renderContext(context: ContextSummary): string[] {
409
- const lines = ["## Contexto da PR", ""];
410
- const src = context.source;
411
-
412
- const ref = [src?.number ? `#${src.number}` : "", src?.title ?? ""]
413
- .filter(Boolean)
414
- .join(" ");
415
- if (ref) lines.push(`**PR:** ${ref}${src?.url ? ` — ${src.url}` : ""}`);
416
- if (src?.head && src?.base)
417
- lines.push(`**Branch:** \`${src.head}\` → \`${src.base}\``);
418
- if (context.objective) lines.push(`**Objetivo:** ${context.objective}`);
419
- if (context.motivation) lines.push(`**Motivação:** ${context.motivation}`);
420
- if (context.changeSummary && context.changeSummary.length > 0) {
421
- lines.push("", "**Resumo das alterações:**", "");
422
- for (const item of context.changeSummary) lines.push(`- ${item}`);
423
- }
424
- lines.push("");
425
- return lines;
426
- }
427
-
428
- export function renderReport(
429
- branch: string,
430
- reports: WatchdogReport[],
431
- merged: MergedFinding[],
432
- coverage: { classified?: Record<string, string[]> },
433
- context?: ContextSummary | null,
434
- candidates?: JevCandidate[],
435
- nameOf: WatchdogNameResolver = identityName,
436
- domains: readonly DomainReport[] = [],
437
- ): string {
438
- const now = systemClock().slice(0, 10);
439
- const counts = {
440
- blocking: merged.filter((f) => f.severity === "blocking").length,
441
- attention: merged.filter((f) => f.severity === "attention").length,
442
- suggestion: merged.filter((f) => f.severity === "suggestion").length,
443
- nit: merged.filter((f) => f.severity === "nit").length,
444
- };
445
-
446
- const lines: string[] = [
447
- `# PR Review — ${branch}`,
448
- "",
449
- `**Data:** ${now}`,
450
- `**Watchdogs ativos:** ${reports.map((r) => r.watchdogId).join(", ") || "—"}`,
451
- `**Achados:** ${counts.blocking} bloqueantes · ${counts.attention} atenção · ${counts.suggestion} sugestões · ${counts.nit} nits`,
452
- "",
453
- ];
454
-
455
- if (context) lines.push(...renderContext(context));
456
-
457
- // Sumário primeiro: quem lê o review.md vê domínios, cobertura e conclusão
458
- // antes dos pontos específicos.
459
- lines.push(...renderDomainIndex(domains));
460
- lines.push(...renderCoverage(reports, coverage, nameOf));
461
- lines.push(...renderConclusion(counts));
462
-
463
- lines.push(...renderFindingsSections(merged, nameOf));
464
-
465
- if (merged.length === 0) {
466
- lines.push("Nenhum achado. ✅", "");
467
- }
468
-
469
- if (candidates && candidates.length > 0) {
470
- lines.push(...renderUnconfirmedCandidates(candidates, merged));
471
- }
472
-
473
- return lines.join("\n");
474
- }
@@ -39,7 +39,7 @@ export class FileStorageProvider implements StorageProvider {
39
39
  // -----------------------------------------------------------------------
40
40
 
41
41
  getOrCreateReview(input: CreateReviewInput): ReviewRecord {
42
- const existing = this.findReview(input.repo.key, input.branch);
42
+ const existing = this.findReview(input.repo.key, input.branch, input.variant);
43
43
  if (existing) {
44
44
  if (
45
45
  existing.repoRoot !== input.repo.root ||
@@ -66,6 +66,7 @@ export class FileStorageProvider implements StorageProvider {
66
66
  repoRoot: input.repo.root,
67
67
  repoRemote: input.repo.remote,
68
68
  branch: input.branch,
69
+ variant: input.variant?.trim() || undefined,
69
70
  slug: input.slug,
70
71
  base: input.base,
71
72
  head: input.head,
@@ -78,9 +79,12 @@ export class FileStorageProvider implements StorageProvider {
78
79
  return this.writeReview(record);
79
80
  }
80
81
 
81
- findReview(repoKey: string, branch: string): ReviewRecord | null {
82
+ findReview(repoKey: string, branch: string, variant?: string): ReviewRecord | null {
83
+ const label = variant?.trim() ?? "";
82
84
  return (
83
- this.readReviews(repoKey).find((review) => review.branch === branch) ?? null
85
+ this.readReviews(repoKey).find(
86
+ (review) => review.branch === branch && (review.variant ?? "") === label,
87
+ ) ?? null
84
88
  );
85
89
  }
86
90