@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
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Engine do houndsman (composition root).
3
+ *
4
+ * Injeta a configuração do projeto e os colaboradores compartilhados (git,
5
+ * classificador, verifier, lote). Comandos e pipelines pedem ao engine em vez
6
+ * de instanciar/importar tudo diretamente.
7
+ */
8
+
9
+ import type { HoundsmanConfig } from "../config";
10
+ import type { ReviewOptions } from "../orchestrator";
11
+ import { ProjectConfig } from "../project-config";
12
+ import { FileClassifier } from "../services/file-classifier";
13
+ import { FixBatchBuilder } from "../services/fix-batch-builder";
14
+ import { GitRepository } from "../services/git-repository";
15
+ import { Verifier } from "../services/verifier";
16
+ import { ReviewSession } from "./review-session";
17
+
18
+ /** Colaboradores injetáveis (testes/CLI). */
19
+ export type EngineDeps = {
20
+ git?: GitRepository;
21
+ classifier?: FileClassifier;
22
+ verifier?: Verifier;
23
+ fixBatch?: FixBatchBuilder;
24
+ };
25
+
26
+ /** Composition root do motor. */
27
+ export class HoundsmanEngine {
28
+ readonly project: ProjectConfig;
29
+ readonly git: GitRepository;
30
+ readonly classifier: FileClassifier;
31
+ readonly verifier: Verifier;
32
+ readonly fixBatch: FixBatchBuilder;
33
+
34
+ constructor(raw: HoundsmanConfig, deps: EngineDeps = {}) {
35
+ this.project = new ProjectConfig(raw);
36
+ this.git = deps.git ?? new GitRepository();
37
+ this.classifier = deps.classifier ?? new FileClassifier(raw);
38
+ this.verifier = deps.verifier ?? new Verifier();
39
+ this.fixBatch = deps.fixBatch ?? new FixBatchBuilder();
40
+ }
41
+
42
+ /** Configuração crua (contrato do consumidor). */
43
+ get config(): HoundsmanConfig {
44
+ return this.project.raw;
45
+ }
46
+
47
+ /** Abre uma sessão de revisão (prepare/finalize). */
48
+ session(options: ReviewOptions): ReviewSession {
49
+ return ReviewSession.from(this.config, options);
50
+ }
51
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Escopo da revisão (engine) — completo vs incremental.
3
+ *
4
+ * Decide se reconstrói o diff inteiro ou só o delta `prevSha..headSha` e
5
+ * carrega os findings de arquivos inalterados (carry-over).
6
+ */
7
+
8
+ import type { HoundsmanConfig } from "../config";
9
+ import { churnToRecord, collectDiff, type DiffSource } from "../diff-source";
10
+ import type { ClassificationResult } from "../file-classifier";
11
+ import type { FindingsStateFile } from "../findings-state";
12
+ import { collectDiffSince, diffEntries, isAncestor } from "../git-revision";
13
+ import type { MergedFinding } from "../report-builder";
14
+ import { FileClassifier } from "../services/file-classifier";
15
+ import { buildCarryover } from "./carryover";
16
+
17
+ /** Opções que afetam o escopo (subset do `ReviewOptions`). */
18
+ export type ScopeOptions = {
19
+ base: string;
20
+ head: string;
21
+ full: boolean;
22
+ clean: boolean;
23
+ };
24
+
25
+ /** Classificação enriquecida gravada no `classification.json`. */
26
+ export type RunClassification = ClassificationResult & {
27
+ /** Commit revisado (head do diff). */
28
+ headSha?: string;
29
+ /** Modo do prepare: completo ou incremental. */
30
+ mode: "full" | "incremental";
31
+ /** Commit da última revisão (só no incremental). */
32
+ reviewedFrom?: string;
33
+ /** Findings preservados do review anterior (arquivos inalterados). */
34
+ carryover?: MergedFinding[];
35
+ };
36
+
37
+ /** Resultado da resolução de escopo. */
38
+ export type ReviewScope = {
39
+ classification: RunClassification;
40
+ diff: DiffSource;
41
+ reviewedFrom?: string;
42
+ };
43
+
44
+ /** Decide entre review completo e incremental. */
45
+ export class ReviewScopeResolver {
46
+ private readonly options: ScopeOptions;
47
+ private readonly classifier: FileClassifier;
48
+
49
+ constructor(options: ScopeOptions, config: HoundsmanConfig) {
50
+ this.options = options;
51
+ this.classifier = new FileClassifier(config);
52
+ }
53
+
54
+ /**
55
+ * Incremental só quando há um `reviewed.headSha` anterior, o `HEAD` atual o
56
+ * sucede (sem rebase) e não veio `--full`/`--clean`.
57
+ */
58
+ resolve(previous: FindingsStateFile | null, headSha: string): ReviewScope {
59
+ const prevSha = previous?.reviewed?.headSha;
60
+ if (
61
+ this.options.full ||
62
+ this.options.clean ||
63
+ !prevSha ||
64
+ prevSha === headSha ||
65
+ !isAncestor(prevSha, headSha)
66
+ ) {
67
+ const diff = collectDiff(this.options.base, this.options.head);
68
+ return {
69
+ diff,
70
+ classification: {
71
+ ...this.classifier
72
+ .classifyRange(this.options.base, this.options.head, diff)
73
+ .toJSON(),
74
+ headSha,
75
+ mode: "full",
76
+ },
77
+ };
78
+ }
79
+
80
+ const range = `${prevSha}..${headSha}`;
81
+ const diff = collectDiffSince(prevSha, headSha);
82
+ const entries = diffEntries(range);
83
+ const changedPaths = new Set(entries.map((entry) => entry.path));
84
+ const classification: RunClassification = {
85
+ ...this.classifier.classify(entries, churnToRecord(diff.churn)).toJSON(),
86
+ base: prevSha,
87
+ head: this.options.head,
88
+ headSha,
89
+ mode: "incremental",
90
+ reviewedFrom: prevSha,
91
+ carryover: buildCarryover(previous, changedPaths),
92
+ };
93
+ return { classification, diff, reviewedFrom: prevSha };
94
+ }
95
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Sessão de uma run (engine) — amarra opções, branch e layout.
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 ReviewOptions,
14
+ resolveBranch,
15
+ } from "../orchestrator";
16
+ import { RunLayout } from "../run-layout";
17
+
18
+ /** Uma revisão em andamento (prepare ou finalize). */
19
+ export class ReviewSession {
20
+ readonly config: HoundsmanConfig;
21
+ readonly options: ReviewOptions;
22
+ readonly branch: string;
23
+ readonly layout: RunLayout;
24
+
25
+ constructor(config: HoundsmanConfig, options: ReviewOptions, branch: string) {
26
+ this.config = config;
27
+ this.options = options;
28
+ this.branch = branch;
29
+ this.layout = new RunLayout(options.dir, branch);
30
+ }
31
+
32
+ /** Resolve a branch (opção explícita ou a atual do git). */
33
+ static from(config: HoundsmanConfig, options: ReviewOptions): ReviewSession {
34
+ return new ReviewSession(config, options, resolveBranch(options));
35
+ }
36
+
37
+ /** Passo 1 — preparar. */
38
+ prepare(): Promise<PrepareResult> {
39
+ return prepare(this.options, this.branch, this.config, this.layout);
40
+ }
41
+
42
+ /** Passo 3 — finalizar. */
43
+ finalize(): FinalizeResult {
44
+ return finalize(this.options, this.branch, this.config, this.layout);
45
+ }
46
+
47
+ /** Executa o passo escolhido em `--finalize`. */
48
+ run(): Promise<PrepareResult | FinalizeResult> {
49
+ return this.options.finalize ? Promise.resolve(this.finalize()) : this.prepare();
50
+ }
51
+ }
@@ -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";
@@ -11,8 +11,8 @@
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";
14
+ import { existsSync, readFileSync } from "node:fs";
15
+ import { join } from "node:path";
16
16
  import {
17
17
  buildAnchor,
18
18
  type FindingAnchor,
@@ -20,7 +20,6 @@ import {
20
20
  locateAnchor,
21
21
  reanchor,
22
22
  } from "./finding-anchor";
23
- import { STATE_FILE } from "./paths";
24
23
  import {
25
24
  dedupeFindings,
26
25
  type MergedFinding,
@@ -28,8 +27,13 @@ import {
28
27
  sanitizeBranch,
29
28
  type WatchdogReport,
30
29
  } from "./report-builder";
30
+ import { RunLayout } from "./run-layout";
31
+ import { systemClock } from "./services/clock";
32
+ import { type FileReader, ProjectFiles } from "./services/project-files";
31
33
  import { SEVERITY_ORDER, type Severity, severityRank } from "./types";
32
34
 
35
+ export type { FileReader };
36
+
33
37
  // ---------------------------------------------------------------------------
34
38
  // Tipos
35
39
  // ---------------------------------------------------------------------------
@@ -140,27 +144,17 @@ export const FINDINGS_STATE_VERSION = 2;
140
144
 
141
145
  /** Diretório de artefatos da run (branch-slug). */
142
146
  export function runDir(artifactDir: string, branch: string): string {
143
- return join(artifactDir, sanitizeBranch(branch));
147
+ return new RunLayout(artifactDir, branch).outDir;
144
148
  }
145
149
 
146
150
  /** Caminho do `state.json` da run. */
147
151
  export function stateFile(artifactDir: string, branch: string): string {
148
- return join(runDir(artifactDir, branch), STATE_FILE);
152
+ return new RunLayout(artifactDir, branch).stateFile;
149
153
  }
150
154
 
151
- /** Leitor de arquivos relativos à raiz do projeto (injetável nos testes). */
152
- export type FileReader = (relativePath: string) => string | null;
153
-
154
155
  /** Leitor padrão a partir do cwd (null se ausente/ilegível). */
155
156
  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
- };
157
+ return new ProjectFiles(cwd).reader();
164
158
  }
165
159
 
166
160
  /** Id curto aleatório (hex) para um finding novo. */
@@ -174,7 +168,7 @@ export function emptyState(branch: string): FindingsStateFile {
174
168
  version: FINDINGS_STATE_VERSION,
175
169
  branch,
176
170
  slug: sanitizeBranch(branch),
177
- updatedAt: new Date().toISOString(),
171
+ updatedAt: systemClock(),
178
172
  order: [],
179
173
  findings: {},
180
174
  };
@@ -297,7 +291,7 @@ export function buildStateFromFindings(
297
291
  merged: readonly MergedFinding[],
298
292
  options: BuildStateOptions,
299
293
  ): FindingsStateFile {
300
- const now = options.now ?? new Date().toISOString();
294
+ const now = options.now ?? systemClock();
301
295
  const previous = options.previous ?? null;
302
296
 
303
297
  const result: Record<string, FindingState> = {};
@@ -437,7 +431,7 @@ export type FindingUpdate = {
437
431
  export function applyFindingUpdate(
438
432
  finding: FindingState,
439
433
  update: FindingUpdate,
440
- now: string = new Date().toISOString(),
434
+ now: string = systemClock(),
441
435
  ): FindingState {
442
436
  if (update.decision && DECISION_SET.has(update.decision)) {
443
437
  finding.decision = update.decision;
@@ -471,7 +465,7 @@ export type SyncResult = {
471
465
  export function syncState(
472
466
  state: FindingsStateFile,
473
467
  readFile: FileReader,
474
- now: string = new Date().toISOString(),
468
+ now: string = systemClock(),
475
469
  ): SyncResult {
476
470
  const moved: string[] = [];
477
471
  const stale: string[] = [];
@@ -521,22 +515,165 @@ export function occurrenceCount(
521
515
  }
522
516
 
523
517
  // ---------------------------------------------------------------------------
524
- // Store (I/O atômico)
518
+ // Domínio (classes)
519
+ // ---------------------------------------------------------------------------
520
+
521
+ /** Um finding triável, com comportamento sobre o DTO serializável. */
522
+ export class Finding {
523
+ readonly state: FindingState;
524
+
525
+ constructor(state: FindingState) {
526
+ this.state = state;
527
+ }
528
+
529
+ static from(state: FindingState): Finding {
530
+ return new Finding(state);
531
+ }
532
+
533
+ get id(): string {
534
+ return this.state.id;
535
+ }
536
+
537
+ get file(): string {
538
+ return this.state.file;
539
+ }
540
+
541
+ get line(): number | null {
542
+ return this.state.line;
543
+ }
544
+
545
+ get severity(): Severity {
546
+ return this.state.severity;
547
+ }
548
+
549
+ get decision(): FindingDecision {
550
+ return this.state.decision;
551
+ }
552
+
553
+ get execState(): FindingExecState {
554
+ return this.state.execState;
555
+ }
556
+
557
+ get present(): boolean {
558
+ return this.state.present;
559
+ }
560
+
561
+ get isDone(): boolean {
562
+ return this.state.execState === "done";
563
+ }
564
+
565
+ /** Aplica uma atualização (com histórico) e devolve o próprio finding. */
566
+ apply(update: FindingUpdate, now: string = systemClock()): Finding {
567
+ applyFindingUpdate(this.state, update, now);
568
+ return this;
569
+ }
570
+
571
+ /** Payload serializável (mesmo shape do `state.json`). */
572
+ toJSON(): FindingState {
573
+ return this.state;
574
+ }
575
+ }
576
+
577
+ /** Coleção de triagem de uma run — superfície OO sobre o `state.json`. */
578
+ export class FindingsState {
579
+ readonly file: FindingsStateFile;
580
+
581
+ constructor(file: FindingsStateFile) {
582
+ this.file = file;
583
+ }
584
+
585
+ static from(file: FindingsStateFile): FindingsState {
586
+ return new FindingsState(file);
587
+ }
588
+
589
+ static empty(branch: string): FindingsState {
590
+ return new FindingsState(emptyState(branch));
591
+ }
592
+
593
+ get branch(): string {
594
+ return this.file.branch;
595
+ }
596
+
597
+ get reviewed(): FindingsReviewed | undefined {
598
+ return this.file.reviewed;
599
+ }
600
+
601
+ /** Findings na ordem de exibição (`order`). */
602
+ list(): Finding[] {
603
+ return this.file.order
604
+ .map((id) => this.file.findings[id])
605
+ .filter((state): state is FindingState => Boolean(state))
606
+ .map((state) => Finding.from(state));
607
+ }
608
+
609
+ /** Busca por id. */
610
+ get(id: string): Finding | null {
611
+ const state = this.file.findings[id];
612
+ return state ? Finding.from(state) : null;
613
+ }
614
+
615
+ /** Todos os findings (ordem de inserção, sem o `order`). */
616
+ values(): Finding[] {
617
+ return Object.values(this.file.findings).map((state) => Finding.from(state));
618
+ }
619
+
620
+ /** Somente os presentes no último relatório. */
621
+ present(): Finding[] {
622
+ return this.values().filter((finding) => finding.present);
623
+ }
624
+
625
+ /** Filtra pela ordem canônica (presentes → severidade → arquivo → linha). */
626
+ filter(filter: FindingsFilter = {}): Finding[] {
627
+ return filterFindings(this.file, filter).map((state) => Finding.from(state));
628
+ }
629
+
630
+ /** Contagens por decisão, execução e severidade. */
631
+ summarize(): FindingsSummary {
632
+ return summarize(this.file);
633
+ }
634
+
635
+ /** Reordena os ids pela ordenação canônica. */
636
+ reorder(): void {
637
+ reorder(this.file);
638
+ }
639
+
640
+ /** Aplica uma atualização a um finding e devolve-o (null se ausente). */
641
+ apply(id: string, update: FindingUpdate, now?: string): Finding | null {
642
+ const state = this.file.findings[id];
643
+ if (!state) return null;
644
+ applyFindingUpdate(state, update, now);
645
+ return Finding.from(state);
646
+ }
647
+
648
+ /** Reancora os presentes contra os arquivos atuais. */
649
+ sync(readFile: FileReader, now?: string): SyncResult {
650
+ return syncState(this.file, readFile, now);
651
+ }
652
+
653
+ /** Payload serializável (mesmo shape do `state.json`). */
654
+ toJSON(): FindingsStateFile {
655
+ return this.file;
656
+ }
657
+ }
658
+
659
+ // ---------------------------------------------------------------------------
660
+ // Repository (I/O atômico)
525
661
  // ---------------------------------------------------------------------------
526
662
 
527
663
  /** Persistência do `state.json` — escrita atômica (tmp + rename). */
528
- export class FindingsStateStore {
664
+ export class FindingsRepository {
529
665
  private readonly path: string;
530
666
  private readonly branch: string;
667
+ private readonly files = new ProjectFiles();
531
668
 
532
669
  constructor(path: string, branch: string) {
533
670
  this.path = path;
534
671
  this.branch = branch;
535
672
  }
536
673
 
537
- /** Store para a run de uma branch. */
538
- static at(artifactDir: string, branch: string): FindingsStateStore {
539
- return new FindingsStateStore(stateFile(artifactDir, branch), branch);
674
+ /** Repository para a run de uma branch. */
675
+ static at(artifactDir: string, branch: string): FindingsRepository {
676
+ return new FindingsRepository(stateFile(artifactDir, branch), branch);
540
677
  }
541
678
 
542
679
  get filePath(): string {
@@ -545,31 +682,26 @@ export class FindingsStateStore {
545
682
 
546
683
  /** Lê o estado; null se ausente/inválido. */
547
684
  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
- }
685
+ const raw = this.files.readJson<FindingsStateFile>(this.path);
686
+ if (!raw || raw.version !== FINDINGS_STATE_VERSION || !raw.findings) return null;
687
+ return raw;
556
688
  }
557
689
 
558
690
  /** Grava o estado de forma atômica. */
559
691
  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);
692
+ this.files.writeAtomic(this.path, `${JSON.stringify(state, null, 2)}\n`);
564
693
  }
565
694
 
566
695
  /** Lê-modifica-grava; parte de um estado vazio se não existir. */
567
696
  update(mutator: (state: FindingsStateFile) => void): FindingsStateFile {
568
697
  const state = this.read() ?? emptyState(this.branch);
569
698
  mutator(state);
570
- state.updatedAt = new Date().toISOString();
699
+ state.updatedAt = systemClock();
571
700
  reorder(state);
572
701
  this.write(state);
573
702
  return state;
574
703
  }
575
704
  }
705
+
706
+ /** Nome anterior do repository (compatibilidade da API pública). */
707
+ export { FindingsRepository as FindingsStateStore };