@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,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Comando `compare` (biblioteca) — compara duas runs da mesma branch.
|
|
3
|
+
*
|
|
4
|
+
* Resolve o par por slug (como na URL da UI) ou por `(branch, variante)`, lê os
|
|
5
|
+
* artefatos de cada review e grava `compare-<a>--<b>.md|.json` na run B.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
buildComparison,
|
|
10
|
+
type ComparisonFinding,
|
|
11
|
+
comparisonToJson,
|
|
12
|
+
countBySeverity,
|
|
13
|
+
type ReviewSnapshot,
|
|
14
|
+
renderComparison,
|
|
15
|
+
} from "../compare";
|
|
16
|
+
import { currentBranch } from "../pr-context";
|
|
17
|
+
import type { RepoIdentity, ReviewRecord, StorageProvider } from "../storage/types";
|
|
18
|
+
import type { Severity } from "../types";
|
|
19
|
+
|
|
20
|
+
/** Seleção do par A/B. */
|
|
21
|
+
export type CompareOptions = {
|
|
22
|
+
/** Slugs prontos (como exibidos na UI). */
|
|
23
|
+
slugA?: string;
|
|
24
|
+
slugB?: string;
|
|
25
|
+
/** Identidade alternativa: branch + rótulos de variante. */
|
|
26
|
+
branch?: string;
|
|
27
|
+
variantA?: string;
|
|
28
|
+
variantB?: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** Resultado do comando. */
|
|
32
|
+
export type CompareResult = {
|
|
33
|
+
markdown: string;
|
|
34
|
+
json: string;
|
|
35
|
+
a: ReviewRecord;
|
|
36
|
+
b: ReviewRecord;
|
|
37
|
+
artifacts: { markdown: string; json: string };
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
type ManifestShape = {
|
|
41
|
+
watchdogs?: { files?: string[]; overflow?: number }[];
|
|
42
|
+
ai?: { model?: string };
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
type ReportShape = { durationMs?: number };
|
|
46
|
+
type HuntShape = {
|
|
47
|
+
model?: string;
|
|
48
|
+
durationMs?: number;
|
|
49
|
+
businessScore?: number;
|
|
50
|
+
totals?: { totalTokens?: number; costUsd?: number };
|
|
51
|
+
watchdogs?: {
|
|
52
|
+
checks?: {
|
|
53
|
+
expected?: number;
|
|
54
|
+
verified?: number;
|
|
55
|
+
missing?: number;
|
|
56
|
+
gaps?: number;
|
|
57
|
+
};
|
|
58
|
+
coverage?: { reviewed?: number; skipped?: number; undeclared?: number };
|
|
59
|
+
checklist?: { costUsd?: number };
|
|
60
|
+
secondOpinion?: { costUsd?: number; findings?: number };
|
|
61
|
+
}[];
|
|
62
|
+
};
|
|
63
|
+
type AiUsageShape = { total?: { totalTokens?: number } };
|
|
64
|
+
|
|
65
|
+
/** Agrega o diagnóstico do `hunt-report.json` (checklist + cobertura declarada). */
|
|
66
|
+
function harnessOf(hunt: HuntShape): NonNullable<ReviewSnapshot["harness"]> {
|
|
67
|
+
const watchdogs = hunt.watchdogs ?? [];
|
|
68
|
+
const sum = (pick: (item: (typeof watchdogs)[number]) => number | undefined) =>
|
|
69
|
+
watchdogs.reduce((total, item) => total + (pick(item) ?? 0), 0);
|
|
70
|
+
return {
|
|
71
|
+
checksExpected: sum((item) => item.checks?.expected),
|
|
72
|
+
checksVerified: sum((item) => item.checks?.verified),
|
|
73
|
+
checksGaps: sum((item) => item.checks?.gaps),
|
|
74
|
+
reviewedFiles: sum((item) => item.coverage?.reviewed),
|
|
75
|
+
skippedFiles: sum((item) => item.coverage?.skipped),
|
|
76
|
+
undeclaredFiles: sum((item) => item.coverage?.undeclared),
|
|
77
|
+
businessScore: hunt.businessScore,
|
|
78
|
+
extraCostUsd:
|
|
79
|
+
sum((item) => item.checklist?.costUsd) +
|
|
80
|
+
sum((item) => item.secondOpinion?.costUsd),
|
|
81
|
+
secondOpinionFindings: sum((item) => item.secondOpinion?.findings),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Resolve a run pelo slug dentro do repositório. */
|
|
86
|
+
function bySlug(
|
|
87
|
+
storage: StorageProvider,
|
|
88
|
+
repo: RepoIdentity,
|
|
89
|
+
slug: string,
|
|
90
|
+
): ReviewRecord {
|
|
91
|
+
const review = storage
|
|
92
|
+
.listReviews(repo.key)
|
|
93
|
+
.find((candidate) => candidate.slug === slug);
|
|
94
|
+
if (!review) throw new Error(`Nenhuma run com slug '${slug}' neste repositório.`);
|
|
95
|
+
return review;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Resolve o par A/B (slug ou branch + variantes). */
|
|
99
|
+
export function resolveComparePair(
|
|
100
|
+
storage: StorageProvider,
|
|
101
|
+
repo: RepoIdentity,
|
|
102
|
+
options: CompareOptions,
|
|
103
|
+
): [ReviewRecord, ReviewRecord] {
|
|
104
|
+
if (options.slugA && options.slugB) {
|
|
105
|
+
return [
|
|
106
|
+
bySlug(storage, repo, options.slugA),
|
|
107
|
+
bySlug(storage, repo, options.slugB),
|
|
108
|
+
];
|
|
109
|
+
}
|
|
110
|
+
const branch = options.branch ?? currentBranch();
|
|
111
|
+
const variantB = options.variantB?.trim();
|
|
112
|
+
if (!variantB) {
|
|
113
|
+
throw new Error(
|
|
114
|
+
"Informe --b <variante> (ou passe dois slugs: `houndsman compare <a> <b>`).",
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
const a = storage.findReview(repo.key, branch, options.variantA);
|
|
118
|
+
const b = storage.findReview(repo.key, branch, variantB);
|
|
119
|
+
if (!a) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
`Nenhuma revisão para '${branch}'${options.variantA ? ` (variante '${options.variantA}')` : ""}.`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
if (!b) throw new Error(`Nenhuma revisão para '${branch}' (variante '${variantB}').`);
|
|
125
|
+
return [a, b];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function readJson<T>(storage: StorageProvider, reviewId: string, name: string): T | null {
|
|
129
|
+
const artifact = storage.readArtifact(reviewId, name);
|
|
130
|
+
if (!artifact) return null;
|
|
131
|
+
try {
|
|
132
|
+
return JSON.parse(artifact.content) as T;
|
|
133
|
+
} catch {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Extrai o snapshot comparável de uma run. */
|
|
139
|
+
function snapshotOf(
|
|
140
|
+
storage: StorageProvider,
|
|
141
|
+
review: ReviewRecord,
|
|
142
|
+
label: string,
|
|
143
|
+
): ReviewSnapshot {
|
|
144
|
+
const state = storage.readFindings(review.id);
|
|
145
|
+
const findings: ComparisonFinding[] = Object.values(state?.findings ?? {})
|
|
146
|
+
.filter((finding) => finding.present)
|
|
147
|
+
.map((finding) => ({
|
|
148
|
+
fingerprint: finding.fingerprint,
|
|
149
|
+
rule: finding.rule,
|
|
150
|
+
severity: finding.severity,
|
|
151
|
+
file: finding.file,
|
|
152
|
+
line: finding.line ?? undefined,
|
|
153
|
+
message: finding.message,
|
|
154
|
+
watchdogs: finding.watchdogIds,
|
|
155
|
+
}));
|
|
156
|
+
|
|
157
|
+
const manifest = readJson<ManifestShape>(storage, review.id, "manifest.json");
|
|
158
|
+
const prepare = readJson<ReportShape>(storage, review.id, "prepare-report.json");
|
|
159
|
+
const hunt = readJson<HuntShape>(storage, review.id, "hunt-report.json");
|
|
160
|
+
const aiUsage = readJson<AiUsageShape>(storage, review.id, "ai-usage.json");
|
|
161
|
+
|
|
162
|
+
const started = review.startedAt ? Date.parse(review.startedAt) : Number.NaN;
|
|
163
|
+
const reviewed = review.reviewedAt ? Date.parse(review.reviewedAt) : Number.NaN;
|
|
164
|
+
const durationMs =
|
|
165
|
+
Number.isFinite(started) && Number.isFinite(reviewed) && reviewed >= started
|
|
166
|
+
? reviewed - started
|
|
167
|
+
: undefined;
|
|
168
|
+
|
|
169
|
+
const watchdogs = manifest?.watchdogs ?? [];
|
|
170
|
+
// Domínios se sobrepõem: conta arquivos únicos, não "slots" por watchdog.
|
|
171
|
+
const files = new Set<string>();
|
|
172
|
+
for (const watchdog of watchdogs) {
|
|
173
|
+
for (const file of watchdog.files ?? []) files.add(file);
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
label,
|
|
177
|
+
slug: review.slug,
|
|
178
|
+
branch: review.branch,
|
|
179
|
+
variant: review.variant,
|
|
180
|
+
headSha: review.headSha ?? review.reviewedSha,
|
|
181
|
+
model: hunt?.model ?? manifest?.ai?.model,
|
|
182
|
+
findings,
|
|
183
|
+
bySeverity: countBySeverity(findings),
|
|
184
|
+
harness: hunt ? harnessOf(hunt) : undefined,
|
|
185
|
+
coverage: {
|
|
186
|
+
watchdogs: watchdogs.length,
|
|
187
|
+
files: files.size,
|
|
188
|
+
overflow: watchdogs.reduce(
|
|
189
|
+
(total, watchdog) => total + (watchdog.overflow ?? 0),
|
|
190
|
+
0,
|
|
191
|
+
),
|
|
192
|
+
},
|
|
193
|
+
durationMs,
|
|
194
|
+
prepareMs: prepare?.durationMs,
|
|
195
|
+
huntMs: hunt?.durationMs,
|
|
196
|
+
tokens: hunt?.totals?.totalTokens ?? aiUsage?.total?.totalTokens,
|
|
197
|
+
costUsd: hunt?.totals?.costUsd,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Executa a comparação e persiste o relatório na run B. */
|
|
202
|
+
export function runCompare(
|
|
203
|
+
storage: StorageProvider,
|
|
204
|
+
repo: RepoIdentity,
|
|
205
|
+
options: CompareOptions,
|
|
206
|
+
): CompareResult {
|
|
207
|
+
const [a, b] = resolveComparePair(storage, repo, options);
|
|
208
|
+
const comparison = buildComparison(
|
|
209
|
+
snapshotOf(storage, a, "A"),
|
|
210
|
+
snapshotOf(storage, b, "B"),
|
|
211
|
+
);
|
|
212
|
+
const markdown = renderComparison(comparison);
|
|
213
|
+
const json = comparisonToJson(comparison);
|
|
214
|
+
const base = `compare-${a.slug}--${b.slug}`;
|
|
215
|
+
storage.writeArtifact(b.id, {
|
|
216
|
+
name: `${base}.md`,
|
|
217
|
+
kind: "compare",
|
|
218
|
+
content: markdown,
|
|
219
|
+
});
|
|
220
|
+
storage.writeArtifact(b.id, {
|
|
221
|
+
name: `${base}.json`,
|
|
222
|
+
kind: "compare",
|
|
223
|
+
content: json,
|
|
224
|
+
});
|
|
225
|
+
return {
|
|
226
|
+
markdown,
|
|
227
|
+
json,
|
|
228
|
+
a,
|
|
229
|
+
b,
|
|
230
|
+
artifacts: { markdown: `${base}.md`, json: `${base}.json` },
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Linha da listagem de variantes da branch. */
|
|
235
|
+
export type CompareListItem = {
|
|
236
|
+
slug: string;
|
|
237
|
+
branch: string;
|
|
238
|
+
variant?: string;
|
|
239
|
+
status: ReviewRecord["status"];
|
|
240
|
+
findings: number;
|
|
241
|
+
bySeverity: Record<Severity, number>;
|
|
242
|
+
updatedAt: string;
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
/** Lista as runs do repositório (filtra por branch quando informado). */
|
|
246
|
+
export function listRunsForCompare(
|
|
247
|
+
storage: StorageProvider,
|
|
248
|
+
repo: RepoIdentity,
|
|
249
|
+
branch?: string,
|
|
250
|
+
): CompareListItem[] {
|
|
251
|
+
return storage
|
|
252
|
+
.listReviews(repo.key)
|
|
253
|
+
.filter((review) => !branch || review.branch === branch)
|
|
254
|
+
.map((review) => {
|
|
255
|
+
const snapshot = snapshotOf(storage, review, review.variant ?? "principal");
|
|
256
|
+
return {
|
|
257
|
+
slug: review.slug,
|
|
258
|
+
branch: review.branch,
|
|
259
|
+
variant: review.variant,
|
|
260
|
+
status: review.status,
|
|
261
|
+
findings: snapshot.findings.length,
|
|
262
|
+
bySeverity: snapshot.bySeverity,
|
|
263
|
+
updatedAt: review.updatedAt,
|
|
264
|
+
};
|
|
265
|
+
});
|
|
266
|
+
}
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
type PrContextRaw,
|
|
13
13
|
renderContextBrief,
|
|
14
14
|
} from "../pr-context";
|
|
15
|
-
import {
|
|
15
|
+
import { reviewSlug } from "../report-builder";
|
|
16
16
|
import type { RepoIdentity, StorageProvider } from "../storage/types";
|
|
17
17
|
import { type ContextPaths, ReviewWorkArea } from "../storage/work-area";
|
|
18
18
|
|
|
@@ -28,6 +28,8 @@ export function runContextCommand(
|
|
|
28
28
|
repo: RepoIdentity,
|
|
29
29
|
opts: {
|
|
30
30
|
branch?: string;
|
|
31
|
+
/** Variante A/B da run (ausente = principal). */
|
|
32
|
+
variant?: string;
|
|
31
33
|
/** Worktree onde a review roda (persistido na review). */
|
|
32
34
|
worktreePath?: string;
|
|
33
35
|
/** PR de origem (persistida na review). */
|
|
@@ -35,10 +37,12 @@ export function runContextCommand(
|
|
|
35
37
|
} = {},
|
|
36
38
|
): ContextRunResult {
|
|
37
39
|
const branch = opts.branch ?? currentBranch();
|
|
40
|
+
const variant = opts.variant?.trim() || undefined;
|
|
38
41
|
const review = storage.getOrCreateReview({
|
|
39
42
|
repo,
|
|
40
43
|
branch,
|
|
41
|
-
|
|
44
|
+
variant,
|
|
45
|
+
slug: reviewSlug(branch, variant),
|
|
42
46
|
worktreePath: opts.worktreePath,
|
|
43
47
|
prNumber: opts.prNumber,
|
|
44
48
|
});
|
|
@@ -14,6 +14,7 @@ import { existsSync } from "node:fs";
|
|
|
14
14
|
import { AiClient } from "../ai/ai-client";
|
|
15
15
|
import { AiVerifier } from "../ai/features/verifier";
|
|
16
16
|
import { type HoundsmanConfig, resolveJevConcurrency } from "../config";
|
|
17
|
+
import { type ReviewContext, reviewContext } from "../engine/review-context";
|
|
17
18
|
import { ENV_NAMES } from "../env";
|
|
18
19
|
import {
|
|
19
20
|
applyFindingUpdate,
|
|
@@ -25,7 +26,6 @@ import {
|
|
|
25
26
|
type FindingsFilter,
|
|
26
27
|
type FindingsStateFile,
|
|
27
28
|
type FindingUpdate,
|
|
28
|
-
fileReader,
|
|
29
29
|
filterFindings,
|
|
30
30
|
reorder,
|
|
31
31
|
resolveFindingId,
|
|
@@ -51,9 +51,9 @@ import { VerifyPresenter } from "../renderers/verify-presenter";
|
|
|
51
51
|
import { pingFindingsStatus } from "../run-status";
|
|
52
52
|
import { systemClock } from "../services/clock";
|
|
53
53
|
import { FixBatchBuilder } from "../services/fix-batch-builder";
|
|
54
|
+
import type { GitRepository } from "../services/git-repository";
|
|
54
55
|
import { Verifier } from "../services/verifier";
|
|
55
|
-
import type { RepoIdentity,
|
|
56
|
-
import { ReviewWorkArea } from "../storage/work-area";
|
|
56
|
+
import type { RepoIdentity, StorageProvider } from "../storage/types";
|
|
57
57
|
import { SEVERITY_ORDER, type Severity } from "../types";
|
|
58
58
|
|
|
59
59
|
export type FindingsCommandResult = {
|
|
@@ -68,6 +68,7 @@ const VALUE_FLAGS = new Set([
|
|
|
68
68
|
"--severity",
|
|
69
69
|
"--file",
|
|
70
70
|
"--branch",
|
|
71
|
+
"--variant",
|
|
71
72
|
"--limit",
|
|
72
73
|
"--comment",
|
|
73
74
|
"--by",
|
|
@@ -108,13 +109,11 @@ function parseArgs(args: string[]): ParsedArgs {
|
|
|
108
109
|
return parsed;
|
|
109
110
|
}
|
|
110
111
|
|
|
111
|
-
/**
|
|
112
|
-
type
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
/** Worktree da review quando existir; senão, a raiz do repositório. */
|
|
117
|
-
root: string;
|
|
112
|
+
/** Opções de resolução do contexto (o CLI passa root/git do engine). */
|
|
113
|
+
export type FindingsContextOptions = {
|
|
114
|
+
/** Raiz operacional (default: cwd de quem chamou). */
|
|
115
|
+
root?: string;
|
|
116
|
+
git?: GitRepository;
|
|
118
117
|
};
|
|
119
118
|
|
|
120
119
|
/** Resolve a review da branch atual (explícita ou via git). */
|
|
@@ -122,22 +121,25 @@ function openContext(
|
|
|
122
121
|
storage: StorageProvider,
|
|
123
122
|
repo: RepoIdentity,
|
|
124
123
|
parsed: ParsedArgs,
|
|
125
|
-
|
|
124
|
+
options: FindingsContextOptions = {},
|
|
125
|
+
): ReviewContext {
|
|
126
126
|
const branch = parsed.values["--branch"] ?? currentBranch();
|
|
127
|
-
const
|
|
127
|
+
const variant = parsed.values["--variant"]?.trim() || undefined;
|
|
128
|
+
const review = storage.findReview(repo.key, branch, variant);
|
|
128
129
|
if (!review) {
|
|
129
130
|
throw new Error(
|
|
130
|
-
`Nenhuma revisão para '${branch}'. Rode \`houndsman --finalize\` primeiro.`,
|
|
131
|
+
`Nenhuma revisão para '${branch}'${variant ? ` (variante '${variant}')` : ""}. Rode \`houndsman --finalize\` primeiro.`,
|
|
131
132
|
);
|
|
132
133
|
}
|
|
133
134
|
const worktree = review.worktreePath;
|
|
134
135
|
// Sem worktree, mantém a raiz onde o comando foi invocado (comportamento histórico).
|
|
135
|
-
const root =
|
|
136
|
-
|
|
136
|
+
const root =
|
|
137
|
+
worktree && existsSync(worktree) ? worktree : (options.root ?? process.cwd());
|
|
138
|
+
return reviewContext(storage, review, { root, git: options.git });
|
|
137
139
|
}
|
|
138
140
|
|
|
139
141
|
/** Lê o estado de triagem; lança se a review ainda não tem findings. */
|
|
140
|
-
function loadState(ctx:
|
|
142
|
+
function loadState(ctx: ReviewContext): FindingsStateFile {
|
|
141
143
|
const state = ctx.storage.readFindings(ctx.review.id);
|
|
142
144
|
if (!state) {
|
|
143
145
|
throw new Error(
|
|
@@ -149,7 +151,7 @@ function loadState(ctx: CommandContext): FindingsStateFile {
|
|
|
149
151
|
|
|
150
152
|
/** Lê-modifica-grava o estado, atualizando `updatedAt` e a ordem. */
|
|
151
153
|
function mutateState(
|
|
152
|
-
ctx:
|
|
154
|
+
ctx: ReviewContext,
|
|
153
155
|
mutator: (state: FindingsStateFile) => void,
|
|
154
156
|
): FindingsStateFile {
|
|
155
157
|
const state = loadState(ctx);
|
|
@@ -221,7 +223,7 @@ function filterFrom(parsed: ParsedArgs): FindingsFilter {
|
|
|
221
223
|
}
|
|
222
224
|
|
|
223
225
|
/** Faz ping no status da review (derivado do estado atual) após uma mutação. */
|
|
224
|
-
function pingStatus(ctx:
|
|
226
|
+
function pingStatus(ctx: ReviewContext, state: FindingsStateFile): void {
|
|
225
227
|
pingFindingsStatus(ctx.storage, ctx.review.id, state);
|
|
226
228
|
}
|
|
227
229
|
|
|
@@ -230,7 +232,7 @@ function pingStatus(ctx: CommandContext, state: FindingsStateFile): void {
|
|
|
230
232
|
* Fail-open (`gh` ausente/sem PR ⇒ só a mensagem). Devolve a linha para o
|
|
231
233
|
* output ou `null` quando não há vínculo remoto pendente.
|
|
232
234
|
*/
|
|
233
|
-
function syncThreadsOutput(ctx:
|
|
235
|
+
function syncThreadsOutput(ctx: ReviewContext, ids: readonly string[]): string | null {
|
|
234
236
|
if (ids.length === 0) return null;
|
|
235
237
|
const state = loadState(ctx);
|
|
236
238
|
const outcome = syncResolvedThreads({ state, ids });
|
|
@@ -246,7 +248,7 @@ function syncThreadsOutput(ctx: CommandContext, ids: readonly string[]): string
|
|
|
246
248
|
|
|
247
249
|
/** Aplica uma atualização a um finding e persiste. */
|
|
248
250
|
function updateOne(
|
|
249
|
-
ctx:
|
|
251
|
+
ctx: ReviewContext,
|
|
250
252
|
input: string,
|
|
251
253
|
update: FindingUpdate,
|
|
252
254
|
): FindingState {
|
|
@@ -261,7 +263,7 @@ function updateOne(
|
|
|
261
263
|
|
|
262
264
|
/** Aplica a mesma atualização a vários ids (done/reset). */
|
|
263
265
|
function updateMany(
|
|
264
|
-
ctx:
|
|
266
|
+
ctx: ReviewContext,
|
|
265
267
|
inputs: string[],
|
|
266
268
|
update: FindingUpdate,
|
|
267
269
|
): FindingState[] {
|
|
@@ -281,10 +283,11 @@ export function runFindingsCommand(
|
|
|
281
283
|
storage: StorageProvider,
|
|
282
284
|
repo: RepoIdentity,
|
|
283
285
|
args: string[],
|
|
286
|
+
options: FindingsContextOptions = {},
|
|
284
287
|
): FindingsCommandResult {
|
|
285
288
|
try {
|
|
286
289
|
const parsed = parseArgs(args);
|
|
287
|
-
return execute(config, storage, repo, parsed);
|
|
290
|
+
return execute(config, storage, repo, parsed, options);
|
|
288
291
|
} catch (error) {
|
|
289
292
|
return {
|
|
290
293
|
output: error instanceof Error ? error.message : String(error),
|
|
@@ -298,8 +301,9 @@ function execute(
|
|
|
298
301
|
storage: StorageProvider,
|
|
299
302
|
repo: RepoIdentity,
|
|
300
303
|
parsed: ParsedArgs,
|
|
304
|
+
options: FindingsContextOptions,
|
|
301
305
|
): FindingsCommandResult {
|
|
302
|
-
const ctx = openContext(storage, repo, parsed);
|
|
306
|
+
const ctx = openContext(storage, repo, parsed, options);
|
|
303
307
|
switch (parsed.sub ?? "list") {
|
|
304
308
|
case "list": {
|
|
305
309
|
const state = loadState(ctx);
|
|
@@ -386,7 +390,7 @@ function execute(
|
|
|
386
390
|
}
|
|
387
391
|
case "sync": {
|
|
388
392
|
const state = loadState(ctx);
|
|
389
|
-
const result = syncState(state,
|
|
393
|
+
const result = syncState(state, ctx.files.reader());
|
|
390
394
|
ctx.storage.writeFindings(ctx.review.id, state);
|
|
391
395
|
pingStatus(ctx, state);
|
|
392
396
|
const output = parsed.bools.has("--json")
|
|
@@ -483,10 +487,11 @@ export async function runVerifyCommand(
|
|
|
483
487
|
storage: StorageProvider,
|
|
484
488
|
repo: RepoIdentity,
|
|
485
489
|
args: string[],
|
|
490
|
+
options: FindingsContextOptions = {},
|
|
486
491
|
): Promise<FindingsCommandResult> {
|
|
487
492
|
try {
|
|
488
493
|
const parsed = parseArgs(args);
|
|
489
|
-
return await executeVerify(config, storage, repo, parsed);
|
|
494
|
+
return await executeVerify(config, storage, repo, parsed, options);
|
|
490
495
|
} catch (error) {
|
|
491
496
|
return {
|
|
492
497
|
output: error instanceof Error ? error.message : String(error),
|
|
@@ -514,8 +519,9 @@ async function executeVerify(
|
|
|
514
519
|
storage: StorageProvider,
|
|
515
520
|
repo: RepoIdentity,
|
|
516
521
|
parsed: ParsedArgs,
|
|
522
|
+
options: FindingsContextOptions,
|
|
517
523
|
): Promise<FindingsCommandResult> {
|
|
518
|
-
const ctx = openContext(storage, repo, parsed);
|
|
524
|
+
const ctx = openContext(storage, repo, parsed, options);
|
|
519
525
|
const state = loadState(ctx);
|
|
520
526
|
|
|
521
527
|
// Lazy: `../jev` (axios) só entra no caminho do verify.
|
|
@@ -523,7 +529,7 @@ async function executeVerify(
|
|
|
523
529
|
const { JevCandidatesStore, JevClient, JevVerifier } = await import("../jev");
|
|
524
530
|
|
|
525
531
|
const findings = verifier.select(state, filterFrom(parsed));
|
|
526
|
-
const entries = verifier.verifyLocal(findings,
|
|
532
|
+
const entries = verifier.verifyLocal(findings, ctx.files.reader());
|
|
527
533
|
|
|
528
534
|
// Tier 1 — Jev só nos ambíguos (fail-open: sem chave/erro mantém o provisório).
|
|
529
535
|
let jevChecked = 0;
|
|
@@ -571,7 +577,7 @@ async function executeVerify(
|
|
|
571
577
|
} else if (!aiVerifier.enabled) {
|
|
572
578
|
aiReason = "feature ai.features.verify desligada";
|
|
573
579
|
} else {
|
|
574
|
-
const readFile =
|
|
580
|
+
const readFile = ctx.files.reader();
|
|
575
581
|
const byId = new Map(findings.map((finding) => [finding.id, finding]));
|
|
576
582
|
const confidence = await aiVerifier.verify({
|
|
577
583
|
findings: stillAmbiguous.map((entry) => ({
|
|
@@ -748,10 +754,11 @@ export async function runSubmitCommand(
|
|
|
748
754
|
storage: StorageProvider,
|
|
749
755
|
repo: RepoIdentity,
|
|
750
756
|
args: string[],
|
|
757
|
+
options: FindingsContextOptions = {},
|
|
751
758
|
): Promise<FindingsCommandResult> {
|
|
752
759
|
try {
|
|
753
760
|
const parsed = parseArgs(args);
|
|
754
|
-
return await executeSubmit(config, storage, repo, parsed);
|
|
761
|
+
return await executeSubmit(config, storage, repo, parsed, options);
|
|
755
762
|
} catch (error) {
|
|
756
763
|
return {
|
|
757
764
|
output: error instanceof Error ? error.message : String(error),
|
|
@@ -765,8 +772,9 @@ async function executeSubmit(
|
|
|
765
772
|
storage: StorageProvider,
|
|
766
773
|
repo: RepoIdentity,
|
|
767
774
|
parsed: ParsedArgs,
|
|
775
|
+
options: FindingsContextOptions,
|
|
768
776
|
): Promise<FindingsCommandResult> {
|
|
769
|
-
const ctx = openContext(storage, repo, parsed);
|
|
777
|
+
const ctx = openContext(storage, repo, parsed, options);
|
|
770
778
|
const state = loadState(ctx);
|
|
771
779
|
const selection = submitSelection(parsed);
|
|
772
780
|
const target = resolvePrTarget(defaultGhRunner, state.branch);
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Comando `golden` (biblioteca) — avalia runs contra defeitos congelados.
|
|
3
|
+
*
|
|
4
|
+
* A contagem de findings de duas variantes nunca é comparável: cada execução
|
|
5
|
+
* amostra pontos diferentes do mesmo diff. Com um golden set (arquivo do
|
|
6
|
+
* consumidor), a comparação vira recall/precisão sobre os defeitos que já
|
|
7
|
+
* foram confirmados no código.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import { resolve } from "node:path";
|
|
12
|
+
import {
|
|
13
|
+
commitMatches,
|
|
14
|
+
type GoldenFinding,
|
|
15
|
+
type GoldenRunScore,
|
|
16
|
+
type GoldenSet,
|
|
17
|
+
goldenToJson,
|
|
18
|
+
parseGoldenSet,
|
|
19
|
+
renderGoldenReport,
|
|
20
|
+
scoreGolden,
|
|
21
|
+
} from "../golden";
|
|
22
|
+
import { normalizePath } from "../hunt/review-checks";
|
|
23
|
+
import { currentBranch } from "../pr-context";
|
|
24
|
+
import type { RepoIdentity, ReviewRecord, StorageProvider } from "../storage/types";
|
|
25
|
+
|
|
26
|
+
/** Seleção das runs a avaliar. */
|
|
27
|
+
export type GoldenOptions = {
|
|
28
|
+
/** Caminho do arquivo de golden set. */
|
|
29
|
+
file: string;
|
|
30
|
+
/** Branch alvo (default: a atual). */
|
|
31
|
+
branch?: string;
|
|
32
|
+
/** Rótulo da variante; ausente = todas as runs da branch. */
|
|
33
|
+
variant?: string;
|
|
34
|
+
/** Slug explícito (vence `variant`). */
|
|
35
|
+
slug?: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** Resultado do comando. */
|
|
39
|
+
export type GoldenResult = {
|
|
40
|
+
markdown: string;
|
|
41
|
+
json: string;
|
|
42
|
+
set: GoldenSet;
|
|
43
|
+
runs: GoldenRunScore[];
|
|
44
|
+
/** Run onde o relatório foi gravado (a mais recente avaliada). */
|
|
45
|
+
target: ReviewRecord;
|
|
46
|
+
artifacts?: { markdown: string; json: string };
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** Lê e valida o arquivo de golden set. */
|
|
50
|
+
export function loadGoldenSet(file: string): {
|
|
51
|
+
set: GoldenSet;
|
|
52
|
+
warnings: string[];
|
|
53
|
+
} {
|
|
54
|
+
let content: string;
|
|
55
|
+
try {
|
|
56
|
+
content = readFileSync(resolve(file), "utf8");
|
|
57
|
+
} catch (error) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
`Golden set ilegível: ${error instanceof Error ? error.message : String(error)}`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
let raw: unknown;
|
|
63
|
+
try {
|
|
64
|
+
raw = JSON.parse(content);
|
|
65
|
+
} catch {
|
|
66
|
+
throw new Error(`Golden set não é JSON válido: ${file}`);
|
|
67
|
+
}
|
|
68
|
+
const parsed = parseGoldenSet(raw);
|
|
69
|
+
if (!parsed.set) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
`Golden set inválido (${file}): ${parsed.warnings.join(" ")}`.trim(),
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
return { set: parsed.set, warnings: parsed.warnings };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Findings presentes de uma run, no formato da avaliação. */
|
|
78
|
+
export function goldenFindingsOf(
|
|
79
|
+
storage: StorageProvider,
|
|
80
|
+
review: ReviewRecord,
|
|
81
|
+
): GoldenFinding[] {
|
|
82
|
+
const state = storage.readFindings(review.id);
|
|
83
|
+
return Object.values(state?.findings ?? {})
|
|
84
|
+
.filter((finding) => finding.present)
|
|
85
|
+
.map((finding) => ({
|
|
86
|
+
fingerprint: finding.fingerprint,
|
|
87
|
+
file: normalizePath(finding.file),
|
|
88
|
+
line: finding.line ?? undefined,
|
|
89
|
+
rule: finding.rule,
|
|
90
|
+
severity: finding.severity,
|
|
91
|
+
message: finding.message,
|
|
92
|
+
evidence: finding.evidence,
|
|
93
|
+
watchdogs: finding.watchdogIds,
|
|
94
|
+
}));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Runs alvo: slug > variante > todas as da branch. */
|
|
98
|
+
function selectReviews(
|
|
99
|
+
storage: StorageProvider,
|
|
100
|
+
repo: RepoIdentity,
|
|
101
|
+
options: GoldenOptions,
|
|
102
|
+
): ReviewRecord[] {
|
|
103
|
+
const reviews = storage.listReviews(repo.key);
|
|
104
|
+
if (options.slug) {
|
|
105
|
+
const found = reviews.find((review) => review.slug === options.slug);
|
|
106
|
+
if (!found) {
|
|
107
|
+
throw new Error(`Nenhuma run com slug '${options.slug}' neste repositório.`);
|
|
108
|
+
}
|
|
109
|
+
return [found];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const branch = options.branch ?? currentBranch();
|
|
113
|
+
const ofBranch = reviews.filter((review) => review.branch === branch);
|
|
114
|
+
const variant = options.variant?.trim();
|
|
115
|
+
const selected = variant
|
|
116
|
+
? ofBranch.filter((review) => review.variant === variant)
|
|
117
|
+
: ofBranch;
|
|
118
|
+
if (selected.length === 0) {
|
|
119
|
+
throw new Error(
|
|
120
|
+
`Nenhuma revisão para '${branch}'${variant ? ` (variante '${variant}')` : ""}.`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
return selected;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Rótulo curto da run (`otimizado-4` ou `principal`). */
|
|
127
|
+
function labelOf(review: ReviewRecord): string {
|
|
128
|
+
return review.variant?.trim() || "principal";
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Executa a avaliação e grava o relatório na run mais recente selecionada. */
|
|
132
|
+
export function runGolden(
|
|
133
|
+
storage: StorageProvider,
|
|
134
|
+
repo: RepoIdentity,
|
|
135
|
+
options: GoldenOptions,
|
|
136
|
+
): GoldenResult {
|
|
137
|
+
const { set } = loadGoldenSet(options.file);
|
|
138
|
+
const reviews = selectReviews(storage, repo, options);
|
|
139
|
+
const runs: GoldenRunScore[] = reviews.map((review) => {
|
|
140
|
+
const findings = goldenFindingsOf(storage, review);
|
|
141
|
+
const commit = review.headSha ?? review.reviewedSha;
|
|
142
|
+
return {
|
|
143
|
+
slug: review.slug,
|
|
144
|
+
label: labelOf(review),
|
|
145
|
+
variant: review.variant,
|
|
146
|
+
scope: review.branch,
|
|
147
|
+
commit,
|
|
148
|
+
commitMismatch: !commitMatches(set.commit, commit),
|
|
149
|
+
findings: findings.length,
|
|
150
|
+
score: scoreGolden(set, findings),
|
|
151
|
+
};
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const markdown = renderGoldenReport(set, runs);
|
|
155
|
+
const json = goldenToJson(set, runs);
|
|
156
|
+
const target = reviews[0];
|
|
157
|
+
const base = `golden-${target.slug}`;
|
|
158
|
+
storage.writeArtifact(target.id, {
|
|
159
|
+
name: `${base}.md`,
|
|
160
|
+
kind: "golden",
|
|
161
|
+
content: markdown,
|
|
162
|
+
});
|
|
163
|
+
storage.writeArtifact(target.id, {
|
|
164
|
+
name: `${base}.json`,
|
|
165
|
+
kind: "golden",
|
|
166
|
+
content: json,
|
|
167
|
+
});
|
|
168
|
+
return {
|
|
169
|
+
markdown,
|
|
170
|
+
json,
|
|
171
|
+
set,
|
|
172
|
+
runs,
|
|
173
|
+
target,
|
|
174
|
+
artifacts: { markdown: `${base}.md`, json: `${base}.json` },
|
|
175
|
+
};
|
|
176
|
+
}
|