@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.
- package/README.md +18 -6
- 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 +40 -0
- package/src/cli/commands/doctor-command.ts +60 -0
- package/src/cli/commands/findings-command.ts +38 -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 +21 -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 +47 -0
- package/src/cli/index.ts +60 -268
- package/src/core/arg-parser.ts +48 -0
- package/src/core/commands/context.ts +29 -13
- package/src/core/commands/findings.ts +185 -260
- package/src/core/commands/review.ts +21 -39
- package/src/core/commands/scaffold.ts +189 -80
- 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 +24 -25
- 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 +65 -0
- package/src/core/engine/review-scope.ts +95 -0
- package/src/core/engine/review-session.ts +79 -0
- package/src/core/file-classifier.ts +11 -67
- package/src/core/findings-state.ts +193 -100
- package/src/core/git-revision.ts +14 -40
- package/src/core/git-runner.ts +46 -0
- package/src/core/github-review.ts +4 -33
- package/src/core/jev/jev-candidates.ts +49 -35
- package/src/core/orchestrator.ts +247 -421
- package/src/core/paths.ts +26 -11
- package/src/core/pr-context.ts +39 -51
- package/src/core/project-config.ts +56 -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 +123 -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 +8 -0
- package/src/core/run-status.ts +29 -59
- 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 +139 -0
- package/src/core/services/project-files.ts +91 -0
- package/src/core/services/verifier.ts +58 -0
- package/src/core/storage/factory.ts +24 -0
- package/src/core/storage/file/file-storage.ts +269 -0
- package/src/core/storage/paths.ts +64 -0
- package/src/core/storage/repo-identity.ts +70 -0
- package/src/core/storage/sqlite/mappers.ts +160 -0
- package/src/core/storage/sqlite/schema.ts +108 -0
- package/src/core/storage/sqlite/sqlite-storage.ts +427 -0
- package/src/core/storage/types.ts +143 -0
- package/src/core/storage/work-area.ts +191 -0
- package/src/core/verify.ts +2 -1
- package/src/core/watchdog-registry.ts +58 -16
- package/src/index.ts +57 -6
- package/src/server/artifacts.ts +8 -61
- package/src/server/client/ArtifactsPanel.tsx +137 -23
- package/src/server/client/FindingCard.tsx +37 -6
- package/src/server/client/MarkdownText.tsx +101 -0
- package/src/server/client/SubmitModal.tsx +9 -4
- package/src/server/client/code-highlight.ts +422 -0
- package/src/server/client/constants.ts +130 -24
- package/src/server/client/json-highlight.ts +55 -0
- package/src/server/client/markdown.ts +153 -0
- package/src/server/client/types.ts +23 -0
- package/src/server/index.ts +155 -245
- package/src/server/run-repository.ts +299 -0
- package/src/skill/OUTPUT-FORMAT.md +6 -4
- package/src/skill/README.md +22 -14
- package/src/skill/SETUP.md +4 -4
- package/src/skill/fix.md +5 -5
- package/src/skill/hunt.md +27 -18
- package/src/skill/train.md +1 -1
- package/src/skill/verify.md +5 -5
- package/src/tests/__snapshots__/baseline.test.ts.snap +825 -0
- package/src/tests/baseline.test.ts +555 -493
- package/src/tests/cli.test.ts +40 -19
- package/src/tests/code-highlight.test.ts +123 -0
- package/src/tests/domain.test.ts +328 -0
- package/src/tests/findings-commands.test.ts +153 -131
- package/src/tests/findings-state.test.ts +6 -3
- package/src/tests/fixtures/fake-project.ts +0 -1
- package/src/tests/infrastructure.test.ts +205 -0
- package/src/tests/markdown.test.ts +89 -0
- package/src/tests/review-scripts.test.ts +8 -5
- package/src/tests/run-status.test.ts +33 -23
- package/src/tests/server.test.ts +108 -75
- 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
|
-
/**
|
|
16
|
-
|
|
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
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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", "--
|
|
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", "--
|
|
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
|
-
|
|
392
|
+
env.cleanup();
|
|
356
393
|
}
|
|
357
394
|
});
|
|
358
395
|
|
|
359
|
-
test("mutações fazem ping no
|
|
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
|
|
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(
|
|
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(
|
|
426
|
+
expect(status()).toBe("fixing");
|
|
390
427
|
|
|
391
428
|
run(["done", id, "--dir", run0.dir, "--branch", "main"]);
|
|
392
|
-
expect(
|
|
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():
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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(
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
run0.
|
|
443
|
-
"--branch",
|
|
444
|
-
|
|
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(
|
|
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 =
|
|
468
|
-
|
|
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
|
|
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, "
|
|
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
|
|
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
|
+
});
|