@payconductor/houndsman 0.1.4 → 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 (84) 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 +62 -260
  15. package/src/core/arg-parser.ts +48 -0
  16. package/src/core/commands/findings.ts +163 -147
  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 +189 -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 +423 -0
  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/client/App.tsx +174 -49
  64. package/src/server/client/ArtifactsPanel.tsx +2 -2
  65. package/src/server/client/AutoRefresh.tsx +38 -0
  66. package/src/server/client/Filters.tsx +72 -53
  67. package/src/server/client/FindingCard.tsx +212 -71
  68. package/src/server/client/FindingsList.tsx +8 -6
  69. package/src/server/client/RunList.tsx +30 -7
  70. package/src/server/client/SubmitModal.tsx +418 -0
  71. package/src/server/client/SummaryBar.tsx +65 -12
  72. package/src/server/client/constants.ts +9 -12
  73. package/src/server/client/format.ts +40 -0
  74. package/src/server/client/icons.tsx +143 -0
  75. package/src/server/client/types.ts +69 -0
  76. package/src/server/index.ts +316 -222
  77. package/src/server/run-repository.ts +291 -0
  78. package/src/tests/__snapshots__/baseline.test.ts.snap +814 -0
  79. package/src/tests/baseline.test.ts +513 -0
  80. package/src/tests/cli.test.ts +167 -0
  81. package/src/tests/domain.test.ts +328 -0
  82. package/src/tests/github-review.test.ts +320 -0
  83. package/src/tests/infrastructure.test.ts +210 -0
  84. package/src/tests/server.test.ts +117 -0
@@ -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
+ });
@@ -47,6 +47,41 @@ function makeEnv(): {
47
47
  };
48
48
  }
49
49
 
50
+ /** Semeia um `state.json` com um finding inline (tem linha) na run. */
51
+ function seedState(artifactDir: string, branch: string): void {
52
+ writeFileSync(
53
+ join(artifactDir, branch, "state.json"),
54
+ JSON.stringify({
55
+ version: 2,
56
+ branch,
57
+ slug: branch,
58
+ updatedAt: "2026-01-01T00:00:00.000Z",
59
+ order: ["f1"],
60
+ findings: {
61
+ f1: {
62
+ id: "f1",
63
+ fingerprint: "fp1",
64
+ watchdogIds: ["alpha"],
65
+ rule: "Nunca usar any",
66
+ severity: "blocking",
67
+ file: "src/alpha/f.ts",
68
+ line: 2,
69
+ message: "any detectado",
70
+ action: "tipar",
71
+ anchor: null,
72
+ count: 1,
73
+ present: true,
74
+ decision: "pending",
75
+ execState: "pending",
76
+ createdAt: "2026-01-01T00:00:00.000Z",
77
+ updatedAt: "2026-01-01T00:00:00.000Z",
78
+ history: [],
79
+ },
80
+ },
81
+ }),
82
+ );
83
+ }
84
+
50
85
  const get = (handler: (r: Request) => Promise<Response>, path: string) =>
51
86
  handler(new Request(`http://localhost${path}`));
52
87
 
@@ -156,6 +191,88 @@ describe("server API", () => {
156
191
  }
157
192
  });
158
193
 
194
+ test("GET submit-preview resolve a PR e lista os candidatos", async () => {
195
+ const env = makeEnv();
196
+ try {
197
+ const handler = createRequestHandler(env.config, {
198
+ ghRunner: () => ({
199
+ ok: true,
200
+ code: 0,
201
+ stderr: "",
202
+ stdout: JSON.stringify({
203
+ number: 12,
204
+ url: "https://github.com/acme/app/pull/12",
205
+ headRefOid: "deadbeef",
206
+ }),
207
+ }),
208
+ });
209
+ const res = await get(handler, "/api/runs/main/submit-preview");
210
+ expect(res.status).toBe(200);
211
+ const body = (await res.json()) as {
212
+ available: boolean;
213
+ target: { number: number };
214
+ candidates: unknown[];
215
+ };
216
+ expect(body.available).toBe(true);
217
+ expect(body.target.number).toBe(12);
218
+ expect(body.candidates).toHaveLength(1);
219
+ } finally {
220
+ env.cleanup();
221
+ }
222
+ });
223
+
224
+ test("POST submit posta comentários e grava o vínculo no estado", async () => {
225
+ const env = makeEnv();
226
+ try {
227
+ seedState(env.artifactDir, "main");
228
+ const handler = createRequestHandler(env.config, {
229
+ ghRunner: (args) =>
230
+ args[0] === "pr"
231
+ ? {
232
+ ok: true,
233
+ code: 0,
234
+ stderr: "",
235
+ stdout: JSON.stringify({
236
+ number: 12,
237
+ url: "https://github.com/acme/app/pull/12",
238
+ headRefOid: "deadbeef",
239
+ }),
240
+ }
241
+ : {
242
+ ok: true,
243
+ code: 0,
244
+ stderr: "",
245
+ stdout: JSON.stringify({
246
+ id: 501,
247
+ html_url: "https://x/501",
248
+ }),
249
+ },
250
+ });
251
+ const res = await handler(
252
+ new Request("http://localhost/api/runs/main/submit", {
253
+ method: "POST",
254
+ headers: { "content-type": "application/json" },
255
+ body: JSON.stringify({}),
256
+ }),
257
+ );
258
+ expect(res.status).toBe(200);
259
+ const body = (await res.json()) as {
260
+ available: boolean;
261
+ outcome: { posted: { kind: string }[] };
262
+ };
263
+ expect(body.available).toBe(true);
264
+ expect(body.outcome.posted).toHaveLength(1);
265
+ expect(body.outcome.posted[0].kind).toBe("line");
266
+
267
+ const after = (await (
268
+ await get(handler, "/api/runs/main/findings?all=1")
269
+ ).json()) as { findings: { github?: { commentId: number } }[] };
270
+ expect(after.findings[0].github?.commentId).toBe(501);
271
+ } finally {
272
+ env.cleanup();
273
+ }
274
+ });
275
+
159
276
  test("artifacts serve arquivos da run e barra o resto", async () => {
160
277
  const env = makeEnv();
161
278
  try {