@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
@@ -1,51 +1,106 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import {
3
- existsSync,
4
- mkdirSync,
5
- mkdtempSync,
6
- readFileSync,
7
- rmSync,
8
- writeFileSync,
9
- } from "node:fs";
2
+ import { mkdtempSync, rmSync } from "node:fs";
10
3
  import { tmpdir } from "node:os";
11
4
  import { join } from "node:path";
12
5
  import { runFindingsCommand, runVerifyCommand } from "../core/commands/findings";
6
+ import { buildStateFromFindings, fileReader } from "../core/findings-state";
7
+ import {
8
+ dedupeFindings,
9
+ parseWatchdogReport,
10
+ type WatchdogReport,
11
+ } from "../core/report-builder";
12
+ import { buildRepoIdentity } from "../core/storage/repo-identity";
13
+ import { SQLiteStorageProvider } from "../core/storage/sqlite/sqlite-storage";
14
+ import type { RepoIdentity } from "../core/storage/types";
13
15
  import { fakeHoundsmanConfig } from "./fixtures/fake-project";
14
16
 
15
- /** Run fake em disco: classification.json + findings/alpha.json. */
16
- function makeRun(findings: unknown[]): { dir: string; cleanup: () => void } {
17
+ /** Ambiente temporário: banco + review semeada com os findings informados. */
18
+ type Env = {
19
+ storage: SQLiteStorageProvider;
20
+ repo: RepoIdentity;
21
+ reviewId: string;
22
+ dir: string;
23
+ cleanup: () => void;
24
+ };
25
+
26
+ let activeEnv: Env | null = null;
27
+
28
+ function makeRun(reports: WatchdogReport[], branch = "main"): Env {
17
29
  const dir = mkdtempSync(join(tmpdir(), "houndsman-findings-"));
18
- const outDir = join(dir, "main");
19
- mkdirSync(join(outDir, "findings"), { recursive: true });
20
- writeFileSync(
21
- join(outDir, "classification.json"),
22
- JSON.stringify({ watchdogs: { alpha: ["src/alpha/f.ts"] } }),
23
- );
24
- writeFileSync(
25
- join(outDir, "findings", "alpha.json"),
26
- JSON.stringify({
27
- watchdogId: "alpha",
28
- reviewedFiles: ["src/alpha/f.ts"],
29
- skipped: [],
30
- findings,
31
- }),
30
+ const storage = new SQLiteStorageProvider(join(dir, "houndsman.db"));
31
+ const repo = buildRepoIdentity(null, dir);
32
+ const review = storage.getOrCreateReview({ repo, branch, slug: branch });
33
+ const state = buildStateFromFindings(dedupeFindings(reports), {
34
+ branch,
35
+ readFile: fileReader(process.cwd()),
36
+ });
37
+ storage.writeFindings(review.id, state);
38
+ const env: Env = {
39
+ storage,
40
+ repo,
41
+ reviewId: review.id,
42
+ dir,
43
+ cleanup: () => {
44
+ storage.close();
45
+ rmSync(dir, { recursive: true, force: true });
46
+ },
47
+ };
48
+ activeEnv = env;
49
+ return env;
50
+ }
51
+
52
+ function reportOf(
53
+ watchdogId: string,
54
+ findings: unknown[],
55
+ files: string[],
56
+ ): WatchdogReport {
57
+ const report = parseWatchdogReport(
58
+ { watchdogId, reviewedFiles: files, skipped: [], findings },
59
+ `${watchdogId}.json`,
32
60
  );
33
- return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
61
+ if (!report) throw new Error("relatório de teste inválido");
62
+ return report;
34
63
  }
35
64
 
36
65
  const ONE_FINDING = [
37
- {
38
- rule: "Nunca usar any",
39
- severity: "blocking",
40
- file: "src/alpha/f.ts",
41
- line: 2,
42
- message: "any detectado",
43
- action: "tipar",
44
- },
66
+ reportOf(
67
+ "alpha",
68
+ [
69
+ {
70
+ rule: "Nunca usar any",
71
+ severity: "blocking",
72
+ file: "src/alpha/f.ts",
73
+ line: 2,
74
+ message: "any detectado",
75
+ action: "tipar",
76
+ },
77
+ ],
78
+ ["src/alpha/f.ts"],
79
+ ),
45
80
  ];
46
81
 
82
+ /** Remove `--dir` (flag extinta) dos args legados usados nos testes. */
83
+ function stripDir(args: string[]): string[] {
84
+ const out: string[] = [];
85
+ for (let i = 0; i < args.length; i++) {
86
+ if (args[i] === "--dir") {
87
+ i++;
88
+ continue;
89
+ }
90
+ out.push(args[i]);
91
+ }
92
+ return out;
93
+ }
94
+
95
+ /** Executa o comando contra o ambiente ativo. */
47
96
  function run(args: string[]) {
48
- return runFindingsCommand(fakeHoundsmanConfig, args);
97
+ if (!activeEnv) throw new Error("makeRun não foi chamado");
98
+ return runFindingsCommand(
99
+ fakeHoundsmanConfig,
100
+ activeEnv.storage,
101
+ activeEnv.repo,
102
+ stripDir(args),
103
+ );
49
104
  }
50
105
 
51
106
  describe("findings command", () => {
@@ -196,11 +251,18 @@ describe("findings command", () => {
196
251
  expect(missing.output).toContain("não encontrado");
197
252
 
198
253
  const empty = mkdtempSync(join(tmpdir(), "houndsman-empty-"));
254
+ const emptyStorage = new SQLiteStorageProvider(join(empty, "db.sqlite"));
199
255
  try {
200
- const absent = run(["list", "--dir", empty, "--branch", "main"]);
256
+ const absent = runFindingsCommand(
257
+ fakeHoundsmanConfig,
258
+ emptyStorage,
259
+ buildRepoIdentity(null, empty),
260
+ ["list", "--branch", "main"],
261
+ );
201
262
  expect(absent.exitCode).toBe(1);
202
263
  expect(absent.output).toContain("--finalize");
203
264
  } finally {
265
+ emptyStorage.close();
204
266
  rmSync(empty, { recursive: true, force: true });
205
267
  }
206
268
  } finally {
@@ -208,13 +270,14 @@ describe("findings command", () => {
208
270
  }
209
271
  });
210
272
 
211
- test("path devolve o caminho do state.json", () => {
273
+ test("path devolve a referência do storage", () => {
212
274
  const run0 = makeRun(ONE_FINDING);
213
275
  try {
214
276
  run(["list", "--dir", run0.dir, "--branch", "main"]);
215
277
  const result = run(["path", "--dir", run0.dir, "--branch", "main"]);
216
278
  expect(result.exitCode).toBe(0);
217
- expect(result.output.replace(/\\/g, "/")).toContain("main/state.json");
279
+ expect(result.output).toContain("sqlite:");
280
+ expect(result.output).toContain(run0.reviewId);
218
281
  } finally {
219
282
  run0.cleanup();
220
283
  }
@@ -232,20 +295,10 @@ describe("findings command", () => {
232
295
  });
233
296
 
234
297
  test("batch agrupa a fila e devolve o grupo atual + outros grupos", () => {
235
- const dir = mkdtempSync(join(tmpdir(), "houndsman-batch-"));
236
- const outDir = join(dir, "main");
237
- mkdirSync(join(outDir, "findings"), { recursive: true });
238
- writeFileSync(
239
- join(outDir, "classification.json"),
240
- JSON.stringify({ watchdogs: { alpha: ["src/a.ts"], beta: ["src/b.ts"] } }),
241
- );
242
- writeFileSync(
243
- join(outDir, "findings", "alpha.json"),
244
- JSON.stringify({
245
- watchdogId: "alpha",
246
- reviewedFiles: ["src/a.ts"],
247
- skipped: [],
248
- findings: [
298
+ const env = makeRun([
299
+ reportOf(
300
+ "alpha",
301
+ [
249
302
  {
250
303
  rule: "R1",
251
304
  severity: "blocking",
@@ -279,15 +332,11 @@ describe("findings command", () => {
279
332
  action: "a",
280
333
  },
281
334
  ],
282
- }),
283
- );
284
- writeFileSync(
285
- join(outDir, "findings", "beta.json"),
286
- JSON.stringify({
287
- watchdogId: "beta",
288
- reviewedFiles: ["src/b.ts"],
289
- skipped: [],
290
- findings: [
335
+ ["src/a.ts"],
336
+ ),
337
+ reportOf(
338
+ "beta",
339
+ [
291
340
  {
292
341
  rule: "R5",
293
342
  severity: "attention",
@@ -305,26 +354,16 @@ describe("findings command", () => {
305
354
  action: "a",
306
355
  },
307
356
  ],
308
- }),
309
- );
357
+ ["src/b.ts"],
358
+ ),
359
+ ]);
310
360
  try {
311
- const list = JSON.parse(
312
- run(["list", "--dir", dir, "--branch", "main", "--json"]).output,
313
- );
361
+ const list = JSON.parse(run(["list", "--branch", "main", "--json"]).output);
314
362
  for (const item of list) {
315
- run([
316
- "set",
317
- item.id,
318
- "--decision",
319
- "execute",
320
- "--dir",
321
- dir,
322
- "--branch",
323
- "main",
324
- ]);
363
+ run(["set", item.id, "--decision", "execute", "--branch", "main"]);
325
364
  }
326
365
 
327
- const result = run(["batch", "--dir", dir, "--branch", "main", "--json"]);
366
+ const result = run(["batch", "--branch", "main", "--json"]);
328
367
  expect(result.exitCode).toBe(0);
329
368
  const parsed = JSON.parse(result.output);
330
369
  expect(parsed.dimension).toBe("file");
@@ -336,7 +375,7 @@ describe("findings command", () => {
336
375
  expect.objectContaining({ key: "file:src/b.ts", count: 2 }),
337
376
  ]);
338
377
 
339
- const markdown = run(["batch", "--dir", dir, "--branch", "main"]);
378
+ const markdown = run(["batch", "--branch", "main"]);
340
379
  expect(markdown.output).toContain("Grupo atual");
341
380
  expect(markdown.output).toContain("Outros grupos");
342
381
 
@@ -344,25 +383,23 @@ describe("findings command", () => {
344
383
  "batch",
345
384
  "--group",
346
385
  "file:src/b.ts",
347
- "--dir",
348
- dir,
349
386
  "--branch",
350
387
  "main",
351
388
  "--json",
352
389
  ]);
353
390
  expect(JSON.parse(scoped.output).current.key).toBe("file:src/b.ts");
354
391
  } finally {
355
- rmSync(dir, { recursive: true, force: true });
392
+ env.cleanup();
356
393
  }
357
394
  });
358
395
 
359
- test("mutações fazem ping no run.json (status)", () => {
396
+ test("mutações fazem ping no status da review", () => {
360
397
  const run0 = makeRun(ONE_FINDING);
361
398
  try {
362
399
  const id = JSON.parse(
363
400
  run(["list", "--dir", run0.dir, "--branch", "main", "--json"]).output,
364
401
  )[0].id;
365
- const runJson = join(run0.dir, "main", "run.json");
402
+ const status = () => run0.storage.getReview(run0.reviewId)?.status;
366
403
 
367
404
  run([
368
405
  "set",
@@ -374,7 +411,7 @@ describe("findings command", () => {
374
411
  "--branch",
375
412
  "main",
376
413
  ]);
377
- expect(JSON.parse(readFileSync(runJson, "utf8")).status).toBe("review_done");
414
+ expect(status()).toBe("review_done");
378
415
 
379
416
  run([
380
417
  "set",
@@ -386,10 +423,10 @@ describe("findings command", () => {
386
423
  "--branch",
387
424
  "main",
388
425
  ]);
389
- expect(JSON.parse(readFileSync(runJson, "utf8")).status).toBe("fixing");
426
+ expect(status()).toBe("fixing");
390
427
 
391
428
  run(["done", id, "--dir", run0.dir, "--branch", "main"]);
392
- expect(JSON.parse(readFileSync(runJson, "utf8")).status).toBe("fix_done");
429
+ expect(status()).toBe("fix_done");
393
430
  } finally {
394
431
  run0.cleanup();
395
432
  }
@@ -400,55 +437,44 @@ describe("findings verify", () => {
400
437
  const FILE = "src/tests/fixtures/fake-project.ts";
401
438
 
402
439
  /** Run fake apontando para um arquivo real (relativo ao cwd → âncora resolvível). */
403
- function makeVerifyRun(): { dir: string; outDir: string; cleanup: () => void } {
404
- const dir = mkdtempSync(join(tmpdir(), "houndsman-verify-"));
405
- const outDir = join(dir, "main");
406
- mkdirSync(join(outDir, "findings"), { recursive: true });
407
- writeFileSync(
408
- join(outDir, "classification.json"),
409
- JSON.stringify({ watchdogs: { alpha: [FILE] } }),
440
+ function makeVerifyRun(): Env {
441
+ return makeRun(
442
+ [
443
+ reportOf(
444
+ "alpha",
445
+ [
446
+ {
447
+ rule: "regra",
448
+ severity: "attention",
449
+ file: FILE,
450
+ line: 1,
451
+ message: "msg",
452
+ action: "ação",
453
+ },
454
+ ],
455
+ [FILE],
456
+ ),
457
+ ],
458
+ "main",
410
459
  );
411
- writeFileSync(
412
- join(outDir, "findings", "alpha.json"),
413
- JSON.stringify({
414
- watchdogId: "alpha",
415
- reviewedFiles: [FILE],
416
- skipped: [],
417
- findings: [
418
- {
419
- rule: "regra",
420
- severity: "attention",
421
- file: FILE,
422
- line: 1,
423
- message: "msg",
424
- action: "ação",
425
- },
426
- ],
427
- }),
428
- );
429
- return {
430
- dir,
431
- outDir,
432
- cleanup: () => rmSync(dir, { recursive: true, force: true }),
433
- };
434
460
  }
435
461
 
436
462
  test("reporta persisting e grava o verify.md (tier local)", async () => {
437
463
  const run0 = makeVerifyRun();
438
464
  try {
439
- const result = await runVerifyCommand(fakeHoundsmanConfig, [
440
- "verify",
441
- "--dir",
442
- run0.dir,
443
- "--branch",
444
- "main",
445
- "--json",
446
- ]);
465
+ const result = await runVerifyCommand(
466
+ fakeHoundsmanConfig,
467
+ run0.storage,
468
+ run0.repo,
469
+ ["verify", "--branch", "main", "--json"],
470
+ );
447
471
  expect(result.exitCode).toBe(0);
448
472
  const parsed = JSON.parse(result.output);
449
473
  expect(parsed.summary.persisting).toBe(1);
450
474
  expect(parsed.entries[0].basis).toBe("anchor");
451
- expect(existsSync(join(run0.outDir, "verify.md"))).toBe(true);
475
+ expect(
476
+ run0.storage.readArtifact(run0.reviewId, "verify.md")?.content,
477
+ ).toContain("# Verificação");
452
478
  } finally {
453
479
  run0.cleanup();
454
480
  }
@@ -457,17 +483,13 @@ describe("findings verify", () => {
457
483
  test("sem --apply não altera o estado", async () => {
458
484
  const run0 = makeVerifyRun();
459
485
  try {
460
- await runVerifyCommand(fakeHoundsmanConfig, [
486
+ await runVerifyCommand(fakeHoundsmanConfig, run0.storage, run0.repo, [
461
487
  "verify",
462
- "--dir",
463
- run0.dir,
464
488
  "--branch",
465
489
  "main",
466
490
  ]);
467
- const state = JSON.parse(
468
- readFileSync(join(run0.outDir, "state.json"), "utf8"),
469
- ) as { findings: Record<string, { execState: string }> };
470
- const only = Object.values(state.findings)[0];
491
+ const state = run0.storage.readFindings(run0.reviewId);
492
+ const only = Object.values(state?.findings ?? {})[0];
471
493
  expect(only.execState).toBe("pending");
472
494
  } finally {
473
495
  run0.cleanup();
@@ -237,14 +237,14 @@ describe("FindingsStateStore", () => {
237
237
  const project = makeProject({ "src/f.ts": DUPLICATED });
238
238
  const outDir = join(project.cwd, "artifacts");
239
239
  try {
240
- const store = FindingsStateStore.at(outDir, "feature/x");
240
+ const store = new FindingsStateStore(join(outDir, "state.json"), "feature/x");
241
241
  const state = buildStateFromFindings(
242
242
  [merged({ file: "src/f.ts", rule: "r", line: 2 })],
243
243
  { branch: "feature/x", readFile: fileReader(project.cwd) },
244
244
  );
245
245
  store.write(state);
246
246
 
247
- expect(store.filePath).toBe(join(outDir, "feature-x", "state.json"));
247
+ expect(store.filePath).toBe(join(outDir, "state.json"));
248
248
  expect(existsSync(store.filePath)).toBe(true);
249
249
  const read = store.read();
250
250
  expect(read?.slug).toBe("feature-x");
@@ -257,7 +257,10 @@ describe("FindingsStateStore", () => {
257
257
  test("read devolve null quando o arquivo não existe", () => {
258
258
  const project = makeProject({});
259
259
  try {
260
- const store = FindingsStateStore.at(join(project.cwd, "artifacts"), "main");
260
+ const store = new FindingsStateStore(
261
+ join(project.cwd, "artifacts", "state.json"),
262
+ "main",
263
+ );
261
264
  expect(store.read()).toBeNull();
262
265
  expect(store.update(() => {})).toBeTruthy();
263
266
  } finally {
@@ -56,7 +56,6 @@ export type FakeWatchdogId = keyof typeof FAKE_WATCHDOG_DEFINITIONS;
56
56
  export const fakeHoundsmanConfig: HoundsmanConfig<FakeWatchdogId> =
57
57
  defineHoundsmanConfig<FakeWatchdogId>({
58
58
  projectName: "Fake Project",
59
- artifactDir: "tmp/fake-review",
60
59
  watchdogs: FAKE_WATCHDOG_DEFINITIONS,
61
60
  exclusions: [/^dist\//, /\.lock$/],
62
61
  docMappings: [[/^src\//, ["README.md"]]],
@@ -0,0 +1,205 @@
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 { GhClient, type GhRunner } from "../core/services/gh-client";
13
+ import { GitRepository } from "../core/services/git-repository";
14
+ import { ProjectFiles } from "../core/services/project-files";
15
+ import type { ReviewRecord } from "../core/storage/types";
16
+ import { ReviewWorkArea } from "../core/storage/work-area";
17
+
18
+ /** Runner roteado por `args.join(" ")`, registrando as chamadas. */
19
+ function fakeGit(routes: Record<string, GitResult>): {
20
+ calls: string[][];
21
+ runner: GitRunner;
22
+ } {
23
+ const calls: string[][] = [];
24
+ const runner: GitRunner = (args) => {
25
+ calls.push(args);
26
+ return (
27
+ routes[args.join(" ")] ?? {
28
+ ok: false,
29
+ code: 1,
30
+ stdout: "",
31
+ stderr: "rota não mockada",
32
+ }
33
+ );
34
+ };
35
+ return { calls, runner };
36
+ }
37
+
38
+ describe("GitRepository", () => {
39
+ test("resolveSha apara o stdout", () => {
40
+ const { runner } = fakeGit({
41
+ "rev-parse HEAD": { ok: true, code: 0, stdout: "abc123\n", stderr: "" },
42
+ });
43
+ expect(new GitRepository(runner).resolveSha("HEAD")).toBe("abc123");
44
+ });
45
+
46
+ test("currentBranch devolve null quando falha ou está vazio", () => {
47
+ const empty = fakeGit({
48
+ "branch --show-current": { ok: true, code: 0, stdout: "\n", stderr: "" },
49
+ });
50
+ expect(new GitRepository(empty.runner).currentBranch()).toBeNull();
51
+
52
+ const failing = fakeGit({});
53
+ expect(new GitRepository(failing.runner).currentBranch()).toBeNull();
54
+ });
55
+
56
+ test("isAncestor usa o exit code sem lançar", () => {
57
+ const { runner } = fakeGit({
58
+ "merge-base --is-ancestor a b": {
59
+ ok: true,
60
+ code: 0,
61
+ stdout: "",
62
+ stderr: "",
63
+ },
64
+ "merge-base --is-ancestor b a": {
65
+ ok: false,
66
+ code: 1,
67
+ stdout: "",
68
+ stderr: "",
69
+ },
70
+ });
71
+ const git = new GitRepository(runner);
72
+ expect(git.isAncestor("a", "b")).toBe(true);
73
+ expect(git.isAncestor("b", "a")).toBe(false);
74
+ });
75
+
76
+ test("readFileAt devolve null quando o commit não tem o arquivo", () => {
77
+ const { runner } = fakeGit({
78
+ "show abc:src/a.ts": { ok: true, code: 0, stdout: "conteúdo", stderr: "" },
79
+ });
80
+ const git = new GitRepository(runner);
81
+ expect(git.readFileAt("abc", "src/a.ts")).toBe("conteúdo");
82
+ expect(git.readFileAt("abc", "src/missing.ts")).toBeNull();
83
+ });
84
+
85
+ test("diffNameStatus monta o intervalo base...head", () => {
86
+ const { calls, runner } = fakeGit({
87
+ "diff main...HEAD --name-status": {
88
+ ok: true,
89
+ code: 0,
90
+ stdout: "M\tsrc/a.ts\n",
91
+ stderr: "",
92
+ },
93
+ });
94
+ new GitRepository(runner).diffNameStatus("main", "HEAD");
95
+ expect(calls).toEqual([["diff", "main...HEAD", "--name-status"]]);
96
+ });
97
+
98
+ test("diffNumstat e collectDiffSince parseiam churn e patch", () => {
99
+ const patch = [
100
+ "diff --git a/src/a.ts b/src/a.ts",
101
+ "--- a/src/a.ts",
102
+ "+++ b/src/a.ts",
103
+ "@@ -1 +1 @@",
104
+ "-old",
105
+ "+new",
106
+ ].join("\n");
107
+ const { runner } = fakeGit({
108
+ "diff from..to": { ok: true, code: 0, stdout: patch, stderr: "" },
109
+ "diff --numstat -z from..to": {
110
+ ok: true,
111
+ code: 0,
112
+ stdout: "1\t2\tsrc/a.ts\0",
113
+ stderr: "",
114
+ },
115
+ });
116
+ const diff = new GitRepository(runner).collectDiffSince("from", "to");
117
+ expect(diff.churn.get("src/a.ts")).toEqual({ additions: 1, deletions: 2 });
118
+ expect(diff.byFile.get("src/a.ts")).toContain("+new");
119
+ });
120
+ });
121
+
122
+ describe("GhClient", () => {
123
+ test("delega ao runner injetado", () => {
124
+ const calls: string[][] = [];
125
+ const runner: GhRunner = (args) => {
126
+ calls.push(args);
127
+ return { ok: true, code: 0, stdout: "ok", stderr: "" };
128
+ };
129
+ const result = new GhClient(runner).run(["auth", "status"]);
130
+ expect(result.stdout).toBe("ok");
131
+ expect(calls).toEqual([["auth", "status"]]);
132
+ });
133
+ });
134
+
135
+ describe("ProjectFiles", () => {
136
+ test("lê, escreve, lê JSON e resolve relativo ao cwd", () => {
137
+ const cwd = mkdtempSync(join(tmpdir(), "houndsman-files-"));
138
+ try {
139
+ const files = new ProjectFiles(cwd);
140
+ files.write("dir/a.txt", "conteúdo");
141
+ files.write("dir/b.json", JSON.stringify({ ok: true }));
142
+
143
+ expect(files.read("dir/a.txt")).toBe("conteúdo");
144
+ expect(files.read("dir/missing.txt")).toBeNull();
145
+ expect(files.readJson<{ ok: boolean }>("dir/b.json")).toEqual({ ok: true });
146
+ expect(files.readJson("dir/a.txt")).toBeNull();
147
+ expect(files.exists("dir/a.txt")).toBe(true);
148
+ expect(files.reader()("dir/a.txt")).toBe("conteúdo");
149
+ } finally {
150
+ rmSync(cwd, { recursive: true, force: true });
151
+ }
152
+ });
153
+
154
+ test("writeAtomic não deixa arquivo temporário", () => {
155
+ const cwd = mkdtempSync(join(tmpdir(), "houndsman-files-atomic-"));
156
+ try {
157
+ const files = new ProjectFiles(cwd);
158
+ files.writeAtomic("state.json", "{}");
159
+ expect(files.read("state.json")).toBe("{}");
160
+ expect(readdirSync(cwd)).toEqual(["state.json"]);
161
+ } finally {
162
+ rmSync(cwd, { recursive: true, force: true });
163
+ }
164
+ });
165
+ });
166
+
167
+ /** Review fake para testar o layout do staging. */
168
+ function reviewFixture(): ReviewRecord {
169
+ return {
170
+ id: "review-uuid",
171
+ repoKey: "path:/tmp/repo",
172
+ repoRoot: "/tmp/repo",
173
+ branch: "feature/x",
174
+ slug: "feature-x",
175
+ status: null,
176
+ createdAt: "2026-01-01T00:00:00.000Z",
177
+ updatedAt: "2026-01-01T00:00:00.000Z",
178
+ };
179
+ }
180
+
181
+ describe("ReviewWorkArea", () => {
182
+ test("centraliza os caminhos do staging (slug normalizado)", () => {
183
+ const work = new ReviewWorkArea(reviewFixture(), "./work");
184
+ const root = work.root.replace(/\\/g, "/");
185
+ expect(root).toBe("work/review-uuid");
186
+ expect(work.briefsDir.replace(/\\/g, "/")).toBe("work/review-uuid/briefs");
187
+ expect(work.findingsDir.replace(/\\/g, "/")).toBe("work/review-uuid/findings");
188
+ expect(work.findingFile("alpha").replace(/\\/g, "/")).toBe(
189
+ "work/review-uuid/findings/alpha.json",
190
+ );
191
+ expect(work.briefFile("alpha").replace(/\\/g, "/")).toBe(
192
+ "work/review-uuid/briefs/alpha.md",
193
+ );
194
+ });
195
+
196
+ test("contextPaths compartilha a pasta da review", () => {
197
+ const paths = new ReviewWorkArea(reviewFixture(), "./work").contextPaths;
198
+ expect(paths.dir.replace(/\\/g, "/")).toBe("work/review-uuid");
199
+ expect(paths.context.replace(/\\/g, "/")).toBe("work/review-uuid/context.json");
200
+ expect(paths.summary.replace(/\\/g, "/")).toBe(
201
+ "work/review-uuid/context-summary.json",
202
+ );
203
+ expect(paths.brief.replace(/\\/g, "/")).toBe("work/review-uuid/context-brief.md");
204
+ });
205
+ });