@payconductor/houndsman 0.2.0 → 0.3.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 (68) hide show
  1. package/package.json +1 -1
  2. package/src/cli/arg-parser.ts +8 -0
  3. package/src/cli/commands/command.ts +23 -0
  4. package/src/cli/commands/context-command.ts +38 -0
  5. package/src/cli/commands/doctor-command.ts +55 -0
  6. package/src/cli/commands/findings-command.ts +28 -0
  7. package/src/cli/commands/help-command.ts +13 -0
  8. package/src/cli/commands/registry.ts +34 -0
  9. package/src/cli/commands/review-command.ts +22 -0
  10. package/src/cli/commands/server-command.ts +37 -0
  11. package/src/cli/commands/setup-command.ts +34 -0
  12. package/src/cli/commands/watchdog-command.ts +34 -0
  13. package/src/cli/help.ts +43 -0
  14. package/src/cli/index.ts +58 -266
  15. package/src/core/arg-parser.ts +48 -0
  16. package/src/core/commands/findings.ts +34 -144
  17. package/src/core/commands/review.ts +18 -35
  18. package/src/core/commands/scaffold.ts +174 -64
  19. package/src/core/commands/setup.ts +16 -11
  20. package/src/core/commands/update.ts +7 -7
  21. package/src/core/commands/watchdog.ts +8 -80
  22. package/src/core/config.ts +9 -19
  23. package/src/core/defaults.ts +20 -0
  24. package/src/core/diff-source.ts +1 -13
  25. package/src/core/doc-discoverer.ts +25 -15
  26. package/src/core/domain/budget.ts +42 -0
  27. package/src/core/domain/classification.ts +94 -0
  28. package/src/core/domain/diff.ts +53 -0
  29. package/src/core/domain/watchdog.ts +96 -0
  30. package/src/core/engine/carryover.ts +67 -0
  31. package/src/core/engine/houndsman-engine.ts +51 -0
  32. package/src/core/engine/review-scope.ts +95 -0
  33. package/src/core/engine/review-session.ts +51 -0
  34. package/src/core/file-classifier.ts +11 -67
  35. package/src/core/findings-state.ts +170 -38
  36. package/src/core/git-revision.ts +14 -40
  37. package/src/core/git-runner.ts +46 -0
  38. package/src/core/github-review.ts +4 -33
  39. package/src/core/orchestrator.ts +116 -337
  40. package/src/core/paths.ts +9 -0
  41. package/src/core/pr-context.ts +28 -54
  42. package/src/core/project-config.ts +57 -0
  43. package/src/core/renderers/batch-presenter.ts +40 -0
  44. package/src/core/renderers/brief-renderer.ts +23 -0
  45. package/src/core/renderers/findings-presenter.ts +74 -0
  46. package/src/core/renderers/manifest-writer.ts +112 -0
  47. package/src/core/renderers/report-renderer.ts +66 -0
  48. package/src/core/renderers/verify-presenter.ts +50 -0
  49. package/src/core/renderers/watchdog-presenter.ts +100 -0
  50. package/src/core/report-builder.ts +12 -7
  51. package/src/core/run-layout.ts +108 -0
  52. package/src/core/run-status.ts +6 -11
  53. package/src/core/services/clock.ts +12 -0
  54. package/src/core/services/file-classifier.ts +34 -0
  55. package/src/core/services/fix-batch-builder.ts +13 -0
  56. package/src/core/services/gh-client.ts +54 -0
  57. package/src/core/services/git-repository.ts +123 -0
  58. package/src/core/services/project-files.ts +79 -0
  59. package/src/core/services/verifier.ts +58 -0
  60. package/src/core/verify.ts +2 -1
  61. package/src/core/watchdog-registry.ts +58 -16
  62. package/src/index.ts +31 -1
  63. package/src/server/index.ts +64 -234
  64. package/src/server/run-repository.ts +291 -0
  65. package/src/tests/__snapshots__/baseline.test.ts.snap +814 -0
  66. package/src/tests/baseline.test.ts +513 -493
  67. package/src/tests/domain.test.ts +328 -0
  68. package/src/tests/infrastructure.test.ts +210 -0
@@ -15,29 +15,21 @@
15
15
  */
16
16
 
17
17
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
18
- import { join, relative } from "node:path";
19
- import { renderBrief } from "./brief";
18
+ import { join } from "node:path";
20
19
  import {
21
20
  type HoundsmanConfig,
22
21
  resolveJevConcurrency,
23
22
  resolveMaxFilesPerBrief,
24
23
  } from "./config";
25
- import { churnToRecord, collectDiff, type DiffSource } from "./diff-source";
24
+ import type { DiffSource } from "./diff-source";
26
25
  import { discoverDocs } from "./doc-discoverer";
27
- import {
28
- type BudgetSelection,
29
- type ClassificationResult,
30
- classifyEntries,
31
- classifyRange,
32
- selectByBudget,
33
- } from "./file-classifier";
34
- import {
35
- buildStateFromFindings,
36
- type FindingsStateFile,
37
- FindingsStateStore,
38
- fileReader,
39
- } from "./findings-state";
40
- import { collectDiffSince, diffEntries, isAncestor, resolveSha } from "./git-revision";
26
+ import { Classification } from "./domain/classification";
27
+ import { Watchdog } from "./domain/watchdog";
28
+ import { mergeCarryover } from "./engine/carryover";
29
+ import { ReviewScopeResolver, type RunClassification } from "./engine/review-scope";
30
+ import type { BudgetSelection, ClassificationResult } from "./file-classifier";
31
+ import { buildStateFromFindings, FindingsStateStore, fileReader } from "./findings-state";
32
+ import { resolveSha } from "./git-revision";
41
33
  import {
42
34
  type JevCandidate,
43
35
  type JevCandidatesFile,
@@ -45,21 +37,24 @@ import {
45
37
  JevClient,
46
38
  JevDetector,
47
39
  } from "./jev";
48
- import { ARTIFACT_DIR, OUTPUT_FORMAT_PATH } from "./paths";
49
- import { contextPaths, currentBranch, readContextSummary } from "./pr-context";
40
+ import { ARTIFACT_DIR, OUTPUT_FORMAT_PATH, relFromRoot } from "./paths";
41
+ import { currentBranch, readContextSummary } from "./pr-context";
42
+ import { ProjectConfig } from "./project-config";
43
+ import { BriefRenderer } from "./renderers/brief-renderer";
44
+ import { ManifestWriter } from "./renderers/manifest-writer";
45
+ import { ReportRenderer } from "./renderers/report-renderer";
50
46
  import {
51
47
  type ContextSummary,
52
48
  type DomainReport,
53
49
  dedupeFindings,
54
- type MergedFinding,
55
50
  parseWatchdogReport,
56
- renderDomainReport,
57
- renderReport,
58
51
  sanitizeBranch,
59
52
  type WatchdogReport,
60
53
  } from "./report-builder";
54
+ import { RunLayout } from "./run-layout";
61
55
  import { writeRunStatus } from "./run-status";
62
- import { severityRank, type WatchdogMeta } from "./types";
56
+ import { systemClock } from "./services/clock";
57
+ import type { WatchdogMeta } from "./types";
63
58
  import { findWatchdog } from "./watchdog-registry";
64
59
 
65
60
  export type ReviewOptions = {
@@ -89,27 +84,11 @@ export function defaultOptions(artifactDir: string = ARTIFACT_DIR): ReviewOption
89
84
  };
90
85
  }
91
86
 
92
- /** Caminho relativo à raiz, sempre com `/` (portável/legível). */
93
- function relFromRoot(absPath: string): string {
94
- return relative(process.cwd(), absPath).replace(/\\/g, "/");
95
- }
96
-
97
- type Paths = {
98
- slug: string;
99
- outDir: string;
100
- briefsDir: string;
101
- findingsDir: string;
102
- };
87
+ /** Layout de uma run (paths de briefs/findings/artefatos). */
88
+ type Paths = RunLayout;
103
89
 
104
90
  function paths(opts: ReviewOptions, branch: string): Paths {
105
- const slug = sanitizeBranch(branch);
106
- const outDir = join(opts.dir, slug);
107
- return {
108
- slug,
109
- outDir,
110
- briefsDir: join(outDir, "briefs"),
111
- findingsDir: join(outDir, "findings"),
112
- };
91
+ return new RunLayout(opts.dir, branch);
113
92
  }
114
93
 
115
94
  function prepareDirs(dirs: Paths, clean: boolean): void {
@@ -125,28 +104,11 @@ function prepareDirs(dirs: Paths, clean: boolean): void {
125
104
  // Escopo da revisão (completo vs incremental)
126
105
  // ---------------------------------------------------------------------------
127
106
 
128
- /** Classificação enriquecida gravada no `classification.json` (modo + revisão + carry-over). */
129
- type RunClassification = ClassificationResult & {
130
- /** Commit revisado (head do diff). */
131
- headSha?: string;
132
- /** Modo do prepare: completo ou incremental (delta desde a última revisão). */
133
- mode: "full" | "incremental";
134
- /** Commit da última revisão (só no incremental). */
135
- reviewedFrom?: string;
136
- /** Findings preservados do review anterior (arquivos inalterados). */
137
- carryover?: MergedFinding[];
138
- };
139
-
140
107
  /** SHA curto para logs (7 chars, como o git). */
141
108
  function shortSha(sha: string): string {
142
109
  return sha.slice(0, 7);
143
110
  }
144
111
 
145
- /** Chave de dedupe de finding: `(file, line, rule)` — igual ao `dedupeFindings`. */
146
- function findingKey(finding: Pick<MergedFinding, "file" | "line" | "rule">): string {
147
- return `${finding.file}|${finding.line ?? ""}|${finding.rule}`;
148
- }
149
-
150
112
  /** Aviso no topo do `review.md` quando a revisão foi incremental (senão vazio). */
151
113
  function incrementalNote(classification: RunClassification): string {
152
114
  if (classification.mode !== "incremental" || !classification.reviewedFrom) return "";
@@ -158,105 +120,6 @@ function incrementalNote(classification: RunClassification): string {
158
120
  );
159
121
  }
160
122
 
161
- /**
162
- * Findings do review anterior em arquivos inalterados — carregados sem
163
- * re-revisão (carry-over). Preserva o `foundAtSha` para o diff focado do verify.
164
- */
165
- function buildCarryover(
166
- previous: FindingsStateFile | null,
167
- changedPaths: Set<string>,
168
- ): MergedFinding[] {
169
- if (!previous) return [];
170
- return Object.values(previous.findings)
171
- .filter((finding) => finding.present && !changedPaths.has(finding.file))
172
- .map((finding) => ({
173
- id: finding.id,
174
- rule: finding.rule,
175
- severity: finding.severity,
176
- file: finding.file,
177
- line: finding.line ?? undefined,
178
- message: finding.message,
179
- action: finding.action,
180
- evidence: finding.evidence,
181
- foundAtSha: finding.foundAtSha,
182
- watchdogs: [...finding.watchdogIds],
183
- }));
184
- }
185
-
186
- /** Une os findings re-reportados com os carry-over (preserva ids do estado anterior). */
187
- function mergeCarryover(
188
- merged: MergedFinding[],
189
- carryover: readonly MergedFinding[],
190
- ): MergedFinding[] {
191
- const byKey = new Map(merged.map((finding) => [findingKey(finding), finding]));
192
- for (const carried of carryover) {
193
- const existing = byKey.get(findingKey(carried));
194
- if (existing) {
195
- for (const watchdog of carried.watchdogs) {
196
- if (!existing.watchdogs.includes(watchdog))
197
- existing.watchdogs.push(watchdog);
198
- }
199
- continue;
200
- }
201
- byKey.set(findingKey(carried), carried);
202
- merged.push(carried);
203
- }
204
- merged.sort((a, b) => {
205
- const bySeverity = severityRank(a.severity) - severityRank(b.severity);
206
- if (bySeverity !== 0) return bySeverity;
207
- if (a.file !== b.file) return a.file.localeCompare(b.file);
208
- return (a.line ?? 0) - (b.line ?? 0);
209
- });
210
- return merged;
211
- }
212
-
213
- /**
214
- * Decide entre review completo e incremental. Incremental só quando há um
215
- * `reviewed.headSha` anterior, o `HEAD` atual o sucede (sem rebase) e não veio
216
- * `--full`/`--clean`. Reconstrói só o delta `prevSha..headSha` e carrega os
217
- * findings de arquivos inalterados do estado anterior.
218
- */
219
- function resolveReviewScope(
220
- opts: ReviewOptions,
221
- previous: FindingsStateFile | null,
222
- headSha: string,
223
- config: HoundsmanConfig,
224
- ): { classification: RunClassification; diff: DiffSource; reviewedFrom?: string } {
225
- const prevSha = previous?.reviewed?.headSha;
226
- if (
227
- opts.full ||
228
- opts.clean ||
229
- !prevSha ||
230
- prevSha === headSha ||
231
- !isAncestor(prevSha, headSha)
232
- ) {
233
- const diff = collectDiff(opts.base, opts.head);
234
- return {
235
- diff,
236
- classification: {
237
- ...classifyRange(opts.base, opts.head, config, diff),
238
- headSha,
239
- mode: "full",
240
- },
241
- };
242
- }
243
-
244
- const range = `${prevSha}..${headSha}`;
245
- const diff = collectDiffSince(prevSha, headSha);
246
- const entries = diffEntries(range);
247
- const changedPaths = new Set(entries.map((entry) => entry.path));
248
- const classification: RunClassification = {
249
- ...classifyEntries(entries, config, churnToRecord(diff.churn)),
250
- base: prevSha,
251
- head: opts.head,
252
- headSha,
253
- mode: "incremental",
254
- reviewedFrom: prevSha,
255
- carryover: buildCarryover(previous, changedPaths),
256
- };
257
- return { classification, diff, reviewedFrom: prevSha };
258
- }
259
-
260
123
  // ---------------------------------------------------------------------------
261
124
  // Findings
262
125
  // ---------------------------------------------------------------------------
@@ -264,9 +127,10 @@ function resolveReviewScope(
264
127
  function readFindings(
265
128
  findingsDir: string,
266
129
  activeIds: string[],
267
- ): { reports: WatchdogReport[]; pending: string[] } {
130
+ ): { reports: WatchdogReport[]; pending: string[]; warnings: string[] } {
268
131
  const reports: WatchdogReport[] = [];
269
132
  const pending: string[] = [];
133
+ const warnings: string[] = [];
270
134
  for (const id of activeIds) {
271
135
  const path = join(findingsDir, `${id}.json`);
272
136
  if (!existsSync(path)) {
@@ -275,129 +139,52 @@ function readFindings(
275
139
  }
276
140
  try {
277
141
  const raw = JSON.parse(readFileSync(path, "utf8"));
278
- const report = parseWatchdogReport(raw, path);
142
+ const report = parseWatchdogReport(raw, path, (message) =>
143
+ warnings.push(message),
144
+ );
279
145
  if (report) reports.push(report);
280
146
  else pending.push(id);
281
147
  } catch (err) {
282
- console.error(
148
+ warnings.push(
283
149
  `[houndsman] ${relFromRoot(path)} inválido: ${err instanceof Error ? err.message : err}`,
284
150
  );
285
151
  pending.push(id);
286
152
  }
287
153
  }
288
- return { reports, pending };
154
+ return { reports, pending, warnings };
289
155
  }
290
156
 
291
157
  // ---------------------------------------------------------------------------
292
- // Manifesto
158
+ // Orçamento (triage de PRs grandes)
293
159
  // ---------------------------------------------------------------------------
294
160
 
295
- function contextManifest(
296
- opts: ReviewOptions,
297
- branch: string,
298
- ): { summarized: boolean; source: ContextSummary["source"] | null } {
299
- const summary = readContextSummary(contextPaths(opts.dir, branch).summary);
300
- if (!summary) return { summarized: false, source: null };
301
- return { summarized: true, source: summary.source ?? null };
161
+ /** Aplica o orçamento aos arquivos de um watchdog. */
162
+ function budgetFor(
163
+ classification: ClassificationResult,
164
+ id: string,
165
+ maxFiles: number,
166
+ ): BudgetSelection {
167
+ return Classification.from(classification).budgetFor(id, maxFiles);
302
168
  }
303
169
 
170
+ /** Grava o manifesto da run (delegado ao `ManifestWriter`). */
304
171
  function writeManifest(
305
- outDir: string,
172
+ layout: RunLayout,
173
+ config: HoundsmanConfig,
306
174
  opts: ReviewOptions,
307
- branch: string,
308
175
  classification: RunClassification,
309
176
  activeIds: string[],
310
177
  pending: string[],
311
178
  jev: JevCandidatesFile,
312
- config: HoundsmanConfig,
313
179
  ): void {
314
- const maxFilesPerBrief = resolveMaxFilesPerBrief(config);
315
- let filesOverBudget = 0;
316
- const watchdogs = activeIds.map((id) => {
317
- const selection = budgetFor(classification, id, maxFilesPerBrief);
318
- filesOverBudget += selection.overflow.length;
319
- return {
320
- id,
321
- name: findWatchdog(config, id)?.name ?? id,
322
- files: selection.selected,
323
- overflow: selection.overflow.length,
324
- brief: relFromRoot(join(outDir, "briefs", `${id}.md`)),
325
- findings: relFromRoot(join(outDir, "findings", `${id}.json`)),
326
- status: pending.includes(id) ? "pending" : "done",
327
- };
328
- });
329
-
330
- const manifest = {
331
- branch,
332
- slug: sanitizeBranch(branch),
333
- base: opts.base,
334
- head: opts.head,
335
- headSha: classification.headSha,
336
- generatedAt: new Date().toISOString(),
337
- outputFormat: OUTPUT_FORMAT_PATH,
338
- budget: { maxFilesPerBrief, filesOverBudget },
339
- churn: churnTotals(classification),
340
- incremental:
341
- classification.mode === "incremental"
342
- ? {
343
- from: classification.reviewedFrom,
344
- changedFiles: Object.keys(classification.files).length,
345
- carried: classification.carryover?.length ?? 0,
346
- }
347
- : undefined,
348
- watchdogs,
349
- context: contextManifest(opts, branch),
350
- jev: jev.available
351
- ? {
352
- available: true,
353
- model: jev.model,
354
- threshold: jev.threshold,
355
- analyzedFiles: jev.analyzedFiles,
356
- candidates: jev.candidates.length,
357
- skipped: jev.skipped?.length ?? 0,
358
- }
359
- : { available: false, reason: jev.reason },
360
- deleted: classification.deleted,
361
- unclassified: classification.unclassified,
362
- excludedCount: classification.excludedCount,
363
- };
364
- writeFileSync(
365
- join(outDir, "manifest.json"),
366
- `${JSON.stringify(manifest, null, 2)}\n`,
367
- );
368
- }
369
-
370
- // ---------------------------------------------------------------------------
371
- // Orçamento (triage de PRs grandes)
372
- // ---------------------------------------------------------------------------
373
-
374
- /** Aplica o orçamento aos arquivos de um watchdog. */
375
- function budgetFor(
376
- classification: ClassificationResult,
377
- id: string,
378
- maxFiles: number,
379
- ): BudgetSelection {
380
- return selectByBudget(
381
- classification.watchdogs[id] ?? [],
382
- classification.churn,
383
- maxFiles,
180
+ new ManifestWriter(layout, new ProjectConfig(config), opts).write(
181
+ classification,
182
+ activeIds,
183
+ pending,
184
+ jev,
384
185
  );
385
186
  }
386
187
 
387
- /** Totais de churn do diff (métricas do manifesto). */
388
- function churnTotals(classification: ClassificationResult): {
389
- additions: number;
390
- deletions: number;
391
- } {
392
- let additions = 0;
393
- let deletions = 0;
394
- for (const entry of Object.values(classification.churn ?? {})) {
395
- additions += entry.additions;
396
- deletions += entry.deletions;
397
- }
398
- return { additions, deletions };
399
- }
400
-
401
188
  // ---------------------------------------------------------------------------
402
189
  // Briefs
403
190
  // ---------------------------------------------------------------------------
@@ -413,11 +200,12 @@ function writeBriefs(
413
200
  ): void {
414
201
  const docs = discoverDocs(Object.keys(classification.files), config);
415
202
  const maxFilesPerBrief = resolveMaxFilesPerBrief(config);
203
+ const renderer = new BriefRenderer();
416
204
  for (const id of activeIds) {
417
205
  const def = findWatchdog(config, id);
418
206
  if (!def) continue;
419
207
  const { selected, overflow } = budgetFor(classification, id, maxFilesPerBrief);
420
- const brief = renderBrief(def, {
208
+ const brief = renderer.render(new Watchdog(def), {
421
209
  watchdogId: id,
422
210
  files: selected,
423
211
  statuses: classification.files,
@@ -425,13 +213,13 @@ function writeBriefs(
425
213
  overflow,
426
214
  deleted: classification.deleted,
427
215
  branch,
428
- outputPath: relFromRoot(join(dirs.findingsDir, `${id}.json`)),
216
+ outputPath: relFromRoot(dirs.findingFile(id)),
429
217
  outputFormatPath: OUTPUT_FORMAT_PATH,
430
218
  docs,
431
219
  context,
432
220
  candidates: candidates.filter((c) => c.watchdogId === id),
433
221
  });
434
- writeFileSync(join(dirs.briefsDir, `${id}.md`), `${brief}\n`);
222
+ writeFileSync(dirs.briefFile(id), `${brief}\n`);
435
223
  }
436
224
  }
437
225
 
@@ -450,10 +238,7 @@ async function resolveCandidates(
450
238
  config: HoundsmanConfig,
451
239
  diff: DiffSource,
452
240
  ): Promise<JevCandidatesFile> {
453
- const store = new JevCandidatesStore(
454
- join(dirs.outDir, "candidates.json"),
455
- config.watchdogIds,
456
- );
241
+ const store = new JevCandidatesStore(dirs.candidatesFile, config.watchdogIds);
457
242
  const save = (data: JevCandidatesFile): JevCandidatesFile => {
458
243
  store.write(data);
459
244
  return data;
@@ -494,55 +279,57 @@ async function resolveCandidates(
494
279
  }
495
280
  }
496
281
 
497
- function printJevSummary(jev: JevCandidatesFile): void {
498
- if (!jev.available) {
499
- console.log(`Jev: pulado (${jev.reason}).`);
500
- return;
501
- }
282
+ function jevSummary(jev: JevCandidatesFile): string[] {
283
+ if (!jev.available) return [`Jev: pulado (${jev.reason}).`];
502
284
  const skipped = jev.skipped?.length ?? 0;
503
- console.log(
285
+ return [
504
286
  `Jev: ${jev.candidates.length} candidato(s) em ${jev.analyzedFiles ?? 0} arquivo(s)` +
505
287
  (skipped > 0 ? `, ${skipped} com falha.` : "."),
506
- );
288
+ ];
507
289
  }
508
290
 
509
- function printDiscovery(
291
+ function discoveryMessages(
510
292
  activeIds: string[],
511
293
  classification: ClassificationResult,
512
294
  briefsDir: string,
513
- ): void {
514
- console.log(
295
+ ): string[] {
296
+ const messages = [
515
297
  `Discovery: ${activeIds.length} watchdog(s) ativado(s), ${Object.keys(classification.files).length} arquivo(s), ${classification.deleted.length} deletado(s).`,
516
- );
517
- console.log(`Briefs: ${relFromRoot(briefsDir)}/`);
298
+ `Briefs: ${relFromRoot(briefsDir)}/`,
299
+ ];
518
300
  for (const file of classification.deleted) {
519
- console.log(`Deletado (breaking change): ${file}`);
301
+ messages.push(`Deletado (breaking change): ${file}`);
520
302
  }
303
+ return messages;
521
304
  }
522
305
 
523
306
  /** Resumo do orçamento — só quando algum arquivo ficou de fora (PR grande). */
524
- function printBudget(
307
+ function budgetMessage(
525
308
  classification: ClassificationResult,
526
309
  config: HoundsmanConfig,
527
- ): void {
310
+ ): string[] {
528
311
  const maxFiles = resolveMaxFilesPerBrief(config);
529
312
  let overflow = 0;
530
313
  for (const id of Object.keys(classification.watchdogs)) {
531
314
  overflow += budgetFor(classification, id, maxFiles).overflow.length;
532
315
  }
533
- if (overflow === 0) return;
534
- console.log(
316
+ if (overflow === 0) return [];
317
+ return [
535
318
  `Orçamento: ${maxFiles} arquivo(s)/brief — ${overflow} fora do orçamento (menor churn; registrar em skipped).`,
536
- );
319
+ ];
537
320
  }
538
321
 
322
+ /** Resultado do prepare — mensagens para o CLI imprimir. */
323
+ export type PrepareResult = { messages: string[] };
324
+
539
325
  /** Passo 1 — descobre os arquivos e grava briefs + manifesto. */
540
326
  export async function prepare(
541
327
  opts: ReviewOptions,
542
328
  branch: string,
543
329
  config: HoundsmanConfig,
544
330
  dirs: Paths = paths(opts, branch),
545
- ): Promise<void> {
331
+ ): Promise<PrepareResult> {
332
+ const messages: string[] = [];
546
333
  prepareDirs(dirs, opts.clean);
547
334
 
548
335
  // Marca o run como "revisando" (reinicia o ciclo) — a UI mostra o status.
@@ -551,9 +338,12 @@ export async function prepare(
551
338
  // Marca o commit revisado e decide entre review completo e incremental.
552
339
  const headSha = resolveSha(opts.head);
553
340
  const previous = FindingsStateStore.at(opts.dir, branch).read();
554
- const { classification, diff } = resolveReviewScope(opts, previous, headSha, config);
341
+ const { classification, diff } = new ReviewScopeResolver(opts, config).resolve(
342
+ previous,
343
+ headSha,
344
+ );
555
345
  writeFileSync(
556
- join(dirs.outDir, "classification.json"),
346
+ dirs.classificationFile,
557
347
  `${JSON.stringify(classification, null, 2)}\n`,
558
348
  );
559
349
 
@@ -562,53 +352,51 @@ export async function prepare(
562
352
  const activeIds = Object.keys(classification.watchdogs);
563
353
  if (Object.keys(classification.files).length === 0) {
564
354
  writeRunStatus(dirs.outDir, { branch, status: "review_done" });
565
- console.log(
355
+ messages.push(
566
356
  classification.mode === "incremental"
567
357
  ? `Nenhuma alteração desde a última revisão (${shortSha(classification.reviewedFrom ?? "")}).`
568
358
  : `Nenhuma alteração entre ${opts.base}...${opts.head}.`,
569
359
  );
570
- return;
360
+ return { messages };
571
361
  }
572
362
  if (activeIds.length === 0) {
573
363
  writeRunStatus(dirs.outDir, { branch, status: "review_done" });
574
- console.log("Nenhum arquivo revisável (tudo excluído, deletado ou irrelevante).");
575
- return;
364
+ messages.push(
365
+ "Nenhum arquivo revisável (tudo excluído, deletado ou irrelevante).",
366
+ );
367
+ return { messages };
576
368
  }
577
369
  if (classification.mode === "incremental") {
578
- console.log(
370
+ messages.push(
579
371
  `Modo incremental: ${Object.keys(classification.files).length} arquivo(s) alterado(s) desde ${shortSha(classification.reviewedFrom ?? "")}; ${classification.carryover?.length ?? 0} finding(s) carregado(s) do review anterior.`,
580
372
  );
581
373
  }
582
374
 
583
- const context = readContextSummary(contextPaths(opts.dir, branch).summary);
375
+ const context = readContextSummary(dirs.contextPaths.summary);
584
376
 
585
377
  writeBriefs(activeIds, classification, dirs, branch, context, jev.candidates, config);
586
- writeManifest(
587
- dirs.outDir,
588
- opts,
589
- branch,
590
- classification,
591
- activeIds,
592
- activeIds,
593
- jev,
594
- config,
378
+ writeManifest(dirs, config, opts, classification, activeIds, activeIds, jev);
379
+
380
+ messages.push(
381
+ ...discoveryMessages(activeIds, classification, dirs.briefsDir),
382
+ ...budgetMessage(classification, config),
383
+ ...jevSummary(jev),
595
384
  );
596
- printDiscovery(activeIds, classification, dirs.briefsDir);
597
- printBudget(classification, config);
598
- printJevSummary(jev);
599
385
 
600
386
  if (context) {
601
- console.log("Contexto da PR resumido e injetado nos briefs.");
387
+ messages.push("Contexto da PR resumido e injetado nos briefs.");
602
388
  } else {
603
- console.log(
389
+ messages.push(
604
390
  "Sem contexto da PR (rode `bun run houndsman context` antes, se houver PR).",
605
391
  );
606
392
  }
607
393
 
608
- console.log(
394
+ messages.push(
609
395
  `\nPróximo passo: despachar os sub-agentes (um por brief em ${relFromRoot(dirs.briefsDir)}/).`,
396
+ "Quando os findings estiverem gravados: bun run houndsman --finalize",
610
397
  );
611
- console.log("Quando os findings estiverem gravados: bun run houndsman --finalize");
398
+
399
+ return { messages };
612
400
  }
613
401
 
614
402
  // ---------------------------------------------------------------------------
@@ -633,7 +421,7 @@ function readClassification(classificationPath: string): RunClassification {
633
421
  * Mantém o `review.md` consolidado navegável em PRs grandes (centenas de achados).
634
422
  */
635
423
  function writeDomainReports(
636
- outDir: string,
424
+ layout: RunLayout,
637
425
  branch: string,
638
426
  reports: WatchdogReport[],
639
427
  classification: ClassificationResult,
@@ -641,20 +429,20 @@ function writeDomainReports(
641
429
  nameOf: (id: string) => string,
642
430
  ): DomainReport[] {
643
431
  const domains: DomainReport[] = [];
432
+ const renderer = new ReportRenderer(nameOf);
644
433
  for (const report of reports) {
645
434
  const findings = dedupeFindings([report]);
646
435
  if (findings.length === 0) continue;
647
436
  const file = `review.${sanitizeBranch(report.watchdogId)}.md`;
648
437
  const expected = classification.watchdogs[report.watchdogId] ?? [];
649
438
  writeFileSync(
650
- join(outDir, file),
651
- `${renderDomainReport(
439
+ layout.file(file),
440
+ `${renderer.renderDomain(
652
441
  branch,
653
442
  report,
654
443
  findings,
655
444
  expected.length,
656
445
  context,
657
- nameOf,
658
446
  )}\n`,
659
447
  );
660
448
  domains.push({
@@ -673,6 +461,7 @@ export type FinalizeResult = {
673
461
  reportPath: string;
674
462
  blockers: number;
675
463
  attention: number;
464
+ messages: string[];
676
465
  };
677
466
 
678
467
  /**
@@ -685,25 +474,16 @@ export function finalize(
685
474
  config: HoundsmanConfig,
686
475
  dirs: Paths = paths(opts, branch),
687
476
  ): FinalizeResult {
688
- const classification = readClassification(join(dirs.outDir, "classification.json"));
477
+ const classification = readClassification(dirs.classificationFile);
689
478
  const activeIds = Object.keys(classification.watchdogs);
690
479
 
691
- const { reports, pending } = readFindings(dirs.findingsDir, activeIds);
480
+ const messages: string[] = [];
481
+ const { reports, pending, warnings } = readFindings(dirs.findingsDir, activeIds);
482
+ messages.push(...warnings);
692
483
  const jev =
693
- new JevCandidatesStore(
694
- join(dirs.outDir, "candidates.json"),
695
- config.watchdogIds,
696
- ).read() ?? JevCandidatesStore.unavailable("candidates.json ausente");
697
- writeManifest(
698
- dirs.outDir,
699
- opts,
700
- branch,
701
- classification,
702
- activeIds,
703
- pending,
704
- jev,
705
- config,
706
- );
484
+ new JevCandidatesStore(dirs.candidatesFile, config.watchdogIds).read() ??
485
+ JevCandidatesStore.unavailable("candidates.json ausente");
486
+ writeManifest(dirs, config, opts, classification, activeIds, pending, jev);
707
487
 
708
488
  if (pending.length > 0) {
709
489
  throw new Error(
@@ -712,14 +492,14 @@ export function finalize(
712
492
  );
713
493
  }
714
494
 
715
- const now = new Date().toISOString();
495
+ const now = systemClock();
716
496
  // Findings re-reportados neste ciclo + carry-over (arquivos inalterados).
717
497
  const merged = mergeCarryover(
718
498
  dedupeFindings(reports),
719
499
  classification.carryover ?? [],
720
500
  );
721
501
  if (classification.mode === "incremental") {
722
- console.log(
502
+ messages.push(
723
503
  `Carry-over: ${classification.carryover?.length ?? 0} finding(s) preservado(s) de ${shortSha(classification.reviewedFrom ?? "")}.`,
724
504
  );
725
505
  }
@@ -740,41 +520,40 @@ export function finalize(
740
520
  : undefined,
741
521
  });
742
522
  store.write(state);
743
- console.log(
523
+ messages.push(
744
524
  `Triagem: ${relFromRoot(store.filePath)} — ${Object.keys(state.findings).length} finding(s).`,
745
525
  );
746
526
  writeRunStatus(dirs.outDir, { branch, status: "review_done" });
747
527
 
748
- const context = readContextSummary(contextPaths(opts.dir, branch).summary);
528
+ const context = readContextSummary(dirs.contextPaths.summary);
749
529
  const nameOf = (id: string): string => findWatchdog(config, id)?.name ?? id;
750
530
  const domains = writeDomainReports(
751
- dirs.outDir,
531
+ dirs,
752
532
  branch,
753
533
  reports,
754
534
  classification,
755
535
  context,
756
536
  nameOf,
757
537
  );
758
- const markdown = renderReport(
538
+ const markdown = new ReportRenderer(nameOf).render(
759
539
  branch,
760
540
  reports,
761
541
  merged,
762
542
  { classified: classification.watchdogs },
763
543
  context,
764
544
  jev.available ? jev.candidates : undefined,
765
- nameOf,
766
545
  domains,
767
546
  );
768
- const reportPath = join(dirs.outDir, "review.md");
547
+ const reportPath = dirs.reviewFile;
769
548
  writeFileSync(reportPath, `${incrementalNote(classification)}${markdown}\n`);
770
549
 
771
550
  const blockers = merged.filter((f) => f.severity === "blocking").length;
772
551
  const attention = merged.filter((f) => f.severity === "attention").length;
773
- console.log(
552
+ messages.push(
774
553
  `Relatório: ${relFromRoot(reportPath)} — ${blockers} bloqueante(s), ${attention} atenção.`,
775
554
  );
776
555
 
777
- return { reportPath, blockers, attention };
556
+ return { reportPath, blockers, attention, messages };
778
557
  }
779
558
 
780
559
  /** Resolve a branch (opção explícita ou a atual do git). */