@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,328 @@
1
+ /**
2
+ * Domínio (Fase 2 do refactor OO): watchdog, registry, config do projeto,
3
+ * diff, classificação e descoberta de docs.
4
+ */
5
+
6
+ import { describe, expect, test } from "bun:test";
7
+ import { defineHoundsmanConfig } from "../core/config";
8
+ import { DocDiscoverer, discoverDocs } from "../core/doc-discoverer";
9
+ import { Classification } from "../core/domain/classification";
10
+ import { Diff } from "../core/domain/diff";
11
+ import { matchesClassifyRule, Watchdog } from "../core/domain/watchdog";
12
+ import {
13
+ emptyState,
14
+ Finding,
15
+ type FindingState,
16
+ FindingsState,
17
+ } from "../core/findings-state";
18
+ import { ProjectConfig } from "../core/project-config";
19
+ import { defineWatchdog } from "../core/types";
20
+ import { WatchdogRegistry } from "../core/watchdog-registry";
21
+ import { fakeHoundsmanConfig } from "./fixtures/fake-project";
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Watchdog / WatchdogRegistry
25
+ // ---------------------------------------------------------------------------
26
+
27
+ describe("Watchdog", () => {
28
+ const alpha = new Watchdog(fakeHoundsmanConfig.watchdogs.alpha);
29
+
30
+ test("casa arquivos do próprio domínio", () => {
31
+ expect(alpha.matches("src/alpha/a.ts")).toBe(true);
32
+ expect(alpha.matches("src/beta/a.ts")).toBe(false);
33
+ });
34
+
35
+ test("ordena regras por severidade e expõe contexto sem regras", () => {
36
+ expect(alpha.rules.map((rule) => rule.severity)).toEqual([
37
+ "blocking",
38
+ "attention",
39
+ "suggestion",
40
+ ]);
41
+ const context = alpha.context();
42
+ expect(context.id).toBe("alpha");
43
+ expect(context.contextFiles).toEqual(["README.md"]);
44
+ expect("rules" in context).toBe(false);
45
+ });
46
+
47
+ test("casa por conteúdo quando a regra é type=content", () => {
48
+ const probe = new Watchdog(
49
+ defineWatchdog({
50
+ id: "probe",
51
+ name: "Probe",
52
+ description: "Por conteúdo.",
53
+ scope: "qualquer",
54
+ classify: [{ type: "content", value: "NEEDLE" }],
55
+ rules: [],
56
+ contextFiles: [],
57
+ }),
58
+ );
59
+ expect(probe.matches("a.ts", "// NEEDLE")).toBe(true);
60
+ expect(probe.matches("a.ts", "sem alvo")).toBe(false);
61
+ expect(probe.matches("a.ts")).toBe(false);
62
+ });
63
+
64
+ test("matchesClassifyRule respeita filename/content", () => {
65
+ expect(
66
+ matchesClassifyRule("src/alpha/a.ts", { value: "^src/alpha/" }, undefined),
67
+ ).toBe(true);
68
+ expect(
69
+ matchesClassifyRule("a.ts", { type: "content", value: "X" }, "contém X"),
70
+ ).toBe(true);
71
+ expect(
72
+ matchesClassifyRule("a.ts", { type: "content", value: "X" }, undefined),
73
+ ).toBe(false);
74
+ });
75
+ });
76
+
77
+ describe("WatchdogRegistry", () => {
78
+ const registry = new WatchdogRegistry(fakeHoundsmanConfig);
79
+
80
+ test("resolve ids, nomes e buscas null-safe", () => {
81
+ expect(registry.ids).toEqual(["alpha", "beta"]);
82
+ expect(registry.get("alpha").name).toBe("Alpha");
83
+ expect(registry.find("alpha")?.name).toBe("Alpha");
84
+ expect(registry.find("nao-existe")).toBeNull();
85
+ expect(registry.nameOf("nao-existe")).toBe("nao-existe");
86
+ expect(registry.all()).toHaveLength(2);
87
+ });
88
+
89
+ test("contexto e matching por arquivo", () => {
90
+ expect(registry.context("beta").contextFiles).toContain("README.md");
91
+ expect(registry.matching("src/alpha/a.ts")).toEqual(["alpha"]);
92
+ expect(registry.matching("lib/special.ts")).toEqual(["beta"]);
93
+ expect(registry.matching("scripts/foo.ts")).toEqual([]);
94
+ });
95
+ });
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // ProjectConfig
99
+ // ---------------------------------------------------------------------------
100
+
101
+ describe("ProjectConfig", () => {
102
+ test("resolve defaults e valores do projeto", () => {
103
+ const project = new ProjectConfig(fakeHoundsmanConfig);
104
+ expect(project.projectName).toBe("Fake Project");
105
+ expect(project.artifactDir).toBe("tmp/fake-review");
106
+ expect(project.maxFilesPerBrief).toBe(40);
107
+ expect(project.jevConcurrency).toBe(5);
108
+ expect(project.alwaysIncludeDocs).toEqual(["README.md"]);
109
+ expect(project.watchdogs.ids).toEqual(["alpha", "beta"]);
110
+ });
111
+
112
+ test("sobrepõe orçamento/concorrência e ignora valores inválidos", () => {
113
+ const custom = defineHoundsmanConfig({
114
+ ...fakeHoundsmanConfig,
115
+ budgets: { maxFilesPerBrief: 60 },
116
+ concurrency: { jev: 8 },
117
+ });
118
+ const project = new ProjectConfig(custom);
119
+ expect(project.maxFilesPerBrief).toBe(60);
120
+ expect(project.jevConcurrency).toBe(8);
121
+
122
+ const invalid = defineHoundsmanConfig({
123
+ ...fakeHoundsmanConfig,
124
+ budgets: { maxFilesPerBrief: 0 },
125
+ concurrency: { jev: -1 },
126
+ });
127
+ const fallback = new ProjectConfig(invalid);
128
+ expect(fallback.maxFilesPerBrief).toBe(40);
129
+ expect(fallback.jevConcurrency).toBe(5);
130
+ });
131
+ });
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // Diff / Classification
135
+ // ---------------------------------------------------------------------------
136
+
137
+ describe("Diff", () => {
138
+ const diff = new Diff({
139
+ byFile: new Map([["src/a.ts", "patch-a"]]),
140
+ churn: new Map([
141
+ ["src/a.ts", { additions: 2, deletions: 1 }],
142
+ ["src/b.ts", { additions: 3, deletions: 0 }],
143
+ ]),
144
+ });
145
+
146
+ test("consulta patch, churn e totais", () => {
147
+ expect(diff.paths.sort()).toEqual(["src/a.ts", "src/b.ts"]);
148
+ expect(diff.patchFor("src/a.ts")).toBe("patch-a");
149
+ expect(diff.patchFor("src/b.ts")).toBeUndefined();
150
+ expect(diff.churnOf("src/a.ts")).toEqual({ additions: 2, deletions: 1 });
151
+ expect(diff.churnOf("src/missing.ts")).toEqual({
152
+ additions: 0,
153
+ deletions: 0,
154
+ });
155
+ expect(diff.totals).toEqual({ additions: 5, deletions: 1 });
156
+ });
157
+
158
+ test("empty não tem paths nem churn", () => {
159
+ expect(Diff.empty().paths).toEqual([]);
160
+ expect(Diff.empty().totals).toEqual({ additions: 0, deletions: 0 });
161
+ });
162
+ });
163
+
164
+ describe("Classification", () => {
165
+ const data = {
166
+ watchdogs: { alpha: ["src/a.ts", "src/b.ts"], beta: ["src/c.ts"] },
167
+ files: {
168
+ "src/a.ts": { status: "modified" as const },
169
+ "src/b.ts": { status: "added" as const },
170
+ "src/c.ts": { status: "modified" as const },
171
+ },
172
+ churn: {
173
+ "src/a.ts": { additions: 10, deletions: 0 },
174
+ "src/b.ts": { additions: 1, deletions: 0 },
175
+ "src/c.ts": { additions: 4, deletions: 2 },
176
+ },
177
+ unclassified: ["scripts/x.ts"],
178
+ excluded: ["dist/x.js"],
179
+ deleted: [],
180
+ stats: { alpha: 2, beta: 1 },
181
+ excludedCount: 1,
182
+ base: "main",
183
+ head: "HEAD",
184
+ };
185
+ const classification = Classification.from(data);
186
+
187
+ test("expõe coleções e ids ativos", () => {
188
+ expect(classification.activeIds).toEqual(["alpha", "beta"]);
189
+ expect(classification.watchdogsFor("alpha")).toEqual(["src/a.ts", "src/b.ts"]);
190
+ expect(classification.watchdogsFor("missing")).toEqual([]);
191
+ expect(classification.unclassified).toEqual(["scripts/x.ts"]);
192
+ expect(classification.excludedCount).toBe(1);
193
+ expect(classification.base).toBe("main");
194
+ });
195
+
196
+ test("aplica o orçamento por risco", () => {
197
+ expect(classification.budgetFor("alpha", 1)).toEqual({
198
+ selected: ["src/a.ts"],
199
+ overflow: ["src/b.ts"],
200
+ });
201
+ expect(classification.budgetFor("alpha", 40)).toEqual({
202
+ selected: ["src/a.ts", "src/b.ts"],
203
+ overflow: [],
204
+ });
205
+ });
206
+
207
+ test("soma churn e serializa no shape do classification.json", () => {
208
+ expect(classification.churnTotals).toEqual({ additions: 15, deletions: 2 });
209
+ expect(classification.toJSON()).toBe(data);
210
+ });
211
+ });
212
+
213
+ // ---------------------------------------------------------------------------
214
+ // DocDiscoverer
215
+ // ---------------------------------------------------------------------------
216
+
217
+ describe("DocDiscoverer", () => {
218
+ test("sempre inclui docs e aplica os mapeamentos do projeto", () => {
219
+ const config = defineHoundsmanConfig({
220
+ ...fakeHoundsmanConfig,
221
+ alwaysIncludeDocs: ["AGENTS.md"],
222
+ docMappings: [
223
+ [/^src\//, ["docs/DOMAIN.md"]],
224
+ [/\.test\.ts$/, ["docs/TESTS.md"]],
225
+ ],
226
+ });
227
+ const docs = new DocDiscoverer(config).discover([
228
+ "src/alpha/a.ts",
229
+ "src/alpha/a.test.ts",
230
+ "scripts/foo.ts",
231
+ ]);
232
+ expect(docs).toEqual(["AGENTS.md", "docs/DOMAIN.md", "docs/TESTS.md"]);
233
+ });
234
+
235
+ test("o adaptador discoverDocs delega para a classe", () => {
236
+ expect(discoverDocs(["src/alpha/a.ts"], fakeHoundsmanConfig)).toEqual([
237
+ "README.md",
238
+ ]);
239
+ });
240
+ });
241
+
242
+ // ---------------------------------------------------------------------------
243
+ // Finding / FindingsState
244
+ // ---------------------------------------------------------------------------
245
+
246
+ /** Finding mínimo para os testes de domínio. */
247
+ function makeFinding(id: string, overrides: Partial<FindingState> = {}): FindingState {
248
+ return {
249
+ id,
250
+ fingerprint: `fp-${id}`,
251
+ watchdogIds: ["alpha"],
252
+ rule: "R",
253
+ severity: "blocking",
254
+ file: "src/a.ts",
255
+ line: 1,
256
+ message: "m",
257
+ action: "a",
258
+ anchor: null,
259
+ count: 1,
260
+ present: true,
261
+ decision: "pending",
262
+ execState: "pending",
263
+ createdAt: "2026-01-01T00:00:00.000Z",
264
+ updatedAt: "2026-01-01T00:00:00.000Z",
265
+ history: [],
266
+ ...overrides,
267
+ };
268
+ }
269
+
270
+ describe("Finding / FindingsState", () => {
271
+ const file = emptyState("main");
272
+ file.findings.a = makeFinding("a");
273
+ file.findings.b = makeFinding("b", {
274
+ severity: "nit",
275
+ file: "src/z.ts",
276
+ present: false,
277
+ decision: "ignore",
278
+ });
279
+ file.order = ["a", "b"];
280
+
281
+ test("Finding expõe o estado e aplica updates", () => {
282
+ const state = makeFinding("x");
283
+ const finding = Finding.from(state);
284
+ expect(finding.id).toBe("x");
285
+ expect(finding.isDone).toBe(false);
286
+ expect(finding.toJSON()).toBe(state);
287
+
288
+ finding.apply({ decision: "execute", execState: "in_progress" }, "agora");
289
+ expect(finding.decision).toBe("execute");
290
+ expect(finding.execState).toBe("in_progress");
291
+ expect(state.updatedAt).toBe("agora");
292
+ expect(state.history).toHaveLength(1);
293
+ });
294
+
295
+ test("FindingsState lista, filtra e resume", () => {
296
+ const state = FindingsState.from(file);
297
+ expect(state.branch).toBe("main");
298
+ expect(state.list().map((f) => f.id)).toEqual(["a", "b"]);
299
+ expect(state.get("a")?.file).toBe("src/a.ts");
300
+ expect(state.get("missing")).toBeNull();
301
+ expect(state.present().map((f) => f.id)).toEqual(["a"]);
302
+ expect(state.filter({ severity: "nit", includeAbsent: true })).toHaveLength(1);
303
+ expect(state.summarize().total).toBe(2);
304
+ expect(state.summarize().present).toBe(1);
305
+ expect(state.toJSON()).toBe(file);
306
+ });
307
+
308
+ test("FindingsState aplica updates e delega sync/reorder", () => {
309
+ const state = FindingsState.from(emptyState("main"));
310
+ state.file.findings.a = makeFinding("a");
311
+ state.file.order = ["a"];
312
+
313
+ expect(state.apply("missing", { decision: "ignore" })).toBeNull();
314
+ const updated = state.apply("a", { decision: "execute" }, "agora");
315
+ expect(updated?.decision).toBe("execute");
316
+ expect(state.file.findings.a?.updatedAt).toBe("agora");
317
+
318
+ const result = state.sync(() => null);
319
+ expect(result.stale).toEqual(["a"]);
320
+ });
321
+
322
+ test("FindingsState.empty cria uma coleção vazia válida", () => {
323
+ const empty = FindingsState.empty("feature/x");
324
+ expect(empty.branch).toBe("feature/x");
325
+ expect(empty.list()).toEqual([]);
326
+ expect(empty.summarize().total).toBe(0);
327
+ });
328
+ });
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Infraestrutura (Fase 1 do refactor OO): git, gh, filesystem e layout da run.
3
+ *
4
+ * Testa as classes com runners injetáveis — sem tocar no git/gh reais.
5
+ */
6
+
7
+ import { describe, expect, test } from "bun:test";
8
+ import { mkdtempSync, readdirSync, rmSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import type { GitResult, GitRunner } from "../core/git-runner";
12
+ import { RunLayout } from "../core/run-layout";
13
+ import { GhClient, type GhRunner } from "../core/services/gh-client";
14
+ import { GitRepository } from "../core/services/git-repository";
15
+ import { ProjectFiles } from "../core/services/project-files";
16
+
17
+ /** Runner roteado por `args.join(" ")`, registrando as chamadas. */
18
+ function fakeGit(routes: Record<string, GitResult>): {
19
+ calls: string[][];
20
+ runner: GitRunner;
21
+ } {
22
+ const calls: string[][] = [];
23
+ const runner: GitRunner = (args) => {
24
+ calls.push(args);
25
+ return (
26
+ routes[args.join(" ")] ?? {
27
+ ok: false,
28
+ code: 1,
29
+ stdout: "",
30
+ stderr: "rota não mockada",
31
+ }
32
+ );
33
+ };
34
+ return { calls, runner };
35
+ }
36
+
37
+ describe("GitRepository", () => {
38
+ test("resolveSha apara o stdout", () => {
39
+ const { runner } = fakeGit({
40
+ "rev-parse HEAD": { ok: true, code: 0, stdout: "abc123\n", stderr: "" },
41
+ });
42
+ expect(new GitRepository(runner).resolveSha("HEAD")).toBe("abc123");
43
+ });
44
+
45
+ test("currentBranch devolve null quando falha ou está vazio", () => {
46
+ const empty = fakeGit({
47
+ "branch --show-current": { ok: true, code: 0, stdout: "\n", stderr: "" },
48
+ });
49
+ expect(new GitRepository(empty.runner).currentBranch()).toBeNull();
50
+
51
+ const failing = fakeGit({});
52
+ expect(new GitRepository(failing.runner).currentBranch()).toBeNull();
53
+ });
54
+
55
+ test("isAncestor usa o exit code sem lançar", () => {
56
+ const { runner } = fakeGit({
57
+ "merge-base --is-ancestor a b": {
58
+ ok: true,
59
+ code: 0,
60
+ stdout: "",
61
+ stderr: "",
62
+ },
63
+ "merge-base --is-ancestor b a": {
64
+ ok: false,
65
+ code: 1,
66
+ stdout: "",
67
+ stderr: "",
68
+ },
69
+ });
70
+ const git = new GitRepository(runner);
71
+ expect(git.isAncestor("a", "b")).toBe(true);
72
+ expect(git.isAncestor("b", "a")).toBe(false);
73
+ });
74
+
75
+ test("readFileAt devolve null quando o commit não tem o arquivo", () => {
76
+ const { runner } = fakeGit({
77
+ "show abc:src/a.ts": { ok: true, code: 0, stdout: "conteúdo", stderr: "" },
78
+ });
79
+ const git = new GitRepository(runner);
80
+ expect(git.readFileAt("abc", "src/a.ts")).toBe("conteúdo");
81
+ expect(git.readFileAt("abc", "src/missing.ts")).toBeNull();
82
+ });
83
+
84
+ test("diffNameStatus monta o intervalo base...head", () => {
85
+ const { calls, runner } = fakeGit({
86
+ "diff main...HEAD --name-status": {
87
+ ok: true,
88
+ code: 0,
89
+ stdout: "M\tsrc/a.ts\n",
90
+ stderr: "",
91
+ },
92
+ });
93
+ new GitRepository(runner).diffNameStatus("main", "HEAD");
94
+ expect(calls).toEqual([["diff", "main...HEAD", "--name-status"]]);
95
+ });
96
+
97
+ test("diffNumstat e collectDiffSince parseiam churn e patch", () => {
98
+ const patch = [
99
+ "diff --git a/src/a.ts b/src/a.ts",
100
+ "--- a/src/a.ts",
101
+ "+++ b/src/a.ts",
102
+ "@@ -1 +1 @@",
103
+ "-old",
104
+ "+new",
105
+ ].join("\n");
106
+ const { runner } = fakeGit({
107
+ "diff from..to": { ok: true, code: 0, stdout: patch, stderr: "" },
108
+ "diff --numstat -z from..to": {
109
+ ok: true,
110
+ code: 0,
111
+ stdout: "1\t2\tsrc/a.ts\0",
112
+ stderr: "",
113
+ },
114
+ });
115
+ const diff = new GitRepository(runner).collectDiffSince("from", "to");
116
+ expect(diff.churn.get("src/a.ts")).toEqual({ additions: 1, deletions: 2 });
117
+ expect(diff.byFile.get("src/a.ts")).toContain("+new");
118
+ });
119
+ });
120
+
121
+ describe("GhClient", () => {
122
+ test("delega ao runner injetado", () => {
123
+ const calls: string[][] = [];
124
+ const runner: GhRunner = (args) => {
125
+ calls.push(args);
126
+ return { ok: true, code: 0, stdout: "ok", stderr: "" };
127
+ };
128
+ const result = new GhClient(runner).run(["auth", "status"]);
129
+ expect(result.stdout).toBe("ok");
130
+ expect(calls).toEqual([["auth", "status"]]);
131
+ });
132
+ });
133
+
134
+ describe("ProjectFiles", () => {
135
+ test("lê, escreve, lê JSON e resolve relativo ao cwd", () => {
136
+ const cwd = mkdtempSync(join(tmpdir(), "houndsman-files-"));
137
+ try {
138
+ const files = new ProjectFiles(cwd);
139
+ files.write("dir/a.txt", "conteúdo");
140
+ files.write("dir/b.json", JSON.stringify({ ok: true }));
141
+
142
+ expect(files.read("dir/a.txt")).toBe("conteúdo");
143
+ expect(files.read("dir/missing.txt")).toBeNull();
144
+ expect(files.readJson<{ ok: boolean }>("dir/b.json")).toEqual({ ok: true });
145
+ expect(files.readJson("dir/a.txt")).toBeNull();
146
+ expect(files.exists("dir/a.txt")).toBe(true);
147
+ expect(files.reader()("dir/a.txt")).toBe("conteúdo");
148
+ } finally {
149
+ rmSync(cwd, { recursive: true, force: true });
150
+ }
151
+ });
152
+
153
+ test("writeAtomic não deixa arquivo temporário", () => {
154
+ const cwd = mkdtempSync(join(tmpdir(), "houndsman-files-atomic-"));
155
+ try {
156
+ const files = new ProjectFiles(cwd);
157
+ files.writeAtomic("state.json", "{}");
158
+ expect(files.read("state.json")).toBe("{}");
159
+ expect(readdirSync(cwd)).toEqual(["state.json"]);
160
+ } finally {
161
+ rmSync(cwd, { recursive: true, force: true });
162
+ }
163
+ });
164
+ });
165
+
166
+ describe("RunLayout", () => {
167
+ test("centraliza os caminhos da run (slug normalizado)", () => {
168
+ const layout = new RunLayout(".houndsman/.runs", "feature/x");
169
+ expect(layout.slug).toBe("feature-x");
170
+ expect(layout.outDir.replace(/\\/g, "/")).toBe(".houndsman/.runs/feature-x");
171
+ expect(layout.briefsDir.replace(/\\/g, "/")).toBe(
172
+ ".houndsman/.runs/feature-x/briefs",
173
+ );
174
+ expect(layout.findingsDir.replace(/\\/g, "/")).toBe(
175
+ ".houndsman/.runs/feature-x/findings",
176
+ );
177
+ expect(layout.stateFile.replace(/\\/g, "/")).toBe(
178
+ ".houndsman/.runs/feature-x/state.json",
179
+ );
180
+ expect(layout.runStatusFile.replace(/\\/g, "/")).toBe(
181
+ ".houndsman/.runs/feature-x/run.json",
182
+ );
183
+ expect(layout.classificationFile.replace(/\\/g, "/")).toBe(
184
+ ".houndsman/.runs/feature-x/classification.json",
185
+ );
186
+ expect(layout.domainReportFile("alpha").replace(/\\/g, "/")).toBe(
187
+ ".houndsman/.runs/feature-x/review.alpha.md",
188
+ );
189
+ expect(layout.findingFile("alpha").replace(/\\/g, "/")).toBe(
190
+ ".houndsman/.runs/feature-x/findings/alpha.json",
191
+ );
192
+ expect(layout.briefFile("alpha").replace(/\\/g, "/")).toBe(
193
+ ".houndsman/.runs/feature-x/briefs/alpha.md",
194
+ );
195
+ });
196
+
197
+ test("contextPaths compartilha a pasta da run", () => {
198
+ const paths = new RunLayout(".houndsman/.runs", "main").contextPaths;
199
+ expect(paths.dir.replace(/\\/g, "/")).toBe(".houndsman/.runs/main");
200
+ expect(paths.context.replace(/\\/g, "/")).toBe(
201
+ ".houndsman/.runs/main/context.json",
202
+ );
203
+ expect(paths.summary.replace(/\\/g, "/")).toBe(
204
+ ".houndsman/.runs/main/context-summary.json",
205
+ );
206
+ expect(paths.brief.replace(/\\/g, "/")).toBe(
207
+ ".houndsman/.runs/main/context-brief.md",
208
+ );
209
+ });
210
+ });