@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
package/src/core/paths.ts CHANGED
@@ -37,3 +37,12 @@ export const RUN_STATUS_FILE = "run.json";
37
37
 
38
38
  /** Relatório da verificação de findings, dentro da pasta da run (`<slug>/verify.md`). */
39
39
  export const VERIFY_FILE = "verify.md";
40
+
41
+ /** Contexto bruto da PR (`gh pr view`), dentro da pasta da run. */
42
+ export const CONTEXT_FILE = "context.json";
43
+
44
+ /** Resumo do contexto gravado pelo subagente, dentro da pasta da run. */
45
+ export const CONTEXT_SUMMARY_FILE = "context-summary.json";
46
+
47
+ /** Brief do subagente que resume o contexto da PR. */
48
+ export const CONTEXT_BRIEF_FILE = "context-brief.md";
@@ -14,16 +14,21 @@
14
14
  */
15
15
 
16
16
  import { existsSync, readFileSync } from "node:fs";
17
- import { join, relative } from "node:path";
18
17
  import {
19
- type ContextSummary,
20
- parseContextSummary,
21
- sanitizeBranch,
22
- } from "./report-builder";
23
-
24
- export const CONTEXT_FILE = "context.json";
25
- export const CONTEXT_SUMMARY_FILE = "context-summary.json";
26
- export const CONTEXT_BRIEF_FILE = "context-brief.md";
18
+ CONTEXT_BRIEF_FILE,
19
+ CONTEXT_FILE,
20
+ CONTEXT_SUMMARY_FILE,
21
+ relFromRoot,
22
+ } from "./paths";
23
+ import type { ContextSummary } from "./report-builder";
24
+ import { parseContextSummary } from "./report-builder";
25
+ import { type ContextPaths, RunLayout } from "./run-layout";
26
+ import { systemClock } from "./services/clock";
27
+ import { defaultGhRunner, GhClient, type GhRunner } from "./services/gh-client";
28
+ import { GitRepository } from "./services/git-repository";
29
+
30
+ export { CONTEXT_BRIEF_FILE, CONTEXT_FILE, CONTEXT_SUMMARY_FILE };
31
+ export type { ContextPaths };
27
32
 
28
33
  const GH_JSON_FIELDS =
29
34
  "number,title,body,url,baseRefName,headRefName,author,commits,files";
@@ -54,55 +59,20 @@ export type PrContextRaw = {
54
59
  stats?: { files: number; additions: number; deletions: number };
55
60
  };
56
61
 
57
- export type ContextPaths = {
58
- dir: string;
59
- context: string;
60
- summary: string;
61
- brief: string;
62
- };
63
-
64
62
  /** Caminhos dos artefatos de contexto para uma branch. */
65
63
  export function contextPaths(dir: string, branch: string): ContextPaths {
66
- const dirPath = join(dir, sanitizeBranch(branch));
67
- return {
68
- dir: dirPath,
69
- context: join(dirPath, CONTEXT_FILE),
70
- summary: join(dirPath, CONTEXT_SUMMARY_FILE),
71
- brief: join(dirPath, CONTEXT_BRIEF_FILE),
72
- };
73
- }
74
-
75
- /** Caminho relativo à raiz, sempre com `/` (portável/legível). */
76
- function relFromRoot(absPath: string): string {
77
- return relative(process.cwd(), absPath).replace(/\\/g, "/");
64
+ return new RunLayout(dir, branch).contextPaths;
78
65
  }
79
66
 
80
- /** Detecta a branch atual; encerra com erro se não houver. */
67
+ /** Detecta a branch atual; lança se não houver (o CLI decide o exit). */
81
68
  export function currentBranch(): string {
82
- const result = Bun.spawnSync(["git", "branch", "--show-current"], {
83
- stdout: "pipe",
84
- stderr: "pipe",
85
- });
86
- const branch = new TextDecoder().decode(result.stdout).trim();
69
+ const branch = new GitRepository().currentBranch();
87
70
  if (!branch) {
88
- console.error("Não foi possível detectar a branch. Use --branch <nome>.");
89
- process.exit(1);
71
+ throw new Error("Não foi possível detectar a branch. Use --branch <nome>.");
90
72
  }
91
73
  return branch;
92
74
  }
93
75
 
94
- function runGh(args: string[]): { ok: boolean; stdout: string; stderr: string } {
95
- const result = Bun.spawnSync(["gh", ...args], {
96
- stdout: "pipe",
97
- stderr: "pipe",
98
- });
99
- return {
100
- ok: result.exitCode === 0,
101
- stdout: new TextDecoder().decode(result.stdout).trim(),
102
- stderr: new TextDecoder().decode(result.stderr).trim(),
103
- };
104
- }
105
-
106
76
  type GhPrView = {
107
77
  number?: number;
108
78
  title?: string;
@@ -120,19 +90,23 @@ function unavailable(reason: ContextUnavailableReason, detail: string): PrContex
120
90
  available: false,
121
91
  reason,
122
92
  detail,
123
- collectedAt: new Date().toISOString(),
93
+ collectedAt: systemClock(),
124
94
  };
125
95
  }
126
96
 
127
97
  /** Lê a PR da branch via `gh` e devolve o payload bruto (nunca lança). */
128
- export function collectPrContext(branch: string): PrContextRaw {
129
- const collectedAt = new Date().toISOString();
98
+ export function collectPrContext(
99
+ branch: string,
100
+ runner: GhRunner = defaultGhRunner,
101
+ ): PrContextRaw {
102
+ const collectedAt = systemClock();
103
+ const gh = new GhClient(runner);
130
104
 
131
- if (!Bun.which("gh")) {
105
+ if (!gh.isAvailable()) {
132
106
  return unavailable("gh-missing", "GitHub CLI (gh) não encontrado no PATH.");
133
107
  }
134
108
 
135
- const auth = runGh(["auth", "status"]);
109
+ const auth = gh.run(["auth", "status"]);
136
110
  if (!auth.ok) {
137
111
  return unavailable(
138
112
  "gh-unauthenticated",
@@ -140,7 +114,7 @@ export function collectPrContext(branch: string): PrContextRaw {
140
114
  );
141
115
  }
142
116
 
143
- const view = runGh(["pr", "view", branch, "--json", GH_JSON_FIELDS]);
117
+ const view = gh.run(["pr", "view", branch, "--json", GH_JSON_FIELDS]);
144
118
  if (!view.ok) {
145
119
  const noPr = /no pull requests? found|not found|could not find/i.test(
146
120
  view.stderr,
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Configuração do projeto (domínio) — resolve defaults e expõe o registry.
3
+ *
4
+ * Evita o "data clump" de passar `HoundsmanConfig` para dezenas de funções:
5
+ * quem precisa de `artifactDir`/orçamento/concorrência/watchdogs pede aqui.
6
+ * O contrato do consumidor (`houndsman.config.ts`) não muda.
7
+ */
8
+
9
+ import type { HoundsmanConfig } from "./config";
10
+ import {
11
+ DEFAULT_ALWAYS_INCLUDE_DOCS,
12
+ DEFAULT_JEV_CONCURRENCY,
13
+ DEFAULT_MAX_FILES_PER_BRIEF,
14
+ } from "./defaults";
15
+ import { ARTIFACT_DIR } from "./paths";
16
+ import { WatchdogRegistry } from "./watchdog-registry";
17
+
18
+ /** Configuração resolvida do projeto. */
19
+ export class ProjectConfig<TId extends string = string> {
20
+ readonly raw: HoundsmanConfig<TId>;
21
+ readonly watchdogs: WatchdogRegistry<TId>;
22
+
23
+ constructor(raw: HoundsmanConfig<TId>) {
24
+ this.raw = raw;
25
+ this.watchdogs = new WatchdogRegistry(raw);
26
+ }
27
+
28
+ get projectName(): string {
29
+ return this.raw.projectName;
30
+ }
31
+
32
+ get artifactDir(): string {
33
+ return this.raw.artifactDir ?? ARTIFACT_DIR;
34
+ }
35
+
36
+ get maxFilesPerBrief(): number {
37
+ const value = this.raw.budgets?.maxFilesPerBrief;
38
+ return value !== undefined && value > 0 ? value : DEFAULT_MAX_FILES_PER_BRIEF;
39
+ }
40
+
41
+ get jevConcurrency(): number {
42
+ const value = this.raw.concurrency?.jev;
43
+ return value !== undefined && value > 0 ? value : DEFAULT_JEV_CONCURRENCY;
44
+ }
45
+
46
+ get alwaysIncludeDocs(): readonly string[] {
47
+ return this.raw.alwaysIncludeDocs ?? DEFAULT_ALWAYS_INCLUDE_DOCS;
48
+ }
49
+
50
+ get exclusions(): readonly RegExp[] {
51
+ return this.raw.exclusions;
52
+ }
53
+
54
+ get docMappings(): readonly (readonly [RegExp, readonly string[]])[] {
55
+ return this.raw.docMappings;
56
+ }
57
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Apresentação do lote de correção (biblioteca) — markdown do `findings batch`.
3
+ */
4
+
5
+ import type { FixBatchResult } from "../fix-batch";
6
+ import { FindingsPresenter } from "./findings-presenter";
7
+
8
+ /** Renderiza o grupo atual paginado + os outros grupos disponíveis. */
9
+ export class BatchPresenter {
10
+ private readonly findings = new FindingsPresenter();
11
+
12
+ render(result: FixBatchResult): string {
13
+ const { dimension, current, others, totals } = result;
14
+ if (totals.queue === 0) return "Nenhum finding pendente de correção.";
15
+
16
+ const lines = [
17
+ `# Lote de correção — agrupado por ${dimension} (${totals.groups} grupo(s))`,
18
+ "",
19
+ `## Grupo atual — ${current.key} (${current.total} finding(s), mostrando ${current.returned})`,
20
+ ];
21
+ for (const finding of current.findings)
22
+ lines.push(...this.findings.line(finding));
23
+ if (current.remaining > 0) {
24
+ lines.push("", `_… +${current.remaining} neste grupo (use \`--offset\`)._`);
25
+ }
26
+ if (others.length > 0) {
27
+ lines.push("", "## Outros grupos disponíveis");
28
+ for (const group of others) {
29
+ lines.push(
30
+ `- ${group.key} — ${group.count} (${this.findings.severityBreakdown(group.bySeverity)})`,
31
+ );
32
+ }
33
+ }
34
+ lines.push(
35
+ "",
36
+ `fila: ${totals.queue} · retornados: ${totals.returned} · grupos: ${totals.groups}`,
37
+ );
38
+ return lines.join("\n");
39
+ }
40
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Renderização de briefs (biblioteca) — superfície OO sobre `brief.ts`.
3
+ *
4
+ * O texto continua em `brief.ts` (funções puras); a classe existe para o
5
+ * pipeline injetar o renderer sem depender da implementação.
6
+ */
7
+
8
+ import { renderBrief } from "../brief";
9
+ import type { Watchdog } from "../domain/watchdog";
10
+ import type { WatchdogDefinition, WatchdogRenderArgs } from "../types";
11
+
12
+ /** Renderiza o brief de um watchdog (hook custom ou default). */
13
+ export class BriefRenderer {
14
+ /** Render a partir de um `Watchdog` (domínio). */
15
+ render(watchdog: Watchdog, args: WatchdogRenderArgs): string {
16
+ return watchdog.renderBrief(args);
17
+ }
18
+
19
+ /** Render a partir da definição declarativa. */
20
+ renderDefinition(definition: WatchdogDefinition, args: WatchdogRenderArgs): string {
21
+ return renderBrief(definition, args);
22
+ }
23
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Apresentação dos findings (biblioteca) — markdown/JSON do CLI.
3
+ *
4
+ * Separado do comando para que o mesmo texto possa ser reutilizado (CLI, UI,
5
+ * testes) sem arrastar o parse de flags.
6
+ */
7
+
8
+ import {
9
+ FINDING_DECISIONS,
10
+ FINDING_EXEC_STATES,
11
+ type FindingDecision,
12
+ type FindingState,
13
+ type FindingsStateFile,
14
+ type FindingsSummary,
15
+ } from "../findings-state";
16
+ import { SEVERITY_EMOJI, SEVERITY_ORDER, type Severity } from "../types";
17
+
18
+ /** Renderiza findings em markdown compacto (uso no agente). */
19
+ export class FindingsPresenter {
20
+ /** Uma linha compacta por finding. */
21
+ line(finding: FindingState): string[] {
22
+ const emoji = SEVERITY_EMOJI[finding.severity];
23
+ const location = finding.line ? `${finding.file}:${finding.line}` : finding.file;
24
+ const flags = `${this.decisionLabel(finding.decision)}/${finding.execState}`;
25
+ const count = finding.count > 1 ? ` ×${finding.count}` : "";
26
+ const lines = [
27
+ `- \`${finding.id}\` ${emoji} **${finding.severity}** \`${location}\` — ${finding.rule}${count}`,
28
+ ` ${finding.message}`,
29
+ ` _${flags}_ · watchdogs: ${finding.watchdogIds.join(", ") || "—"}`,
30
+ ];
31
+ if (finding.comment) lines.push(` **Comentário:** ${finding.comment}`);
32
+ return lines;
33
+ }
34
+
35
+ /** Lista de findings com cabeçalho. */
36
+ list(state: FindingsStateFile, findings: FindingState[]): string {
37
+ if (findings.length === 0) return "Nenhum finding para o filtro informado.";
38
+ const lines = [`# Findings — ${state.branch} (${findings.length})`, ""];
39
+ for (const finding of findings) lines.push(...this.line(finding));
40
+ return lines.join("\n");
41
+ }
42
+
43
+ /** Resumo da triagem (decisões, execução e severidade). */
44
+ summary(state: FindingsStateFile, summary: FindingsSummary): string {
45
+ const decisions = FINDING_DECISIONS.map(
46
+ (decision) => `${decision} ${summary.byDecision[decision]}`,
47
+ ).join(" · ");
48
+ const exec = FINDING_EXEC_STATES.map(
49
+ (stateName) => `${stateName} ${summary.byExecState[stateName]}`,
50
+ ).join(" · ");
51
+ const severities = SEVERITY_ORDER.map(
52
+ (severity) => `${severity} ${summary.bySeverity[severity]}`,
53
+ ).join(" · ");
54
+ return [
55
+ `# Triagem — ${state.branch}`,
56
+ "",
57
+ `Total: ${summary.total} (presentes: ${summary.present})`,
58
+ `Decisões: ${decisions}`,
59
+ `Execução: ${exec}`,
60
+ `Severidade: ${severities}`,
61
+ ].join("\n");
62
+ }
63
+
64
+ /** Mix de severidade compacto (ex.: `❌1 ⚠️2`). */
65
+ severityBreakdown(bySeverity: Record<Severity, number>): string {
66
+ return SEVERITY_ORDER.filter((severity) => bySeverity[severity] > 0)
67
+ .map((severity) => `${SEVERITY_EMOJI[severity]}${bySeverity[severity]}`)
68
+ .join(" ");
69
+ }
70
+
71
+ private decisionLabel(decision: FindingDecision): string {
72
+ return decision;
73
+ }
74
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Manifesto da run (biblioteca) — monta e grava o `manifest.json`.
3
+ *
4
+ * Concentra o resumo do que foi ativado (watchdogs, orçamento, churn, contexto,
5
+ * Jev e incremental), hoje espalhado em helpers do orquestrador.
6
+ */
7
+
8
+ import { Classification } from "../domain/classification";
9
+ import type { ClassificationResult } from "../file-classifier";
10
+ import type { JevCandidatesFile } from "../jev";
11
+ import { OUTPUT_FORMAT_PATH, relFromRoot } from "../paths";
12
+ import { readContextSummary } from "../pr-context";
13
+ import type { ProjectConfig } from "../project-config";
14
+ import type { MergedFinding } from "../report-builder";
15
+ import type { RunLayout } from "../run-layout";
16
+ import { systemClock } from "../services/clock";
17
+ import { ProjectFiles } from "../services/project-files";
18
+
19
+ /** Opções do manifesto (subset do `ReviewOptions`). */
20
+ export type ManifestOptions = {
21
+ base: string;
22
+ head: string;
23
+ };
24
+
25
+ /** Classificação enriquecida gravada pelo prepare (modo + incremental). */
26
+ export type ManifestClassification = ClassificationResult & {
27
+ headSha?: string;
28
+ mode: "full" | "incremental";
29
+ reviewedFrom?: string;
30
+ carryover?: MergedFinding[];
31
+ };
32
+
33
+ /** Escreve o `manifest.json` de uma run. */
34
+ export class ManifestWriter {
35
+ private readonly layout: RunLayout;
36
+ private readonly project: ProjectConfig;
37
+ private readonly options: ManifestOptions;
38
+
39
+ constructor(layout: RunLayout, project: ProjectConfig, options: ManifestOptions) {
40
+ this.layout = layout;
41
+ this.project = project;
42
+ this.options = options;
43
+ }
44
+
45
+ write(
46
+ classification: ManifestClassification,
47
+ activeIds: string[],
48
+ pending: string[],
49
+ jev: JevCandidatesFile,
50
+ ): void {
51
+ const view = Classification.from(classification);
52
+ const maxFilesPerBrief = this.project.maxFilesPerBrief;
53
+ let filesOverBudget = 0;
54
+
55
+ const watchdogs = activeIds.map((id) => {
56
+ const selection = view.budgetFor(id, maxFilesPerBrief);
57
+ filesOverBudget += selection.overflow.length;
58
+ return {
59
+ id,
60
+ name: this.project.watchdogs.nameOf(id),
61
+ files: selection.selected,
62
+ overflow: selection.overflow.length,
63
+ brief: relFromRoot(this.layout.briefFile(id)),
64
+ findings: relFromRoot(this.layout.findingFile(id)),
65
+ status: pending.includes(id) ? "pending" : "done",
66
+ };
67
+ });
68
+
69
+ const summary = readContextSummary(this.layout.contextPaths.summary);
70
+ const manifest = {
71
+ branch: this.layout.branch,
72
+ slug: this.layout.slug,
73
+ base: this.options.base,
74
+ head: this.options.head,
75
+ headSha: classification.headSha,
76
+ generatedAt: systemClock(),
77
+ outputFormat: OUTPUT_FORMAT_PATH,
78
+ budget: { maxFilesPerBrief, filesOverBudget },
79
+ churn: view.churnTotals,
80
+ incremental:
81
+ classification.mode === "incremental"
82
+ ? {
83
+ from: classification.reviewedFrom,
84
+ changedFiles: Object.keys(classification.files).length,
85
+ carried: classification.carryover?.length ?? 0,
86
+ }
87
+ : undefined,
88
+ watchdogs,
89
+ context: summary
90
+ ? { summarized: true, source: summary.source ?? null }
91
+ : { summarized: false, source: null },
92
+ jev: jev.available
93
+ ? {
94
+ available: true,
95
+ model: jev.model,
96
+ threshold: jev.threshold,
97
+ analyzedFiles: jev.analyzedFiles,
98
+ candidates: jev.candidates.length,
99
+ skipped: jev.skipped?.length ?? 0,
100
+ }
101
+ : { available: false, reason: jev.reason },
102
+ deleted: classification.deleted,
103
+ unclassified: classification.unclassified,
104
+ excludedCount: classification.excludedCount,
105
+ };
106
+
107
+ new ProjectFiles().write(
108
+ this.layout.manifestFile,
109
+ `${JSON.stringify(manifest, null, 2)}\n`,
110
+ );
111
+ }
112
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Renderização do relatório (biblioteca) — superfície OO sobre `report-builder`.
3
+ *
4
+ * O texto continua nas funções puras; a classe fixa o resolver de nomes e evita
5
+ * repetir a lista de argumentos no orquestrador.
6
+ */
7
+
8
+ import type { JevCandidate } from "../jev";
9
+ import {
10
+ type ContextSummary,
11
+ type DomainReport,
12
+ type MergedFinding,
13
+ renderDomainReport,
14
+ renderReport,
15
+ type WatchdogNameResolver,
16
+ type WatchdogReport,
17
+ } from "../report-builder";
18
+
19
+ /** Renderiza o relatório consolidado e os relatórios por domínio. */
20
+ export class ReportRenderer {
21
+ private readonly nameOf: WatchdogNameResolver;
22
+
23
+ constructor(nameOf: WatchdogNameResolver) {
24
+ this.nameOf = nameOf;
25
+ }
26
+
27
+ /** Relatório consolidado (`review.md`). */
28
+ render(
29
+ branch: string,
30
+ reports: WatchdogReport[],
31
+ merged: MergedFinding[],
32
+ coverage: { classified?: Record<string, string[]> },
33
+ context?: ContextSummary | null,
34
+ candidates?: JevCandidate[],
35
+ domains: readonly DomainReport[] = [],
36
+ ): string {
37
+ return renderReport(
38
+ branch,
39
+ reports,
40
+ merged,
41
+ coverage,
42
+ context,
43
+ candidates,
44
+ this.nameOf,
45
+ domains,
46
+ );
47
+ }
48
+
49
+ /** Relatório isolado de um watchdog (`review.<id>.md`). */
50
+ renderDomain(
51
+ branch: string,
52
+ report: WatchdogReport,
53
+ merged: MergedFinding[],
54
+ expectedFiles: number,
55
+ context?: ContextSummary | null,
56
+ ): string {
57
+ return renderDomainReport(
58
+ branch,
59
+ report,
60
+ merged,
61
+ expectedFiles,
62
+ context,
63
+ this.nameOf,
64
+ );
65
+ }
66
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Apresentação da verificação de findings (biblioteca) — markdown do `verify`.
3
+ */
4
+
5
+ import type { FindingsStateFile } from "../findings-state";
6
+ import type { VerifyEntry, VerifyStatus, VerifySummary } from "../verify";
7
+
8
+ /** Rótulo curto de cada status de verificação. */
9
+ const VERIFY_LABEL: Record<VerifyStatus, string> = {
10
+ resolved: "✅ resolvido",
11
+ persisting: "❌ persiste",
12
+ uncertain: "⚠️ incerto",
13
+ "file-missing": "🗑️ arquivo ausente",
14
+ };
15
+
16
+ /** Metadados do Jev exibidos no cabeçalho. */
17
+ export type VerifyMeta = { jevChecked: number; jevReason?: string };
18
+
19
+ /** Renderiza a verificação: contagem por status + uma linha por finding. */
20
+ export class VerifyPresenter {
21
+ render(
22
+ state: FindingsStateFile,
23
+ entries: VerifyEntry[],
24
+ summary: VerifySummary,
25
+ meta: VerifyMeta,
26
+ ): string {
27
+ if (entries.length === 0) return "Nenhum finding para verificar.";
28
+ const lines = [
29
+ `# Verificação — ${state.branch} (${entries.length})`,
30
+ "",
31
+ `Resolvidos: ${summary.resolved} · Persistem: ${summary.persisting} · Incertos: ${summary.uncertain} · Arquivo ausente: ${summary["file-missing"]}`,
32
+ ];
33
+ if (meta.jevChecked > 0) lines.push(`Confirmados por Jev: ${meta.jevChecked}.`);
34
+ else if (meta.jevReason) lines.push(`Jev: pulado (${meta.jevReason}).`);
35
+ lines.push("");
36
+ for (const entry of entries) {
37
+ const location = entry.line ? `${entry.file}:${entry.line}` : entry.file;
38
+ const via =
39
+ entry.basis === "jev"
40
+ ? ` · Jev ${(entry.confidence ?? 0).toFixed(2)}`
41
+ : "";
42
+ lines.push(
43
+ `- \`${entry.id}\` ${VERIFY_LABEL[entry.status]} — **${entry.severity}** \`${location}\` — ${entry.rule}${via}`,
44
+ ` ${entry.message}`,
45
+ );
46
+ if (entry.detail) lines.push(` _${entry.detail}_`);
47
+ }
48
+ return lines.join("\n");
49
+ }
50
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Apresentação do contexto de um watchdog (biblioteca) — markdown/JSON.
3
+ */
4
+
5
+ import type { WatchdogContext } from "../domain/watchdog";
6
+ import {
7
+ type Rule,
8
+ SEVERITY_EMOJI,
9
+ severityRank,
10
+ type WatchdogDefinition,
11
+ } from "../types";
12
+
13
+ /** Opções de apresentação do watchdog. */
14
+ export type WatchdogRenderOptions = {
15
+ /** Inclui as regras. */
16
+ withRules: boolean;
17
+ /** Saída JSON (default: markdown). */
18
+ json: boolean;
19
+ };
20
+
21
+ /** Renderiza o contexto de um watchdog. */
22
+ export class WatchdogPresenter {
23
+ render(
24
+ context: WatchdogContext,
25
+ definition: WatchdogDefinition,
26
+ options: WatchdogRenderOptions,
27
+ ): string {
28
+ return options.json
29
+ ? this.serialize(context, definition, options.withRules)
30
+ : this.markdown(context, definition, options.withRules);
31
+ }
32
+
33
+ /** Payload serializável (regras incluídas apenas com `withRules`). */
34
+ private serialize(
35
+ context: WatchdogContext,
36
+ definition: WatchdogDefinition,
37
+ withRules: boolean,
38
+ ): string {
39
+ const payload = withRules
40
+ ? { ...context, rules: definition.rules }
41
+ : { ...context };
42
+ return JSON.stringify(payload, null, 2);
43
+ }
44
+
45
+ /** Lista de itens em bloco `## <título>` (vazio se não houver itens). */
46
+ private section(title: string, items: readonly string[] | undefined): string[] {
47
+ if (!items || items.length === 0) return [];
48
+ return [`## ${title}`, "", ...items.map((item) => `- ${item}`), ""];
49
+ }
50
+
51
+ /** Regras com emoji de severidade em bloco `## <título>`. */
52
+ private ruleSection(title: string, rules: readonly Rule[]): string[] {
53
+ return [
54
+ `## ${title}`,
55
+ "",
56
+ ...rules.map(
57
+ (rule) =>
58
+ `- ${SEVERITY_EMOJI[rule.severity]} **${rule.severity}** — ${rule.text}`,
59
+ ),
60
+ "",
61
+ ];
62
+ }
63
+
64
+ /** Render markdown legível do contexto do watchdog. */
65
+ private markdown(
66
+ context: WatchdogContext,
67
+ definition: WatchdogDefinition,
68
+ withRules: boolean,
69
+ ): string {
70
+ const lines: string[] = [
71
+ `# ${context.name} (\`${context.id}\`)`,
72
+ "",
73
+ `**Descrição:** ${context.description}`,
74
+ `**Escopo:** ${context.scope}`,
75
+ "",
76
+ ];
77
+
78
+ lines.push(...this.section("Prioridade de leitura", context.reviewFocus));
79
+ lines.push(...this.section("Não carregar", context.exclude));
80
+ lines.push(
81
+ ...this.section(
82
+ "Docs de contexto",
83
+ context.contextFiles.map((doc) => `\`${doc}\``),
84
+ ),
85
+ );
86
+
87
+ if (withRules) {
88
+ lines.push(
89
+ ...this.ruleSection(
90
+ "Regras",
91
+ [...definition.rules].sort(
92
+ (a, b) => severityRank(a.severity) - severityRank(b.severity),
93
+ ),
94
+ ),
95
+ );
96
+ }
97
+
98
+ return lines.join("\n").trimEnd();
99
+ }
100
+ }