@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
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Comparação A/B de duas revisões da mesma branch (funções puras).
|
|
3
|
+
*
|
|
4
|
+
* O objetivo é responder "o que mudou entre as duas execuções?" sem tentar
|
|
5
|
+
* eleger um vencedor: conta achados por severidade, cobertura, tempo e tokens,
|
|
6
|
+
* e lista o que apareceu/sumiu entre A e B.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { SEVERITY_ORDER, type Severity } from "./types";
|
|
10
|
+
|
|
11
|
+
/** Finding normalizado para a comparação. */
|
|
12
|
+
export type ComparisonFinding = {
|
|
13
|
+
fingerprint: string;
|
|
14
|
+
rule: string;
|
|
15
|
+
severity: Severity;
|
|
16
|
+
file: string;
|
|
17
|
+
line?: number;
|
|
18
|
+
message: string;
|
|
19
|
+
watchdogs: readonly string[];
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Snapshot de uma execução (uma das pontas do A/B). */
|
|
23
|
+
export type ReviewSnapshot = {
|
|
24
|
+
/** Rótulo exibido (`A`, `B` ou a variante). */
|
|
25
|
+
label: string;
|
|
26
|
+
slug: string;
|
|
27
|
+
branch: string;
|
|
28
|
+
variant?: string;
|
|
29
|
+
headSha?: string;
|
|
30
|
+
model?: string;
|
|
31
|
+
/** Achados presentes no último relatório. */
|
|
32
|
+
findings: readonly ComparisonFinding[];
|
|
33
|
+
bySeverity: Record<Severity, number>;
|
|
34
|
+
coverage: { watchdogs: number; files: number; overflow: number };
|
|
35
|
+
/** Duração do ciclo (`startedAt` → `reviewedAt`), quando medida. */
|
|
36
|
+
durationMs?: number;
|
|
37
|
+
/** Duração do prepare (`prepare-report.json`). */
|
|
38
|
+
prepareMs?: number;
|
|
39
|
+
/** Duração do harness maki (`hunt-report.json`). */
|
|
40
|
+
huntMs?: number;
|
|
41
|
+
tokens?: number;
|
|
42
|
+
costUsd?: number;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** Resultado da comparação. */
|
|
46
|
+
export type ReviewComparison = {
|
|
47
|
+
branch: string;
|
|
48
|
+
headSha?: string;
|
|
49
|
+
a: ReviewSnapshot;
|
|
50
|
+
b: ReviewSnapshot;
|
|
51
|
+
onlyA: readonly ComparisonFinding[];
|
|
52
|
+
onlyB: readonly ComparisonFinding[];
|
|
53
|
+
common: readonly { a: ComparisonFinding; b: ComparisonFinding }[];
|
|
54
|
+
severityChanged: readonly { a: ComparisonFinding; b: ComparisonFinding }[];
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** Conta os achados por severidade. */
|
|
58
|
+
export function countBySeverity(
|
|
59
|
+
findings: readonly ComparisonFinding[],
|
|
60
|
+
): Record<Severity, number> {
|
|
61
|
+
const counts: Record<Severity, number> = {
|
|
62
|
+
blocking: 0,
|
|
63
|
+
attention: 0,
|
|
64
|
+
suggestion: 0,
|
|
65
|
+
nit: 0,
|
|
66
|
+
};
|
|
67
|
+
for (const finding of findings) counts[finding.severity]++;
|
|
68
|
+
return counts;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Compara dois snapshots (ordem estável por severidade e caminho). */
|
|
72
|
+
export function buildComparison(a: ReviewSnapshot, b: ReviewSnapshot): ReviewComparison {
|
|
73
|
+
const aByKey = new Map(a.findings.map((finding) => [finding.fingerprint, finding]));
|
|
74
|
+
const bByKey = new Map(b.findings.map((finding) => [finding.fingerprint, finding]));
|
|
75
|
+
const onlyA = a.findings.filter((finding) => !bByKey.has(finding.fingerprint));
|
|
76
|
+
const onlyB = b.findings.filter((finding) => !aByKey.has(finding.fingerprint));
|
|
77
|
+
const common: { a: ComparisonFinding; b: ComparisonFinding }[] = [];
|
|
78
|
+
const severityChanged: { a: ComparisonFinding; b: ComparisonFinding }[] = [];
|
|
79
|
+
|
|
80
|
+
for (const finding of a.findings) {
|
|
81
|
+
const other = bByKey.get(finding.fingerprint);
|
|
82
|
+
if (!other) continue;
|
|
83
|
+
common.push({ a: finding, b: other });
|
|
84
|
+
if (finding.severity !== other.severity) {
|
|
85
|
+
severityChanged.push({ a: finding, b: other });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const sortFindings = (items: ComparisonFinding[]): ComparisonFinding[] =>
|
|
90
|
+
[...items].sort(
|
|
91
|
+
(x, y) =>
|
|
92
|
+
SEVERITY_ORDER.indexOf(x.severity) - SEVERITY_ORDER.indexOf(y.severity) ||
|
|
93
|
+
x.file.localeCompare(y.file) ||
|
|
94
|
+
(x.line ?? 0) - (y.line ?? 0) ||
|
|
95
|
+
x.rule.localeCompare(y.rule),
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
branch: a.branch,
|
|
100
|
+
headSha: a.headSha ?? b.headSha,
|
|
101
|
+
a,
|
|
102
|
+
b,
|
|
103
|
+
onlyA: sortFindings(onlyA),
|
|
104
|
+
onlyB: sortFindings(onlyB),
|
|
105
|
+
common,
|
|
106
|
+
severityChanged: severityChanged.sort(
|
|
107
|
+
(x, y) =>
|
|
108
|
+
SEVERITY_ORDER.indexOf(x.a.severity) -
|
|
109
|
+
SEVERITY_ORDER.indexOf(y.a.severity) ||
|
|
110
|
+
x.a.file.localeCompare(y.a.file),
|
|
111
|
+
),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Duração legível (`820ms`, `12.3s`, `2m05s`). */
|
|
116
|
+
function formatMs(ms: number | undefined): string {
|
|
117
|
+
if (ms === undefined || !Number.isFinite(ms)) return "—";
|
|
118
|
+
const safe = Math.max(0, Math.round(ms));
|
|
119
|
+
if (safe < 1_000) return `${safe}ms`;
|
|
120
|
+
const seconds = safe / 1_000;
|
|
121
|
+
if (seconds < 60) return `${seconds.toFixed(1)}s`;
|
|
122
|
+
const minutes = Math.floor(seconds / 60);
|
|
123
|
+
return `${minutes}m${String(Math.round(seconds % 60)).padStart(2, "0")}s`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function formatDelta(
|
|
127
|
+
a: number | undefined,
|
|
128
|
+
b: number | undefined,
|
|
129
|
+
format: (value: number) => string,
|
|
130
|
+
): string {
|
|
131
|
+
if (a === undefined || b === undefined) return "—";
|
|
132
|
+
const delta = b - a;
|
|
133
|
+
return `${delta > 0 ? "+" : ""}${format(delta)}`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Linha da tabela de métricas. */
|
|
137
|
+
function metricRow(label: string, a: string, b: string, delta = "—"): string {
|
|
138
|
+
return `| ${label} | ${a} | ${b} | ${delta} |`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Lista compacta de um conjunto de achados. */
|
|
142
|
+
function findingList(title: string, findings: readonly ComparisonFinding[]): string[] {
|
|
143
|
+
if (findings.length === 0) return [`## ${title}`, "", "_Nenhum._", ""];
|
|
144
|
+
const shown = findings.slice(0, 20);
|
|
145
|
+
const lines = [`## ${title} (${findings.length})`, ""];
|
|
146
|
+
for (const finding of shown) {
|
|
147
|
+
const line = finding.line !== undefined ? `:${finding.line}` : "";
|
|
148
|
+
lines.push(
|
|
149
|
+
`- **${finding.severity}** · \`${finding.file}${line}\` — ${finding.message}`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
if (findings.length > shown.length) {
|
|
153
|
+
lines.push(`- … +${findings.length - shown.length} achado(s)`);
|
|
154
|
+
}
|
|
155
|
+
lines.push("");
|
|
156
|
+
return lines;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Renderiza a comparação em Markdown (artefato e stdout). */
|
|
160
|
+
export function renderComparison(comparison: ReviewComparison): string {
|
|
161
|
+
const { a, b } = comparison;
|
|
162
|
+
const severityDelta = SEVERITY_ORDER.map((severity) =>
|
|
163
|
+
metricRow(
|
|
164
|
+
severity,
|
|
165
|
+
String(a.bySeverity[severity]),
|
|
166
|
+
String(b.bySeverity[severity]),
|
|
167
|
+
formatDelta(a.bySeverity[severity], b.bySeverity[severity], (value) =>
|
|
168
|
+
String(value),
|
|
169
|
+
),
|
|
170
|
+
),
|
|
171
|
+
);
|
|
172
|
+
const totalA = a.findings.length;
|
|
173
|
+
const totalB = b.findings.length;
|
|
174
|
+
const lines = [
|
|
175
|
+
`# Comparação A/B — \`${comparison.branch}\``,
|
|
176
|
+
"",
|
|
177
|
+
`**A:** ${labelOf(a)} · **B:** ${labelOf(b)}`,
|
|
178
|
+
...(comparison.headSha
|
|
179
|
+
? [`**Commit:** \`${comparison.headSha.slice(0, 7)}\``]
|
|
180
|
+
: []),
|
|
181
|
+
"",
|
|
182
|
+
"> Compara o que apareceu/sumiu entre as execuções. Não é veredito: confirme",
|
|
183
|
+
"> cada achado novo no código antes de considerá-lo ganho ou perda.",
|
|
184
|
+
"",
|
|
185
|
+
"## Métricas",
|
|
186
|
+
"",
|
|
187
|
+
`| Métrica | A | B | Δ |`,
|
|
188
|
+
`| --- | --- | --- | --- |`,
|
|
189
|
+
metricRow(
|
|
190
|
+
"Findings (presentes)",
|
|
191
|
+
String(totalA),
|
|
192
|
+
String(totalB),
|
|
193
|
+
formatDelta(totalA, totalB, (value) => String(value)),
|
|
194
|
+
),
|
|
195
|
+
...severityDelta,
|
|
196
|
+
metricRow(
|
|
197
|
+
"Cobertura (arquivos)",
|
|
198
|
+
String(a.coverage.files),
|
|
199
|
+
String(b.coverage.files),
|
|
200
|
+
),
|
|
201
|
+
metricRow(
|
|
202
|
+
"Watchdogs",
|
|
203
|
+
String(a.coverage.watchdogs),
|
|
204
|
+
String(b.coverage.watchdogs),
|
|
205
|
+
),
|
|
206
|
+
metricRow("Duração total", formatMs(a.durationMs), formatMs(b.durationMs)),
|
|
207
|
+
metricRow("Prepare", formatMs(a.prepareMs), formatMs(b.prepareMs)),
|
|
208
|
+
metricRow("Harness", formatMs(a.huntMs), formatMs(b.huntMs)),
|
|
209
|
+
metricRow(
|
|
210
|
+
"Tokens",
|
|
211
|
+
a.tokens !== undefined ? String(a.tokens) : "—",
|
|
212
|
+
b.tokens !== undefined ? String(b.tokens) : "—",
|
|
213
|
+
),
|
|
214
|
+
metricRow(
|
|
215
|
+
"Custo",
|
|
216
|
+
a.costUsd !== undefined ? `$${a.costUsd.toFixed(4)}` : "—",
|
|
217
|
+
b.costUsd !== undefined ? `$${b.costUsd.toFixed(4)}` : "—",
|
|
218
|
+
),
|
|
219
|
+
"",
|
|
220
|
+
...findingList("Só em B (novos)", comparison.onlyB),
|
|
221
|
+
...findingList("Só em A (não apareceram em B)", comparison.onlyA),
|
|
222
|
+
];
|
|
223
|
+
if (comparison.severityChanged.length > 0) {
|
|
224
|
+
lines.push(`## Severidade mudou (${comparison.severityChanged.length})`, "");
|
|
225
|
+
for (const item of comparison.severityChanged.slice(0, 20)) {
|
|
226
|
+
lines.push(
|
|
227
|
+
`- \`${item.a.file}\` — ${item.a.rule}: **${item.a.severity} → ${item.b.severity}**`,
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
lines.push("");
|
|
231
|
+
}
|
|
232
|
+
return `${lines.join("\n").trimEnd()}\n`;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** JSON estável da comparação (artefato para automação). */
|
|
236
|
+
export function comparisonToJson(comparison: ReviewComparison): string {
|
|
237
|
+
return `${JSON.stringify(comparison, null, 2)}\n`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function labelOf(snapshot: ReviewSnapshot): string {
|
|
241
|
+
const variant = snapshot.variant ? ` (variante \`${snapshot.variant}\`)` : "";
|
|
242
|
+
return `\`${snapshot.slug}\`${variant} · ${snapshot.findings.length} finding(s)`;
|
|
243
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pool de concorrência mínimo (biblioteca).
|
|
3
|
+
*
|
|
4
|
+
* Mantém no máximo `limit` promessas ativas e preserva a ordem dos resultados.
|
|
5
|
+
* Não trata erros: o chamador decide se cada item falha isoladamente.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Executa `mapper` para cada item com concorrência limitada. */
|
|
9
|
+
export async function mapPool<T, R>(
|
|
10
|
+
items: readonly T[],
|
|
11
|
+
limit: number,
|
|
12
|
+
mapper: (item: T, index: number) => Promise<R>,
|
|
13
|
+
): Promise<R[]> {
|
|
14
|
+
const results = new Array<R>(items.length);
|
|
15
|
+
if (items.length === 0) return results;
|
|
16
|
+
|
|
17
|
+
let next = 0;
|
|
18
|
+
const workers = Array.from(
|
|
19
|
+
{ length: Math.max(1, Math.min(limit, items.length)) },
|
|
20
|
+
async () => {
|
|
21
|
+
while (true) {
|
|
22
|
+
const index = next++;
|
|
23
|
+
if (index >= items.length) return;
|
|
24
|
+
results[index] = await mapper(items[index], index);
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
);
|
|
28
|
+
await Promise.all(workers);
|
|
29
|
+
return results;
|
|
30
|
+
}
|
package/src/core/config.ts
CHANGED
|
@@ -72,6 +72,8 @@ export type AiProviderOptions = Record<string, Record<string, unknown>>;
|
|
|
72
72
|
export type HoundsmanAiFeatures = {
|
|
73
73
|
/** Resume o contexto da PR (`context-summary`) quando o subagente não gerou. */
|
|
74
74
|
context?: boolean;
|
|
75
|
+
/** Reconstrói a intenção da PR (`business-summary`) antes dos watchdogs. */
|
|
76
|
+
business?: boolean;
|
|
75
77
|
/** Enriquece os briefs com um plano de revisão sugerido. */
|
|
76
78
|
brief?: boolean;
|
|
77
79
|
/** Gera os pontos técnicos estruturais do resumo final. */
|
|
@@ -99,7 +101,7 @@ export type HoundsmanAi = {
|
|
|
99
101
|
model?: string;
|
|
100
102
|
/** Base URL customizada do provider. */
|
|
101
103
|
baseURL?: string;
|
|
102
|
-
/** Chamadas simultâneas (default:
|
|
104
|
+
/** Chamadas simultâneas (default: 4). */
|
|
103
105
|
concurrency?: number;
|
|
104
106
|
/** Opções do AI SDK por provider (repassadas sem interpretação). */
|
|
105
107
|
providerOptions?: AiProviderOptions;
|
|
@@ -130,7 +132,7 @@ export type HoundsmanWorktrees = {
|
|
|
130
132
|
* Harness local da capability `hunt` (`houndsman hunt`) — opcional.
|
|
131
133
|
*
|
|
132
134
|
* Sem esta config, o comando usa defaults seguros: modelo derivado de `ai`,
|
|
133
|
-
*
|
|
135
|
+
* 8 processos simultâneos (uma onda) e timeout de 15 min por watchdog. A skill
|
|
134
136
|
* `/houndsman hunt` (harness externo) não depende deste bloco.
|
|
135
137
|
*/
|
|
136
138
|
export type HoundsmanHunt = {
|
|
@@ -138,7 +140,7 @@ export type HoundsmanHunt = {
|
|
|
138
140
|
enabled?: boolean;
|
|
139
141
|
/** Modelo no formato do maki (`provider/model-id`). Default: derivado de `ai`. */
|
|
140
142
|
model?: string;
|
|
141
|
-
/** Processos maki simultâneos (default:
|
|
143
|
+
/** Processos maki simultâneos (default: 8). */
|
|
142
144
|
concurrency?: number;
|
|
143
145
|
/** Timeout por watchdog em ms (default: 900_000). */
|
|
144
146
|
timeoutMs?: number;
|
|
@@ -162,6 +164,27 @@ export type HoundsmanStorage = {
|
|
|
162
164
|
path?: string;
|
|
163
165
|
};
|
|
164
166
|
|
|
167
|
+
/**
|
|
168
|
+
* Regra de prioridade por caminho (ordenada; a primeira que casar vence).
|
|
169
|
+
*
|
|
170
|
+
* O risco do arquivo é `min(churn, maxRiskFromChurn) + bonus`. O `bonus` promove
|
|
171
|
+
* caminhos críticos (em linhas de churn equivalentes) e o `maxRiskFromChurn`
|
|
172
|
+
* satura o superchurn de caminhos de baixa criticidade: um frontend que
|
|
173
|
+
* reformatou 2.000 linhas não escala além do teto, mas um ajuste pequeno no
|
|
174
|
+
* mesmo caminho mantém o peso natural.
|
|
175
|
+
*/
|
|
176
|
+
export type HoundsmanPriority = {
|
|
177
|
+
/** Padrão sobre o caminho relativo (ex.: `/^server\\/src\\/db\\//`). */
|
|
178
|
+
pattern: RegExp;
|
|
179
|
+
/** Peso em linhas de churn equivalentes (≥ 0; default: 0). */
|
|
180
|
+
bonus?: number;
|
|
181
|
+
/**
|
|
182
|
+
* Teto do churn considerado no risco (em linhas equivalentes). Acima dele os
|
|
183
|
+
* arquivos do caminho empatam e só o `bonus` diferencia. Default: sem teto.
|
|
184
|
+
*/
|
|
185
|
+
maxRiskFromChurn?: number;
|
|
186
|
+
};
|
|
187
|
+
|
|
165
188
|
/**
|
|
166
189
|
* Configuração completa do motor de revisão para um projeto.
|
|
167
190
|
*
|
|
@@ -185,6 +208,11 @@ export type HoundsmanConfig<TId extends string = string> = {
|
|
|
185
208
|
exclusions: readonly RegExp[];
|
|
186
209
|
/** Mapeamentos path → documentação relevante. */
|
|
187
210
|
docMappings: readonly (readonly [RegExp, readonly string[]])[];
|
|
211
|
+
/**
|
|
212
|
+
* Prioridade por caminho (opcional). Ordena arquivos no orçamento, no rateio
|
|
213
|
+
* do diff e no recorte do `general` — a primeira regra que casar vence.
|
|
214
|
+
*/
|
|
215
|
+
priorities?: readonly HoundsmanPriority[];
|
|
188
216
|
/** Docs sempre incluídos nos briefs (default: `DEFAULT_ALWAYS_INCLUDE_DOCS`). */
|
|
189
217
|
alwaysIncludeDocs?: readonly string[];
|
|
190
218
|
/** Limites de triage para PRs grandes (default: `DEFAULT_MAX_FILES_PER_BRIEF`). */
|
package/src/core/defaults.ts
CHANGED
|
@@ -38,8 +38,13 @@ export const DEFAULT_AI_PROVIDER = "deepseek" as const;
|
|
|
38
38
|
/** Modelo default da camada de IA. */
|
|
39
39
|
export const DEFAULT_AI_MODEL = "deepseek-v4-pro";
|
|
40
40
|
|
|
41
|
-
/**
|
|
42
|
-
|
|
41
|
+
/**
|
|
42
|
+
* Concorrência default das chamadas de IA do prepare.
|
|
43
|
+
*
|
|
44
|
+
* Vale principalmente para os planos de brief (1 chamada por watchdog): com 4,
|
|
45
|
+
* 8 watchdogs fecham em 2 rodadas em vez de 4.
|
|
46
|
+
*/
|
|
47
|
+
export const DEFAULT_AI_CONCURRENCY = 4;
|
|
43
48
|
|
|
44
49
|
/** Timeout default de uma chamada de modelo (ms). */
|
|
45
50
|
export const DEFAULT_AI_TIMEOUT_MS = 120_000;
|
|
@@ -47,12 +52,53 @@ export const DEFAULT_AI_TIMEOUT_MS = 120_000;
|
|
|
47
52
|
/** Teto default de chamadas de IA por review. */
|
|
48
53
|
export const DEFAULT_AI_MAX_CALLS_PER_REVIEW = 40;
|
|
49
54
|
|
|
50
|
-
/**
|
|
51
|
-
|
|
55
|
+
/**
|
|
56
|
+
* Processos maki simultâneos no harness local (`houndsman hunt`).
|
|
57
|
+
*
|
|
58
|
+
* 8 = uma onda para os watchdogs usuais (domínios + `general`), em vez de 2.
|
|
59
|
+
*/
|
|
60
|
+
export const DEFAULT_HUNT_CONCURRENCY = 8;
|
|
52
61
|
|
|
53
62
|
/** Timeout default de um watchdog no harness local (15 min). */
|
|
54
63
|
export const DEFAULT_HUNT_TIMEOUT_MS = 900_000;
|
|
55
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Trechos alterados injetados no brief (watchdog de domínio).
|
|
67
|
+
*
|
|
68
|
+
* Substituem a leitura do arquivo inteiro: o subagente começa pelo diff e só
|
|
69
|
+
* abre o arquivo para contexto adjacente. O orçamento é distribuído entre os
|
|
70
|
+
* arquivos (metade em partes iguais, o resto por churn) — antes, os primeiros
|
|
71
|
+
* arquivos consumiam o teto e o subagente lia os demais à mão.
|
|
72
|
+
*/
|
|
73
|
+
export const DEFAULT_BRIEF_SNIPPET_CHARS = 24_000;
|
|
74
|
+
export const DEFAULT_BRIEF_SNIPPET_CHARS_PER_FILE = 4_000;
|
|
75
|
+
|
|
76
|
+
/** Mesmos limites para o revisor de fluxo (`general`), que vê todos os arquivos. */
|
|
77
|
+
export const GENERAL_BRIEF_SNIPPET_CHARS = 32_000;
|
|
78
|
+
export const GENERAL_BRIEF_SNIPPET_CHARS_PER_FILE = 5_000;
|
|
79
|
+
|
|
80
|
+
/** Fração do teto dividida igualmente entre os arquivos (o resto vai por churn). */
|
|
81
|
+
export const BRIEF_SNIPPET_FLOOR_RATIO = 0.5;
|
|
82
|
+
|
|
83
|
+
/** Piso de chars por arquivo no rateio igual (evita trechos inúteis). */
|
|
84
|
+
export const BRIEF_SNIPPET_MIN_PER_FILE = 120;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* A partir daqui o `general` revisa por **fluxo** em vez de amostrar arquivos.
|
|
88
|
+
*
|
|
89
|
+
* Em PRs grandes, 40 arquivos com ~800 chars cada viram amostragem: melhor ver
|
|
90
|
+
* os arquivos que participam dos fluxos do modelo de intenção (com diff de
|
|
91
|
+
* verdade) e declarar o resto em `skipped`.
|
|
92
|
+
*/
|
|
93
|
+
export const GENERAL_FLOW_MIN_FILES = 40;
|
|
94
|
+
export const GENERAL_FLOW_MIN_LINES = 2_000;
|
|
95
|
+
|
|
96
|
+
/** Arquivos do recorte de fluxo do `general`. */
|
|
97
|
+
export const GENERAL_FLOW_MAX_FILES = 15;
|
|
98
|
+
|
|
99
|
+
/** Diff por arquivo no recorte de fluxo — profundidade em vez de cobertura. */
|
|
100
|
+
export const GENERAL_FLOW_SNIPPET_CHARS_PER_FILE = 2_000;
|
|
101
|
+
|
|
56
102
|
/** Retentativas default quando a saída do watchdog é inválida. */
|
|
57
103
|
export const DEFAULT_HUNT_RETRIES = 1;
|
|
58
104
|
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* orçamento sem depender do serviço de classificação.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import type { HoundsmanPriority } from "../config";
|
|
8
9
|
import type { ChurnMap } from "../types";
|
|
9
10
|
|
|
10
11
|
/** Churn total (adições + remoções) de um arquivo; 0 quando ausente. */
|
|
@@ -13,10 +14,66 @@ export function churnOf(filePath: string, churn: ChurnMap | undefined): number {
|
|
|
13
14
|
return entry ? entry.additions + entry.deletions : 0;
|
|
14
15
|
}
|
|
15
16
|
|
|
16
|
-
/**
|
|
17
|
-
export function
|
|
17
|
+
/** Regra de prioridade do caminho (a primeira que casar vence). */
|
|
18
|
+
export function priorityFor(
|
|
19
|
+
filePath: string,
|
|
20
|
+
priorities: readonly HoundsmanPriority[] | undefined,
|
|
21
|
+
): HoundsmanPriority | undefined {
|
|
22
|
+
for (const rule of priorities ?? []) {
|
|
23
|
+
// Regex global mantém `lastIndex` entre chamadas — zera para não pular match.
|
|
24
|
+
if (rule.pattern.global) rule.pattern.lastIndex = 0;
|
|
25
|
+
if (rule.pattern.test(filePath)) return rule;
|
|
26
|
+
}
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Bônus da regra (linhas de churn equivalentes; ausente/inválido = 0). */
|
|
31
|
+
function bonusOf(rule: HoundsmanPriority | undefined): number {
|
|
32
|
+
const bonus = rule?.bonus;
|
|
33
|
+
return bonus !== undefined && Number.isFinite(bonus) ? Math.max(0, bonus) : 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Bônus de prioridade do caminho (primeira regra que casar vence). */
|
|
37
|
+
export function priorityBonus(
|
|
38
|
+
filePath: string,
|
|
39
|
+
priorities: readonly HoundsmanPriority[] | undefined,
|
|
40
|
+
): number {
|
|
41
|
+
return bonusOf(priorityFor(filePath, priorities));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Teto de churn da regra (ausente/inválido = sem teto). */
|
|
45
|
+
function churnLimitOf(rule: HoundsmanPriority | undefined): number {
|
|
46
|
+
const limit = rule?.maxRiskFromChurn;
|
|
47
|
+
return limit !== undefined && Number.isFinite(limit) && limit > 0
|
|
48
|
+
? limit
|
|
49
|
+
: Number.POSITIVE_INFINITY;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Risco do arquivo: `min(churn, maxRiskFromChurn) + bonus`.
|
|
54
|
+
*
|
|
55
|
+
* O teto existe para superchurn de caminho de baixa criticidade (frontend que
|
|
56
|
+
* reformatou uma tabela inteira) não competir com churn real de caminho crítico;
|
|
57
|
+
* abaixo do teto, o peso do caminho é o churn natural.
|
|
58
|
+
*/
|
|
59
|
+
export function riskOf(
|
|
60
|
+
filePath: string,
|
|
61
|
+
churn: ChurnMap | undefined,
|
|
62
|
+
priorities?: readonly HoundsmanPriority[],
|
|
63
|
+
): number {
|
|
64
|
+
const rule = priorityFor(filePath, priorities);
|
|
65
|
+
const churnRisk = Math.min(churnOf(filePath, churn), churnLimitOf(rule));
|
|
66
|
+
return churnRisk + bonusOf(rule);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Ordena os arquivos por risco (churn + prioridade; empate pelo caminho). */
|
|
70
|
+
export function sortByRisk(
|
|
71
|
+
files: readonly string[],
|
|
72
|
+
churn?: ChurnMap,
|
|
73
|
+
priorities?: readonly HoundsmanPriority[],
|
|
74
|
+
): string[] {
|
|
18
75
|
return [...files].sort((a, b) => {
|
|
19
|
-
const delta =
|
|
76
|
+
const delta = riskOf(b, churn, priorities) - riskOf(a, churn, priorities);
|
|
20
77
|
return delta !== 0 ? delta : a.localeCompare(b);
|
|
21
78
|
});
|
|
22
79
|
}
|
|
@@ -33,8 +90,9 @@ export function selectByBudget(
|
|
|
33
90
|
files: readonly string[],
|
|
34
91
|
churn: ChurnMap | undefined,
|
|
35
92
|
maxFiles: number,
|
|
93
|
+
priorities?: readonly HoundsmanPriority[],
|
|
36
94
|
): BudgetSelection {
|
|
37
|
-
const ordered = sortByRisk(files, churn);
|
|
95
|
+
const ordered = sortByRisk(files, churn, priorities);
|
|
38
96
|
if (!Number.isFinite(maxFiles) || maxFiles <= 0 || ordered.length <= maxFiles) {
|
|
39
97
|
return { selected: ordered, overflow: [] };
|
|
40
98
|
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* consultas usadas pelo orquestrador (arquivos por watchdog, orçamento, churn).
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import type { HoundsmanPriority } from "../config";
|
|
8
9
|
import type { ClassificationResult } from "../file-classifier";
|
|
9
10
|
import type { ChurnMap } from "../types";
|
|
10
11
|
import { type BudgetSelection, selectByBudget } from "./budget";
|
|
@@ -72,8 +73,17 @@ export class Classification {
|
|
|
72
73
|
}
|
|
73
74
|
|
|
74
75
|
/** Aplica o orçamento aos arquivos de um watchdog. */
|
|
75
|
-
budgetFor(
|
|
76
|
-
|
|
76
|
+
budgetFor(
|
|
77
|
+
id: string,
|
|
78
|
+
maxFiles: number,
|
|
79
|
+
priorities?: readonly HoundsmanPriority[],
|
|
80
|
+
): BudgetSelection {
|
|
81
|
+
return selectByBudget(
|
|
82
|
+
this.watchdogsFor(id),
|
|
83
|
+
this.data.churn,
|
|
84
|
+
maxFiles,
|
|
85
|
+
priorities,
|
|
86
|
+
);
|
|
77
87
|
}
|
|
78
88
|
|
|
79
89
|
/** Totais de churn do diff. */
|
|
@@ -34,6 +34,10 @@ export function buildCarryover(
|
|
|
34
34
|
message: finding.message,
|
|
35
35
|
action: finding.action,
|
|
36
36
|
evidence: finding.evidence,
|
|
37
|
+
scenario: finding.scenario,
|
|
38
|
+
impact: finding.impact,
|
|
39
|
+
confidence: finding.confidence,
|
|
40
|
+
basis: finding.basis,
|
|
37
41
|
foundAtSha: finding.foundAtSha,
|
|
38
42
|
watchdogs: [...finding.watchdogIds],
|
|
39
43
|
}));
|