@payconductor/houndsman 0.13.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/golden-command.ts +92 -0
- package/src/cli/commands/hunt-command.ts +105 -4
- package/src/cli/commands/review-command.ts +38 -0
- package/src/cli/commands/status-command.ts +3 -2
- package/src/cli/help.ts +6 -1
- package/src/cli/index.ts +5 -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 +278 -16
- package/src/core/builtins.ts +108 -0
- package/src/core/business-summary.ts +583 -0
- package/src/core/commands/compare.ts +266 -0
- package/src/core/commands/context.ts +6 -2
- package/src/core/commands/findings.ts +38 -30
- package/src/core/commands/golden.ts +176 -0
- package/src/core/commands/review.ts +6 -0
- package/src/core/commands/scaffold.ts +29 -0
- package/src/core/commands/status.ts +9 -4
- package/src/core/compare.ts +310 -0
- package/src/core/concurrency.ts +30 -0
- package/src/core/config.ts +41 -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 +371 -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 +22 -2
- package/src/core/github-review.ts +5 -2
- package/src/core/golden.ts +346 -0
- package/src/core/hunt/hunt-runner.ts +621 -34
- package/src/core/hunt/index.ts +40 -1
- package/src/core/hunt/maki-intent-prompt.ts +50 -0
- package/src/core/hunt/maki-prompt.ts +298 -5
- package/src/core/hunt/maki-runner.ts +93 -16
- package/src/core/hunt/pool.ts +4 -26
- package/src/core/hunt/review-checks.ts +460 -0
- 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 +18 -1
- package/src/core/propagation.ts +127 -0
- package/src/core/registry-check.ts +90 -27
- package/src/core/renderers/manifest-writer.ts +13 -0
- package/src/core/renderers/resume-renderer.ts +151 -11
- package/src/core/report-builder.ts +116 -278
- package/src/core/severity.ts +245 -0
- 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 +17 -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 +51 -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 +55 -10
- package/src/skill/README.md +19 -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 +205 -58
- package/src/skill/train.md +3 -0
- package/src/tests/__snapshots__/baseline.test.ts.snap +208 -184
- 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 +256 -0
- package/src/tests/findings-state.test.ts +82 -0
- package/src/tests/fixtures/fake-model-provider.ts +2 -1
- package/src/tests/golden.test.ts +195 -0
- package/src/tests/hunt-maki.test.ts +710 -2
- 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 +125 -218
- package/src/tests/server.test.ts +7 -4
- package/src/tests/severity.test.ts +235 -0
- 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
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Severidade determinística (biblioteca) — a regra manda, não a opinião do watchdog.
|
|
3
|
+
*
|
|
4
|
+
* Finding de regra (`basis: "rule"`) usa a severidade declarada em `Rule.severity`;
|
|
5
|
+
* finding emergente (`Lógica:`/`Domínio:`) usa a tabela fixa de `impactClass`. O valor
|
|
6
|
+
* emitido só prevalece quando não há como resolver (paráfrase sem casamento, relatório
|
|
7
|
+
* legado sem `impactClass`) — fail-open. Toda divergência é contabilizada para o
|
|
8
|
+
* finalize avisar e para o `compare` provar a estabilidade entre runs.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { type Finding, inferBasis, type WatchdogReport } from "./report-builder";
|
|
12
|
+
import { type Rule, type Severity, severityRank } from "./types";
|
|
13
|
+
|
|
14
|
+
/** Classe de impacto de um finding emergente — define a severidade fora de regra. */
|
|
15
|
+
export type ImpactClass = "flow-break" | "contract-drift" | "degradation" | "cosmetic";
|
|
16
|
+
|
|
17
|
+
/** Classes válidas, da mais para a menos grave. */
|
|
18
|
+
export const IMPACT_CLASSES: readonly ImpactClass[] = [
|
|
19
|
+
"flow-break",
|
|
20
|
+
"contract-drift",
|
|
21
|
+
"degradation",
|
|
22
|
+
"cosmetic",
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
/** Tabela fixa `impactClass` → severidade. */
|
|
26
|
+
export const SEVERITY_BY_IMPACT: Record<ImpactClass, Severity> = {
|
|
27
|
+
"flow-break": "blocking",
|
|
28
|
+
"contract-drift": "attention",
|
|
29
|
+
degradation: "suggestion",
|
|
30
|
+
cosmetic: "nit",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const IMPACT_SET = new Set<string>(IMPACT_CLASSES);
|
|
34
|
+
|
|
35
|
+
/** Tamanho mínimo para casar uma regra com o trecho citado pelo watchdog. */
|
|
36
|
+
const MIN_FRAGMENT_CHARS = 24;
|
|
37
|
+
|
|
38
|
+
/** `true` quando `value` é uma classe de impacto conhecida. */
|
|
39
|
+
export function isImpactClass(value: unknown): value is ImpactClass {
|
|
40
|
+
return typeof value === "string" && IMPACT_SET.has(value);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Texto canônico da regra para casar `findings[].rule` com `rules[].text`.
|
|
45
|
+
*
|
|
46
|
+
* O brief imprime `- ❌ **blocking** — Promover: …`; o watchdog às vezes copia a
|
|
47
|
+
* linha inteira, às vezes só o trecho. A normalização remove markdown, emoji,
|
|
48
|
+
* bullet e o prefixo de severidade antes de comparar.
|
|
49
|
+
*/
|
|
50
|
+
export function normalizeRuleText(text: string): string {
|
|
51
|
+
return text
|
|
52
|
+
.normalize("NFC")
|
|
53
|
+
.replace(/[`*_]/g, "")
|
|
54
|
+
.replace(/\p{Extended_Pictographic}|\uFE0F/gu, "")
|
|
55
|
+
.replace(/\s+/g, " ")
|
|
56
|
+
.trim()
|
|
57
|
+
.toLowerCase()
|
|
58
|
+
.replace(/^[-•]\s*/u, "")
|
|
59
|
+
.replace(/^(blocking|attention|suggestion|nit)\s*[—–-]\s*/u, "")
|
|
60
|
+
.replace(/[.:;]+$/u, "")
|
|
61
|
+
.trim();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Regra declarada correspondente a um texto de finding. */
|
|
65
|
+
export type DeclaredSeverity = { rule: Rule; severity: Severity };
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Resolve a regra declarada que corresponde ao texto reportado.
|
|
69
|
+
*
|
|
70
|
+
* Exato primeiro; depois contenção (o modelo citou a regra inteira ou um trecho
|
|
71
|
+
* com pelo menos `MIN_FRAGMENT_CHARS`). Empate entre watchdogs fica com a regra
|
|
72
|
+
* mais grave — determinístico e coerente com o dedupe.
|
|
73
|
+
*/
|
|
74
|
+
export function resolveDeclaredSeverity(
|
|
75
|
+
ruleText: string,
|
|
76
|
+
rules: readonly Rule[],
|
|
77
|
+
): DeclaredSeverity | null {
|
|
78
|
+
const needle = normalizeRuleText(ruleText);
|
|
79
|
+
if (!needle) return null;
|
|
80
|
+
|
|
81
|
+
const candidates = rules.map((rule) => ({
|
|
82
|
+
rule,
|
|
83
|
+
text: normalizeRuleText(rule.text),
|
|
84
|
+
}));
|
|
85
|
+
const exact = candidates.filter((item) => item.text === needle);
|
|
86
|
+
const matches =
|
|
87
|
+
exact.length > 0
|
|
88
|
+
? exact
|
|
89
|
+
: candidates.filter((item) => isFragment(needle, item.text));
|
|
90
|
+
if (matches.length === 0) return null;
|
|
91
|
+
|
|
92
|
+
let best = matches[0].rule;
|
|
93
|
+
for (const item of matches) {
|
|
94
|
+
if (severityRank(item.rule.severity) < severityRank(best.severity)) {
|
|
95
|
+
best = item.rule;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return { rule: best, severity: best.severity };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Contenção nos dois sentidos, exigindo fragmento longo o bastante. */
|
|
102
|
+
function isFragment(a: string, b: string): boolean {
|
|
103
|
+
if (a.length < MIN_FRAGMENT_CHARS || b.length < MIN_FRAGMENT_CHARS) return false;
|
|
104
|
+
return a.includes(b) || b.includes(a);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** De onde veio a severidade aplicada. */
|
|
108
|
+
export type SeveritySource = "rule" | "impact";
|
|
109
|
+
|
|
110
|
+
/** Divergência entre o valor emitido e o resolvido (para aviso no finalize). */
|
|
111
|
+
export type SeverityDivergence = {
|
|
112
|
+
watchdogId: string;
|
|
113
|
+
file: string;
|
|
114
|
+
rule: string;
|
|
115
|
+
emitted: Severity;
|
|
116
|
+
resolved: Severity;
|
|
117
|
+
source: SeveritySource;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/** Resultado da ancoragem sobre os relatórios de uma run. */
|
|
121
|
+
export type SeverityAnchoring = {
|
|
122
|
+
reports: WatchdogReport[];
|
|
123
|
+
/** Findings de regra cujo texto casou com uma regra declarada. */
|
|
124
|
+
anchored: number;
|
|
125
|
+
/** Findings emergentes com `impactClass` válido. */
|
|
126
|
+
classified: number;
|
|
127
|
+
/** Findings de regra sem casamento (severidade emitida mantida). */
|
|
128
|
+
unmatched: number;
|
|
129
|
+
diverged: SeverityDivergence[];
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
type Resolution = { severity: Severity; source: SeveritySource };
|
|
133
|
+
|
|
134
|
+
/** Severidade declarada pelo motor (null = manter a emitida). */
|
|
135
|
+
function resolveSeverity(finding: Finding, rules: readonly Rule[]): Resolution | null {
|
|
136
|
+
if ((finding.basis ?? inferBasis(finding.rule)) !== "rule") {
|
|
137
|
+
const impact = finding.impactClass;
|
|
138
|
+
if (!isImpactClass(impact)) return null;
|
|
139
|
+
return { severity: SEVERITY_BY_IMPACT[impact], source: "impact" };
|
|
140
|
+
}
|
|
141
|
+
const declared = resolveDeclaredSeverity(finding.rule, rules);
|
|
142
|
+
return declared ? { severity: declared.severity, source: "rule" } : null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Gate de prova: fora de regra, `blocking` exige gatilho + evidência.
|
|
147
|
+
*
|
|
148
|
+
* `normalizeFinding` já aplica isso na leitura do relatório; a checagem aqui
|
|
149
|
+
* cobre chamadas com findings construídos em memória (harness/testes).
|
|
150
|
+
*/
|
|
151
|
+
function provenSeverity(finding: Finding, resolution: Resolution): Severity {
|
|
152
|
+
if (resolution.source !== "impact" || resolution.severity !== "blocking") {
|
|
153
|
+
return resolution.severity;
|
|
154
|
+
}
|
|
155
|
+
const proven = Boolean(finding.scenario?.trim() && finding.evidence?.trim());
|
|
156
|
+
return proven ? "blocking" : "attention";
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Ancora a severidade de todos os relatórios (função pura — devolve novos objetos).
|
|
161
|
+
* `rules` é a união das regras de todos os watchdogs ativos (inclui o piso do `general`).
|
|
162
|
+
*/
|
|
163
|
+
export function anchorReports(
|
|
164
|
+
reports: readonly WatchdogReport[],
|
|
165
|
+
rules: readonly Rule[],
|
|
166
|
+
): SeverityAnchoring {
|
|
167
|
+
const anchoring: SeverityAnchoring = {
|
|
168
|
+
reports: [],
|
|
169
|
+
anchored: 0,
|
|
170
|
+
classified: 0,
|
|
171
|
+
unmatched: 0,
|
|
172
|
+
diverged: [],
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
for (const report of reports) {
|
|
176
|
+
const findings = report.findings.map((finding) => {
|
|
177
|
+
const resolution = resolveSeverity(finding, rules);
|
|
178
|
+
if (!resolution) {
|
|
179
|
+
if ((finding.basis ?? inferBasis(finding.rule)) === "rule") {
|
|
180
|
+
anchoring.unmatched++;
|
|
181
|
+
}
|
|
182
|
+
return finding;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (resolution.source === "rule") anchoring.anchored++;
|
|
186
|
+
else anchoring.classified++;
|
|
187
|
+
|
|
188
|
+
const severity = provenSeverity(finding, resolution);
|
|
189
|
+
if (severity === finding.severity) return finding;
|
|
190
|
+
|
|
191
|
+
anchoring.diverged.push({
|
|
192
|
+
watchdogId: report.watchdogId,
|
|
193
|
+
file: finding.file,
|
|
194
|
+
rule: finding.rule,
|
|
195
|
+
emitted: finding.severity,
|
|
196
|
+
resolved: severity,
|
|
197
|
+
source: resolution.source,
|
|
198
|
+
});
|
|
199
|
+
return { ...finding, severity };
|
|
200
|
+
});
|
|
201
|
+
anchoring.reports.push({ ...report, findings });
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return anchoring;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Texto curto do finding nas mensagens do finalize. */
|
|
208
|
+
function describe(divergence: SeverityDivergence, max = 72): string {
|
|
209
|
+
const text =
|
|
210
|
+
divergence.rule.length <= max
|
|
211
|
+
? divergence.rule
|
|
212
|
+
: `${divergence.rule.slice(0, max - 1).trimEnd()}…`;
|
|
213
|
+
return `· ${divergence.watchdogId} \`${divergence.file}\` — ${divergence.emitted} → ${divergence.resolved} (${divergence.source === "rule" ? "regra" : "impacto"}): ${text}`;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Mensagens de resumo da ancoragem (vazias quando nada foi resolvido nem corrigido).
|
|
218
|
+
* `limit` controla quantos exemplos de divergência aparecem.
|
|
219
|
+
*/
|
|
220
|
+
export function severityAnchoringMessages(
|
|
221
|
+
anchoring: SeverityAnchoring,
|
|
222
|
+
limit = 3,
|
|
223
|
+
): string[] {
|
|
224
|
+
const messages: string[] = [];
|
|
225
|
+
if (anchoring.anchored > 0 || anchoring.classified > 0) {
|
|
226
|
+
const parts = [
|
|
227
|
+
anchoring.anchored > 0 ? `${anchoring.anchored} pela regra` : "",
|
|
228
|
+
anchoring.classified > 0 ? `${anchoring.classified} pelo impacto` : "",
|
|
229
|
+
].filter(Boolean);
|
|
230
|
+
const corrected =
|
|
231
|
+
anchoring.diverged.length > 0
|
|
232
|
+
? ` — ${anchoring.diverged.length} divergência(s) corrigida(s).`
|
|
233
|
+
: ".";
|
|
234
|
+
messages.push(`Severidade: ${parts.join(", ")}${corrected}`);
|
|
235
|
+
}
|
|
236
|
+
for (const divergence of anchoring.diverged.slice(0, limit)) {
|
|
237
|
+
messages.push(describe(divergence));
|
|
238
|
+
}
|
|
239
|
+
if (anchoring.unmatched > 0) {
|
|
240
|
+
messages.push(
|
|
241
|
+
`Severidade: ${anchoring.unmatched} finding(s) de regra sem casamento com as regras declaradas (valor emitido mantido).`,
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
return messages;
|
|
245
|
+
}
|
|
@@ -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
|
}
|
|
@@ -69,7 +69,7 @@ export class SQLiteStorageProvider implements StorageProvider {
|
|
|
69
69
|
// -----------------------------------------------------------------------
|
|
70
70
|
|
|
71
71
|
getOrCreateReview(input: CreateReviewInput): ReviewRecord {
|
|
72
|
-
const existing = this.findReview(input.repo.key, input.branch);
|
|
72
|
+
const existing = this.findReview(input.repo.key, input.branch, input.variant);
|
|
73
73
|
if (existing) {
|
|
74
74
|
// O repo pode ter sido movido; a identidade continua a mesma.
|
|
75
75
|
const moved =
|
|
@@ -102,6 +102,7 @@ export class SQLiteStorageProvider implements StorageProvider {
|
|
|
102
102
|
repoRoot: input.repo.root,
|
|
103
103
|
repoRemote: input.repo.remote,
|
|
104
104
|
branch: input.branch,
|
|
105
|
+
variant: input.variant?.trim() || undefined,
|
|
105
106
|
slug: input.slug,
|
|
106
107
|
base: input.base,
|
|
107
108
|
head: input.head,
|
|
@@ -113,15 +114,16 @@ export class SQLiteStorageProvider implements StorageProvider {
|
|
|
113
114
|
};
|
|
114
115
|
this.db.run(
|
|
115
116
|
`INSERT INTO reviews
|
|
116
|
-
(id, repo_key, repo_root, repo_remote, branch, slug, base, head,
|
|
117
|
+
(id, repo_key, repo_root, repo_remote, branch, variant, slug, base, head,
|
|
117
118
|
worktree_path, pr_number, created_at, updated_at)
|
|
118
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
119
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
119
120
|
[
|
|
120
121
|
record.id,
|
|
121
122
|
record.repoKey,
|
|
122
123
|
record.repoRoot,
|
|
123
124
|
record.repoRemote ?? null,
|
|
124
125
|
record.branch,
|
|
126
|
+
record.variant ?? "",
|
|
125
127
|
record.slug,
|
|
126
128
|
record.base ?? null,
|
|
127
129
|
record.head ?? null,
|
|
@@ -134,10 +136,12 @@ export class SQLiteStorageProvider implements StorageProvider {
|
|
|
134
136
|
return record;
|
|
135
137
|
}
|
|
136
138
|
|
|
137
|
-
findReview(repoKey: string, branch: string): ReviewRecord | null {
|
|
139
|
+
findReview(repoKey: string, branch: string, variant?: string): ReviewRecord | null {
|
|
138
140
|
const row = this.db
|
|
139
|
-
.query(
|
|
140
|
-
|
|
141
|
+
.query(
|
|
142
|
+
"SELECT * FROM reviews WHERE repo_key = ? AND branch = ? AND variant = ?",
|
|
143
|
+
)
|
|
144
|
+
.get(repoKey, branch, variant?.trim() ?? "") as ReviewRow | null;
|
|
141
145
|
return row ? reviewFromRow(row) : null;
|
|
142
146
|
}
|
|
143
147
|
|
|
@@ -253,9 +257,10 @@ export class SQLiteStorageProvider implements StorageProvider {
|
|
|
253
257
|
const upsert = this.db.query(
|
|
254
258
|
`INSERT INTO findings
|
|
255
259
|
(id, review_id, short_id, fingerprint, watchdog_ids, rule, severity,
|
|
256
|
-
file, line, message, action, evidence,
|
|
257
|
-
present, decision, comment, exec_state, github,
|
|
258
|
-
|
|
260
|
+
file, line, message, action, evidence, scenario, impact, confidence, basis,
|
|
261
|
+
anchor, found_at_sha, count, present, decision, comment, exec_state, github,
|
|
262
|
+
created_at, updated_at)
|
|
263
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
259
264
|
ON CONFLICT(id) DO UPDATE SET
|
|
260
265
|
short_id = excluded.short_id,
|
|
261
266
|
fingerprint = excluded.fingerprint,
|
|
@@ -267,6 +272,10 @@ export class SQLiteStorageProvider implements StorageProvider {
|
|
|
267
272
|
message = excluded.message,
|
|
268
273
|
action = excluded.action,
|
|
269
274
|
evidence = excluded.evidence,
|
|
275
|
+
scenario = excluded.scenario,
|
|
276
|
+
impact = excluded.impact,
|
|
277
|
+
confidence = excluded.confidence,
|
|
278
|
+
basis = excluded.basis,
|
|
270
279
|
anchor = excluded.anchor,
|
|
271
280
|
found_at_sha = excluded.found_at_sha,
|
|
272
281
|
count = excluded.count,
|
|
@@ -325,6 +334,10 @@ export class SQLiteStorageProvider implements StorageProvider {
|
|
|
325
334
|
finding.message,
|
|
326
335
|
finding.action,
|
|
327
336
|
finding.evidence ?? null,
|
|
337
|
+
finding.scenario ?? null,
|
|
338
|
+
finding.impact ?? null,
|
|
339
|
+
finding.confidence ?? null,
|
|
340
|
+
finding.basis ?? null,
|
|
328
341
|
finding.anchor ? JSON.stringify(finding.anchor) : null,
|
|
329
342
|
finding.foundAtSha ?? null,
|
|
330
343
|
finding.count,
|
|
@@ -28,6 +28,13 @@ export type ReviewRecord = {
|
|
|
28
28
|
repoRoot: string;
|
|
29
29
|
repoRemote?: string;
|
|
30
30
|
branch: string;
|
|
31
|
+
/**
|
|
32
|
+
* Rótulo da variante A/B (ausente = run principal da branch).
|
|
33
|
+
*
|
|
34
|
+
* Faz parte da identidade junto com `(repoKey, branch)`: permite rodar a
|
|
35
|
+
* mesma PR com dois experimentos sem apagar a revisão anterior.
|
|
36
|
+
*/
|
|
37
|
+
variant?: string;
|
|
31
38
|
slug: string;
|
|
32
39
|
base?: string;
|
|
33
40
|
head?: string;
|
|
@@ -81,6 +88,8 @@ export type CreateReviewInput = {
|
|
|
81
88
|
repo: RepoIdentity;
|
|
82
89
|
branch: string;
|
|
83
90
|
slug: string;
|
|
91
|
+
/** Variante A/B (ausente = run principal da branch). */
|
|
92
|
+
variant?: string;
|
|
84
93
|
base?: string;
|
|
85
94
|
head?: string;
|
|
86
95
|
/** Worktree onde a revisão roda (ausente = clone principal). */
|
|
@@ -99,11 +108,11 @@ export type ArtifactKind =
|
|
|
99
108
|
| "context"
|
|
100
109
|
| "context-brief"
|
|
101
110
|
| "context-summary"
|
|
111
|
+
| "business"
|
|
112
|
+
| "business-brief"
|
|
102
113
|
| "finding-report"
|
|
103
|
-
| "review"
|
|
104
114
|
| "resume"
|
|
105
115
|
| "technical"
|
|
106
|
-
| "domain-review"
|
|
107
116
|
| "verify"
|
|
108
117
|
| "ast"
|
|
109
118
|
| "ast-impact"
|
|
@@ -111,6 +120,9 @@ export type ArtifactKind =
|
|
|
111
120
|
| "ai-usage"
|
|
112
121
|
| "ai-brief"
|
|
113
122
|
| "hunt"
|
|
123
|
+
| "prepare"
|
|
124
|
+
| "compare"
|
|
125
|
+
| "golden"
|
|
114
126
|
| "other";
|
|
115
127
|
|
|
116
128
|
/** Artefato persistido de uma review. */
|
|
@@ -142,9 +154,10 @@ export type WriteArtifactInput = {
|
|
|
142
154
|
export interface StorageProvider {
|
|
143
155
|
readonly kind: "sqlite" | "file";
|
|
144
156
|
|
|
145
|
-
/**
|
|
157
|
+
/** Busca por identidade `(repo, branch, variante)`; variante ausente = principal. */
|
|
158
|
+
findReview(repoKey: string, branch: string, variant?: string): ReviewRecord | null;
|
|
159
|
+
/** Run da branch + variante (cria quando não existe). */
|
|
146
160
|
getOrCreateReview(input: CreateReviewInput): ReviewRecord;
|
|
147
|
-
findReview(repoKey: string, branch: string): ReviewRecord | null;
|
|
148
161
|
getReview(id: string): ReviewRecord | null;
|
|
149
162
|
/** Reviews do repositório (mais recentes primeiro); sem chave = todas. */
|
|
150
163
|
listReviews(repoKey?: string): ReviewRecord[];
|