@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.
Files changed (116) hide show
  1. package/README.md +6 -1
  2. package/package.json +1 -1
  3. package/src/cli/commands/compare-command.ts +112 -0
  4. package/src/cli/commands/context-command.ts +1 -0
  5. package/src/cli/commands/findings-command.ts +11 -1
  6. package/src/cli/commands/golden-command.ts +92 -0
  7. package/src/cli/commands/hunt-command.ts +105 -4
  8. package/src/cli/commands/review-command.ts +38 -0
  9. package/src/cli/commands/status-command.ts +3 -2
  10. package/src/cli/help.ts +6 -1
  11. package/src/cli/index.ts +5 -1
  12. package/src/cli/progress.ts +7 -2
  13. package/src/core/ai/ai-client.ts +38 -2
  14. package/src/core/ai/features/brief-enricher.ts +1 -1
  15. package/src/core/ai/features/business-analyzer.ts +61 -0
  16. package/src/core/ai/features/business-summarizer.ts +44 -0
  17. package/src/core/ai/index.ts +8 -0
  18. package/src/core/ai/prompts/brief.ts +33 -7
  19. package/src/core/ai/prompts/business.ts +76 -0
  20. package/src/core/ai/provider-capabilities.ts +12 -0
  21. package/src/core/ai/schemas.ts +70 -2
  22. package/src/core/ai/usage-recorder.ts +53 -1
  23. package/src/core/brief.ts +278 -16
  24. package/src/core/builtins.ts +108 -0
  25. package/src/core/business-summary.ts +583 -0
  26. package/src/core/commands/compare.ts +266 -0
  27. package/src/core/commands/context.ts +6 -2
  28. package/src/core/commands/findings.ts +38 -30
  29. package/src/core/commands/golden.ts +176 -0
  30. package/src/core/commands/review.ts +6 -0
  31. package/src/core/commands/scaffold.ts +29 -0
  32. package/src/core/commands/status.ts +9 -4
  33. package/src/core/compare.ts +310 -0
  34. package/src/core/concurrency.ts +30 -0
  35. package/src/core/config.ts +41 -3
  36. package/src/core/defaults.ts +50 -4
  37. package/src/core/domain/budget.ts +62 -4
  38. package/src/core/domain/classification.ts +12 -2
  39. package/src/core/engine/carryover.ts +4 -0
  40. package/src/core/engine/finalize-pipeline.ts +371 -0
  41. package/src/core/engine/prepare-pipeline.ts +1294 -0
  42. package/src/core/engine/review-context.ts +51 -0
  43. package/src/core/engine/review-options.ts +47 -0
  44. package/src/core/engine/review-scope.ts +14 -1
  45. package/src/core/engine/review-session.ts +11 -13
  46. package/src/core/engine/run-artifacts.ts +196 -0
  47. package/src/core/engine/run-format.ts +4 -0
  48. package/src/core/findings-state.ts +22 -2
  49. package/src/core/github-review.ts +5 -2
  50. package/src/core/golden.ts +346 -0
  51. package/src/core/hunt/hunt-runner.ts +621 -34
  52. package/src/core/hunt/index.ts +40 -1
  53. package/src/core/hunt/maki-intent-prompt.ts +50 -0
  54. package/src/core/hunt/maki-prompt.ts +298 -5
  55. package/src/core/hunt/maki-runner.ts +93 -16
  56. package/src/core/hunt/pool.ts +4 -26
  57. package/src/core/hunt/review-checks.ts +460 -0
  58. package/src/core/logging/index.ts +6 -1
  59. package/src/core/logging/progress.ts +85 -1
  60. package/src/core/orchestrator.ts +33 -1289
  61. package/src/core/paths.ts +6 -3
  62. package/src/core/pr-context.ts +2 -4
  63. package/src/core/prepare-report.ts +61 -0
  64. package/src/core/project-config.ts +18 -1
  65. package/src/core/propagation.ts +127 -0
  66. package/src/core/registry-check.ts +90 -27
  67. package/src/core/renderers/manifest-writer.ts +13 -0
  68. package/src/core/renderers/resume-renderer.ts +151 -11
  69. package/src/core/report-builder.ts +116 -278
  70. package/src/core/severity.ts +245 -0
  71. package/src/core/storage/file/file-storage.ts +7 -3
  72. package/src/core/storage/sqlite/mappers.ts +10 -0
  73. package/src/core/storage/sqlite/schema.ts +104 -23
  74. package/src/core/storage/sqlite/sqlite-storage.ts +22 -9
  75. package/src/core/storage/types.ts +17 -4
  76. package/src/core/storage/work-area.ts +59 -1
  77. package/src/core/types.ts +31 -0
  78. package/src/core/watchdog-registry.ts +21 -3
  79. package/src/index.ts +51 -4
  80. package/src/server/client/ArtifactsPanel.tsx +4 -2
  81. package/src/server/client/FindingCard.tsx +21 -1
  82. package/src/server/client/RunsSection.tsx +5 -0
  83. package/src/server/client/utils/constants.ts +21 -11
  84. package/src/server/client/utils/markdown.ts +1 -1
  85. package/src/server/client/utils/types.ts +14 -2
  86. package/src/server/run-repository.ts +35 -14
  87. package/src/server/styles.css +2 -0
  88. package/src/skill/OUTPUT-FORMAT.md +55 -10
  89. package/src/skill/README.md +19 -14
  90. package/src/skill/SETUP.md +1 -0
  91. package/src/skill/SKILL.md +13 -4
  92. package/src/skill/example-resume.md +43 -0
  93. package/src/skill/hunt.md +205 -58
  94. package/src/skill/train.md +3 -0
  95. package/src/tests/__snapshots__/baseline.test.ts.snap +208 -184
  96. package/src/tests/ai-core.test.ts +46 -0
  97. package/src/tests/ai-features.test.ts +95 -4
  98. package/src/tests/baseline.test.ts +29 -121
  99. package/src/tests/brief-scope.test.ts +250 -0
  100. package/src/tests/business-summary.test.ts +258 -0
  101. package/src/tests/compare.test.ts +256 -0
  102. package/src/tests/findings-state.test.ts +82 -0
  103. package/src/tests/fixtures/fake-model-provider.ts +2 -1
  104. package/src/tests/golden.test.ts +195 -0
  105. package/src/tests/hunt-maki.test.ts +710 -2
  106. package/src/tests/markdown.test.ts +3 -3
  107. package/src/tests/progress.test.ts +29 -1
  108. package/src/tests/propagation.test.ts +119 -0
  109. package/src/tests/review-resume.test.ts +60 -1
  110. package/src/tests/review-scope.test.ts +38 -29
  111. package/src/tests/review-scripts.test.ts +125 -218
  112. package/src/tests/server.test.ts +7 -4
  113. package/src/tests/severity.test.ts +235 -0
  114. package/src/tests/storage.test.ts +194 -9
  115. package/src/core/renderers/report-renderer.ts +0 -66
  116. 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 { sanitizeBranch } from "../report-builder";
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
- slug: sanitizeBranch(branch),
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, ReviewRecord, StorageProvider } from "../storage/types";
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
- /** Contexto de uma review (storage + registro + staging + raiz). */
112
- type CommandContext = {
113
- storage: StorageProvider;
114
- review: ReviewRecord;
115
- work: ReviewWorkArea;
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
- ): CommandContext {
124
+ options: FindingsContextOptions = {},
125
+ ): ReviewContext {
126
126
  const branch = parsed.values["--branch"] ?? currentBranch();
127
- const review = storage.findReview(repo.key, branch);
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 = worktree && existsSync(worktree) ? worktree : process.cwd();
136
- return { storage, review, work: new ReviewWorkArea(review), root };
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: CommandContext): FindingsStateFile {
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: CommandContext,
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: CommandContext, state: FindingsStateFile): void {
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: CommandContext, ids: readonly string[]): string | null {
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: CommandContext,
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: CommandContext,
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, fileReader(ctx.root));
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, fileReader(ctx.root));
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 = fileReader(ctx.root);
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
+ }