@payconductor/houndsman 0.2.0 → 0.4.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 (107) hide show
  1. package/README.md +18 -6
  2. package/package.json +1 -1
  3. package/src/cli/arg-parser.ts +8 -0
  4. package/src/cli/commands/command.ts +23 -0
  5. package/src/cli/commands/context-command.ts +40 -0
  6. package/src/cli/commands/doctor-command.ts +60 -0
  7. package/src/cli/commands/findings-command.ts +38 -0
  8. package/src/cli/commands/help-command.ts +13 -0
  9. package/src/cli/commands/registry.ts +34 -0
  10. package/src/cli/commands/review-command.ts +21 -0
  11. package/src/cli/commands/server-command.ts +37 -0
  12. package/src/cli/commands/setup-command.ts +34 -0
  13. package/src/cli/commands/watchdog-command.ts +34 -0
  14. package/src/cli/help.ts +47 -0
  15. package/src/cli/index.ts +60 -268
  16. package/src/core/arg-parser.ts +48 -0
  17. package/src/core/commands/context.ts +29 -13
  18. package/src/core/commands/findings.ts +185 -260
  19. package/src/core/commands/review.ts +21 -39
  20. package/src/core/commands/scaffold.ts +189 -80
  21. package/src/core/commands/setup.ts +16 -11
  22. package/src/core/commands/update.ts +7 -7
  23. package/src/core/commands/watchdog.ts +8 -80
  24. package/src/core/config.ts +24 -25
  25. package/src/core/defaults.ts +20 -0
  26. package/src/core/diff-source.ts +1 -13
  27. package/src/core/doc-discoverer.ts +25 -15
  28. package/src/core/domain/budget.ts +42 -0
  29. package/src/core/domain/classification.ts +94 -0
  30. package/src/core/domain/diff.ts +53 -0
  31. package/src/core/domain/watchdog.ts +96 -0
  32. package/src/core/engine/carryover.ts +67 -0
  33. package/src/core/engine/houndsman-engine.ts +65 -0
  34. package/src/core/engine/review-scope.ts +95 -0
  35. package/src/core/engine/review-session.ts +79 -0
  36. package/src/core/file-classifier.ts +11 -67
  37. package/src/core/findings-state.ts +193 -100
  38. package/src/core/git-revision.ts +14 -40
  39. package/src/core/git-runner.ts +46 -0
  40. package/src/core/github-review.ts +4 -33
  41. package/src/core/jev/jev-candidates.ts +49 -35
  42. package/src/core/orchestrator.ts +247 -421
  43. package/src/core/paths.ts +26 -11
  44. package/src/core/pr-context.ts +39 -51
  45. package/src/core/project-config.ts +56 -0
  46. package/src/core/renderers/batch-presenter.ts +40 -0
  47. package/src/core/renderers/brief-renderer.ts +23 -0
  48. package/src/core/renderers/findings-presenter.ts +74 -0
  49. package/src/core/renderers/manifest-writer.ts +123 -0
  50. package/src/core/renderers/report-renderer.ts +66 -0
  51. package/src/core/renderers/verify-presenter.ts +50 -0
  52. package/src/core/renderers/watchdog-presenter.ts +100 -0
  53. package/src/core/report-builder.ts +12 -7
  54. package/src/core/run-layout.ts +8 -0
  55. package/src/core/run-status.ts +29 -59
  56. package/src/core/services/clock.ts +12 -0
  57. package/src/core/services/file-classifier.ts +34 -0
  58. package/src/core/services/fix-batch-builder.ts +13 -0
  59. package/src/core/services/gh-client.ts +54 -0
  60. package/src/core/services/git-repository.ts +139 -0
  61. package/src/core/services/project-files.ts +91 -0
  62. package/src/core/services/verifier.ts +58 -0
  63. package/src/core/storage/factory.ts +24 -0
  64. package/src/core/storage/file/file-storage.ts +269 -0
  65. package/src/core/storage/paths.ts +64 -0
  66. package/src/core/storage/repo-identity.ts +70 -0
  67. package/src/core/storage/sqlite/mappers.ts +160 -0
  68. package/src/core/storage/sqlite/schema.ts +108 -0
  69. package/src/core/storage/sqlite/sqlite-storage.ts +427 -0
  70. package/src/core/storage/types.ts +143 -0
  71. package/src/core/storage/work-area.ts +191 -0
  72. package/src/core/verify.ts +2 -1
  73. package/src/core/watchdog-registry.ts +58 -16
  74. package/src/index.ts +57 -6
  75. package/src/server/artifacts.ts +8 -61
  76. package/src/server/client/ArtifactsPanel.tsx +137 -23
  77. package/src/server/client/FindingCard.tsx +37 -6
  78. package/src/server/client/MarkdownText.tsx +101 -0
  79. package/src/server/client/SubmitModal.tsx +9 -4
  80. package/src/server/client/code-highlight.ts +422 -0
  81. package/src/server/client/constants.ts +130 -24
  82. package/src/server/client/json-highlight.ts +55 -0
  83. package/src/server/client/markdown.ts +153 -0
  84. package/src/server/client/types.ts +23 -0
  85. package/src/server/index.ts +155 -245
  86. package/src/server/run-repository.ts +299 -0
  87. package/src/skill/OUTPUT-FORMAT.md +6 -4
  88. package/src/skill/README.md +22 -14
  89. package/src/skill/SETUP.md +4 -4
  90. package/src/skill/fix.md +5 -5
  91. package/src/skill/hunt.md +27 -18
  92. package/src/skill/train.md +1 -1
  93. package/src/skill/verify.md +5 -5
  94. package/src/tests/__snapshots__/baseline.test.ts.snap +825 -0
  95. package/src/tests/baseline.test.ts +555 -493
  96. package/src/tests/cli.test.ts +40 -19
  97. package/src/tests/code-highlight.test.ts +123 -0
  98. package/src/tests/domain.test.ts +328 -0
  99. package/src/tests/findings-commands.test.ts +153 -131
  100. package/src/tests/findings-state.test.ts +6 -3
  101. package/src/tests/fixtures/fake-project.ts +0 -1
  102. package/src/tests/infrastructure.test.ts +205 -0
  103. package/src/tests/markdown.test.ts +89 -0
  104. package/src/tests/review-scripts.test.ts +8 -5
  105. package/src/tests/run-status.test.ts +33 -23
  106. package/src/tests/server.test.ts +108 -75
  107. package/src/tests/storage.test.ts +386 -0
@@ -1,24 +1,18 @@
1
1
  /**
2
2
  * Subcomando `findings` — triagem dos findings da revisão (biblioteca).
3
3
  *
4
- * Lê/grava o `state.json` da run (`findings-state.ts`). Devolve o texto pronto
5
- * (markdown ou JSON) + exit code; o CLI decide o log/exit. O agente do usuário
6
- * usa `list`/`set`/`done` (e `batch` para lotes de correção) para interagir sem abrir o JSON à mão.
4
+ * Lê/grava o estado de triagem no storage (SQLite por default). Devolve o texto
5
+ * pronto (markdown ou JSON) + exit code; o CLI decide o log/exit. O agente do
6
+ * usuário usa `list`/`set`/`done` (e `batch` para lotes de correção) para
7
+ * interagir sem abrir o banco à mão.
7
8
  *
8
- * Se o `state.json` não existir, é re-construído a partir de
9
- * `findings/*.json` + `classification.json` (não exige `--finalize`).
9
+ * Ids aceitam prefixo único (exato primeiro) — útil porque o id curto convive
10
+ * com o UUID interno no banco.
10
11
  */
11
12
 
12
- import { writeFileSync } from "node:fs";
13
- import { join } from "node:path";
14
- import {
15
- type HoundsmanConfig,
16
- resolveArtifactDir,
17
- resolveJevConcurrency,
18
- } from "../config";
13
+ import { type HoundsmanConfig, resolveJevConcurrency } from "../config";
19
14
  import {
20
15
  applyFindingUpdate,
21
- buildStateFromRunDir,
22
16
  FINDING_DECISIONS,
23
17
  FINDING_EXEC_STATES,
24
18
  type FindingDecision,
@@ -26,16 +20,14 @@ import {
26
20
  type FindingState,
27
21
  type FindingsFilter,
28
22
  type FindingsStateFile,
29
- FindingsStateStore,
30
- type FindingsSummary,
31
23
  type FindingUpdate,
32
24
  fileReader,
33
25
  filterFindings,
34
- runDir,
26
+ reorder,
27
+ resolveFindingId,
35
28
  summarize,
36
29
  syncState,
37
30
  } from "../findings-state";
38
- import { buildFixBatch, type FixBatchResult } from "../fix-batch";
39
31
  import {
40
32
  buildSubmitPreview,
41
33
  defaultGhRunner,
@@ -45,13 +37,18 @@ import {
45
37
  type SubmitSelection,
46
38
  submitFindings,
47
39
  } from "../github-review";
48
- import { relFromRoot, VERIFY_FILE } from "../paths";
40
+ import { VERIFY_FILE } from "../paths";
49
41
  import { currentBranch } from "../pr-context";
42
+ import { BatchPresenter } from "../renderers/batch-presenter";
43
+ import { FindingsPresenter } from "../renderers/findings-presenter";
44
+ import { VerifyPresenter } from "../renderers/verify-presenter";
50
45
  import { pingFindingsStatus } from "../run-status";
51
- import { SEVERITY_EMOJI, SEVERITY_ORDER, type Severity } from "../types";
52
- // Tipos são apagados no transpile (custo zero). Os **valores** de `../verify` e de
53
- // `../jev` (axios) entram por import dinâmico — só o subcomando `verify` os usa.
54
- import type { VerifyEntry, VerifyStatus, VerifySummary } from "../verify";
46
+ import { systemClock } from "../services/clock";
47
+ import { FixBatchBuilder } from "../services/fix-batch-builder";
48
+ import { Verifier } from "../services/verifier";
49
+ import type { RepoIdentity, ReviewRecord, StorageProvider } from "../storage/types";
50
+ import { ReviewWorkArea } from "../storage/work-area";
51
+ import { SEVERITY_ORDER, type Severity } from "../types";
55
52
 
56
53
  export type FindingsCommandResult = {
57
54
  output: string;
@@ -65,7 +62,6 @@ const VALUE_FLAGS = new Set([
65
62
  "--severity",
66
63
  "--file",
67
64
  "--branch",
68
- "--dir",
69
65
  "--limit",
70
66
  "--comment",
71
67
  "--by",
@@ -85,6 +81,11 @@ function parseArgs(args: string[]): ParsedArgs {
85
81
  const parsed: ParsedArgs = { positionals: [], values: {}, bools: new Set() };
86
82
  for (let i = 0; i < args.length; i++) {
87
83
  const arg = args[i];
84
+ if (arg === "--dir") {
85
+ throw new Error(
86
+ "--dir foi removido; configure `storage` no houndsman.config.ts.",
87
+ );
88
+ }
88
89
  if (arg.startsWith("--")) {
89
90
  if (VALUE_FLAGS.has(arg)) {
90
91
  const value = args[i + 1];
@@ -101,123 +102,76 @@ function parseArgs(args: string[]): ParsedArgs {
101
102
  return parsed;
102
103
  }
103
104
 
104
- /** Diretório da run + branch resolvida. */
105
- function resolveDir(
106
- config: HoundsmanConfig,
107
- parsed: ParsedArgs,
108
- ): { dir: string; branch: string } {
109
- return {
110
- dir: parsed.values["--dir"] ?? resolveArtifactDir(config),
111
- branch: parsed.values["--branch"] ?? currentBranch(),
112
- };
113
- }
105
+ /** Contexto de uma review (storage + registro + staging). */
106
+ type CommandContext = {
107
+ storage: StorageProvider;
108
+ review: ReviewRecord;
109
+ work: ReviewWorkArea;
110
+ };
114
111
 
115
- /** Lê o estado da run; reconstrói dos findings se ausente. Lança se impossível. */
116
- function loadState(config: HoundsmanConfig, parsed: ParsedArgs): FindingsStateFile {
117
- const { dir, branch } = resolveDir(config, parsed);
118
- const store = FindingsStateStore.at(dir, branch);
119
- const existing = store.read();
120
- if (existing) return existing;
121
-
122
- const built = buildStateFromRunDir(
123
- runDir(dir, branch),
124
- branch,
125
- fileReader(process.cwd()),
126
- );
127
- if (!built) {
112
+ /** Resolve a review da branch atual (explícita ou via git). */
113
+ function openContext(
114
+ storage: StorageProvider,
115
+ repo: RepoIdentity,
116
+ parsed: ParsedArgs,
117
+ ): CommandContext {
118
+ const branch = parsed.values["--branch"] ?? currentBranch();
119
+ const review = storage.findReview(repo.key, branch);
120
+ if (!review) {
128
121
  throw new Error(
129
- `Nenhum estado de triagem para '${branch}'. Rode \`houndsman --finalize\` primeiro.`,
122
+ `Nenhuma revisão para '${branch}'. Rode \`houndsman --finalize\` primeiro.`,
130
123
  );
131
124
  }
132
- store.write(built);
133
- return built;
125
+ return { storage, review, work: new ReviewWorkArea(review) };
134
126
  }
135
127
 
136
- // ---------------------------------------------------------------------------
137
- // Render
138
- // ---------------------------------------------------------------------------
139
-
140
- function decisionLabel(decision: FindingDecision): string {
141
- return decision;
128
+ /** Lê o estado de triagem; lança se a review ainda não tem findings. */
129
+ function loadState(ctx: CommandContext): FindingsStateFile {
130
+ const state = ctx.storage.readFindings(ctx.review.id);
131
+ if (!state) {
132
+ throw new Error(
133
+ `Nenhum estado de triagem para '${ctx.review.branch}'. Rode \`houndsman --finalize\` primeiro.`,
134
+ );
135
+ }
136
+ return state;
142
137
  }
143
138
 
144
- /** Uma linha compacta por finding (uso no agente). */
145
- function renderFindingLine(finding: FindingState): string[] {
146
- const emoji = SEVERITY_EMOJI[finding.severity];
147
- const location = finding.line ? `${finding.file}:${finding.line}` : finding.file;
148
- const flags = `${decisionLabel(finding.decision)}/${finding.execState}`;
149
- const count = finding.count > 1 ? ` ×${finding.count}` : "";
150
- const lines = [
151
- `- \`${finding.id}\` ${emoji} **${finding.severity}** \`${location}\` — ${finding.rule}${count}`,
152
- ` ${finding.message}`,
153
- ` _${flags}_ · watchdogs: ${finding.watchdogIds.join(", ") || "—"}`,
154
- ];
155
- if (finding.comment) lines.push(` **Comentário:** ${finding.comment}`);
156
- return lines;
139
+ /** Lê-modifica-grava o estado, atualizando `updatedAt` e a ordem. */
140
+ function mutateState(
141
+ ctx: CommandContext,
142
+ mutator: (state: FindingsStateFile) => void,
143
+ ): FindingsStateFile {
144
+ const state = loadState(ctx);
145
+ mutator(state);
146
+ state.updatedAt = systemClock();
147
+ reorder(state);
148
+ ctx.storage.writeFindings(ctx.review.id, state);
149
+ return state;
157
150
  }
158
151
 
159
- function renderList(state: FindingsStateFile, findings: FindingState[]): string {
160
- if (findings.length === 0) return "Nenhum finding para o filtro informado.";
161
- const lines = [`# Findings — ${state.branch} (${findings.length})`, ""];
162
- for (const finding of findings) lines.push(...renderFindingLine(finding));
163
- return lines.join("\n");
152
+ /** Resolve um id (exato ou prefixo único), lançando erro explicativo. */
153
+ function requireFindingId(state: FindingsStateFile, input: string): string {
154
+ const resolution = resolveFindingId(state, input);
155
+ if (resolution.status === "found") return resolution.id;
156
+ if (resolution.status === "ambiguous") {
157
+ throw new Error(
158
+ `Id '${input}' ambíguo — candidatos: ${resolution.matches.join(", ")}.`,
159
+ );
160
+ }
161
+ throw new Error(`Finding '${input}' não encontrado.`);
164
162
  }
165
163
 
166
- function renderSummary(state: FindingsStateFile, summary: FindingsSummary): string {
167
- const decisions = FINDING_DECISIONS.map((d) => `${d} ${summary.byDecision[d]}`).join(
168
- " · ",
169
- );
170
- const exec = FINDING_EXEC_STATES.map((e) => `${e} ${summary.byExecState[e]}`).join(
171
- " · ",
172
- );
173
- const severities = SEVERITY_ORDER.map((s) => `${s} ${summary.bySeverity[s]}`).join(
174
- " · ",
175
- );
176
- return [
177
- `# Triagem — ${state.branch}`,
178
- "",
179
- `Total: ${summary.total} (presentes: ${summary.present})`,
180
- `Decisões: ${decisions}`,
181
- `Execução: ${exec}`,
182
- `Severidade: ${severities}`,
183
- ].join("\n");
184
- }
164
+ // ---------------------------------------------------------------------------
165
+ // Apresentação
166
+ // ---------------------------------------------------------------------------
185
167
 
186
- /** Mix de severidade compacto (ex.: `❌1 ⚠️2`). */
187
- function severityBreakdown(bySeverity: Record<Severity, number>): string {
188
- return SEVERITY_ORDER.filter((severity) => bySeverity[severity] > 0)
189
- .map((severity) => `${SEVERITY_EMOJI[severity]}${bySeverity[severity]}`)
190
- .join(" ");
191
- }
168
+ const presenters = {
169
+ findings: new FindingsPresenter(),
170
+ batch: new BatchPresenter(),
171
+ verify: new VerifyPresenter(),
172
+ };
192
173
 
193
- /** Render do lote de correção: grupo atual paginado + outros grupos. */
194
- function renderBatch(result: FixBatchResult): string {
195
- const { dimension, current, others, totals } = result;
196
- if (totals.queue === 0) return "Nenhum finding pendente de correção.";
197
-
198
- const lines = [
199
- `# Lote de correção — agrupado por ${dimension} (${totals.groups} grupo(s))`,
200
- "",
201
- `## Grupo atual — ${current.key} (${current.total} finding(s), mostrando ${current.returned})`,
202
- ];
203
- for (const finding of current.findings) lines.push(...renderFindingLine(finding));
204
- if (current.remaining > 0) {
205
- lines.push("", `_… +${current.remaining} neste grupo (use \`--offset\`)._`);
206
- }
207
- if (others.length > 0) {
208
- lines.push("", "## Outros grupos disponíveis");
209
- for (const group of others) {
210
- lines.push(
211
- `- ${group.key} — ${group.count} (${severityBreakdown(group.bySeverity)})`,
212
- );
213
- }
214
- }
215
- lines.push(
216
- "",
217
- `fila: ${totals.queue} · retornados: ${totals.returned} · grupos: ${totals.groups}`,
218
- );
219
- return lines.join("\n");
220
- }
174
+ const fixBatchBuilder = new FixBatchBuilder();
221
175
 
222
176
  // ---------------------------------------------------------------------------
223
177
  // Execução
@@ -255,64 +209,52 @@ function filterFrom(parsed: ParsedArgs): FindingsFilter {
255
209
  };
256
210
  }
257
211
 
258
- /** Faz ping no `run.json` (status derivado do estado atual) após uma mutação. */
259
- function pingStatus(config: HoundsmanConfig, parsed: ParsedArgs): void {
260
- const { dir, branch } = resolveDir(config, parsed);
261
- const state = FindingsStateStore.at(dir, branch).read();
262
- if (state) pingFindingsStatus(runDir(dir, branch), state);
212
+ /** Faz ping no status da review (derivado do estado atual) após uma mutação. */
213
+ function pingStatus(ctx: CommandContext, state: FindingsStateFile): void {
214
+ pingFindingsStatus(ctx.storage, ctx.review.id, state);
263
215
  }
264
216
 
265
217
  /** Aplica uma atualização a um finding e persiste. */
266
218
  function updateOne(
267
- config: HoundsmanConfig,
268
- parsed: ParsedArgs,
269
- id: string,
219
+ ctx: CommandContext,
220
+ input: string,
270
221
  update: FindingUpdate,
271
222
  ): FindingState {
272
- const { dir, branch } = resolveDir(config, parsed);
273
- const store = FindingsStateStore.at(dir, branch);
274
223
  let updated: FindingState | undefined;
275
- store.update((state) => {
276
- const finding = state.findings[id];
277
- if (!finding) return;
278
- updated = applyFindingUpdate(finding, update);
224
+ mutateState(ctx, (state) => {
225
+ const id = requireFindingId(state, input);
226
+ updated = applyFindingUpdate(state.findings[id], update);
279
227
  });
280
- if (!updated) throw new Error(`Finding '${id}' não encontrado.`);
228
+ if (!updated) throw new Error(`Finding '${input}' não encontrado.`);
281
229
  return updated;
282
230
  }
283
231
 
284
232
  /** Aplica a mesma atualização a vários ids (done/reset). */
285
233
  function updateMany(
286
- config: HoundsmanConfig,
287
- parsed: ParsedArgs,
288
- ids: string[],
234
+ ctx: CommandContext,
235
+ inputs: string[],
289
236
  update: FindingUpdate,
290
237
  ): FindingState[] {
291
- const { dir, branch } = resolveDir(config, parsed);
292
- const store = FindingsStateStore.at(dir, branch);
293
238
  const updated: FindingState[] = [];
294
- store.update((state) => {
239
+ mutateState(ctx, (state) => {
240
+ const ids = inputs.map((input) => requireFindingId(state, input));
295
241
  for (const id of ids) {
296
- const finding = state.findings[id];
297
- if (!finding) continue;
298
- updated.push(applyFindingUpdate(finding, update));
242
+ updated.push(applyFindingUpdate(state.findings[id], update));
299
243
  }
300
244
  });
301
- const missing = ids.filter((id) => !updated.some((f) => f.id === id));
302
- if (missing.length > 0) {
303
- throw new Error(`Findings não encontrados: ${missing.join(", ")}.`);
304
- }
305
245
  return updated;
306
246
  }
307
247
 
308
248
  /** Executa um subcomando de `findings` e devolve o texto + exit code. */
309
249
  export function runFindingsCommand(
310
250
  config: HoundsmanConfig,
251
+ storage: StorageProvider,
252
+ repo: RepoIdentity,
311
253
  args: string[],
312
254
  ): FindingsCommandResult {
313
- const parsed = parseArgs(args);
314
255
  try {
315
- return execute(config, parsed);
256
+ const parsed = parseArgs(args);
257
+ return execute(config, storage, repo, parsed);
316
258
  } catch (error) {
317
259
  return {
318
260
  output: error instanceof Error ? error.message : String(error),
@@ -321,10 +263,16 @@ export function runFindingsCommand(
321
263
  }
322
264
  }
323
265
 
324
- function execute(config: HoundsmanConfig, parsed: ParsedArgs): FindingsCommandResult {
266
+ function execute(
267
+ _config: HoundsmanConfig,
268
+ storage: StorageProvider,
269
+ repo: RepoIdentity,
270
+ parsed: ParsedArgs,
271
+ ): FindingsCommandResult {
272
+ const ctx = openContext(storage, repo, parsed);
325
273
  switch (parsed.sub ?? "list") {
326
274
  case "list": {
327
- const state = loadState(config, parsed);
275
+ const state = loadState(ctx);
328
276
  const filtered = filterFindings(state, filterFrom(parsed));
329
277
  const limit = Number(parsed.values["--limit"]);
330
278
  const findings =
@@ -332,34 +280,35 @@ function execute(config: HoundsmanConfig, parsed: ParsedArgs): FindingsCommandRe
332
280
  return {
333
281
  output: parsed.bools.has("--json")
334
282
  ? JSON.stringify(findings, null, 2)
335
- : renderList(state, findings),
283
+ : presenters.findings.list(state, findings),
336
284
  exitCode: 0,
337
285
  };
338
286
  }
339
287
  case "show": {
340
- const state = loadState(config, parsed);
341
- const id = parsed.positionals[0];
342
- if (!id) throw new Error("Informe o id: houndsman findings show <id>");
288
+ const state = loadState(ctx);
289
+ const input = parsed.positionals[0];
290
+ if (!input) throw new Error("Informe o id: houndsman findings show <id>");
291
+ const id = requireFindingId(state, input);
343
292
  const finding = state.findings[id];
344
- if (!finding) throw new Error(`Finding '${id}' não encontrado.`);
345
293
  return {
346
294
  output: parsed.bools.has("--json")
347
295
  ? JSON.stringify(finding, null, 2)
348
- : renderList(state, [finding]),
296
+ : presenters.findings.list(state, [finding]),
349
297
  exitCode: 0,
350
298
  };
351
299
  }
352
300
  case "set": {
353
- const id = parsed.positionals[0];
354
- if (!id) throw new Error("Informe o id: houndsman findings set <id> [flags]");
301
+ const input = parsed.positionals[0];
302
+ if (!input)
303
+ throw new Error("Informe o id: houndsman findings set <id> [flags]");
355
304
  const update = updateFrom(parsed);
356
305
  if (!hasUpdate(update)) {
357
306
  throw new Error(
358
307
  "Nada para atualizar (use --decision, --exec ou --comment).",
359
308
  );
360
309
  }
361
- const finding = updateOne(config, parsed, id, update);
362
- pingStatus(config, parsed);
310
+ const finding = updateOne(ctx, input, update);
311
+ pingStatus(ctx, loadState(ctx));
363
312
  return {
364
313
  output: parsed.bools.has("--json")
365
314
  ? JSON.stringify(finding, null, 2)
@@ -369,8 +318,8 @@ function execute(config: HoundsmanConfig, parsed: ParsedArgs): FindingsCommandRe
369
318
  }
370
319
  case "done": {
371
320
  const ids = requireIds(parsed, "done");
372
- const updated = updateMany(config, parsed, ids, { execState: "done" });
373
- pingStatus(config, parsed);
321
+ const updated = updateMany(ctx, ids, { execState: "done" });
322
+ pingStatus(ctx, loadState(ctx));
374
323
  return {
375
324
  output: `Marcados como done: ${updated.map((f) => f.id).join(", ")}.`,
376
325
  exitCode: 0,
@@ -378,28 +327,26 @@ function execute(config: HoundsmanConfig, parsed: ParsedArgs): FindingsCommandRe
378
327
  }
379
328
  case "reset": {
380
329
  const ids = requireIds(parsed, "reset");
381
- const updated = updateMany(config, parsed, ids, {
330
+ const updated = updateMany(ctx, ids, {
382
331
  decision: "pending",
383
332
  execState: "pending",
384
333
  comment: "",
385
334
  });
386
- pingStatus(config, parsed);
335
+ pingStatus(ctx, loadState(ctx));
387
336
  return {
388
337
  output: `Triagem reiniciada: ${updated.map((f) => f.id).join(", ")}.`,
389
338
  exitCode: 0,
390
339
  };
391
340
  }
392
341
  case "sync": {
393
- const { dir, branch } = resolveDir(config, parsed);
394
- const store = FindingsStateStore.at(dir, branch);
395
- const state = loadState(config, parsed);
342
+ const state = loadState(ctx);
396
343
  const result = syncState(state, fileReader(process.cwd()));
397
- store.write(state);
398
- pingStatus(config, parsed);
344
+ ctx.storage.writeFindings(ctx.review.id, state);
345
+ pingStatus(ctx, state);
399
346
  const output = parsed.bools.has("--json")
400
347
  ? JSON.stringify(result, null, 2)
401
348
  : [
402
- `Sync — ${branch}: ${result.ok} ok, ${result.moved.length} movido(s), ${result.stale.length} stale.`,
349
+ `Sync — ${ctx.review.branch}: ${result.ok} ok, ${result.moved.length} movido(s), ${result.stale.length} stale.`,
403
350
  result.moved.length > 0
404
351
  ? `Movidos: ${result.moved.join(", ")}`
405
352
  : "",
@@ -412,25 +359,26 @@ function execute(config: HoundsmanConfig, parsed: ParsedArgs): FindingsCommandRe
412
359
  return { output, exitCode: 0 };
413
360
  }
414
361
  case "summary": {
415
- const state = loadState(config, parsed);
362
+ const state = loadState(ctx);
416
363
  const summary = summarize(state);
417
364
  return {
418
365
  output: parsed.bools.has("--json")
419
366
  ? JSON.stringify(summary, null, 2)
420
- : renderSummary(state, summary),
367
+ : presenters.findings.summary(state, summary),
421
368
  exitCode: 0,
422
369
  };
423
370
  }
424
371
  case "path": {
425
- const { dir, branch } = resolveDir(config, parsed);
426
- const path = FindingsStateStore.at(dir, branch).filePath;
427
- return { output: relFromRoot(path), exitCode: 0 };
372
+ return {
373
+ output: ctx.storage.location(ctx.review.id),
374
+ exitCode: 0,
375
+ };
428
376
  }
429
377
  case "batch": {
430
- const state = loadState(config, parsed);
378
+ const state = loadState(ctx);
431
379
  const severity = parsed.values["--severity"];
432
380
  const offset = Number(parsed.values["--offset"]);
433
- const result = buildFixBatch(state, {
381
+ const result = fixBatchBuilder.build(state, {
434
382
  severity: severity ? (severity as Severity) : undefined,
435
383
  includeDone: parsed.bools.has("--include-done"),
436
384
  offset: Number.isFinite(offset) ? offset : undefined,
@@ -439,7 +387,7 @@ function execute(config: HoundsmanConfig, parsed: ParsedArgs): FindingsCommandRe
439
387
  return {
440
388
  output: parsed.bools.has("--json")
441
389
  ? JSON.stringify(result, null, 2)
442
- : renderBatch(result),
390
+ : presenters.batch.render(result),
443
391
  exitCode: 0,
444
392
  };
445
393
  }
@@ -479,43 +427,6 @@ function requireIds(parsed: ParsedArgs, sub: string): string[] {
479
427
  // verify — as violações foram resolvidas? (sem re-revisar a PR)
480
428
  // ---------------------------------------------------------------------------
481
429
 
482
- /** Rótulo curto de cada status de verificação. */
483
- const VERIFY_LABEL: Record<VerifyStatus, string> = {
484
- resolved: "✅ resolvido",
485
- persisting: "❌ persiste",
486
- uncertain: "⚠️ incerto",
487
- "file-missing": "🗑️ arquivo ausente",
488
- };
489
-
490
- /** Render da verificação: contagem por status + uma linha por finding. */
491
- function renderVerify(
492
- state: FindingsStateFile,
493
- entries: VerifyEntry[],
494
- summary: VerifySummary,
495
- meta: { jevChecked: number; jevReason?: string },
496
- ): string {
497
- if (entries.length === 0) return "Nenhum finding para verificar.";
498
- const lines = [
499
- `# Verificação — ${state.branch} (${entries.length})`,
500
- "",
501
- `Resolvidos: ${summary.resolved} · Persistem: ${summary.persisting} · Incertos: ${summary.uncertain} · Arquivo ausente: ${summary["file-missing"]}`,
502
- ];
503
- if (meta.jevChecked > 0) lines.push(`Confirmados por Jev: ${meta.jevChecked}.`);
504
- else if (meta.jevReason) lines.push(`Jev: pulado (${meta.jevReason}).`);
505
- lines.push("");
506
- for (const entry of entries) {
507
- const location = entry.line ? `${entry.file}:${entry.line}` : entry.file;
508
- const via =
509
- entry.basis === "jev" ? ` · Jev ${(entry.confidence ?? 0).toFixed(2)}` : "";
510
- lines.push(
511
- `- \`${entry.id}\` ${VERIFY_LABEL[entry.status]} — **${entry.severity}** \`${location}\` — ${entry.rule}${via}`,
512
- ` ${entry.message}`,
513
- );
514
- if (entry.detail) lines.push(` _${entry.detail}_`);
515
- }
516
- return lines.join("\n");
517
- }
518
-
519
430
  /**
520
431
  * Subcomando `findings verify` — checa se as violações foram resolvidas, sem
521
432
  * re-revisar a PR. Tier local (grátis) + confirmação Jev dos casos ambíguos
@@ -523,11 +434,13 @@ function renderVerify(
523
434
  */
524
435
  export async function runVerifyCommand(
525
436
  config: HoundsmanConfig,
437
+ storage: StorageProvider,
438
+ repo: RepoIdentity,
526
439
  args: string[],
527
440
  ): Promise<FindingsCommandResult> {
528
- const parsed = parseArgs(args);
529
441
  try {
530
- return await executeVerify(config, parsed);
442
+ const parsed = parseArgs(args);
443
+ return await executeVerify(config, storage, repo, parsed);
531
444
  } catch (error) {
532
445
  return {
533
446
  output: error instanceof Error ? error.message : String(error),
@@ -538,34 +451,33 @@ export async function runVerifyCommand(
538
451
 
539
452
  async function executeVerify(
540
453
  config: HoundsmanConfig,
454
+ storage: StorageProvider,
455
+ repo: RepoIdentity,
541
456
  parsed: ParsedArgs,
542
457
  ): Promise<FindingsCommandResult> {
543
- const { dir, branch } = resolveDir(config, parsed);
544
- const store = FindingsStateStore.at(dir, branch);
545
- const state = loadState(config, parsed);
458
+ const ctx = openContext(storage, repo, parsed);
459
+ const state = loadState(ctx);
546
460
 
547
- // Lazy: `../verify` (leve) e `../jev` (axios) só entram no caminho do verify.
548
- const [
549
- { selectVerifiable, verifyLocal, summarizeVerify, applyJev, applyResolutions },
550
- { JevCandidatesStore, JevClient, JevVerifier },
551
- ] = await Promise.all([import("../verify"), import("../jev")]);
461
+ // Lazy: `../jev` (axios) só entra no caminho do verify.
462
+ const verifier = new Verifier();
463
+ const { JevCandidatesStore, JevClient, JevVerifier } = await import("../jev");
552
464
 
553
- const findings = selectVerifiable(state, filterFrom(parsed));
554
- const entries = verifyLocal(findings, fileReader(process.cwd()));
465
+ const findings = verifier.select(state, filterFrom(parsed));
466
+ const entries = verifier.verifyLocal(findings, fileReader(process.cwd()));
555
467
 
556
468
  // Tier 1 — Jev só nos ambíguos (fail-open: sem chave/erro mantém o provisório).
557
469
  let jevChecked = 0;
558
470
  let jevReason: string | undefined;
559
471
  const ambiguous = entries.filter((entry) => entry.ambiguous);
560
472
  if (ambiguous.length > 0) {
561
- const verifier = new JevVerifier(new JevClient());
473
+ const jevVerifier = new JevVerifier(new JevClient());
562
474
  if (parsed.bools.has("--no-jev")) {
563
475
  jevReason = "desativado (--no-jev)";
564
- } else if (!verifier.isConfigured()) {
476
+ } else if (!jevVerifier.isConfigured()) {
565
477
  jevReason = "TYPESAFE_API_KEY não configurada";
566
478
  } else {
567
479
  const byId = new Map(findings.map((finding) => [finding.id, finding]));
568
- const outcome = await verifier.verify(
480
+ const outcome = await jevVerifier.verify(
569
481
  ambiguous.map((entry) => ({
570
482
  id: entry.id,
571
483
  file: entry.file,
@@ -577,37 +489,48 @@ async function executeVerify(
577
489
  concurrency: resolveJevConcurrency(config),
578
490
  },
579
491
  );
580
- applyJev(entries, outcome.confidence, JevCandidatesStore.THRESHOLD);
492
+ verifier.applyJev(entries, outcome.confidence, JevCandidatesStore.THRESHOLD);
581
493
  jevChecked = outcome.checked;
582
494
  jevReason = outcome.reason;
583
495
  }
584
496
  }
585
497
 
586
- const summary = summarizeVerify(entries);
587
- const reportPath = join(runDir(dir, branch), VERIFY_FILE);
588
- writeFileSync(
589
- reportPath,
590
- `${renderVerify(state, entries, summary, { jevChecked, jevReason })}\n`,
591
- );
498
+ const summary = verifier.summarize(entries);
499
+ const location = ctx.storage.location(ctx.review.id);
500
+ ctx.storage.writeArtifact(ctx.review.id, {
501
+ name: VERIFY_FILE,
502
+ kind: "verify",
503
+ content: `${presenters.verify.render(state, entries, summary, { jevChecked, jevReason })}\n`,
504
+ });
592
505
 
593
506
  let applied: string[] = [];
594
507
  if (parsed.bools.has("--apply")) {
595
- store.update((current) => {
596
- applied = applyResolutions(current, entries);
508
+ const current = mutateState(ctx, (draft) => {
509
+ applied = verifier.applyResolutions(draft, entries);
597
510
  });
598
- pingStatus(config, parsed);
511
+ pingStatus(ctx, current);
599
512
  }
600
513
 
601
514
  const output = parsed.bools.has("--json")
602
515
  ? JSON.stringify(
603
- { branch, summary, jevChecked, jevReason, applied, entries },
516
+ {
517
+ branch: ctx.review.branch,
518
+ summary,
519
+ jevChecked,
520
+ jevReason,
521
+ applied,
522
+ entries,
523
+ },
604
524
  null,
605
525
  2,
606
526
  )
607
527
  : [
608
- renderVerify(state, entries, summary, { jevChecked, jevReason }),
528
+ presenters.verify.render(state, entries, summary, {
529
+ jevChecked,
530
+ jevReason,
531
+ }),
609
532
  "",
610
- `Relatório: ${relFromRoot(reportPath)}`,
533
+ `Relatório: ${location} (${VERIFY_FILE})`,
611
534
  parsed.bools.has("--apply")
612
535
  ? `Aplicados (done): ${applied.length > 0 ? applied.join(", ") : "—"}.`
613
536
  : "",
@@ -697,11 +620,13 @@ function renderSubmitOutcome(outcome: SubmitOutcome, json: boolean): string {
697
620
  */
698
621
  export async function runSubmitCommand(
699
622
  config: HoundsmanConfig,
623
+ storage: StorageProvider,
624
+ repo: RepoIdentity,
700
625
  args: string[],
701
626
  ): Promise<FindingsCommandResult> {
702
- const parsed = parseArgs(args);
703
627
  try {
704
- return await executeSubmit(config, parsed);
628
+ const parsed = parseArgs(args);
629
+ return await executeSubmit(config, storage, repo, parsed);
705
630
  } catch (error) {
706
631
  return {
707
632
  output: error instanceof Error ? error.message : String(error),
@@ -711,10 +636,13 @@ export async function runSubmitCommand(
711
636
  }
712
637
 
713
638
  async function executeSubmit(
714
- config: HoundsmanConfig,
639
+ _config: HoundsmanConfig,
640
+ storage: StorageProvider,
641
+ repo: RepoIdentity,
715
642
  parsed: ParsedArgs,
716
643
  ): Promise<FindingsCommandResult> {
717
- const state = loadState(config, parsed);
644
+ const ctx = openContext(storage, repo, parsed);
645
+ const state = loadState(ctx);
718
646
  const selection = submitSelection(parsed);
719
647
  const target = resolvePrTarget(defaultGhRunner, state.branch);
720
648
  const preview = buildSubmitPreview(state, target, selection);
@@ -731,11 +659,8 @@ async function executeSubmit(
731
659
  runner: defaultGhRunner,
732
660
  selection,
733
661
  });
734
- FindingsStateStore.at(
735
- parsed.values["--dir"] ?? resolveArtifactDir(config),
736
- state.branch,
737
- ).write(state);
738
- pingStatus(config, parsed);
662
+ ctx.storage.writeFindings(ctx.review.id, state);
663
+ pingStatus(ctx, state);
739
664
 
740
665
  return {
741
666
  output: renderSubmitOutcome(outcome, json),