@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.
- package/README.md +6 -1
- package/package.json +1 -1
- package/src/cli/commands/compare-command.ts +112 -0
- package/src/cli/commands/context-command.ts +1 -0
- package/src/cli/commands/findings-command.ts +11 -1
- package/src/cli/commands/hunt-command.ts +20 -3
- package/src/cli/commands/review-command.ts +38 -0
- package/src/cli/commands/status-command.ts +3 -2
- package/src/cli/help.ts +3 -1
- package/src/cli/index.ts +3 -1
- package/src/cli/progress.ts +7 -2
- package/src/core/ai/ai-client.ts +38 -2
- package/src/core/ai/features/brief-enricher.ts +1 -1
- package/src/core/ai/features/business-analyzer.ts +61 -0
- package/src/core/ai/features/business-summarizer.ts +44 -0
- package/src/core/ai/index.ts +8 -0
- package/src/core/ai/prompts/brief.ts +33 -7
- package/src/core/ai/prompts/business.ts +76 -0
- package/src/core/ai/provider-capabilities.ts +12 -0
- package/src/core/ai/schemas.ts +70 -2
- package/src/core/ai/usage-recorder.ts +53 -1
- package/src/core/brief.ts +246 -12
- package/src/core/builtins.ts +108 -0
- package/src/core/business-summary.ts +583 -0
- package/src/core/commands/compare.ts +233 -0
- package/src/core/commands/context.ts +6 -2
- package/src/core/commands/findings.ts +38 -30
- package/src/core/commands/review.ts +6 -0
- package/src/core/commands/scaffold.ts +27 -0
- package/src/core/commands/status.ts +9 -4
- package/src/core/compare.ts +243 -0
- package/src/core/concurrency.ts +30 -0
- package/src/core/config.ts +31 -3
- package/src/core/defaults.ts +50 -4
- package/src/core/domain/budget.ts +62 -4
- package/src/core/domain/classification.ts +12 -2
- package/src/core/engine/carryover.ts +4 -0
- package/src/core/engine/finalize-pipeline.ts +347 -0
- package/src/core/engine/prepare-pipeline.ts +1294 -0
- package/src/core/engine/review-context.ts +51 -0
- package/src/core/engine/review-options.ts +47 -0
- package/src/core/engine/review-scope.ts +14 -1
- package/src/core/engine/review-session.ts +11 -13
- package/src/core/engine/run-artifacts.ts +196 -0
- package/src/core/engine/run-format.ts +4 -0
- package/src/core/findings-state.ts +13 -1
- package/src/core/github-review.ts +5 -2
- package/src/core/hunt/hunt-runner.ts +90 -0
- package/src/core/hunt/index.ts +2 -0
- package/src/core/hunt/maki-intent-prompt.ts +50 -0
- package/src/core/hunt/maki-prompt.ts +13 -1
- package/src/core/hunt/pool.ts +4 -26
- package/src/core/logging/index.ts +6 -1
- package/src/core/logging/progress.ts +85 -1
- package/src/core/orchestrator.ts +33 -1289
- package/src/core/paths.ts +6 -3
- package/src/core/pr-context.ts +2 -4
- package/src/core/prepare-report.ts +61 -0
- package/src/core/project-config.ts +8 -1
- package/src/core/propagation.ts +127 -0
- package/src/core/registry-check.ts +60 -21
- package/src/core/renderers/manifest-writer.ts +13 -0
- package/src/core/renderers/resume-renderer.ts +151 -11
- package/src/core/report-builder.ts +108 -278
- package/src/core/storage/file/file-storage.ts +7 -3
- package/src/core/storage/sqlite/mappers.ts +10 -0
- package/src/core/storage/sqlite/schema.ts +104 -23
- package/src/core/storage/sqlite/sqlite-storage.ts +22 -9
- package/src/core/storage/types.ts +16 -4
- package/src/core/storage/work-area.ts +59 -1
- package/src/core/types.ts +31 -0
- package/src/core/watchdog-registry.ts +21 -3
- package/src/index.ts +29 -4
- package/src/server/client/ArtifactsPanel.tsx +4 -2
- package/src/server/client/FindingCard.tsx +21 -1
- package/src/server/client/RunsSection.tsx +5 -0
- package/src/server/client/utils/constants.ts +21 -11
- package/src/server/client/utils/markdown.ts +1 -1
- package/src/server/client/utils/types.ts +14 -2
- package/src/server/run-repository.ts +35 -14
- package/src/server/styles.css +2 -0
- package/src/skill/OUTPUT-FORMAT.md +36 -10
- package/src/skill/README.md +18 -14
- package/src/skill/SETUP.md +1 -0
- package/src/skill/SKILL.md +13 -4
- package/src/skill/example-resume.md +43 -0
- package/src/skill/hunt.md +145 -58
- package/src/tests/__snapshots__/baseline.test.ts.snap +187 -178
- package/src/tests/ai-core.test.ts +46 -0
- package/src/tests/ai-features.test.ts +95 -4
- package/src/tests/baseline.test.ts +29 -121
- package/src/tests/brief-scope.test.ts +250 -0
- package/src/tests/business-summary.test.ts +258 -0
- package/src/tests/compare.test.ts +233 -0
- package/src/tests/fixtures/fake-model-provider.ts +2 -1
- package/src/tests/hunt-maki.test.ts +58 -0
- package/src/tests/markdown.test.ts +3 -3
- package/src/tests/progress.test.ts +29 -1
- package/src/tests/propagation.test.ts +119 -0
- package/src/tests/review-resume.test.ts +60 -1
- package/src/tests/review-scope.test.ts +38 -29
- package/src/tests/review-scripts.test.ts +106 -218
- package/src/tests/server.test.ts +7 -4
- package/src/tests/storage.test.ts +194 -9
- package/src/core/renderers/report-renderer.ts +0 -66
- package/src/skill/example-review.md +0 -66
|
@@ -1,20 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Relatórios JSON dos watchdogs: tipos, validação, normalização e dedupe.
|
|
3
3
|
*
|
|
4
|
-
* Entrypoint: `cli/review.ts` (via `core/orchestrator.ts`).
|
|
5
|
-
*
|
|
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
|
|
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
|
-
/**
|
|
15
|
-
export type
|
|
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 =
|
|
130
|
-
|
|
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
|
-
|
|
214
|
+
continue;
|
|
133
215
|
}
|
|
134
|
-
|
|
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(
|
|
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 =
|
|
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)
|
|
109
|
+
if (version >= SCHEMA_VERSION) {
|
|
110
|
+
db.exec("PRAGMA foreign_keys = ON;");
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
106
113
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
[
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
}
|