@payconductor/houndsman 0.1.0 → 0.1.1

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 (45) hide show
  1. package/README.md +39 -78
  2. package/assets/logo-legacy.png +0 -0
  3. package/assets/logo.png +0 -0
  4. package/package.json +3 -2
  5. package/src/cli/index.ts +89 -9
  6. package/src/core/assets.ts +8 -0
  7. package/src/core/brief.ts +25 -2
  8. package/src/core/commands/findings.ts +390 -0
  9. package/src/core/commands/scaffold.ts +20 -0
  10. package/src/core/config.ts +42 -0
  11. package/src/core/diff-source.ts +140 -0
  12. package/src/core/file-classifier.ts +50 -2
  13. package/src/core/finding-anchor.ts +147 -0
  14. package/src/core/findings-state.ts +538 -0
  15. package/src/core/jev/jev-client.ts +72 -11
  16. package/src/core/jev/jev-detector.ts +21 -19
  17. package/src/core/orchestrator.ts +169 -17
  18. package/src/core/paths.ts +3 -0
  19. package/src/core/registry-check.ts +15 -0
  20. package/src/core/report-builder.ts +113 -9
  21. package/src/core/types.ts +10 -0
  22. package/src/index.ts +78 -2
  23. package/src/server/artifacts.ts +66 -0
  24. package/src/server/client/App.tsx +158 -0
  25. package/src/server/client/ArtifactsPanel.tsx +48 -0
  26. package/src/server/client/Filters.tsx +83 -0
  27. package/src/server/client/FindingCard.tsx +122 -0
  28. package/src/server/client/FindingsList.tsx +54 -0
  29. package/src/server/client/RunList.tsx +43 -0
  30. package/src/server/client/SummaryBar.tsx +21 -0
  31. package/src/server/client/api.ts +37 -0
  32. package/src/server/client/constants.ts +63 -0
  33. package/src/server/client/index.tsx +12 -0
  34. package/src/server/client/types.ts +48 -0
  35. package/src/server/client-build.ts +47 -0
  36. package/src/server/index.ts +365 -0
  37. package/src/skill/README.md +14 -6
  38. package/src/skill/SETUP.md +5 -3
  39. package/src/skill/houndsman-update.md +1 -1
  40. package/src/skill/houndsman.md +87 -10
  41. package/src/tests/findings-anchor.test.ts +120 -0
  42. package/src/tests/findings-commands.test.ts +226 -0
  43. package/src/tests/findings-state.test.ts +274 -0
  44. package/src/tests/review-scripts.test.ts +299 -0
  45. package/src/tests/server.test.ts +212 -0
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import { readFileSync } from "node:fs";
12
+ import { collectDiff, type DiffSource } from "../diff-source";
12
13
  import type { ClassificationResult } from "../file-classifier";
13
14
  import type { WatchdogMeta } from "../types";
14
15
  import {
@@ -25,6 +26,8 @@ export type DetectOptions = {
25
26
  threshold?: number;
26
27
  /** Nº de arquivos avaliados em paralelo. */
27
28
  concurrency?: number;
29
+ /** Diff já coletado (evita novo `git diff`); ausente → coleta aqui. */
30
+ diff?: DiffSource;
28
31
  };
29
32
 
30
33
  export type DetectResult = {
@@ -60,6 +63,7 @@ export class JevDetector {
60
63
  ): Promise<DetectResult> {
61
64
  const threshold = options.threshold ?? JevCandidatesStore.THRESHOLD;
62
65
  const concurrency = options.concurrency ?? JevDetector.DEFAULT_CONCURRENCY;
66
+ const diff = this.resolveDiff(base, head, options.diff);
63
67
 
64
68
  const jobs = this.buildJobs(classification);
65
69
  const questionByKey = new Map<string, DerivedQuestion>();
@@ -76,7 +80,7 @@ export class JevDetector {
76
80
  for (const dq of job.questions) questions[dq.key] = dq.question;
77
81
 
78
82
  const status = classification.files[job.file]?.status ?? "modified";
79
- const state = this.buildState(job.file, status, base, head);
83
+ const state = this.buildState(job.file, status, diff);
80
84
 
81
85
  try {
82
86
  const response = await this.client.evaluate({ state, questions });
@@ -125,15 +129,24 @@ export class JevDetector {
125
129
  .filter((job) => job.questions.length > 0);
126
130
  }
127
131
 
132
+ /**
133
+ * Diff para o `state`: reaproveita o injetado ou coleta **uma vez** (antes era
134
+ * 1 `git diff` por arquivo). Tolerante a falha — o Jev é opcional/fail-open.
135
+ */
136
+ private resolveDiff(base: string, head: string, provided?: DiffSource): DiffSource {
137
+ if (provided) return provided;
138
+ try {
139
+ return collectDiff(base, head);
140
+ } catch {
141
+ return { byFile: new Map<string, string>(), churn: new Map() };
142
+ }
143
+ }
144
+
128
145
  /** Monta o `state` de um arquivo: cabeçalho + hunk do diff, com cap de tamanho. */
129
- private buildState(
130
- filePath: string,
131
- status: string,
132
- base: string,
133
- head: string,
134
- ): string {
146
+ private buildState(filePath: string, status: string, diff: DiffSource): string {
135
147
  const header = this.readHeader(filePath);
136
- const hunk = this.readHunk(base, head, filePath);
148
+ const hunk =
149
+ diff.byFile.get(filePath)?.slice(0, JevDetector.MAX_DIFF_CHARS) ?? "";
137
150
  const parts = [`Arquivo: ${filePath} (${status})`];
138
151
  if (header) {
139
152
  parts.push("--- topo do arquivo (imports/declarações) ---", header);
@@ -153,17 +166,6 @@ export class JevDetector {
153
166
  }
154
167
  }
155
168
 
156
- private readHunk(base: string, head: string, filePath: string): string {
157
- const result = Bun.spawnSync(
158
- ["git", "diff", `${base}...${head}`, "--", filePath],
159
- { stdout: "pipe", stderr: "pipe" },
160
- );
161
- if (result.exitCode !== 0) return "";
162
- return new TextDecoder()
163
- .decode(result.stdout)
164
- .slice(0, JevDetector.MAX_DIFF_CHARS);
165
- }
166
-
167
169
  /** Arquivo → watchdogs que o cobrem. */
168
170
  private static mapFilesToWatchdogs(
169
171
  classification: ClassificationResult,
@@ -17,9 +17,20 @@
17
17
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
18
18
  import { join, relative } from "node:path";
19
19
  import { renderBrief } from "./brief";
20
- import type { HoundsmanConfig } from "./config";
20
+ import {
21
+ type HoundsmanConfig,
22
+ resolveJevConcurrency,
23
+ resolveMaxFilesPerBrief,
24
+ } from "./config";
25
+ import { collectDiff, type DiffSource } from "./diff-source";
21
26
  import { discoverDocs } from "./doc-discoverer";
22
- import { type ClassificationResult, classifyRange } from "./file-classifier";
27
+ import {
28
+ type BudgetSelection,
29
+ type ClassificationResult,
30
+ classifyRange,
31
+ selectByBudget,
32
+ } from "./file-classifier";
33
+ import { buildStateFromFindings, FindingsStateStore, fileReader } from "./findings-state";
23
34
  import {
24
35
  type JevCandidate,
25
36
  type JevCandidatesFile,
@@ -31,8 +42,10 @@ import { ARTIFACT_DIR, OUTPUT_FORMAT_PATH } from "./paths";
31
42
  import { contextPaths, currentBranch, readContextSummary } from "./pr-context";
32
43
  import {
33
44
  type ContextSummary,
45
+ type DomainReport,
34
46
  dedupeFindings,
35
47
  parseWatchdogReport,
48
+ renderDomainReport,
36
49
  renderReport,
37
50
  sanitizeBranch,
38
51
  type WatchdogReport,
@@ -150,6 +163,22 @@ function writeManifest(
150
163
  jev: JevCandidatesFile,
151
164
  config: HoundsmanConfig,
152
165
  ): void {
166
+ const maxFilesPerBrief = resolveMaxFilesPerBrief(config);
167
+ let filesOverBudget = 0;
168
+ const watchdogs = activeIds.map((id) => {
169
+ const selection = budgetFor(classification, id, maxFilesPerBrief);
170
+ filesOverBudget += selection.overflow.length;
171
+ return {
172
+ id,
173
+ name: findWatchdog(config, id)?.name ?? id,
174
+ files: selection.selected,
175
+ overflow: selection.overflow.length,
176
+ brief: relFromRoot(join(outDir, "briefs", `${id}.md`)),
177
+ findings: relFromRoot(join(outDir, "findings", `${id}.json`)),
178
+ status: pending.includes(id) ? "pending" : "done",
179
+ };
180
+ });
181
+
153
182
  const manifest = {
154
183
  branch,
155
184
  slug: sanitizeBranch(branch),
@@ -157,14 +186,9 @@ function writeManifest(
157
186
  head: opts.head,
158
187
  generatedAt: new Date().toISOString(),
159
188
  outputFormat: OUTPUT_FORMAT_PATH,
160
- watchdogs: activeIds.map((id) => ({
161
- id,
162
- name: findWatchdog(config, id)?.name ?? id,
163
- files: classification.watchdogs[id] ?? [],
164
- brief: relFromRoot(join(outDir, "briefs", `${id}.md`)),
165
- findings: relFromRoot(join(outDir, "findings", `${id}.json`)),
166
- status: pending.includes(id) ? "pending" : "done",
167
- })),
189
+ budget: { maxFilesPerBrief, filesOverBudget },
190
+ churn: churnTotals(classification),
191
+ watchdogs,
168
192
  context: contextManifest(opts, branch),
169
193
  jev: jev.available
170
194
  ? {
@@ -186,6 +210,37 @@ function writeManifest(
186
210
  );
187
211
  }
188
212
 
213
+ // ---------------------------------------------------------------------------
214
+ // Orçamento (triage de PRs grandes)
215
+ // ---------------------------------------------------------------------------
216
+
217
+ /** Aplica o orçamento aos arquivos de um watchdog. */
218
+ function budgetFor(
219
+ classification: ClassificationResult,
220
+ id: string,
221
+ maxFiles: number,
222
+ ): BudgetSelection {
223
+ return selectByBudget(
224
+ classification.watchdogs[id] ?? [],
225
+ classification.churn,
226
+ maxFiles,
227
+ );
228
+ }
229
+
230
+ /** Totais de churn do diff (métricas do manifesto). */
231
+ function churnTotals(classification: ClassificationResult): {
232
+ additions: number;
233
+ deletions: number;
234
+ } {
235
+ let additions = 0;
236
+ let deletions = 0;
237
+ for (const entry of Object.values(classification.churn ?? {})) {
238
+ additions += entry.additions;
239
+ deletions += entry.deletions;
240
+ }
241
+ return { additions, deletions };
242
+ }
243
+
189
244
  // ---------------------------------------------------------------------------
190
245
  // Briefs
191
246
  // ---------------------------------------------------------------------------
@@ -200,14 +255,17 @@ function writeBriefs(
200
255
  config: HoundsmanConfig,
201
256
  ): void {
202
257
  const docs = discoverDocs(Object.keys(classification.files), config);
258
+ const maxFilesPerBrief = resolveMaxFilesPerBrief(config);
203
259
  for (const id of activeIds) {
204
260
  const def = findWatchdog(config, id);
205
261
  if (!def) continue;
206
- const files = classification.watchdogs[id] ?? [];
262
+ const { selected, overflow } = budgetFor(classification, id, maxFilesPerBrief);
207
263
  const brief = renderBrief(def, {
208
264
  watchdogId: id,
209
- files,
265
+ files: selected,
210
266
  statuses: classification.files,
267
+ churn: classification.churn,
268
+ overflow,
211
269
  deleted: classification.deleted,
212
270
  branch,
213
271
  outputPath: relFromRoot(join(dirs.findingsDir, `${id}.json`)),
@@ -233,6 +291,7 @@ async function resolveCandidates(
233
291
  classification: ClassificationResult,
234
292
  dirs: Paths,
235
293
  config: HoundsmanConfig,
294
+ diff: DiffSource,
236
295
  ): Promise<JevCandidatesFile> {
237
296
  const store = new JevCandidatesStore(
238
297
  join(dirs.outDir, "candidates.json"),
@@ -257,7 +316,10 @@ async function resolveCandidates(
257
316
  client,
258
317
  config.watchdogs as Record<string, WatchdogMeta>,
259
318
  );
260
- const result = await detector.detect(classification, opts.base, opts.head);
319
+ const result = await detector.detect(classification, opts.base, opts.head, {
320
+ diff,
321
+ concurrency: resolveJevConcurrency(config),
322
+ });
261
323
  return save({
262
324
  available: true,
263
325
  model: result.model,
@@ -301,6 +363,22 @@ function printDiscovery(
301
363
  }
302
364
  }
303
365
 
366
+ /** Resumo do orçamento — só quando algum arquivo ficou de fora (PR grande). */
367
+ function printBudget(
368
+ classification: ClassificationResult,
369
+ config: HoundsmanConfig,
370
+ ): void {
371
+ const maxFiles = resolveMaxFilesPerBrief(config);
372
+ let overflow = 0;
373
+ for (const id of Object.keys(classification.watchdogs)) {
374
+ overflow += budgetFor(classification, id, maxFiles).overflow.length;
375
+ }
376
+ if (overflow === 0) return;
377
+ console.log(
378
+ `Orçamento: ${maxFiles} arquivo(s)/brief — ${overflow} fora do orçamento (menor churn; registrar em skipped).`,
379
+ );
380
+ }
381
+
304
382
  /** Passo 1 — descobre os arquivos e grava briefs + manifesto. */
305
383
  export async function prepare(
306
384
  opts: ReviewOptions,
@@ -310,13 +388,15 @@ export async function prepare(
310
388
  ): Promise<void> {
311
389
  prepareDirs(dirs, opts.clean);
312
390
 
313
- const classification = classifyRange(opts.base, opts.head, config);
391
+ // Um único diff para toda a execução (churn + patch) — antes: 1 spawn por arquivo.
392
+ const diff = collectDiff(opts.base, opts.head);
393
+ const classification = classifyRange(opts.base, opts.head, config, diff);
314
394
  writeFileSync(
315
395
  join(dirs.outDir, "classification.json"),
316
396
  `${JSON.stringify(classification, null, 2)}\n`,
317
397
  );
318
398
 
319
- const jev = await resolveCandidates(opts, classification, dirs, config);
399
+ const jev = await resolveCandidates(opts, classification, dirs, config, diff);
320
400
 
321
401
  const activeIds = Object.keys(classification.watchdogs);
322
402
  if (Object.keys(classification.files).length === 0) {
@@ -342,6 +422,7 @@ export async function prepare(
342
422
  config,
343
423
  );
344
424
  printDiscovery(activeIds, classification, dirs.briefsDir);
425
+ printBudget(classification, config);
345
426
  printJevSummary(jev);
346
427
 
347
428
  if (context) {
@@ -369,7 +450,53 @@ function readClassification(classificationPath: string): ClassificationResult {
369
450
  `Não encontrei ${relFromRoot(classificationPath)}. Rode \`bun run houndsman\` (preparar) primeiro.`,
370
451
  );
371
452
  }
372
- return JSON.parse(readFileSync(classificationPath, "utf8"));
453
+ const parsed = JSON.parse(
454
+ readFileSync(classificationPath, "utf8"),
455
+ ) as ClassificationResult;
456
+ // Compatível com classification.json gerado antes do churn existir.
457
+ parsed.churn ??= {};
458
+ return parsed;
459
+ }
460
+
461
+ /**
462
+ * Grava um `review.<watchdog>.md` por watchdog com achados e devolve o índice.
463
+ * Mantém o `review.md` consolidado navegável em PRs grandes (centenas de achados).
464
+ */
465
+ function writeDomainReports(
466
+ outDir: string,
467
+ branch: string,
468
+ reports: WatchdogReport[],
469
+ classification: ClassificationResult,
470
+ context: ContextSummary | null,
471
+ nameOf: (id: string) => string,
472
+ ): DomainReport[] {
473
+ const domains: DomainReport[] = [];
474
+ for (const report of reports) {
475
+ const findings = dedupeFindings([report]);
476
+ if (findings.length === 0) continue;
477
+ const file = `review.${sanitizeBranch(report.watchdogId)}.md`;
478
+ const expected = classification.watchdogs[report.watchdogId] ?? [];
479
+ writeFileSync(
480
+ join(outDir, file),
481
+ `${renderDomainReport(
482
+ branch,
483
+ report,
484
+ findings,
485
+ expected.length,
486
+ context,
487
+ nameOf,
488
+ )}\n`,
489
+ );
490
+ domains.push({
491
+ id: report.watchdogId,
492
+ name: nameOf(report.watchdogId),
493
+ file,
494
+ findings: findings.length,
495
+ reviewedFiles: report.reviewedFiles.length,
496
+ expectedFiles: expected.length,
497
+ });
498
+ }
499
+ return domains;
373
500
  }
374
501
 
375
502
  export type FinalizeResult = {
@@ -416,7 +543,31 @@ export function finalize(
416
543
  }
417
544
 
418
545
  const merged = dedupeFindings(reports);
546
+
547
+ // Estado de triagem: preserva o que já existia (casando por fingerprint) e
548
+ // anexa o `id` de cada finding aos itens do relatório. A linha é cache —
549
+ // recalculada por `houndsman findings sync` após edições.
550
+ const store = FindingsStateStore.at(opts.dir, branch);
551
+ const state = buildStateFromFindings(merged, {
552
+ branch,
553
+ readFile: fileReader(process.cwd()),
554
+ previous: store.read(),
555
+ });
556
+ store.write(state);
557
+ console.log(
558
+ `Triagem: ${relFromRoot(store.filePath)} — ${Object.keys(state.findings).length} finding(s).`,
559
+ );
560
+
419
561
  const context = readContextSummary(contextPaths(opts.dir, branch).summary);
562
+ const nameOf = (id: string): string => findWatchdog(config, id)?.name ?? id;
563
+ const domains = writeDomainReports(
564
+ dirs.outDir,
565
+ branch,
566
+ reports,
567
+ classification,
568
+ context,
569
+ nameOf,
570
+ );
420
571
  const markdown = renderReport(
421
572
  branch,
422
573
  reports,
@@ -424,7 +575,8 @@ export function finalize(
424
575
  { classified: classification.watchdogs },
425
576
  context,
426
577
  jev.available ? jev.candidates : undefined,
427
- (id) => findWatchdog(config, id)?.name ?? id,
578
+ nameOf,
579
+ domains,
428
580
  );
429
581
  const reportPath = join(dirs.outDir, "review.md");
430
582
  writeFileSync(reportPath, `${markdown}\n`);
package/src/core/paths.ts CHANGED
@@ -28,3 +28,6 @@ export const ARTIFACT_DIR = `${PROJECT_DIR}/.runs`;
28
28
 
29
29
  /** Schema de output dos watchdogs, citado nos briefs. */
30
30
  export const OUTPUT_FORMAT_PATH = `${CORE_SKILL_DIR}/${OUTPUT_FORMAT_FILE}`;
31
+
32
+ /** Estado de triagem dos findings, dentro da pasta da run (`<slug>/state.json`). */
33
+ export const STATE_FILE = "state.json";
@@ -127,6 +127,20 @@ function checkRulePolarity(config: HoundsmanConfig, warnings: string[]): void {
127
127
  }
128
128
  }
129
129
 
130
+ /** Aviso para orçamento/concorrência inválidos — o motor cai no default. */
131
+ function checkBudgets(config: HoundsmanConfig, warnings: string[]): void {
132
+ const maxFiles = config.budgets?.maxFilesPerBrief;
133
+ if (maxFiles !== undefined && maxFiles <= 0) {
134
+ warnings.push(
135
+ `budgets.maxFilesPerBrief = ${maxFiles} — não-positivo; usando o default.`,
136
+ );
137
+ }
138
+ const jev = config.concurrency?.jev;
139
+ if (jev !== undefined && jev <= 0) {
140
+ warnings.push(`concurrency.jev = ${jev} — não-positivo; usando o default.`);
141
+ }
142
+ }
143
+
130
144
  export function checkRegistry(config: HoundsmanConfig): {
131
145
  problems: string[];
132
146
  warnings: string[];
@@ -139,6 +153,7 @@ export function checkRegistry(config: HoundsmanConfig): {
139
153
  checkClassification(config, problems);
140
154
  checkRendering(config, problems);
141
155
  checkRulePolarity(config, warnings);
156
+ checkBudgets(config, warnings);
142
157
  const jevQuestions = checkJevSpecs(config, problems);
143
158
 
144
159
  return { problems, warnings, jevQuestions };
@@ -16,6 +16,8 @@ export type WatchdogNameResolver = (watchdogId: string) => string;
16
16
  const identityName: WatchdogNameResolver = (id) => id;
17
17
 
18
18
  export type Finding = {
19
+ /** Id curto do finding (preenchido no `--finalize`; usado pela triagem). */
20
+ id?: string;
19
21
  rule: string;
20
22
  severity: Severity;
21
23
  file: string;
@@ -206,8 +208,9 @@ function watchdogName(nameOf: WatchdogNameResolver, id: string): string {
206
208
  function renderFinding(finding: MergedFinding, nameOf: WatchdogNameResolver): string[] {
207
209
  const location = finding.line ? `${finding.file}:${finding.line}` : finding.file;
208
210
  const watchdogs = finding.watchdogs.map((r) => watchdogName(nameOf, r)).join(", ");
211
+ const id = finding.id ? ` · \`${finding.id}\`` : "";
209
212
  const lines = [
210
- `- **${finding.rule}** — \`${location}\` · _${watchdogs}_`,
213
+ `- **${finding.rule}** — \`${location}\` · _${watchdogs}_${id}`,
211
214
  ` ${finding.message}`,
212
215
  ` **Ação:** ${finding.action}`,
213
216
  ];
@@ -284,6 +287,112 @@ function renderConclusion(counts: Record<Severity, number>): string[] {
284
287
  ];
285
288
  }
286
289
 
290
+ /**
291
+ * Agrupa os achados por arquivo (navegabilidade em PRs grandes) e renderiza cada
292
+ * bloco. O `merged` já vem ordenado por severidade/arquivo/linha.
293
+ */
294
+ function renderFindingsByFile(
295
+ findings: readonly MergedFinding[],
296
+ nameOf: WatchdogNameResolver,
297
+ ): string[] {
298
+ const byFile = new Map<string, MergedFinding[]>();
299
+ for (const finding of findings) {
300
+ const list = byFile.get(finding.file);
301
+ if (list) list.push(finding);
302
+ else byFile.set(finding.file, [finding]);
303
+ }
304
+
305
+ const lines: string[] = [];
306
+ for (const [file, items] of byFile) {
307
+ lines.push(`### \`${file}\``, "");
308
+ for (const item of items) lines.push(...renderFinding(item, nameOf));
309
+ }
310
+ return lines;
311
+ }
312
+
313
+ /** Seções por severidade (❌ > ⚠️ > 💡 > 🔎), com achados agrupados por arquivo. */
314
+ function renderFindingsSections(
315
+ merged: readonly MergedFinding[],
316
+ nameOf: WatchdogNameResolver,
317
+ ): string[] {
318
+ const lines: string[] = [];
319
+ for (const severity of SEVERITY_ORDER) {
320
+ const items = merged.filter((f) => f.severity === severity);
321
+ if (items.length === 0) continue;
322
+ lines.push(`## ${SEVERITY_EMOJI[severity]} ${severity}`, "");
323
+ lines.push(...renderFindingsByFile(items, nameOf));
324
+ }
325
+ return lines;
326
+ }
327
+
328
+ /** Relatório de domínio gravado à parte (um por watchdog com achados). */
329
+ export type DomainReport = {
330
+ id: string;
331
+ name: string;
332
+ /** Nome do arquivo do relatório (relativo ao diretório de artefatos). */
333
+ file: string;
334
+ findings: number;
335
+ reviewedFiles: number;
336
+ expectedFiles: number;
337
+ };
338
+
339
+ /** Índice dos relatórios por domínio (seção do `review.md` consolidado). */
340
+ function renderDomainIndex(domains: readonly DomainReport[]): string[] {
341
+ if (domains.length === 0) return [];
342
+ const lines = [
343
+ "## Relatórios por domínio",
344
+ "",
345
+ "| Watchdog | Achados | Revisados | Relatório |",
346
+ "|---|---|---|---|",
347
+ ];
348
+ for (const domain of domains) {
349
+ lines.push(
350
+ `| ${domain.name} | ${domain.findings} | ${domain.reviewedFiles}/${domain.expectedFiles} | [\`${domain.file}\`](${domain.file}) |`,
351
+ );
352
+ }
353
+ lines.push("");
354
+ return lines;
355
+ }
356
+
357
+ /**
358
+ * Relatório isolado de um watchdog — mantém o `review.md` enxuto em PRs grandes.
359
+ */
360
+ export function renderDomainReport(
361
+ branch: string,
362
+ report: WatchdogReport,
363
+ merged: MergedFinding[],
364
+ expectedFiles: number,
365
+ context?: ContextSummary | null,
366
+ nameOf: WatchdogNameResolver = identityName,
367
+ ): string {
368
+ const now = new Date().toISOString().slice(0, 10);
369
+ const name = watchdogName(nameOf, report.watchdogId);
370
+ const lines: string[] = [
371
+ `# Review — ${name} (\`${report.watchdogId}\`) — ${branch}`,
372
+ "",
373
+ `**Data:** ${now}`,
374
+ `**Arquivos revisados:** ${report.reviewedFiles.length}/${expectedFiles}`,
375
+ `**Achados:** ${merged.length}`,
376
+ "",
377
+ ];
378
+
379
+ if (context?.available) lines.push(...renderContext(context));
380
+
381
+ lines.push(...renderFindingsSections(merged, nameOf));
382
+ if (merged.length === 0) lines.push("Nenhum achado. ✅", "");
383
+
384
+ if (report.skipped.length > 0) {
385
+ lines.push(
386
+ "## Não revisados",
387
+ "",
388
+ ...report.skipped.map((item) => `- \`${item}\``),
389
+ "",
390
+ );
391
+ }
392
+
393
+ return lines.join("\n");
394
+ }
395
+
287
396
  /** Seção de contexto da PR (Objetivo / Motivação / Resumo das alterações). */
288
397
  function renderContext(context: ContextSummary): string[] {
289
398
  const lines = ["## Contexto da PR", ""];
@@ -313,6 +422,7 @@ export function renderReport(
313
422
  context?: ContextSummary | null,
314
423
  candidates?: JevCandidate[],
315
424
  nameOf: WatchdogNameResolver = identityName,
425
+ domains: readonly DomainReport[] = [],
316
426
  ): string {
317
427
  const now = new Date().toISOString().slice(0, 10);
318
428
  const counts = {
@@ -333,14 +443,7 @@ export function renderReport(
333
443
 
334
444
  if (context) lines.push(...renderContext(context));
335
445
 
336
- for (const severity of SEVERITY_ORDER) {
337
- const items = merged.filter((f) => f.severity === severity);
338
- if (items.length === 0) continue;
339
- lines.push(`## ${SEVERITY_EMOJI[severity]} ${severity}`, "");
340
- for (const finding of items) {
341
- lines.push(...renderFinding(finding, nameOf));
342
- }
343
- }
446
+ lines.push(...renderFindingsSections(merged, nameOf));
344
447
 
345
448
  if (merged.length === 0) {
346
449
  lines.push("Nenhum achado. ✅", "");
@@ -350,6 +453,7 @@ export function renderReport(
350
453
  lines.push(...renderUnconfirmedCandidates(candidates, merged));
351
454
  }
352
455
 
456
+ lines.push(...renderDomainIndex(domains));
353
457
  lines.push(...renderCoverage(reports, coverage, nameOf));
354
458
  lines.push(...renderConclusion(counts));
355
459
 
package/src/core/types.ts CHANGED
@@ -79,6 +79,12 @@ export type FileStatus =
79
79
  /** Status por arquivo, conforme o `classification.json`. */
80
80
  export type FileStatusMap = Record<string, { status: FileStatus; oldPath?: string }>;
81
81
 
82
+ /** Linhas adicionadas/removidas de um arquivo no diff (churn). */
83
+ export type FileChurn = { additions: number; deletions: number };
84
+
85
+ /** Churn por caminho de arquivo (chave = caminho novo). */
86
+ export type ChurnMap = Record<string, FileChurn>;
87
+
82
88
  /** Contexto da PR injetado no brief (estrutural — sem dependência de runtime). */
83
89
  export type WatchdogRenderContext = {
84
90
  objective?: string;
@@ -99,6 +105,10 @@ export type WatchdogRenderArgs = {
99
105
  watchdogId: string;
100
106
  files: readonly string[];
101
107
  statuses: FileStatusMap;
108
+ /** Churn por arquivo — insumo para priorizar a leitura (PRs grandes). */
109
+ churn?: ChurnMap;
110
+ /** Arquivos do domínio fora do orçamento — NÃO revisar (registrar em `skipped`). */
111
+ overflow?: readonly string[];
102
112
  deleted: readonly string[];
103
113
  branch: string;
104
114
  outputPath: string;