@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
package/src/core/paths.ts CHANGED
@@ -2,11 +2,13 @@
2
2
  * Caminhos do houndsman (biblioteca): raiz relativa para logs + layout fixo da
3
3
  * camada de projeto (`.houndsman/`).
4
4
  *
5
- * A camada gerada tem local fixo, então nada aqui é configurável pelo projeto:
6
- * o CLI deriva os artefatos e o schema de output destas constantes.
5
+ * A camada gerada tem local fixo, então nada aqui é configurável pelo projeto.
6
+ * Dados de execução (reviews/findings/artefatos) vivem no storage global; aqui
7
+ * ficam só os paths da camada e os nomes dos artefatos de staging.
7
8
  */
8
9
 
9
- import { relative } from "node:path";
10
+ import { homedir } from "node:os";
11
+ import { relative, sep } from "node:path";
10
12
  import { OUTPUT_FORMAT_FILE } from "./assets";
11
13
 
12
14
  /** Caminho relativo ao cwd, sempre com `/` (portável/legível em logs). */
@@ -14,6 +16,16 @@ export function relFromRoot(absPath: string): string {
14
16
  return relative(process.cwd(), absPath).replace(/\\/g, "/");
15
17
  }
16
18
 
19
+ /** Caminho legível para mensagens: `~/...` quando sob o home, senão absoluto. */
20
+ export function displayPath(absPath: string): string {
21
+ const home = homedir();
22
+ if (absPath === home) return "~";
23
+ if (absPath.startsWith(`${home}${sep}`)) {
24
+ return `~${absPath.slice(home.length).replace(/\\/g, "/")}`;
25
+ }
26
+ return absPath.replace(/\\/g, "/");
27
+ }
28
+
17
29
  /** Diretório da camada de projeto (regras + artefatos). */
18
30
  export const PROJECT_DIR = ".houndsman";
19
31
 
@@ -23,17 +35,20 @@ export const CORE_DIR = `${PROJECT_DIR}/core`;
23
35
  /** Skill canônico (markdown) dentro da camada gerada. */
24
36
  export const CORE_SKILL_DIR = `${CORE_DIR}/skill`;
25
37
 
26
- /** Artefatos da revisão (briefs, findings, review.md) — default de `artifactDir`. */
27
- export const ARTIFACT_DIR = `${PROJECT_DIR}/.runs`;
28
-
29
38
  /** Schema de output dos watchdogs, citado nos briefs. */
30
39
  export const OUTPUT_FORMAT_PATH = `${CORE_SKILL_DIR}/${OUTPUT_FORMAT_FILE}`;
31
40
 
32
- /** Estado de triagem dos findings, dentro da pasta da run (`<slug>/state.json`). */
33
- export const STATE_FILE = "state.json";
41
+ /** Contexto bruto da PR (`gh pr view`), no staging da review. */
42
+ export const CONTEXT_FILE = "context.json";
34
43
 
35
- /** Status da run (revisão/correção), dentro da pasta da run (`<slug>/run.json`). */
36
- export const RUN_STATUS_FILE = "run.json";
44
+ /** Resumo do contexto gravado pelo subagente, no staging da review. */
45
+ export const CONTEXT_SUMMARY_FILE = "context-summary.json";
37
46
 
38
- /** Relatório da verificação de findings, dentro da pasta da run (`<slug>/verify.md`). */
47
+ /** Brief do subagente que resume o contexto da PR. */
48
+ export const CONTEXT_BRIEF_FILE = "context-brief.md";
49
+
50
+ /** Relatório da verificação de findings (artefato `verify.md`). */
39
51
  export const VERIFY_FILE = "verify.md";
52
+
53
+ /** Relatório consolidado da review (artefato `review.md`). */
54
+ export const REVIEW_FILE = "review.md";
@@ -5,7 +5,7 @@
5
5
  * OPCIONAL: sem binário, sem login ou sem PR para a branch, grava
6
6
  * `context.json` com `{ available: false, reason }` e sai com código 0.
7
7
  *
8
- * Artefatos (em --dir, default .houndsman/.runs/<branch-slug>/):
8
+ * Artefatos no staging da review (`<HOUNDSMAN_HOME>/work/<reviewId>/`):
9
9
  * context.json payload bruto do `gh pr view` (ou marcador de indisponível)
10
10
  * context-brief.md instruções para o subagente resumir o contexto
11
11
  *
@@ -14,16 +14,27 @@
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";
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 { systemClock } from "./services/clock";
26
+ import { defaultGhRunner, GhClient, type GhRunner } from "./services/gh-client";
27
+ import { GitRepository } from "./services/git-repository";
23
28
 
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";
29
+ export { CONTEXT_BRIEF_FILE, CONTEXT_FILE, CONTEXT_SUMMARY_FILE };
30
+
31
+ /** Caminhos dos artefatos de contexto da PR (staging da review). */
32
+ export type ContextPaths = {
33
+ dir: string;
34
+ context: string;
35
+ summary: string;
36
+ brief: string;
37
+ };
27
38
 
28
39
  const GH_JSON_FIELDS =
29
40
  "number,title,body,url,baseRefName,headRefName,author,commits,files";
@@ -54,55 +65,28 @@ export type PrContextRaw = {
54
65
  stats?: { files: number; additions: number; deletions: number };
55
66
  };
56
67
 
57
- export type ContextPaths = {
58
- dir: string;
68
+ /** Caminhos dos artefatos de contexto para uma branch. */
69
+ export function contextFileNames(): {
59
70
  context: string;
60
71
  summary: string;
61
72
  brief: string;
62
- };
63
-
64
- /** Caminhos dos artefatos de contexto para uma branch. */
65
- export function contextPaths(dir: string, branch: string): ContextPaths {
66
- const dirPath = join(dir, sanitizeBranch(branch));
73
+ } {
67
74
  return {
68
- dir: dirPath,
69
- context: join(dirPath, CONTEXT_FILE),
70
- summary: join(dirPath, CONTEXT_SUMMARY_FILE),
71
- brief: join(dirPath, CONTEXT_BRIEF_FILE),
75
+ context: CONTEXT_FILE,
76
+ summary: CONTEXT_SUMMARY_FILE,
77
+ brief: CONTEXT_BRIEF_FILE,
72
78
  };
73
79
  }
74
80
 
75
- /** Caminho relativo à raiz, sempre com `/` (portável/legível). */
76
- function relFromRoot(absPath: string): string {
77
- return relative(process.cwd(), absPath).replace(/\\/g, "/");
78
- }
79
-
80
- /** Detecta a branch atual; encerra com erro se não houver. */
81
+ /** Detecta a branch atual; lança se não houver (o CLI decide o exit). */
81
82
  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();
83
+ const branch = new GitRepository().currentBranch();
87
84
  if (!branch) {
88
- console.error("Não foi possível detectar a branch. Use --branch <nome>.");
89
- process.exit(1);
85
+ throw new Error("Não foi possível detectar a branch. Use --branch <nome>.");
90
86
  }
91
87
  return branch;
92
88
  }
93
89
 
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
90
  type GhPrView = {
107
91
  number?: number;
108
92
  title?: string;
@@ -120,19 +104,23 @@ function unavailable(reason: ContextUnavailableReason, detail: string): PrContex
120
104
  available: false,
121
105
  reason,
122
106
  detail,
123
- collectedAt: new Date().toISOString(),
107
+ collectedAt: systemClock(),
124
108
  };
125
109
  }
126
110
 
127
111
  /** 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();
112
+ export function collectPrContext(
113
+ branch: string,
114
+ runner: GhRunner = defaultGhRunner,
115
+ ): PrContextRaw {
116
+ const collectedAt = systemClock();
117
+ const gh = new GhClient(runner);
130
118
 
131
- if (!Bun.which("gh")) {
119
+ if (!gh.isAvailable()) {
132
120
  return unavailable("gh-missing", "GitHub CLI (gh) não encontrado no PATH.");
133
121
  }
134
122
 
135
- const auth = runGh(["auth", "status"]);
123
+ const auth = gh.run(["auth", "status"]);
136
124
  if (!auth.ok) {
137
125
  return unavailable(
138
126
  "gh-unauthenticated",
@@ -140,7 +128,7 @@ export function collectPrContext(branch: string): PrContextRaw {
140
128
  );
141
129
  }
142
130
 
143
- const view = runGh(["pr", "view", branch, "--json", GH_JSON_FIELDS]);
131
+ const view = gh.run(["pr", "view", branch, "--json", GH_JSON_FIELDS]);
144
132
  if (!view.ok) {
145
133
  const noPr = /no pull requests? found|not found|could not find/i.test(
146
134
  view.stderr,
@@ -0,0 +1,56 @@
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 orçamento/concorrência/watchdogs pede aqui.
6
+ * O contrato do consumidor (`houndsman.config.ts`) não muda.
7
+ */
8
+
9
+ import type { HoundsmanConfig, HoundsmanStorage } from "./config";
10
+ import {
11
+ DEFAULT_ALWAYS_INCLUDE_DOCS,
12
+ DEFAULT_JEV_CONCURRENCY,
13
+ DEFAULT_MAX_FILES_PER_BRIEF,
14
+ } from "./defaults";
15
+ import { WatchdogRegistry } from "./watchdog-registry";
16
+
17
+ /** Configuração resolvida do projeto. */
18
+ export class ProjectConfig<TId extends string = string> {
19
+ readonly raw: HoundsmanConfig<TId>;
20
+ readonly watchdogs: WatchdogRegistry<TId>;
21
+
22
+ constructor(raw: HoundsmanConfig<TId>) {
23
+ this.raw = raw;
24
+ this.watchdogs = new WatchdogRegistry(raw);
25
+ }
26
+
27
+ get projectName(): string {
28
+ return this.raw.projectName;
29
+ }
30
+
31
+ get storage(): HoundsmanStorage {
32
+ return this.raw.storage ?? {};
33
+ }
34
+
35
+ get maxFilesPerBrief(): number {
36
+ const value = this.raw.budgets?.maxFilesPerBrief;
37
+ return value !== undefined && value > 0 ? value : DEFAULT_MAX_FILES_PER_BRIEF;
38
+ }
39
+
40
+ get jevConcurrency(): number {
41
+ const value = this.raw.concurrency?.jev;
42
+ return value !== undefined && value > 0 ? value : DEFAULT_JEV_CONCURRENCY;
43
+ }
44
+
45
+ get alwaysIncludeDocs(): readonly string[] {
46
+ return this.raw.alwaysIncludeDocs ?? DEFAULT_ALWAYS_INCLUDE_DOCS;
47
+ }
48
+
49
+ get exclusions(): readonly RegExp[] {
50
+ return this.raw.exclusions;
51
+ }
52
+
53
+ get docMappings(): readonly (readonly [RegExp, readonly string[]])[] {
54
+ return this.raw.docMappings;
55
+ }
56
+ }
@@ -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,123 @@
1
+ /**
2
+ * Manifesto da run (biblioteca) — monta e grava o artefato `manifest.json`.
3
+ *
4
+ * Concentra o resumo do que foi ativado (watchdogs, orçamento, churn, contexto,
5
+ * Jev e incremental). O manifesto vive no storage; os caminhos de briefs e
6
+ * findings apontam para o staging da review.
7
+ */
8
+
9
+ import { Classification } from "../domain/classification";
10
+ import type { ClassificationResult } from "../file-classifier";
11
+ import type { JevCandidatesFile } from "../jev";
12
+ import { OUTPUT_FORMAT_PATH } from "../paths";
13
+ import type { ProjectConfig } from "../project-config";
14
+ import type { MergedFinding } from "../report-builder";
15
+ import { systemClock } from "../services/clock";
16
+ import type { ReviewRecord, StorageProvider } from "../storage/types";
17
+ import type { ReviewWorkArea } from "../storage/work-area";
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 storage: StorageProvider;
36
+ private readonly review: ReviewRecord;
37
+ private readonly work: ReviewWorkArea;
38
+ private readonly project: ProjectConfig;
39
+ private readonly options: ManifestOptions;
40
+
41
+ constructor(
42
+ storage: StorageProvider,
43
+ review: ReviewRecord,
44
+ work: ReviewWorkArea,
45
+ project: ProjectConfig,
46
+ options: ManifestOptions,
47
+ ) {
48
+ this.storage = storage;
49
+ this.review = review;
50
+ this.work = work;
51
+ this.project = project;
52
+ this.options = options;
53
+ }
54
+
55
+ write(
56
+ classification: ManifestClassification,
57
+ activeIds: string[],
58
+ pending: string[],
59
+ jev: JevCandidatesFile,
60
+ ): void {
61
+ const view = Classification.from(classification);
62
+ const maxFilesPerBrief = this.project.maxFilesPerBrief;
63
+ let filesOverBudget = 0;
64
+
65
+ const watchdogs = activeIds.map((id) => {
66
+ const selection = view.budgetFor(id, maxFilesPerBrief);
67
+ filesOverBudget += selection.overflow.length;
68
+ return {
69
+ id,
70
+ name: this.project.watchdogs.nameOf(id),
71
+ files: selection.selected,
72
+ overflow: selection.overflow.length,
73
+ brief: this.work.briefFile(id),
74
+ findings: this.work.findingFile(id),
75
+ status: pending.includes(id) ? "pending" : "done",
76
+ };
77
+ });
78
+
79
+ const summary = this.work.readContextSummary();
80
+ const manifest = {
81
+ branch: this.review.branch,
82
+ slug: this.review.slug,
83
+ base: this.options.base,
84
+ head: this.options.head,
85
+ headSha: classification.headSha,
86
+ generatedAt: systemClock(),
87
+ outputFormat: OUTPUT_FORMAT_PATH,
88
+ budget: { maxFilesPerBrief, filesOverBudget },
89
+ churn: view.churnTotals,
90
+ incremental:
91
+ classification.mode === "incremental"
92
+ ? {
93
+ from: classification.reviewedFrom,
94
+ changedFiles: Object.keys(classification.files).length,
95
+ carried: classification.carryover?.length ?? 0,
96
+ }
97
+ : undefined,
98
+ watchdogs,
99
+ context: summary
100
+ ? { summarized: true, source: summary.source ?? null }
101
+ : { summarized: false, source: null },
102
+ jev: jev.available
103
+ ? {
104
+ available: true,
105
+ model: jev.model,
106
+ threshold: jev.threshold,
107
+ analyzedFiles: jev.analyzedFiles,
108
+ candidates: jev.candidates.length,
109
+ skipped: jev.skipped?.length ?? 0,
110
+ }
111
+ : { available: false, reason: jev.reason },
112
+ deleted: classification.deleted,
113
+ unclassified: classification.unclassified,
114
+ excludedCount: classification.excludedCount,
115
+ };
116
+
117
+ this.storage.writeArtifact(this.review.id, {
118
+ name: "manifest.json",
119
+ kind: "manifest",
120
+ content: `${JSON.stringify(manifest, null, 2)}\n`,
121
+ });
122
+ }
123
+ }
@@ -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
+ }