@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,79 @@
1
+ /**
2
+ * Sessão de uma run (engine) — amarra opções, branch, storage e staging.
3
+ *
4
+ * É o ponto de entrada do `prepare`/`finalize` para o CLI e para o engine.
5
+ */
6
+
7
+ import type { HoundsmanConfig } from "../config";
8
+ import {
9
+ type FinalizeResult,
10
+ finalize,
11
+ type PrepareResult,
12
+ prepare,
13
+ type ReviewContext,
14
+ type ReviewOptions,
15
+ resolveBranch,
16
+ reviewContext,
17
+ } from "../orchestrator";
18
+ import { sanitizeBranch } from "../report-builder";
19
+ import type { RepoIdentity, StorageProvider } from "../storage/types";
20
+
21
+ /** Uma revisão em andamento (prepare ou finalize). */
22
+ export class ReviewSession {
23
+ readonly config: HoundsmanConfig;
24
+ readonly options: ReviewOptions;
25
+ readonly branch: string;
26
+ readonly context: ReviewContext;
27
+
28
+ constructor(
29
+ config: HoundsmanConfig,
30
+ options: ReviewOptions,
31
+ branch: string,
32
+ storage: StorageProvider,
33
+ repo: RepoIdentity,
34
+ ) {
35
+ this.config = config;
36
+ this.options = options;
37
+ this.branch = branch;
38
+ const review = options.finalize
39
+ ? storage.findReview(repo.key, branch)
40
+ : storage.getOrCreateReview({
41
+ repo,
42
+ branch,
43
+ slug: sanitizeBranch(branch),
44
+ base: options.base,
45
+ head: options.head,
46
+ });
47
+ if (!review) {
48
+ throw new Error(
49
+ `Nenhuma revisão para '${branch}'. Rode \`houndsman\` (preparar) primeiro.`,
50
+ );
51
+ }
52
+ this.context = reviewContext(storage, review);
53
+ }
54
+
55
+ /** Resolve a branch (opção explícita ou a atual do git). */
56
+ static from(
57
+ config: HoundsmanConfig,
58
+ options: ReviewOptions,
59
+ storage: StorageProvider,
60
+ repo: RepoIdentity,
61
+ ): ReviewSession {
62
+ return new ReviewSession(config, options, resolveBranch(options), storage, repo);
63
+ }
64
+
65
+ /** Passo 1 — preparar. */
66
+ prepare(): Promise<PrepareResult> {
67
+ return prepare(this.options, this.branch, this.config, this.context);
68
+ }
69
+
70
+ /** Passo 3 — finalizar. */
71
+ finalize(): FinalizeResult {
72
+ return finalize(this.options, this.branch, this.config, this.context);
73
+ }
74
+
75
+ /** Executa o passo escolhido em `--finalize`. */
76
+ run(): Promise<PrepareResult | FinalizeResult> {
77
+ return this.options.finalize ? Promise.resolve(this.finalize()) : this.prepare();
78
+ }
79
+ }
@@ -8,7 +8,9 @@
8
8
  import { readFileSync } from "node:fs";
9
9
  import type { HoundsmanConfig } from "./config";
10
10
  import { churnToRecord, collectChurn, type DiffSource } from "./diff-source";
11
- import type { ChurnMap, ClassifyRule, WatchdogDefinition } from "./types";
11
+ import { matchesClassifyRule } from "./domain/watchdog";
12
+ import { runGit } from "./git-runner";
13
+ import type { ChurnMap, WatchdogDefinition } from "./types";
12
14
 
13
15
  type FileStatus = "added" | "modified" | "deleted" | "renamed" | "copied" | "unknown";
14
16
 
@@ -42,9 +44,6 @@ export type ClassificationResult = {
42
44
  // Matchers genéricos (reutilizados pelo projeto)
43
45
  // ---------------------------------------------------------------------------
44
46
 
45
- /** Tipo de classificação assumido quando `type` é omitido. */
46
- const DEFAULT_CLASSIFY_TYPE: NonNullable<ClassifyRule["type"]> = "filename";
47
-
48
47
  /** `true` se há entradas de conteúdo (as únicas que exigem ler o arquivo). */
49
48
  function hasContentRules<TId extends string>(
50
49
  watchdogs: Record<TId, WatchdogDefinition>,
@@ -64,18 +63,6 @@ function readFileContent(filePath: string): string | undefined {
64
63
  }
65
64
  }
66
65
 
67
- /** `true` se o arquivo casa a entrada de classificação. */
68
- function matchesClassifyRule(
69
- filePath: string,
70
- rule: ClassifyRule,
71
- content: string | undefined,
72
- ): boolean {
73
- if ((rule.type ?? DEFAULT_CLASSIFY_TYPE) === "content") {
74
- return content !== undefined && new RegExp(rule.value).test(content);
75
- }
76
- return new RegExp(rule.value).test(filePath);
77
- }
78
-
79
66
  /** Watchdogs que declaram o arquivo (união dos `classify`). */
80
67
  export function matchWatchdogs<TId extends string>(
81
68
  filePath: string,
@@ -101,22 +88,6 @@ export function isExcludedPath(filePath: string, patterns: readonly RegExp[]): b
101
88
  return patterns.some((pattern) => pattern.test(filePath));
102
89
  }
103
90
 
104
- // ---------------------------------------------------------------------------
105
- // Execução do git diff
106
- // ---------------------------------------------------------------------------
107
-
108
- function runGitDiff(base: string, head: string): string {
109
- const result = Bun.spawnSync(["git", "diff", `${base}...${head}`, "--name-status"], {
110
- stdout: "pipe",
111
- stderr: "pipe",
112
- });
113
- if (result.exitCode !== 0) {
114
- const stderr = new TextDecoder().decode(result.stderr).trim();
115
- throw new Error(`git diff falhou (base='${base}', head='${head}'): ${stderr}`);
116
- }
117
- return new TextDecoder().decode(result.stdout);
118
- }
119
-
120
91
  // ---------------------------------------------------------------------------
121
92
  // Parsing de `git diff --name-status`
122
93
  // ---------------------------------------------------------------------------
@@ -248,7 +219,7 @@ export function classifyRange<TId extends string>(
248
219
  config: HoundsmanConfig<TId>,
249
220
  diff?: DiffSource,
250
221
  ): ClassificationResult {
251
- const entries = parseEntries(runGitDiff(base, head));
222
+ const entries = parseEntries(runGit(["diff", `${base}...${head}`, "--name-status"]));
252
223
  const churn = diff?.churn ?? collectChurn(base, head);
253
224
  const result = classifyEntries(entries, config, churnToRecord(churn));
254
225
  result.base = base;
@@ -257,39 +228,12 @@ export function classifyRange<TId extends string>(
257
228
  }
258
229
 
259
230
  // ---------------------------------------------------------------------------
260
- // Triage (PRs grandes)
231
+ // Triage (PRs grandes) — reexportado do domínio
261
232
  // ---------------------------------------------------------------------------
262
233
 
263
- /** Churn total (adições + remoções) de um arquivo; 0 quando ausente. */
264
- export function churnOf(filePath: string, churn: ChurnMap | undefined): number {
265
- const entry = churn?.[filePath];
266
- return entry ? entry.additions + entry.deletions : 0;
267
- }
268
-
269
- /** Ordena os arquivos por risco (churn desc; empate pelo caminho). */
270
- export function sortByRisk(files: readonly string[], churn?: ChurnMap): string[] {
271
- return [...files].sort((a, b) => {
272
- const delta = churnOf(b, churn) - churnOf(a, churn);
273
- return delta !== 0 ? delta : a.localeCompare(b);
274
- });
275
- }
276
-
277
- /** Resultado da aplicação do orçamento: o que revisar e o que ficou de fora. */
278
- export type BudgetSelection = { selected: string[]; overflow: string[] };
279
-
280
- /**
281
- * Aplica o orçamento por brief: mantém os `maxFiles` de maior risco e devolve o
282
- * restante em `overflow` (nunca descarta silenciosamente). `maxFiles` não-positivo
283
- * ou não-finito desativa o orçamento.
284
- */
285
- export function selectByBudget(
286
- files: readonly string[],
287
- churn: ChurnMap | undefined,
288
- maxFiles: number,
289
- ): BudgetSelection {
290
- const ordered = sortByRisk(files, churn);
291
- if (!Number.isFinite(maxFiles) || maxFiles <= 0 || ordered.length <= maxFiles) {
292
- return { selected: ordered, overflow: [] };
293
- }
294
- return { selected: ordered.slice(0, maxFiles), overflow: ordered.slice(maxFiles) };
295
- }
234
+ export {
235
+ type BudgetSelection,
236
+ churnOf,
237
+ selectByBudget,
238
+ sortByRisk,
239
+ } from "./domain/budget";
@@ -1,18 +1,16 @@
1
1
  /**
2
2
  * Estado de triagem dos findings (biblioteca).
3
3
  *
4
- * Persistido em `.houndsman/.runs/<slug>/state.json` (local — coberto pelo
5
- * `.gitignore` gerado, que ignora `.runs/`). Cada finding tem um `id` curto e um
6
- * `fingerprint` de conteúdo: nas re-execuções do `--finalize` o estado é
7
- * preservado casando por fingerprint.
4
+ * O dump (`state.json`) é um detalhe do provider de arquivos; no SQLite o
5
+ * estado é reconstruído de `findings` + `finding_history`. Cada finding tem um
6
+ * `id` curto e um `fingerprint` de conteúdo: nas re-execuções do `--finalize`
7
+ * o estado é preservado casando por fingerprint.
8
8
  *
9
9
  * A `line` é cache — `syncState` a recalcula após edições (auto-fix) e marca
10
10
  * `stale` quando a âncora some.
11
11
  */
12
12
 
13
13
  import { randomUUID } from "node:crypto";
14
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
15
- import { dirname, join } from "node:path";
16
14
  import {
17
15
  buildAnchor,
18
16
  type FindingAnchor,
@@ -20,16 +18,13 @@ import {
20
18
  locateAnchor,
21
19
  reanchor,
22
20
  } from "./finding-anchor";
23
- import { STATE_FILE } from "./paths";
24
- import {
25
- dedupeFindings,
26
- type MergedFinding,
27
- parseWatchdogReport,
28
- sanitizeBranch,
29
- type WatchdogReport,
30
- } from "./report-builder";
21
+ import { type MergedFinding, sanitizeBranch } from "./report-builder";
22
+ import { systemClock } from "./services/clock";
23
+ import { type FileReader, ProjectFiles } from "./services/project-files";
31
24
  import { SEVERITY_ORDER, type Severity, severityRank } from "./types";
32
25
 
26
+ export type { FileReader };
27
+
33
28
  // ---------------------------------------------------------------------------
34
29
  // Tipos
35
30
  // ---------------------------------------------------------------------------
@@ -138,29 +133,9 @@ export const FINDINGS_STATE_VERSION = 2;
138
133
  // Caminhos
139
134
  // ---------------------------------------------------------------------------
140
135
 
141
- /** Diretório de artefatos da run (branch-slug). */
142
- export function runDir(artifactDir: string, branch: string): string {
143
- return join(artifactDir, sanitizeBranch(branch));
144
- }
145
-
146
- /** Caminho do `state.json` da run. */
147
- export function stateFile(artifactDir: string, branch: string): string {
148
- return join(runDir(artifactDir, branch), STATE_FILE);
149
- }
150
-
151
- /** Leitor de arquivos relativos à raiz do projeto (injetável nos testes). */
152
- export type FileReader = (relativePath: string) => string | null;
153
-
154
136
  /** Leitor padrão a partir do cwd (null se ausente/ilegível). */
155
137
  export function fileReader(cwd: string): FileReader {
156
- return (relativePath: string) => {
157
- try {
158
- const full = join(cwd, relativePath);
159
- return existsSync(full) ? readFileSync(full, "utf8") : null;
160
- } catch {
161
- return null;
162
- }
163
- };
138
+ return new ProjectFiles(cwd).reader();
164
139
  }
165
140
 
166
141
  /** Id curto aleatório (hex) para um finding novo. */
@@ -174,7 +149,7 @@ export function emptyState(branch: string): FindingsStateFile {
174
149
  version: FINDINGS_STATE_VERSION,
175
150
  branch,
176
151
  slug: sanitizeBranch(branch),
177
- updatedAt: new Date().toISOString(),
152
+ updatedAt: systemClock(),
178
153
  order: [],
179
154
  findings: {},
180
155
  };
@@ -270,6 +245,32 @@ export function filterFindings(
270
245
  .sort(compareFindings);
271
246
  }
272
247
 
248
+ /** Resultado da resolução de um id (completo ou prefixo único). */
249
+ export type FindingIdResolution =
250
+ | { status: "found"; id: string }
251
+ | { status: "ambiguous"; matches: string[] }
252
+ | { status: "missing" };
253
+
254
+ /**
255
+ * Resolve um id de finding: exato primeiro, depois prefixo único.
256
+ * Ambíguo devolve os candidatos para a mensagem de erro.
257
+ */
258
+ export function resolveFindingId(
259
+ state: FindingsStateFile,
260
+ input: string,
261
+ ): FindingIdResolution {
262
+ const needle = input.trim();
263
+ if (needle.length === 0) return { status: "missing" };
264
+ if (state.findings[needle]) return { status: "found", id: needle };
265
+
266
+ const matches = Object.keys(state.findings).filter((id) =>
267
+ id.toLowerCase().startsWith(needle.toLowerCase()),
268
+ );
269
+ if (matches.length === 1) return { status: "found", id: matches[0] };
270
+ if (matches.length > 1) return { status: "ambiguous", matches };
271
+ return { status: "missing" };
272
+ }
273
+
273
274
  // ---------------------------------------------------------------------------
274
275
  // Construção (finalize)
275
276
  // ---------------------------------------------------------------------------
@@ -297,7 +298,7 @@ export function buildStateFromFindings(
297
298
  merged: readonly MergedFinding[],
298
299
  options: BuildStateOptions,
299
300
  ): FindingsStateFile {
300
- const now = options.now ?? new Date().toISOString();
301
+ const now = options.now ?? systemClock();
301
302
  const previous = options.previous ?? null;
302
303
 
303
304
  const result: Record<string, FindingState> = {};
@@ -383,47 +384,6 @@ export function buildStateFromFindings(
383
384
  return state;
384
385
  }
385
386
 
386
- /**
387
- * Reconstrói o estado a partir dos artefatos da run (`classification.json` +
388
- * `findings/<id>.json`), sem exigir `--finalize`. Devolve null se não houver
389
- * findings gravados.
390
- */
391
- export function buildStateFromRunDir(
392
- outDir: string,
393
- branch: string,
394
- readFile: FileReader,
395
- ): FindingsStateFile | null {
396
- const classificationPath = join(outDir, "classification.json");
397
- if (!existsSync(classificationPath)) return null;
398
-
399
- let activeIds: string[] = [];
400
- try {
401
- const classification = JSON.parse(readFileSync(classificationPath, "utf8")) as {
402
- watchdogs?: Record<string, string[]>;
403
- };
404
- activeIds = Object.keys(classification.watchdogs ?? {});
405
- } catch {
406
- return null;
407
- }
408
-
409
- const reports: WatchdogReport[] = [];
410
- for (const id of activeIds) {
411
- const path = join(outDir, "findings", `${id}.json`);
412
- if (!existsSync(path)) continue;
413
- try {
414
- const report = parseWatchdogReport(
415
- JSON.parse(readFileSync(path, "utf8")),
416
- path,
417
- );
418
- if (report) reports.push(report);
419
- } catch {
420
- // Ignora findings ilegíveis — reconstrução best-effort.
421
- }
422
- }
423
- if (reports.length === 0) return null;
424
-
425
- return buildStateFromFindings(dedupeFindings(reports), { branch, readFile });
426
- }
427
387
  // ---------------------------------------------------------------------------
428
388
 
429
389
  export type FindingUpdate = {
@@ -437,7 +397,7 @@ export type FindingUpdate = {
437
397
  export function applyFindingUpdate(
438
398
  finding: FindingState,
439
399
  update: FindingUpdate,
440
- now: string = new Date().toISOString(),
400
+ now: string = systemClock(),
441
401
  ): FindingState {
442
402
  if (update.decision && DECISION_SET.has(update.decision)) {
443
403
  finding.decision = update.decision;
@@ -471,7 +431,7 @@ export type SyncResult = {
471
431
  export function syncState(
472
432
  state: FindingsStateFile,
473
433
  readFile: FileReader,
474
- now: string = new Date().toISOString(),
434
+ now: string = systemClock(),
475
435
  ): SyncResult {
476
436
  const moved: string[] = [];
477
437
  const stale: string[] = [];
@@ -521,55 +481,188 @@ export function occurrenceCount(
521
481
  }
522
482
 
523
483
  // ---------------------------------------------------------------------------
524
- // Store (I/O atômico)
484
+ // Domínio (classes)
485
+ // ---------------------------------------------------------------------------
486
+
487
+ /** Um finding triável, com comportamento sobre o DTO serializável. */
488
+ export class Finding {
489
+ readonly state: FindingState;
490
+
491
+ constructor(state: FindingState) {
492
+ this.state = state;
493
+ }
494
+
495
+ static from(state: FindingState): Finding {
496
+ return new Finding(state);
497
+ }
498
+
499
+ get id(): string {
500
+ return this.state.id;
501
+ }
502
+
503
+ get file(): string {
504
+ return this.state.file;
505
+ }
506
+
507
+ get line(): number | null {
508
+ return this.state.line;
509
+ }
510
+
511
+ get severity(): Severity {
512
+ return this.state.severity;
513
+ }
514
+
515
+ get decision(): FindingDecision {
516
+ return this.state.decision;
517
+ }
518
+
519
+ get execState(): FindingExecState {
520
+ return this.state.execState;
521
+ }
522
+
523
+ get present(): boolean {
524
+ return this.state.present;
525
+ }
526
+
527
+ get isDone(): boolean {
528
+ return this.state.execState === "done";
529
+ }
530
+
531
+ /** Aplica uma atualização (com histórico) e devolve o próprio finding. */
532
+ apply(update: FindingUpdate, now: string = systemClock()): Finding {
533
+ applyFindingUpdate(this.state, update, now);
534
+ return this;
535
+ }
536
+
537
+ /** Payload serializável (mesmo shape do `state.json`). */
538
+ toJSON(): FindingState {
539
+ return this.state;
540
+ }
541
+ }
542
+
543
+ /** Coleção de triagem de uma run — superfície OO sobre o dump persistido. */
544
+ export class FindingsState {
545
+ readonly file: FindingsStateFile;
546
+
547
+ constructor(file: FindingsStateFile) {
548
+ this.file = file;
549
+ }
550
+
551
+ static from(file: FindingsStateFile): FindingsState {
552
+ return new FindingsState(file);
553
+ }
554
+
555
+ static empty(branch: string): FindingsState {
556
+ return new FindingsState(emptyState(branch));
557
+ }
558
+
559
+ get branch(): string {
560
+ return this.file.branch;
561
+ }
562
+
563
+ get reviewed(): FindingsReviewed | undefined {
564
+ return this.file.reviewed;
565
+ }
566
+
567
+ /** Findings na ordem de exibição (`order`). */
568
+ list(): Finding[] {
569
+ return this.file.order
570
+ .map((id) => this.file.findings[id])
571
+ .filter((state): state is FindingState => Boolean(state))
572
+ .map((state) => Finding.from(state));
573
+ }
574
+
575
+ /** Busca por id. */
576
+ get(id: string): Finding | null {
577
+ const state = this.file.findings[id];
578
+ return state ? Finding.from(state) : null;
579
+ }
580
+
581
+ /** Todos os findings (ordem de inserção, sem o `order`). */
582
+ values(): Finding[] {
583
+ return Object.values(this.file.findings).map((state) => Finding.from(state));
584
+ }
585
+
586
+ /** Somente os presentes no último relatório. */
587
+ present(): Finding[] {
588
+ return this.values().filter((finding) => finding.present);
589
+ }
590
+
591
+ /** Filtra pela ordem canônica (presentes → severidade → arquivo → linha). */
592
+ filter(filter: FindingsFilter = {}): Finding[] {
593
+ return filterFindings(this.file, filter).map((state) => Finding.from(state));
594
+ }
595
+
596
+ /** Contagens por decisão, execução e severidade. */
597
+ summarize(): FindingsSummary {
598
+ return summarize(this.file);
599
+ }
600
+
601
+ /** Reordena os ids pela ordenação canônica. */
602
+ reorder(): void {
603
+ reorder(this.file);
604
+ }
605
+
606
+ /** Aplica uma atualização a um finding e devolve-o (null se ausente). */
607
+ apply(id: string, update: FindingUpdate, now?: string): Finding | null {
608
+ const state = this.file.findings[id];
609
+ if (!state) return null;
610
+ applyFindingUpdate(state, update, now);
611
+ return Finding.from(state);
612
+ }
613
+
614
+ /** Reancora os presentes contra os arquivos atuais. */
615
+ sync(readFile: FileReader, now?: string): SyncResult {
616
+ return syncState(this.file, readFile, now);
617
+ }
618
+
619
+ /** Payload serializável (mesmo shape do `state.json`). */
620
+ toJSON(): FindingsStateFile {
621
+ return this.file;
622
+ }
623
+ }
624
+
625
+ // ---------------------------------------------------------------------------
626
+ // Repository (I/O atômico)
525
627
  // ---------------------------------------------------------------------------
526
628
 
527
629
  /** Persistência do `state.json` — escrita atômica (tmp + rename). */
528
- export class FindingsStateStore {
630
+ export class FindingsRepository {
529
631
  private readonly path: string;
530
632
  private readonly branch: string;
633
+ private readonly files = new ProjectFiles();
531
634
 
532
635
  constructor(path: string, branch: string) {
533
636
  this.path = path;
534
637
  this.branch = branch;
535
638
  }
536
639
 
537
- /** Store para a run de uma branch. */
538
- static at(artifactDir: string, branch: string): FindingsStateStore {
539
- return new FindingsStateStore(stateFile(artifactDir, branch), branch);
540
- }
541
-
542
640
  get filePath(): string {
543
641
  return this.path;
544
642
  }
545
643
 
546
644
  /** Lê o estado; null se ausente/inválido. */
547
645
  read(): FindingsStateFile | null {
548
- if (!existsSync(this.path)) return null;
549
- try {
550
- const raw = JSON.parse(readFileSync(this.path, "utf8")) as FindingsStateFile;
551
- if (raw?.version !== FINDINGS_STATE_VERSION || !raw.findings) return null;
552
- return raw;
553
- } catch {
554
- return null;
555
- }
646
+ const raw = this.files.readJson<FindingsStateFile>(this.path);
647
+ if (!raw || raw.version !== FINDINGS_STATE_VERSION || !raw.findings) return null;
648
+ return raw;
556
649
  }
557
650
 
558
651
  /** Grava o estado de forma atômica. */
559
652
  write(state: FindingsStateFile): void {
560
- mkdirSync(dirname(this.path), { recursive: true });
561
- const tmp = `${this.path}.${process.pid}.tmp`;
562
- writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`);
563
- renameSync(tmp, this.path);
653
+ this.files.writeAtomic(this.path, `${JSON.stringify(state, null, 2)}\n`);
564
654
  }
565
655
 
566
656
  /** Lê-modifica-grava; parte de um estado vazio se não existir. */
567
657
  update(mutator: (state: FindingsStateFile) => void): FindingsStateFile {
568
658
  const state = this.read() ?? emptyState(this.branch);
569
659
  mutator(state);
570
- state.updatedAt = new Date().toISOString();
660
+ state.updatedAt = systemClock();
571
661
  reorder(state);
572
662
  this.write(state);
573
663
  return state;
574
664
  }
575
665
  }
666
+
667
+ /** Nome anterior do repository (compatibilidade da API pública). */
668
+ export { FindingsRepository as FindingsStateStore };
@@ -11,31 +11,20 @@
11
11
  * (`parseEntries`) — não reimplementa leitura de diff.
12
12
  */
13
13
 
14
- import {
15
- DEFAULT_MAX_PER_FILE_CHARS,
16
- type DiffSource,
17
- parseDiffIndex,
18
- parseNumstat,
19
- } from "./diff-source";
14
+ import { DEFAULT_MAX_PER_FILE_CHARS, type DiffSource } from "./diff-source";
20
15
  import { parseEntries } from "./file-classifier";
16
+ import { runGit } from "./git-runner";
17
+ import { GitRepository } from "./services/git-repository";
21
18
  import type { FileChurn } from "./types";
22
19
 
23
- /** Executa `git` (paths literais) e devolve o stdout; lança se falhar. */
24
- export function runGit(args: string[]): string {
25
- const result = Bun.spawnSync(["git", "-c", "core.quotepath=false", ...args], {
26
- stdout: "pipe",
27
- stderr: "pipe",
28
- });
29
- if (result.exitCode !== 0) {
30
- const stderr = new TextDecoder().decode(result.stderr).trim();
31
- throw new Error(`git ${args.join(" ")} falhou: ${stderr}`);
32
- }
33
- return new TextDecoder().decode(result.stdout);
34
- }
20
+ export { runGit };
21
+
22
+ /** Repositório padrão (runner real) usado pelos helpers funcionais. */
23
+ const git = new GitRepository();
35
24
 
36
25
  /** SHA completo de uma ref (`git rev-parse`). Lança se a ref não resolver. */
37
26
  export function resolveSha(ref: string): string {
38
- return runGit(["rev-parse", ref]).trim();
27
+ return git.resolveSha(ref);
39
28
  }
40
29
 
41
30
  /** SHA do commit atual (`HEAD`). */
@@ -50,31 +39,22 @@ export function currentHeadSha(): string {
50
39
  * comportamento seguro.
51
40
  */
52
41
  export function isAncestor(ancestor: string, descendant: string): boolean {
53
- const result = Bun.spawnSync(
54
- ["git", "merge-base", "--is-ancestor", ancestor, descendant],
55
- { stdout: "ignore", stderr: "ignore" },
56
- );
57
- return result.exitCode === 0;
42
+ return git.isAncestor(ancestor, descendant);
58
43
  }
59
44
 
60
45
  /** Conteúdo de um arquivo num commit (`git show sha:path`); null se ausente. */
61
46
  export function readFileAt(sha: string, path: string): string | null {
62
- const result = Bun.spawnSync(
63
- ["git", "-c", "core.quotepath=false", "show", `${sha}:${path}`],
64
- { stdout: "pipe", stderr: "ignore" },
65
- );
66
- if (result.exitCode !== 0) return null;
67
- return new TextDecoder().decode(result.stdout);
47
+ return git.readFileAt(sha, path);
68
48
  }
69
49
 
70
50
  /** Arquivos alterados num intervalo (`git diff range --name-status`). */
71
51
  export function diffEntries(range: string): ReturnType<typeof parseEntries> {
72
- return parseEntries(runGit(["diff", range, "--name-status"]));
52
+ return parseEntries(git.run(["diff", range, "--name-status"]));
73
53
  }
74
54
 
75
55
  /** Churn por arquivo de um intervalo (`git diff --numstat -z range`). */
76
56
  export function diffNumstat(range: string): Map<string, FileChurn> {
77
- return parseNumstat(runGit(["diff", "--numstat", "-z", range]));
57
+ return git.diffNumstat(range);
78
58
  }
79
59
 
80
60
  /**
@@ -86,9 +66,7 @@ export function diffPatch(
86
66
  paths?: readonly string[],
87
67
  maxPerFileChars: number = DEFAULT_MAX_PER_FILE_CHARS,
88
68
  ): Map<string, string> {
89
- const args = ["diff", range];
90
- if (paths && paths.length > 0) args.push("--", ...paths);
91
- return parseDiffIndex(runGit(args), maxPerFileChars);
69
+ return git.diffPatch(range, paths, maxPerFileChars);
92
70
  }
93
71
 
94
72
  /** Diff (patch + churn) de `from..to` — 2 spawns, base do review incremental. */
@@ -97,9 +75,5 @@ export function collectDiffSince(
97
75
  to: string,
98
76
  maxPerFileChars: number = DEFAULT_MAX_PER_FILE_CHARS,
99
77
  ): DiffSource {
100
- const range = `${from}..${to}`;
101
- return {
102
- byFile: parseDiffIndex(runGit(["diff", range]), maxPerFileChars),
103
- churn: diffNumstat(range),
104
- };
78
+ return git.collectDiffSince(from, to, maxPerFileChars);
105
79
  }