@payconductor/houndsman 0.13.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 (106) 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/hunt-command.ts +20 -3
  7. package/src/cli/commands/review-command.ts +38 -0
  8. package/src/cli/commands/status-command.ts +3 -2
  9. package/src/cli/help.ts +3 -1
  10. package/src/cli/index.ts +3 -1
  11. package/src/cli/progress.ts +7 -2
  12. package/src/core/ai/ai-client.ts +38 -2
  13. package/src/core/ai/features/brief-enricher.ts +1 -1
  14. package/src/core/ai/features/business-analyzer.ts +61 -0
  15. package/src/core/ai/features/business-summarizer.ts +44 -0
  16. package/src/core/ai/index.ts +8 -0
  17. package/src/core/ai/prompts/brief.ts +33 -7
  18. package/src/core/ai/prompts/business.ts +76 -0
  19. package/src/core/ai/provider-capabilities.ts +12 -0
  20. package/src/core/ai/schemas.ts +70 -2
  21. package/src/core/ai/usage-recorder.ts +53 -1
  22. package/src/core/brief.ts +246 -12
  23. package/src/core/builtins.ts +108 -0
  24. package/src/core/business-summary.ts +583 -0
  25. package/src/core/commands/compare.ts +233 -0
  26. package/src/core/commands/context.ts +6 -2
  27. package/src/core/commands/findings.ts +38 -30
  28. package/src/core/commands/review.ts +6 -0
  29. package/src/core/commands/scaffold.ts +27 -0
  30. package/src/core/commands/status.ts +9 -4
  31. package/src/core/compare.ts +243 -0
  32. package/src/core/concurrency.ts +30 -0
  33. package/src/core/config.ts +31 -3
  34. package/src/core/defaults.ts +50 -4
  35. package/src/core/domain/budget.ts +62 -4
  36. package/src/core/domain/classification.ts +12 -2
  37. package/src/core/engine/carryover.ts +4 -0
  38. package/src/core/engine/finalize-pipeline.ts +347 -0
  39. package/src/core/engine/prepare-pipeline.ts +1294 -0
  40. package/src/core/engine/review-context.ts +51 -0
  41. package/src/core/engine/review-options.ts +47 -0
  42. package/src/core/engine/review-scope.ts +14 -1
  43. package/src/core/engine/review-session.ts +11 -13
  44. package/src/core/engine/run-artifacts.ts +196 -0
  45. package/src/core/engine/run-format.ts +4 -0
  46. package/src/core/findings-state.ts +13 -1
  47. package/src/core/github-review.ts +5 -2
  48. package/src/core/hunt/hunt-runner.ts +90 -0
  49. package/src/core/hunt/index.ts +2 -0
  50. package/src/core/hunt/maki-intent-prompt.ts +50 -0
  51. package/src/core/hunt/maki-prompt.ts +13 -1
  52. package/src/core/hunt/pool.ts +4 -26
  53. package/src/core/logging/index.ts +6 -1
  54. package/src/core/logging/progress.ts +85 -1
  55. package/src/core/orchestrator.ts +33 -1289
  56. package/src/core/paths.ts +6 -3
  57. package/src/core/pr-context.ts +2 -4
  58. package/src/core/prepare-report.ts +61 -0
  59. package/src/core/project-config.ts +8 -1
  60. package/src/core/propagation.ts +127 -0
  61. package/src/core/registry-check.ts +60 -21
  62. package/src/core/renderers/manifest-writer.ts +13 -0
  63. package/src/core/renderers/resume-renderer.ts +151 -11
  64. package/src/core/report-builder.ts +108 -278
  65. package/src/core/storage/file/file-storage.ts +7 -3
  66. package/src/core/storage/sqlite/mappers.ts +10 -0
  67. package/src/core/storage/sqlite/schema.ts +104 -23
  68. package/src/core/storage/sqlite/sqlite-storage.ts +22 -9
  69. package/src/core/storage/types.ts +16 -4
  70. package/src/core/storage/work-area.ts +59 -1
  71. package/src/core/types.ts +31 -0
  72. package/src/core/watchdog-registry.ts +21 -3
  73. package/src/index.ts +29 -4
  74. package/src/server/client/ArtifactsPanel.tsx +4 -2
  75. package/src/server/client/FindingCard.tsx +21 -1
  76. package/src/server/client/RunsSection.tsx +5 -0
  77. package/src/server/client/utils/constants.ts +21 -11
  78. package/src/server/client/utils/markdown.ts +1 -1
  79. package/src/server/client/utils/types.ts +14 -2
  80. package/src/server/run-repository.ts +35 -14
  81. package/src/server/styles.css +2 -0
  82. package/src/skill/OUTPUT-FORMAT.md +36 -10
  83. package/src/skill/README.md +18 -14
  84. package/src/skill/SETUP.md +1 -0
  85. package/src/skill/SKILL.md +13 -4
  86. package/src/skill/example-resume.md +43 -0
  87. package/src/skill/hunt.md +145 -58
  88. package/src/tests/__snapshots__/baseline.test.ts.snap +187 -178
  89. package/src/tests/ai-core.test.ts +46 -0
  90. package/src/tests/ai-features.test.ts +95 -4
  91. package/src/tests/baseline.test.ts +29 -121
  92. package/src/tests/brief-scope.test.ts +250 -0
  93. package/src/tests/business-summary.test.ts +258 -0
  94. package/src/tests/compare.test.ts +233 -0
  95. package/src/tests/fixtures/fake-model-provider.ts +2 -1
  96. package/src/tests/hunt-maki.test.ts +58 -0
  97. package/src/tests/markdown.test.ts +3 -3
  98. package/src/tests/progress.test.ts +29 -1
  99. package/src/tests/propagation.test.ts +119 -0
  100. package/src/tests/review-resume.test.ts +60 -1
  101. package/src/tests/review-scope.test.ts +38 -29
  102. package/src/tests/review-scripts.test.ts +106 -218
  103. package/src/tests/server.test.ts +7 -4
  104. package/src/tests/storage.test.ts +194 -9
  105. package/src/core/renderers/report-renderer.ts +0 -66
  106. package/src/skill/example-review.md +0 -66
@@ -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
 
@@ -22,6 +22,7 @@ export type ReviewRow = {
22
22
  repo_root: string;
23
23
  repo_remote: string | null;
24
24
  branch: string;
25
+ variant: string;
25
26
  slug: string;
26
27
  base: string | null;
27
28
  head: string | null;
@@ -50,6 +51,10 @@ export type FindingRow = {
50
51
  message: string;
51
52
  action: string;
52
53
  evidence: string | null;
54
+ scenario: string | null;
55
+ impact: string | null;
56
+ confidence: number | null;
57
+ basis: string | null;
53
58
  anchor: string | null;
54
59
  found_at_sha: string | null;
55
60
  count: number;
@@ -91,6 +96,7 @@ export function reviewFromRow(row: ReviewRow): ReviewRecord {
91
96
  repoRoot: row.repo_root,
92
97
  repoRemote: row.repo_remote ?? undefined,
93
98
  branch: row.branch,
99
+ variant: row.variant || undefined,
94
100
  slug: row.slug,
95
101
  base: row.base ?? undefined,
96
102
  head: row.head ?? undefined,
@@ -129,6 +135,10 @@ export function findingFromRow(row: FindingRow, history: HistoryRow[]): FindingS
129
135
  message: row.message,
130
136
  action: row.action,
131
137
  evidence: row.evidence ?? undefined,
138
+ scenario: row.scenario ?? undefined,
139
+ impact: row.impact ?? undefined,
140
+ confidence: row.confidence === null ? undefined : Number(row.confidence),
141
+ basis: (row.basis as FindingState["basis"]) ?? undefined,
132
142
  anchor: parseJson<FindingAnchor>(row.anchor) ?? null,
133
143
  foundAtSha: row.found_at_sha ?? undefined,
134
144
  count: Number(row.count),
@@ -7,7 +7,7 @@
7
7
 
8
8
  import type { Database } from "bun:sqlite";
9
9
 
10
- export const SCHEMA_VERSION = 3;
10
+ export const SCHEMA_VERSION = 5;
11
11
 
12
12
  const DDL = `
13
13
  CREATE TABLE IF NOT EXISTS reviews (
@@ -16,6 +16,7 @@ CREATE TABLE IF NOT EXISTS reviews (
16
16
  repo_root TEXT NOT NULL,
17
17
  repo_remote TEXT,
18
18
  branch TEXT NOT NULL,
19
+ variant TEXT NOT NULL DEFAULT '',
19
20
  slug TEXT NOT NULL,
20
21
  base TEXT,
21
22
  head TEXT,
@@ -29,7 +30,7 @@ CREATE TABLE IF NOT EXISTS reviews (
29
30
  pr_number INTEGER,
30
31
  created_at TEXT NOT NULL,
31
32
  updated_at TEXT NOT NULL,
32
- UNIQUE(repo_key, branch)
33
+ UNIQUE(repo_key, branch, variant)
33
34
  );
34
35
 
35
36
  CREATE INDEX IF NOT EXISTS idx_reviews_repo_updated
@@ -48,6 +49,10 @@ CREATE TABLE IF NOT EXISTS findings (
48
49
  message TEXT NOT NULL,
49
50
  action TEXT NOT NULL,
50
51
  evidence TEXT,
52
+ scenario TEXT,
53
+ impact TEXT,
54
+ confidence REAL,
55
+ basis TEXT,
51
56
  anchor TEXT,
52
57
  found_at_sha TEXT,
53
58
  count INTEGER NOT NULL DEFAULT 1,
@@ -93,7 +98,6 @@ CREATE INDEX IF NOT EXISTS idx_artifacts_review ON artifacts(review_id);
93
98
 
94
99
  /** Aplica pragmas e cria o schema quando necessário. */
95
100
  export function migrate(db: Database, path: string): void {
96
- db.exec("PRAGMA foreign_keys = ON;");
97
101
  db.exec("PRAGMA busy_timeout = 5000;");
98
102
  db.exec("PRAGMA synchronous = NORMAL;");
99
103
  if (path !== ":memory:") db.exec("PRAGMA journal_mode = WAL;");
@@ -102,25 +106,102 @@ export function migrate(db: Database, path: string): void {
102
106
  user_version?: number;
103
107
  } | null;
104
108
  const version = Number(row?.user_version ?? 0);
105
- if (version >= SCHEMA_VERSION) return;
109
+ if (version >= SCHEMA_VERSION) {
110
+ db.exec("PRAGMA foreign_keys = ON;");
111
+ return;
112
+ }
106
113
 
107
- db.transaction(() => {
108
- db.exec(DDL);
109
- // v2: heartbeat explícito da execução (bancos v1 não tinham a coluna).
110
- // v3: worktree/pr da revisão (revisões paralelas).
111
- const columns = db.query("PRAGMA table_info(reviews)").all() as {
112
- name: string;
113
- }[];
114
- const missing: [string, string][] = [
115
- ["heartbeat_at", "TEXT"],
116
- ["worktree_path", "TEXT"],
117
- ["pr_number", "INTEGER"],
118
- ];
119
- for (const [name, type] of missing) {
120
- if (!columns.some((column) => column.name === name)) {
121
- db.exec(`ALTER TABLE reviews ADD COLUMN ${name} ${type}`);
122
- }
123
- }
124
- db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
125
- })();
114
+ // O rebuild de `reviews` (v5) troca a chave única e precisa de FKs desligadas:
115
+ // com elas ligadas, `DROP TABLE reviews` cascatearia e apagaria os filhos.
116
+ db.exec("PRAGMA foreign_keys = OFF;");
117
+ try {
118
+ db.transaction(() => {
119
+ db.exec(DDL);
120
+ // v2: heartbeat explícito da execução; v3: worktree/pr da revisão;
121
+ // v4: cenário/impacto/confiança/origem do finding.
122
+ const addMissing = (table: string, columns: [string, string][]): void => {
123
+ const info = db.query(`PRAGMA table_info(${table})`).all() as {
124
+ name: string;
125
+ }[];
126
+ for (const [name, type] of columns) {
127
+ if (!info.some((column) => column.name === name)) {
128
+ db.exec(`ALTER TABLE ${table} ADD COLUMN ${name} ${type}`);
129
+ }
130
+ }
131
+ };
132
+ addMissing("reviews", [
133
+ ["heartbeat_at", "TEXT"],
134
+ ["worktree_path", "TEXT"],
135
+ ["pr_number", "INTEGER"],
136
+ ]);
137
+ addMissing("findings", [
138
+ ["scenario", "TEXT"],
139
+ ["impact", "TEXT"],
140
+ ["confidence", "REAL"],
141
+ ["basis", "TEXT"],
142
+ ]);
143
+ // v5: variante A/B na identidade da review.
144
+ rebuildReviewsWithVariant(db);
145
+ db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
146
+ })();
147
+ } finally {
148
+ db.exec("PRAGMA foreign_keys = ON;");
149
+ }
150
+ const violations = db.query("PRAGMA foreign_key_check").all();
151
+ if (violations.length > 0) {
152
+ throw new Error(
153
+ `Migração do banco deixou ${violations.length} referência(s) inválida(s).`,
154
+ );
155
+ }
156
+ }
157
+
158
+ /**
159
+ * v5 — adiciona `variant` e troca `UNIQUE(repo_key, branch)` por
160
+ * `UNIQUE(repo_key, branch, variant)`.
161
+ *
162
+ * SQLite não remove constraint inline: recria a tabela preservando os ids (as
163
+ * FKs dos filhos continuam válidas) e recria o índice.
164
+ */
165
+ function rebuildReviewsWithVariant(db: Database): void {
166
+ const columns = db.query("PRAGMA table_info(reviews)").all() as {
167
+ name: string;
168
+ }[];
169
+ if (columns.some((column) => column.name === "variant")) return;
170
+
171
+ db.exec(`
172
+ CREATE TABLE reviews_v5 (
173
+ id TEXT PRIMARY KEY,
174
+ repo_key TEXT NOT NULL,
175
+ repo_root TEXT NOT NULL,
176
+ repo_remote TEXT,
177
+ branch TEXT NOT NULL,
178
+ variant TEXT NOT NULL DEFAULT '',
179
+ slug TEXT NOT NULL,
180
+ base TEXT,
181
+ head TEXT,
182
+ head_sha TEXT,
183
+ reviewed_sha TEXT,
184
+ reviewed_at TEXT,
185
+ status TEXT,
186
+ started_at TEXT,
187
+ heartbeat_at TEXT,
188
+ worktree_path TEXT,
189
+ pr_number INTEGER,
190
+ created_at TEXT NOT NULL,
191
+ updated_at TEXT NOT NULL,
192
+ UNIQUE(repo_key, branch, variant)
193
+ );
194
+ INSERT INTO reviews_v5
195
+ (id, repo_key, repo_root, repo_remote, branch, variant, slug, base, head,
196
+ head_sha, reviewed_sha, reviewed_at, status, started_at, heartbeat_at,
197
+ worktree_path, pr_number, created_at, updated_at)
198
+ SELECT id, repo_key, repo_root, repo_remote, branch, '', slug, base, head,
199
+ head_sha, reviewed_sha, reviewed_at, status, started_at, heartbeat_at,
200
+ worktree_path, pr_number, created_at, updated_at
201
+ FROM reviews;
202
+ DROP TABLE reviews;
203
+ ALTER TABLE reviews_v5 RENAME TO reviews;
204
+ CREATE INDEX IF NOT EXISTS idx_reviews_repo_updated
205
+ ON reviews(repo_key, updated_at DESC);
206
+ `);
126
207
  }