@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
@@ -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,108 @@
1
+ /**
2
+ * Layout de uma run (biblioteca) — único ponto que conhece os nomes/estrutura
3
+ * dos artefatos em `.houndsman/.runs/<slug>/`.
4
+ *
5
+ * Evita o "data clump" de `(dir, branch)`/`(outDir, branch)` espalhado pelo
6
+ * motor: quem precisa de um caminho pede ao layout.
7
+ */
8
+
9
+ import { join } from "node:path";
10
+ import {
11
+ CONTEXT_BRIEF_FILE,
12
+ CONTEXT_FILE,
13
+ CONTEXT_SUMMARY_FILE,
14
+ RUN_STATUS_FILE,
15
+ STATE_FILE,
16
+ VERIFY_FILE,
17
+ } from "./paths";
18
+ import { sanitizeBranch } from "./report-builder";
19
+
20
+ /** Caminhos dos artefatos de contexto da PR. */
21
+ export type ContextPaths = {
22
+ dir: string;
23
+ context: string;
24
+ summary: string;
25
+ brief: string;
26
+ };
27
+
28
+ /** Diretório e caminhos de uma run (branch-slug). */
29
+ export class RunLayout {
30
+ readonly artifactDir: string;
31
+ readonly branch: string;
32
+
33
+ constructor(artifactDir: string, branch: string) {
34
+ this.artifactDir = artifactDir;
35
+ this.branch = branch;
36
+ }
37
+
38
+ get slug(): string {
39
+ return sanitizeBranch(this.branch);
40
+ }
41
+
42
+ get outDir(): string {
43
+ return join(this.artifactDir, this.slug);
44
+ }
45
+
46
+ get briefsDir(): string {
47
+ return join(this.outDir, "briefs");
48
+ }
49
+
50
+ get findingsDir(): string {
51
+ return join(this.outDir, "findings");
52
+ }
53
+
54
+ /** Caminho de um arquivo dentro da pasta da run. */
55
+ file(name: string): string {
56
+ return join(this.outDir, name);
57
+ }
58
+
59
+ briefFile(id: string): string {
60
+ return join(this.briefsDir, `${id}.md`);
61
+ }
62
+
63
+ findingFile(id: string): string {
64
+ return join(this.findingsDir, `${id}.json`);
65
+ }
66
+
67
+ get classificationFile(): string {
68
+ return this.file("classification.json");
69
+ }
70
+
71
+ get manifestFile(): string {
72
+ return this.file("manifest.json");
73
+ }
74
+
75
+ get candidatesFile(): string {
76
+ return this.file("candidates.json");
77
+ }
78
+
79
+ get reviewFile(): string {
80
+ return this.file("review.md");
81
+ }
82
+
83
+ get stateFile(): string {
84
+ return this.file(STATE_FILE);
85
+ }
86
+
87
+ get runStatusFile(): string {
88
+ return this.file(RUN_STATUS_FILE);
89
+ }
90
+
91
+ get verifyFile(): string {
92
+ return this.file(VERIFY_FILE);
93
+ }
94
+
95
+ /** Relatório isolado de um watchdog (`review.<id>.md`). */
96
+ domainReportFile(id: string): string {
97
+ return this.file(`review.${sanitizeBranch(id)}.md`);
98
+ }
99
+
100
+ get contextPaths(): ContextPaths {
101
+ return {
102
+ dir: this.outDir,
103
+ context: this.file(CONTEXT_FILE),
104
+ summary: this.file(CONTEXT_SUMMARY_FILE),
105
+ brief: this.file(CONTEXT_BRIEF_FILE),
106
+ };
107
+ }
108
+ }
@@ -9,11 +9,12 @@
9
9
  * não é um status persistido.
10
10
  */
11
11
 
12
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
13
12
  import { join } from "node:path";
14
13
  import type { FindingsStateFile } from "./findings-state";
15
14
  import { RUN_STATUS_FILE } from "./paths";
16
15
  import { sanitizeBranch } from "./report-builder";
16
+ import { systemClock } from "./services/clock";
17
+ import { ProjectFiles } from "./services/project-files";
17
18
 
18
19
  /** Estados de execução de uma run (persistidos). */
19
20
  export type RunStatus = "reviewing" | "review_done" | "fixing" | "fix_done";
@@ -47,13 +48,7 @@ export function runStatusPath(runDir: string): string {
47
48
 
48
49
  /** Lê o `run.json` da run (null se ausente/inválido). */
49
50
  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
- }
51
+ return new ProjectFiles().readJson<RunStatusFile>(runStatusPath(runDir));
57
52
  }
58
53
 
59
54
  export type WriteRunStatusOptions = {
@@ -69,7 +64,8 @@ export function writeRunStatus(
69
64
  runDir: string,
70
65
  options: WriteRunStatusOptions,
71
66
  ): RunStatusFile {
72
- const now = options.now ?? new Date().toISOString();
67
+ const files = new ProjectFiles();
68
+ const now = options.now ?? systemClock();
73
69
  const previous = readRunStatus(runDir);
74
70
  const file: RunStatusFile = {
75
71
  version: RUN_STATUS_VERSION,
@@ -79,8 +75,7 @@ export function writeRunStatus(
79
75
  startedAt: options.resetStartedAt ? now : (previous?.startedAt ?? now),
80
76
  updatedAt: now,
81
77
  };
82
- mkdirSync(runDir, { recursive: true });
83
- writeFileSync(runStatusPath(runDir), `${JSON.stringify(file, null, 2)}\n`);
78
+ files.write(runStatusPath(runDir), `${JSON.stringify(file, null, 2)}\n`);
84
79
  return file;
85
80
  }
86
81
 
@@ -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,123 @@
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
+ /**
56
+ * `true` se `ancestor` é ancestral de `descendant`. Qualquer outro desfecho
57
+ * (ref desconhecida, exit ≠ 0) devolve `false` — cai no review completo.
58
+ */
59
+ isAncestor(ancestor: string, descendant: string): boolean {
60
+ return this.tryRun(["merge-base", "--is-ancestor", ancestor, descendant]).ok;
61
+ }
62
+
63
+ /** Conteúdo de um arquivo num commit; `null` se ausente. */
64
+ readFileAt(sha: string, path: string): string | null {
65
+ const result = this.tryRun(["show", `${sha}:${path}`]);
66
+ return result.ok ? result.stdout : null;
67
+ }
68
+
69
+ /** `git diff base...head --name-status` cru. */
70
+ diffNameStatus(base: string, head: string): string {
71
+ return this.run(["diff", `${base}...${head}`, "--name-status"]);
72
+ }
73
+
74
+ /** Churn por arquivo de um intervalo (`--numstat -z`). */
75
+ diffNumstat(range: string): Map<string, FileChurn> {
76
+ return parseNumstat(this.run(["diff", "--numstat", "-z", range]));
77
+ }
78
+
79
+ /** Patch por arquivo de um intervalo, opcionalmente restrito a `paths`. */
80
+ diffPatch(
81
+ range: string,
82
+ paths?: readonly string[],
83
+ maxPerFileChars: number = DEFAULT_MAX_PER_FILE_CHARS,
84
+ ): Map<string, string> {
85
+ const args = ["diff", range];
86
+ if (paths && paths.length > 0) args.push("--", ...paths);
87
+ return parseDiffIndex(this.run(args), maxPerFileChars);
88
+ }
89
+
90
+ /** Churn de `base...head` — 1 spawn. */
91
+ collectChurn(base: string, head: string): Map<string, FileChurn> {
92
+ return this.diffNumstat(`${base}...${head}`);
93
+ }
94
+
95
+ /** Patch + churn da PR (`base...head`) — 2 spawns fixos. */
96
+ collectDiff(
97
+ base: string,
98
+ head: string,
99
+ options: CollectDiffOptions = {},
100
+ ): DiffSource {
101
+ const range = `${base}...${head}`;
102
+ return {
103
+ byFile: parseDiffIndex(
104
+ this.run(["diff", range]),
105
+ options.maxPerFileChars ?? DEFAULT_MAX_PER_FILE_CHARS,
106
+ ),
107
+ churn: this.collectChurn(base, head),
108
+ };
109
+ }
110
+
111
+ /** Patch + churn de `from..to` — base do review incremental. */
112
+ collectDiffSince(
113
+ from: string,
114
+ to: string,
115
+ maxPerFileChars: number = DEFAULT_MAX_PER_FILE_CHARS,
116
+ ): DiffSource {
117
+ const range = `${from}..${to}`;
118
+ return {
119
+ byFile: parseDiffIndex(this.run(["diff", range]), maxPerFileChars),
120
+ churn: this.diffNumstat(range),
121
+ };
122
+ }
123
+ }
@@ -0,0 +1,79 @@
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 { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
10
+ import { dirname, isAbsolute, join } from "node:path";
11
+
12
+ /** Leitor de arquivos relativos à raiz do projeto (injetável nos testes). */
13
+ export type FileReader = (relativePath: string) => string | null;
14
+
15
+ /** Acesso ao filesystem resolvido a partir de um `cwd`. */
16
+ export class ProjectFiles {
17
+ private readonly cwd: string;
18
+
19
+ constructor(cwd: string = process.cwd()) {
20
+ this.cwd = cwd;
21
+ }
22
+
23
+ /** Resolve um caminho (relativo → absoluto). */
24
+ resolve(path: string): string {
25
+ return isAbsolute(path) ? path : join(this.cwd, path);
26
+ }
27
+
28
+ /** Lê UTF-8; `null` se ausente/ilegível. */
29
+ read(path: string): string | null {
30
+ const full = this.resolve(path);
31
+ try {
32
+ return existsSync(full) ? readFileSync(full, "utf8") : null;
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ /** Lê + parseia JSON; `null` se ausente/inválido. */
39
+ readJson<T>(path: string): T | null {
40
+ const raw = this.read(path);
41
+ if (raw === null) return null;
42
+ try {
43
+ return JSON.parse(raw) as T;
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+
49
+ /** Escreve UTF-8, criando o diretório pai. */
50
+ write(path: string, content: string): void {
51
+ const full = this.resolve(path);
52
+ mkdirSync(dirname(full), { recursive: true });
53
+ writeFileSync(full, content);
54
+ }
55
+
56
+ /** Escrita atômica (tmp + rename). */
57
+ writeAtomic(path: string, content: string): void {
58
+ const full = this.resolve(path);
59
+ mkdirSync(dirname(full), { recursive: true });
60
+ const tmp = `${full}.${process.pid}.tmp`;
61
+ writeFileSync(tmp, content);
62
+ renameSync(tmp, full);
63
+ }
64
+
65
+ /** Cria o diretório (recursivo). */
66
+ ensureDir(path: string): void {
67
+ mkdirSync(this.resolve(path), { recursive: true });
68
+ }
69
+
70
+ /** `true` se o caminho existe. */
71
+ exists(path: string): boolean {
72
+ return existsSync(this.resolve(path));
73
+ }
74
+
75
+ /** Função de leitura ligada a este `cwd` (compatível com `FileReader`). */
76
+ reader(): FileReader {
77
+ return (relativePath: string) => this.read(relativePath);
78
+ }
79
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Verificação de findings (biblioteca) — superfície OO sobre `verify.ts`.
3
+ *
4
+ * O tier local (âncora de conteúdo) e o tier Jev continuam em funções puras; a
5
+ * classe concentra a sequência usada pelo comando `findings verify`.
6
+ */
7
+
8
+ import type {
9
+ FileReader,
10
+ FindingState,
11
+ FindingsFilter,
12
+ FindingsStateFile,
13
+ } from "../findings-state";
14
+ import {
15
+ applyJev,
16
+ applyResolutions,
17
+ selectVerifiable,
18
+ summarizeVerify,
19
+ type VerifyEntry,
20
+ type VerifySummary,
21
+ verifyLocal,
22
+ } from "../verify";
23
+
24
+ /** Encadeia seleção, tier local, confirmação Jev e persistência. */
25
+ export class Verifier {
26
+ /** Fila de verificação (presentes, sem `ignore`). */
27
+ select(state: FindingsStateFile, filter: FindingsFilter = {}): FindingState[] {
28
+ return selectVerifiable(state, filter);
29
+ }
30
+
31
+ /** Tier 0 — checagem local por reancoragem de conteúdo. */
32
+ verifyLocal(findings: readonly FindingState[], readFile: FileReader): VerifyEntry[] {
33
+ return verifyLocal(findings, readFile);
34
+ }
35
+
36
+ /** Tier 1 — aplica a confirmação do Jev aos casos ambíguos. */
37
+ applyJev(
38
+ entries: VerifyEntry[],
39
+ confidence: Map<string, number>,
40
+ threshold: number,
41
+ ): number {
42
+ return applyJev(entries, confidence, threshold);
43
+ }
44
+
45
+ /** Contagem por status. */
46
+ summarize(entries: readonly VerifyEntry[]): VerifySummary {
47
+ return summarizeVerify(entries);
48
+ }
49
+
50
+ /** Persiste os `resolved` como `done`. */
51
+ applyResolutions(
52
+ state: FindingsStateFile,
53
+ entries: readonly VerifyEntry[],
54
+ now?: string,
55
+ ): string[] {
56
+ return applyResolutions(state, entries, now);
57
+ }
58
+ }
@@ -19,6 +19,7 @@ import {
19
19
  type FindingsStateFile,
20
20
  filterFindings,
21
21
  } from "./findings-state";
22
+ import { systemClock } from "./services/clock";
22
23
  import type { Severity } from "./types";
23
24
 
24
25
  /** Resultado da verificação de um finding. */
@@ -160,7 +161,7 @@ export function summarizeVerify(entries: readonly VerifyEntry[]): VerifySummary
160
161
  export function applyResolutions(
161
162
  state: FindingsStateFile,
162
163
  entries: readonly VerifyEntry[],
163
- now: string = new Date().toISOString(),
164
+ now: string = systemClock(),
164
165
  ): string[] {
165
166
  const changed: string[] = [];
166
167
  for (const entry of entries) {