@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.
- package/package.json +1 -1
- package/src/cli/arg-parser.ts +8 -0
- package/src/cli/commands/command.ts +23 -0
- package/src/cli/commands/context-command.ts +38 -0
- package/src/cli/commands/doctor-command.ts +55 -0
- package/src/cli/commands/findings-command.ts +28 -0
- package/src/cli/commands/help-command.ts +13 -0
- package/src/cli/commands/registry.ts +34 -0
- package/src/cli/commands/review-command.ts +22 -0
- package/src/cli/commands/server-command.ts +37 -0
- package/src/cli/commands/setup-command.ts +34 -0
- package/src/cli/commands/watchdog-command.ts +34 -0
- package/src/cli/help.ts +43 -0
- package/src/cli/index.ts +62 -260
- package/src/core/arg-parser.ts +48 -0
- package/src/core/commands/findings.ts +163 -147
- package/src/core/commands/review.ts +18 -35
- package/src/core/commands/scaffold.ts +174 -64
- package/src/core/commands/setup.ts +16 -11
- package/src/core/commands/update.ts +7 -7
- package/src/core/commands/watchdog.ts +8 -80
- package/src/core/config.ts +9 -19
- package/src/core/defaults.ts +20 -0
- package/src/core/diff-source.ts +1 -13
- package/src/core/doc-discoverer.ts +25 -15
- package/src/core/domain/budget.ts +42 -0
- package/src/core/domain/classification.ts +94 -0
- package/src/core/domain/diff.ts +53 -0
- package/src/core/domain/watchdog.ts +96 -0
- package/src/core/engine/carryover.ts +67 -0
- package/src/core/engine/houndsman-engine.ts +51 -0
- package/src/core/engine/review-scope.ts +95 -0
- package/src/core/engine/review-session.ts +51 -0
- package/src/core/file-classifier.ts +11 -67
- package/src/core/findings-state.ts +189 -38
- package/src/core/git-revision.ts +14 -40
- package/src/core/git-runner.ts +46 -0
- package/src/core/github-review.ts +423 -0
- package/src/core/orchestrator.ts +116 -337
- package/src/core/paths.ts +9 -0
- package/src/core/pr-context.ts +28 -54
- package/src/core/project-config.ts +57 -0
- package/src/core/renderers/batch-presenter.ts +40 -0
- package/src/core/renderers/brief-renderer.ts +23 -0
- package/src/core/renderers/findings-presenter.ts +74 -0
- package/src/core/renderers/manifest-writer.ts +112 -0
- package/src/core/renderers/report-renderer.ts +66 -0
- package/src/core/renderers/verify-presenter.ts +50 -0
- package/src/core/renderers/watchdog-presenter.ts +100 -0
- package/src/core/report-builder.ts +12 -7
- package/src/core/run-layout.ts +108 -0
- package/src/core/run-status.ts +6 -11
- package/src/core/services/clock.ts +12 -0
- package/src/core/services/file-classifier.ts +34 -0
- package/src/core/services/fix-batch-builder.ts +13 -0
- package/src/core/services/gh-client.ts +54 -0
- package/src/core/services/git-repository.ts +123 -0
- package/src/core/services/project-files.ts +79 -0
- package/src/core/services/verifier.ts +58 -0
- package/src/core/verify.ts +2 -1
- package/src/core/watchdog-registry.ts +58 -16
- package/src/index.ts +31 -1
- package/src/server/client/App.tsx +174 -49
- package/src/server/client/ArtifactsPanel.tsx +2 -2
- package/src/server/client/AutoRefresh.tsx +38 -0
- package/src/server/client/Filters.tsx +72 -53
- package/src/server/client/FindingCard.tsx +212 -71
- package/src/server/client/FindingsList.tsx +8 -6
- package/src/server/client/RunList.tsx +30 -7
- package/src/server/client/SubmitModal.tsx +418 -0
- package/src/server/client/SummaryBar.tsx +65 -12
- package/src/server/client/constants.ts +9 -12
- package/src/server/client/format.ts +40 -0
- package/src/server/client/icons.tsx +143 -0
- package/src/server/client/types.ts +69 -0
- package/src/server/index.ts +316 -222
- package/src/server/run-repository.ts +291 -0
- package/src/tests/__snapshots__/baseline.test.ts.snap +814 -0
- package/src/tests/baseline.test.ts +513 -0
- package/src/tests/cli.test.ts +167 -0
- package/src/tests/domain.test.ts +328 -0
- package/src/tests/github-review.test.ts +320 -0
- package/src/tests/infrastructure.test.ts +210 -0
- 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
|
+
});
|
package/src/tests/server.test.ts
CHANGED
|
@@ -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 {
|