@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
@@ -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
+ }
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import type { JevCandidate } from "./jev";
11
+ import { systemClock } from "./services/clock";
11
12
  import { SEVERITY_EMOJI, SEVERITY_ORDER, type Severity, severityRank } from "./types";
12
13
 
13
14
  /** Resolve o nome legível de um watchdog (default: identidade). */
@@ -106,24 +107,28 @@ function isFinding(value: unknown): value is Finding {
106
107
  }
107
108
 
108
109
  /** Valida e normaliza um relatório. Retorna null se inválido. */
109
- export function parseWatchdogReport(raw: unknown, source: string): WatchdogReport | null {
110
+ export function parseWatchdogReport(
111
+ raw: unknown,
112
+ source: string,
113
+ onError: (message: string) => void = () => {},
114
+ ): WatchdogReport | null {
110
115
  if (typeof raw !== "object" || raw === null) {
111
- console.error(`[report-builder] ${source}: não é um objeto JSON`);
116
+ onError(`[report-builder] ${source}: não é um objeto JSON`);
112
117
  return null;
113
118
  }
114
119
  const obj = raw as Record<string, unknown>;
115
120
  if (typeof obj.watchdogId !== "string") {
116
- console.error(`[report-builder] ${source}: campo 'watchdogId' ausente`);
121
+ onError(`[report-builder] ${source}: campo 'watchdogId' ausente`);
117
122
  return null;
118
123
  }
119
124
  if (!Array.isArray(obj.findings)) {
120
- console.error(`[report-builder] ${source}: campo 'findings' ausente`);
125
+ onError(`[report-builder] ${source}: campo 'findings' ausente`);
121
126
  return null;
122
127
  }
123
128
 
124
129
  const findings = obj.findings.filter((f) => {
125
130
  if (!isFinding(f)) {
126
- console.error(`[report-builder] ${source}: finding inválido ignorado`);
131
+ onError(`[report-builder] ${source}: finding inválido ignorado`);
127
132
  return false;
128
133
  }
129
134
  return true;
@@ -370,7 +375,7 @@ export function renderDomainReport(
370
375
  context?: ContextSummary | null,
371
376
  nameOf: WatchdogNameResolver = identityName,
372
377
  ): string {
373
- const now = new Date().toISOString().slice(0, 10);
378
+ const now = systemClock().slice(0, 10);
374
379
  const name = watchdogName(nameOf, report.watchdogId);
375
380
  const lines: string[] = [
376
381
  `# Review — ${name} (\`${report.watchdogId}\`) — ${branch}`,
@@ -429,7 +434,7 @@ export function renderReport(
429
434
  nameOf: WatchdogNameResolver = identityName,
430
435
  domains: readonly DomainReport[] = [],
431
436
  ): string {
432
- const now = new Date().toISOString().slice(0, 10);
437
+ const now = systemClock().slice(0, 10);
433
438
  const counts = {
434
439
  blocking: merged.filter((f) => f.severity === "blocking").length,
435
440
  attention: merged.filter((f) => f.severity === "attention").length,
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Removido: o layout de artefatos do projeto deu lugar ao staging global por
3
+ * review (`ReviewWorkArea` em `core/storage/work-area.ts`).
4
+ *
5
+ * Mantido apenas para não quebrar imports antigos de tipos.
6
+ */
7
+
8
+ export type { ContextPaths } from "./storage/work-area";
@@ -1,22 +1,20 @@
1
1
  /**
2
- * Status do run (biblioteca) — persistido em `.houndsman/.runs/<slug>/run.json`.
2
+ * Status do run (biblioteca) — colunas da review no storage.
3
3
  *
4
4
  * Diz à UI em que pé está uma run: `reviewing` (revisando) → `review_done`
5
5
  * (revisão finalizada) → `fixing` (corrigindo) → `fix_done` (correção finalizada).
6
6
  *
7
- * Toda ação que muda o estado faz um **ping** no `updatedAt`, mantendo o arquivo
8
- * "quente". Timeout é **display-only** (a UI compara `updatedAt` com o agora) —
9
- * não é um status persistido.
7
+ * Toda ação que muda o estado faz um **ping** no `updatedAt`. Timeout é
8
+ * **display-only** (a UI compara `updatedAt` com o agora) — não é um status
9
+ * persistido.
10
10
  */
11
11
 
12
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
13
- import { join } from "node:path";
14
12
  import type { FindingsStateFile } from "./findings-state";
15
- import { RUN_STATUS_FILE } from "./paths";
16
- import { sanitizeBranch } from "./report-builder";
13
+ import { systemClock } from "./services/clock";
14
+ import type { ReviewRecord, ReviewStatus, StorageProvider } from "./storage/types";
17
15
 
18
16
  /** Estados de execução de uma run (persistidos). */
19
- export type RunStatus = "reviewing" | "review_done" | "fixing" | "fix_done";
17
+ export type RunStatus = ReviewStatus;
20
18
 
21
19
  /** Valores canônicos de status (ordem de exibição/lifecycle). */
22
20
  export const RUN_STATUSES: readonly RunStatus[] = [
@@ -26,62 +24,34 @@ export const RUN_STATUSES: readonly RunStatus[] = [
26
24
  "fix_done",
27
25
  ];
28
26
 
29
- export const RUN_STATUS_VERSION = 1;
30
-
31
- /** Conteúdo do `run.json`. */
32
- export type RunStatusFile = {
33
- version: 1;
34
- slug: string;
35
- branch: string;
36
- status: RunStatus;
37
- /** Início do ciclo de revisão atual (reiniciado a cada `prepare`). */
38
- startedAt: string;
39
- /** Última ação importante (ping). */
40
- updatedAt: string;
41
- };
42
-
43
- /** Caminho do `run.json` dentro da pasta da run. */
44
- export function runStatusPath(runDir: string): string {
45
- return join(runDir, RUN_STATUS_FILE);
46
- }
47
-
48
- /** Lê o `run.json` da run (null se ausente/inválido). */
49
- export function readRunStatus(runDir: string): RunStatusFile | null {
50
- const path = runStatusPath(runDir);
51
- if (!existsSync(path)) return null;
52
- try {
53
- return JSON.parse(readFileSync(path, "utf8")) as RunStatusFile;
54
- } catch {
55
- return null;
56
- }
27
+ /** Lê o status atual (null se a review não existir). */
28
+ export function readRunStatus(
29
+ storage: StorageProvider,
30
+ reviewId: string,
31
+ ): ReviewRecord | null {
32
+ return storage.getReview(reviewId);
57
33
  }
58
34
 
59
35
  export type WriteRunStatusOptions = {
60
- branch: string;
61
36
  status: RunStatus;
62
37
  /** Reinicia o `startedAt` — usado no início de uma nova revisão. */
63
38
  resetStartedAt?: boolean;
64
39
  now?: string;
65
40
  };
66
41
 
67
- /** Grava/atualiza o `run.json` (ping no `updatedAt`). */
42
+ /** Grava/atualiza o status da review (ping no `updatedAt`). */
68
43
  export function writeRunStatus(
69
- runDir: string,
44
+ storage: StorageProvider,
45
+ reviewId: string,
70
46
  options: WriteRunStatusOptions,
71
- ): RunStatusFile {
72
- const now = options.now ?? new Date().toISOString();
73
- const previous = readRunStatus(runDir);
74
- const file: RunStatusFile = {
75
- version: RUN_STATUS_VERSION,
76
- slug: sanitizeBranch(options.branch),
77
- branch: options.branch,
47
+ ): ReviewRecord {
48
+ const updated = storage.updateReview(reviewId, {
78
49
  status: options.status,
79
- startedAt: options.resetStartedAt ? now : (previous?.startedAt ?? now),
80
- updatedAt: now,
81
- };
82
- mkdirSync(runDir, { recursive: true });
83
- writeFileSync(runStatusPath(runDir), `${JSON.stringify(file, null, 2)}\n`);
84
- return file;
50
+ resetStartedAt: options.resetStartedAt,
51
+ updatedAt: options.now ?? systemClock(),
52
+ });
53
+ if (!updated) throw new Error(`Review '${reviewId}' não encontrada.`);
54
+ return updated;
85
55
  }
86
56
 
87
57
  /**
@@ -110,14 +80,14 @@ export function statusAfterFindingsChange(
110
80
  return previous ?? "review_done";
111
81
  }
112
82
 
113
- /** Ping: deriva o status da mudança nos findings e grava o `run.json`. */
83
+ /** Ping: deriva o status da mudança nos findings e grava na review. */
114
84
  export function pingFindingsStatus(
115
- runDir: string,
85
+ storage: StorageProvider,
86
+ reviewId: string,
116
87
  state: FindingsStateFile,
117
- ): RunStatusFile {
118
- const previous = readRunStatus(runDir)?.status ?? null;
119
- return writeRunStatus(runDir, {
120
- branch: state.branch,
88
+ ): ReviewRecord {
89
+ const previous = storage.getReview(reviewId)?.status ?? null;
90
+ return writeRunStatus(storage, reviewId, {
121
91
  status: statusAfterFindingsChange(previous, state),
122
92
  });
123
93
  }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Relógio injetável (biblioteca) — timestamps ISO 8601.
3
+ *
4
+ * Centraliza `new Date().toISOString()` para que o domínio não dependa do
5
+ * relógio do sistema e os testes possam congelar o tempo.
6
+ */
7
+
8
+ /** Devolve um timestamp ISO 8601. */
9
+ export type Clock = () => string;
10
+
11
+ /** Relógio do sistema (default do motor). */
12
+ export const systemClock: Clock = () => new Date().toISOString();
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Serviço de classificação (biblioteca) — superfície OO sobre as funções puras.
3
+ *
4
+ * Mantém as funções exportadas (`classifyEntries`/`classifyRange`) para a API
5
+ * pública e os testes; a classe devolve `Classification` (domínio).
6
+ */
7
+
8
+ import type { HoundsmanConfig } from "../config";
9
+ import type { DiffSource } from "../diff-source";
10
+ import { Classification } from "../domain/classification";
11
+ import { classifyEntries, classifyRange, type parseEntries } from "../file-classifier";
12
+ import type { ChurnMap } from "../types";
13
+
14
+ /** Classificador de arquivos alterados por domínio de review. */
15
+ export class FileClassifier {
16
+ private readonly config: HoundsmanConfig;
17
+
18
+ constructor(config: HoundsmanConfig) {
19
+ this.config = config;
20
+ }
21
+
22
+ /** Classifica entradas já parseadas (`git diff --name-status`). */
23
+ classify(
24
+ entries: ReturnType<typeof parseEntries>,
25
+ churn: ChurnMap = {},
26
+ ): Classification {
27
+ return new Classification(classifyEntries(entries, this.config, churn));
28
+ }
29
+
30
+ /** Executa o diff e classifica os arquivos entre `base` e `head`. */
31
+ classifyRange(base: string, head: string, diff?: DiffSource): Classification {
32
+ return new Classification(classifyRange(base, head, this.config, diff));
33
+ }
34
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Montagem do lote de correção (biblioteca) — superfície OO sobre `fix-batch`.
3
+ */
4
+
5
+ import type { FindingsStateFile } from "../findings-state";
6
+ import { buildFixBatch, type FixBatchOptions, type FixBatchResult } from "../fix-batch";
7
+
8
+ /** Constrói o lote de correção a partir do estado de triagem. */
9
+ export class FixBatchBuilder {
10
+ build(state: FindingsStateFile, options: FixBatchOptions = {}): FixBatchResult {
11
+ return buildFixBatch(state, options);
12
+ }
13
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Cliente do GitHub CLI (`gh`) — biblioteca.
3
+ *
4
+ * Um único runner para todo o motor (`context` e `findings submit`). O `gh` é
5
+ * opcional e falha-aberto: o runner nunca lança, devolve o resultado cru.
6
+ */
7
+
8
+ export type GhResult = { ok: boolean; code: number; stdout: string; stderr: string };
9
+
10
+ /** Executa o `gh` e devolve o resultado cru (sem lançar). */
11
+ export type GhRunner = (args: string[]) => GhResult;
12
+
13
+ /** Runner padrão: `gh` a partir do cwd; ausência do binário → `ok:false`. */
14
+ export const defaultGhRunner: GhRunner = (args) => {
15
+ try {
16
+ const result = Bun.spawnSync(["gh", ...args], {
17
+ stdout: "pipe",
18
+ stderr: "pipe",
19
+ });
20
+ const code = result.exitCode ?? 1;
21
+ return {
22
+ ok: code === 0,
23
+ code,
24
+ stdout: new TextDecoder().decode(result.stdout).trim(),
25
+ stderr: new TextDecoder().decode(result.stderr).trim(),
26
+ };
27
+ } catch (error) {
28
+ return {
29
+ ok: false,
30
+ code: 127,
31
+ stdout: "",
32
+ stderr: error instanceof Error ? error.message : String(error),
33
+ };
34
+ }
35
+ };
36
+
37
+ /** Superfície OO sobre o runner injetável. */
38
+ export class GhClient {
39
+ private readonly runner: GhRunner;
40
+
41
+ constructor(runner: GhRunner = defaultGhRunner) {
42
+ this.runner = runner;
43
+ }
44
+
45
+ /** Executa `gh` e devolve o resultado cru. */
46
+ run(args: string[]): GhResult {
47
+ return this.runner(args);
48
+ }
49
+
50
+ /** `true` se o binário existe no PATH (independe de login). */
51
+ isAvailable(): boolean {
52
+ return Boolean(Bun.which("gh"));
53
+ }
54
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Acesso ao repositório git (biblioteca) — superfície OO sobre `git-runner`.
3
+ *
4
+ * Centraliza resolução de refs, ancestralidade, leitura por commit e coleta de
5
+ * diffs. Nenhum path/id de consumidor aqui.
6
+ */
7
+
8
+ import {
9
+ type CollectDiffOptions,
10
+ DEFAULT_MAX_PER_FILE_CHARS,
11
+ type DiffSource,
12
+ parseDiffIndex,
13
+ parseNumstat,
14
+ } from "../diff-source";
15
+ import {
16
+ defaultGitRunner,
17
+ type GitResult,
18
+ type GitRunner,
19
+ runGit,
20
+ tryGit,
21
+ } from "../git-runner";
22
+ import type { FileChurn } from "../types";
23
+
24
+ /** Repositório git com runner injetável (testes). */
25
+ export class GitRepository {
26
+ private readonly runner: GitRunner;
27
+
28
+ constructor(runner: GitRunner = defaultGitRunner) {
29
+ this.runner = runner;
30
+ }
31
+
32
+ /** Executa git e devolve stdout; lança em falha. */
33
+ run(args: string[]): string {
34
+ return runGit(args, this.runner);
35
+ }
36
+
37
+ /** Executa git sem lançar (sondagens). */
38
+ tryRun(args: string[]): GitResult {
39
+ return tryGit(args, this.runner);
40
+ }
41
+
42
+ /** SHA completo de uma ref. */
43
+ resolveSha(ref: string): string {
44
+ return this.run(["rev-parse", ref]).trim();
45
+ }
46
+
47
+ /** Branch atual; `null` se indisponível (sem git, detached ou erro). */
48
+ currentBranch(): string | null {
49
+ const result = this.tryRun(["branch", "--show-current"]);
50
+ if (!result.ok) return null;
51
+ const branch = result.stdout.trim();
52
+ return branch.length > 0 ? branch : null;
53
+ }
54
+
55
+ /** Raiz do repositório; `null` fora de um repo git. */
56
+ rootPath(): string | null {
57
+ const result = this.tryRun(["rev-parse", "--show-toplevel"]);
58
+ if (!result.ok) return null;
59
+ const root = result.stdout.trim();
60
+ return root.length > 0 ? root : null;
61
+ }
62
+
63
+ /** URL de um remote (default `origin`); `null` se ausente. */
64
+ remoteUrl(name = "origin"): string | null {
65
+ const result = this.tryRun(["remote", "get-url", name]);
66
+ if (!result.ok) return null;
67
+ const url = result.stdout.trim();
68
+ return url.length > 0 ? url : null;
69
+ }
70
+
71
+ /**
72
+ * `true` se `ancestor` é ancestral de `descendant`. Qualquer outro desfecho
73
+ * (ref desconhecida, exit ≠ 0) devolve `false` — cai no review completo.
74
+ */
75
+ isAncestor(ancestor: string, descendant: string): boolean {
76
+ return this.tryRun(["merge-base", "--is-ancestor", ancestor, descendant]).ok;
77
+ }
78
+
79
+ /** Conteúdo de um arquivo num commit; `null` se ausente. */
80
+ readFileAt(sha: string, path: string): string | null {
81
+ const result = this.tryRun(["show", `${sha}:${path}`]);
82
+ return result.ok ? result.stdout : null;
83
+ }
84
+
85
+ /** `git diff base...head --name-status` cru. */
86
+ diffNameStatus(base: string, head: string): string {
87
+ return this.run(["diff", `${base}...${head}`, "--name-status"]);
88
+ }
89
+
90
+ /** Churn por arquivo de um intervalo (`--numstat -z`). */
91
+ diffNumstat(range: string): Map<string, FileChurn> {
92
+ return parseNumstat(this.run(["diff", "--numstat", "-z", range]));
93
+ }
94
+
95
+ /** Patch por arquivo de um intervalo, opcionalmente restrito a `paths`. */
96
+ diffPatch(
97
+ range: string,
98
+ paths?: readonly string[],
99
+ maxPerFileChars: number = DEFAULT_MAX_PER_FILE_CHARS,
100
+ ): Map<string, string> {
101
+ const args = ["diff", range];
102
+ if (paths && paths.length > 0) args.push("--", ...paths);
103
+ return parseDiffIndex(this.run(args), maxPerFileChars);
104
+ }
105
+
106
+ /** Churn de `base...head` — 1 spawn. */
107
+ collectChurn(base: string, head: string): Map<string, FileChurn> {
108
+ return this.diffNumstat(`${base}...${head}`);
109
+ }
110
+
111
+ /** Patch + churn da PR (`base...head`) — 2 spawns fixos. */
112
+ collectDiff(
113
+ base: string,
114
+ head: string,
115
+ options: CollectDiffOptions = {},
116
+ ): DiffSource {
117
+ const range = `${base}...${head}`;
118
+ return {
119
+ byFile: parseDiffIndex(
120
+ this.run(["diff", range]),
121
+ options.maxPerFileChars ?? DEFAULT_MAX_PER_FILE_CHARS,
122
+ ),
123
+ churn: this.collectChurn(base, head),
124
+ };
125
+ }
126
+
127
+ /** Patch + churn de `from..to` — base do review incremental. */
128
+ collectDiffSince(
129
+ from: string,
130
+ to: string,
131
+ maxPerFileChars: number = DEFAULT_MAX_PER_FILE_CHARS,
132
+ ): DiffSource {
133
+ const range = `${from}..${to}`;
134
+ return {
135
+ byFile: parseDiffIndex(this.run(["diff", range]), maxPerFileChars),
136
+ churn: this.diffNumstat(range),
137
+ };
138
+ }
139
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Acesso a arquivos do projeto (biblioteca) — I/O centralizado e injetável.
3
+ *
4
+ * Caminhos relativos são resolvidos contra o `cwd` do projeto; absolutos passam
5
+ * intactos. Operações que falham devolvem `null`/`false` em vez de lançar, para
6
+ * manter o comportamento fail-open do motor.
7
+ */
8
+
9
+ import {
10
+ existsSync,
11
+ mkdirSync,
12
+ readFileSync,
13
+ renameSync,
14
+ rmSync,
15
+ writeFileSync,
16
+ } from "node:fs";
17
+ import { dirname, isAbsolute, join } from "node:path";
18
+
19
+ /** Leitor de arquivos relativos à raiz do projeto (injetável nos testes). */
20
+ export type FileReader = (relativePath: string) => string | null;
21
+
22
+ /** Acesso ao filesystem resolvido a partir de um `cwd`. */
23
+ export class ProjectFiles {
24
+ private readonly cwd: string;
25
+
26
+ constructor(cwd: string = process.cwd()) {
27
+ this.cwd = cwd;
28
+ }
29
+
30
+ /** Resolve um caminho (relativo → absoluto). */
31
+ resolve(path: string): string {
32
+ return isAbsolute(path) ? path : join(this.cwd, path);
33
+ }
34
+
35
+ /** Lê UTF-8; `null` se ausente/ilegível. */
36
+ read(path: string): string | null {
37
+ const full = this.resolve(path);
38
+ try {
39
+ return existsSync(full) ? readFileSync(full, "utf8") : null;
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ /** Lê + parseia JSON; `null` se ausente/inválido. */
46
+ readJson<T>(path: string): T | null {
47
+ const raw = this.read(path);
48
+ if (raw === null) return null;
49
+ try {
50
+ return JSON.parse(raw) as T;
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ /** Escreve UTF-8, criando o diretório pai. */
57
+ write(path: string, content: string): void {
58
+ const full = this.resolve(path);
59
+ mkdirSync(dirname(full), { recursive: true });
60
+ writeFileSync(full, content);
61
+ }
62
+
63
+ /** Escrita atômica (tmp + rename). */
64
+ writeAtomic(path: string, content: string): void {
65
+ const full = this.resolve(path);
66
+ mkdirSync(dirname(full), { recursive: true });
67
+ const tmp = `${full}.${process.pid}.tmp`;
68
+ writeFileSync(tmp, content);
69
+ renameSync(tmp, full);
70
+ }
71
+
72
+ /** Cria o diretório (recursivo). */
73
+ ensureDir(path: string): void {
74
+ mkdirSync(this.resolve(path), { recursive: true });
75
+ }
76
+
77
+ /** Remove um diretório (recursivo, best-effort). */
78
+ removeDir(path: string): void {
79
+ rmSync(this.resolve(path), { recursive: true, force: true });
80
+ }
81
+
82
+ /** `true` se o caminho existe. */
83
+ exists(path: string): boolean {
84
+ return existsSync(this.resolve(path));
85
+ }
86
+
87
+ /** Função de leitura ligada a este `cwd` (compatível com `FileReader`). */
88
+ reader(): FileReader {
89
+ return (relativePath: string) => this.read(relativePath);
90
+ }
91
+ }