@payconductor/houndsman 0.1.0 → 0.1.3
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 +68 -89
- package/assets/logo-legacy.png +0 -0
- package/assets/logo.png +0 -0
- package/package.json +3 -2
- package/src/cli/index.ts +101 -10
- package/src/core/assets.ts +11 -0
- package/src/core/brief.ts +27 -5
- package/src/core/commands/findings.ts +618 -0
- package/src/core/commands/review.ts +2 -0
- package/src/core/commands/scaffold.ts +33 -44
- package/src/core/config.ts +42 -0
- package/src/core/diff-source.ts +140 -0
- package/src/core/file-classifier.ts +50 -2
- package/src/core/finding-anchor.ts +147 -0
- package/src/core/findings-state.ts +556 -0
- package/src/core/fix-batch.ts +236 -0
- package/src/core/git-revision.ts +105 -0
- package/src/core/jev/index.ts +1 -0
- package/src/core/jev/jev-client.ts +72 -11
- package/src/core/jev/jev-detector.ts +21 -19
- package/src/core/jev/jev-verify.ts +178 -0
- package/src/core/orchestrator.ts +362 -23
- package/src/core/paths.ts +9 -0
- package/src/core/registry-check.ts +15 -0
- package/src/core/report-builder.ts +118 -9
- package/src/core/run-status.ts +123 -0
- package/src/core/types.ts +10 -0
- package/src/core/verify.ts +175 -0
- package/src/core/watchdog-registry.ts +1 -2
- package/src/index.ts +78 -2
- package/src/server/artifacts.ts +67 -0
- package/src/server/client/App.tsx +158 -0
- package/src/server/client/ArtifactsPanel.tsx +48 -0
- package/src/server/client/Filters.tsx +83 -0
- package/src/server/client/FindingCard.tsx +122 -0
- package/src/server/client/FindingsList.tsx +54 -0
- package/src/server/client/RunList.tsx +52 -0
- package/src/server/client/SummaryBar.tsx +21 -0
- package/src/server/client/api.ts +37 -0
- package/src/server/client/constants.ts +94 -0
- package/src/server/client/index.tsx +12 -0
- package/src/server/client/types.ts +52 -0
- package/src/server/client-build.ts +47 -0
- package/src/server/index.ts +376 -0
- package/src/skill/AUDIT-PROTOCOL.md +2 -2
- package/src/skill/README.md +43 -11
- package/src/skill/SETUP.md +16 -4
- package/src/skill/SKILL.md +48 -0
- package/src/skill/fix.md +206 -0
- package/src/skill/{houndsman.md → hunt.md} +83 -33
- package/src/skill/{houndsman-update.md → train.md} +12 -12
- package/src/skill/verify.md +70 -0
- package/src/tests/findings-anchor.test.ts +120 -0
- package/src/tests/findings-commands.test.ts +476 -0
- package/src/tests/findings-state.test.ts +325 -0
- package/src/tests/fix-batch.test.ts +257 -0
- package/src/tests/review-scripts.test.ts +299 -0
- package/src/tests/run-status.test.ts +147 -0
- package/src/tests/scaffold.test.ts +59 -0
- package/src/tests/server.test.ts +242 -0
- package/src/tests/verify.test.ts +170 -0
|
@@ -6,13 +6,23 @@ import type { AxiosInstance } from "axios";
|
|
|
6
6
|
import { renderBrief } from "../core/brief";
|
|
7
7
|
import { parseReviewArgs } from "../core/commands/review";
|
|
8
8
|
import { renderWatchdogContext } from "../core/commands/watchdog";
|
|
9
|
+
import {
|
|
10
|
+
DEFAULT_JEV_CONCURRENCY,
|
|
11
|
+
DEFAULT_MAX_FILES_PER_BRIEF,
|
|
12
|
+
resolveJevConcurrency,
|
|
13
|
+
resolveMaxFilesPerBrief,
|
|
14
|
+
} from "../core/config";
|
|
15
|
+
import { parseDiffIndex, parseNumstat } from "../core/diff-source";
|
|
9
16
|
import { discoverDocs } from "../core/doc-discoverer";
|
|
10
17
|
import {
|
|
11
18
|
type ClassificationResult,
|
|
19
|
+
churnOf,
|
|
12
20
|
classifyEntries,
|
|
13
21
|
isExcludedPath,
|
|
14
22
|
matchWatchdogs,
|
|
15
23
|
parseEntries,
|
|
24
|
+
selectByBudget,
|
|
25
|
+
sortByRisk,
|
|
16
26
|
} from "../core/file-classifier";
|
|
17
27
|
import {
|
|
18
28
|
type JevCandidatesFile,
|
|
@@ -27,6 +37,7 @@ import {
|
|
|
27
37
|
dedupeFindings,
|
|
28
38
|
parseContextSummary,
|
|
29
39
|
parseWatchdogReport,
|
|
40
|
+
renderDomainReport,
|
|
30
41
|
renderReport,
|
|
31
42
|
sanitizeBranch,
|
|
32
43
|
type WatchdogReport,
|
|
@@ -306,6 +317,136 @@ describe("isExcludedPath", () => {
|
|
|
306
317
|
});
|
|
307
318
|
});
|
|
308
319
|
|
|
320
|
+
// ---------------------------------------------------------------------------
|
|
321
|
+
// diff-source (diff único: patch + churn)
|
|
322
|
+
// ---------------------------------------------------------------------------
|
|
323
|
+
|
|
324
|
+
describe("diff-source", () => {
|
|
325
|
+
test("parseNumstat lê adições/remoções por arquivo", () => {
|
|
326
|
+
const map = parseNumstat("3\t1\tsrc/a.ts\0");
|
|
327
|
+
expect(map.get("src/a.ts")).toEqual({ additions: 3, deletions: 1 });
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test("parseNumstat trata rename (path vazio) e binário ('-')", () => {
|
|
331
|
+
const nul = String.fromCharCode(0);
|
|
332
|
+
const raw = [
|
|
333
|
+
"2 0 ",
|
|
334
|
+
"src/old.ts",
|
|
335
|
+
"src/new.ts",
|
|
336
|
+
"10 - dist/x.png",
|
|
337
|
+
"",
|
|
338
|
+
].join(nul);
|
|
339
|
+
const map = parseNumstat(raw);
|
|
340
|
+
expect(map.get("src/new.ts")).toEqual({ additions: 2, deletions: 0 });
|
|
341
|
+
expect(map.get("dist/x.png")).toEqual({ additions: 10, deletions: 0 });
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test("parseDiffIndex fatia o patch e extrai o path novo (renames)", () => {
|
|
345
|
+
const patch = [
|
|
346
|
+
"diff --git a/src/a.ts b/src/a.ts",
|
|
347
|
+
"index 111..222 100644",
|
|
348
|
+
"--- a/src/a.ts",
|
|
349
|
+
"+++ b/src/a.ts",
|
|
350
|
+
"@@ -1 +1 @@",
|
|
351
|
+
"-old",
|
|
352
|
+
"+new",
|
|
353
|
+
"diff --git a/src/old.ts b/src/new.ts",
|
|
354
|
+
"similarity index 90%",
|
|
355
|
+
"rename from src/old.ts",
|
|
356
|
+
"rename to src/new.ts",
|
|
357
|
+
"--- a/src/old.ts",
|
|
358
|
+
"+++ b/src/new.ts",
|
|
359
|
+
"@@ -1 +1 @@",
|
|
360
|
+
"-a",
|
|
361
|
+
"+b",
|
|
362
|
+
].join("\n");
|
|
363
|
+
|
|
364
|
+
const map = parseDiffIndex(patch);
|
|
365
|
+
expect([...map.keys()]).toEqual(["src/a.ts", "src/new.ts"]);
|
|
366
|
+
expect(map.get("src/a.ts")).toContain("+new");
|
|
367
|
+
expect(map.get("src/new.ts")).toContain("+b");
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
test("parseDiffIndex não confunde linha de conteúdo com o header", () => {
|
|
371
|
+
const patch = [
|
|
372
|
+
"diff --git a/x.ts b/x.ts",
|
|
373
|
+
"--- a/x.ts",
|
|
374
|
+
"+++ b/x.ts",
|
|
375
|
+
"@@ -1 +1,2 @@",
|
|
376
|
+
" keep",
|
|
377
|
+
"+++ b/fake.ts",
|
|
378
|
+
].join("\n");
|
|
379
|
+
expect([...parseDiffIndex(patch).keys()]).toEqual(["x.ts"]);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test("parseDiffIndex respeita o teto por arquivo", () => {
|
|
383
|
+
const patch = "diff --git a/x.ts b/x.ts\n--- a/x.ts\n+++ b/x.ts\n@@ -1 +1 @@\n+a";
|
|
384
|
+
expect(parseDiffIndex(patch, 10).get("x.ts")).toHaveLength(10);
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
// ---------------------------------------------------------------------------
|
|
389
|
+
// triage (churn + orçamento por brief)
|
|
390
|
+
// ---------------------------------------------------------------------------
|
|
391
|
+
|
|
392
|
+
describe("triage", () => {
|
|
393
|
+
const churn = {
|
|
394
|
+
"a.ts": { additions: 1, deletions: 0 },
|
|
395
|
+
"b.ts": { additions: 50, deletions: 50 },
|
|
396
|
+
"c.ts": { additions: 10, deletions: 0 },
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
test("churnOf soma adições/remoções e trata ausente", () => {
|
|
400
|
+
expect(churnOf("b.ts", churn)).toBe(100);
|
|
401
|
+
expect(churnOf("ausente.ts", churn)).toBe(0);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
test("sortByRisk ordena por churn desc", () => {
|
|
405
|
+
expect(sortByRisk(["a.ts", "b.ts", "c.ts"], churn)).toEqual([
|
|
406
|
+
"b.ts",
|
|
407
|
+
"c.ts",
|
|
408
|
+
"a.ts",
|
|
409
|
+
]);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
test("selectByBudget mantém os de maior risco e devolve o excedente", () => {
|
|
413
|
+
const { selected, overflow } = selectByBudget(["a.ts", "b.ts", "c.ts"], churn, 2);
|
|
414
|
+
expect(selected).toEqual(["b.ts", "c.ts"]);
|
|
415
|
+
expect(overflow).toEqual(["a.ts"]);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
test("maxFiles não-positivo desativa o orçamento", () => {
|
|
419
|
+
const { selected, overflow } = selectByBudget(["a.ts", "b.ts"], churn, 0);
|
|
420
|
+
expect(selected).toHaveLength(2);
|
|
421
|
+
expect(overflow).toEqual([]);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
test("resolveMaxFilesPerBrief usa default e sobrepõe", () => {
|
|
425
|
+
expect(resolveMaxFilesPerBrief(fakeHoundsmanConfig)).toBe(
|
|
426
|
+
DEFAULT_MAX_FILES_PER_BRIEF,
|
|
427
|
+
);
|
|
428
|
+
expect(
|
|
429
|
+
resolveMaxFilesPerBrief({
|
|
430
|
+
...fakeHoundsmanConfig,
|
|
431
|
+
budgets: { maxFilesPerBrief: 5 },
|
|
432
|
+
}),
|
|
433
|
+
).toBe(5);
|
|
434
|
+
expect(
|
|
435
|
+
resolveMaxFilesPerBrief({
|
|
436
|
+
...fakeHoundsmanConfig,
|
|
437
|
+
budgets: { maxFilesPerBrief: 0 },
|
|
438
|
+
}),
|
|
439
|
+
).toBe(DEFAULT_MAX_FILES_PER_BRIEF);
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
test("resolveJevConcurrency usa default e sobrepõe", () => {
|
|
443
|
+
expect(resolveJevConcurrency(fakeHoundsmanConfig)).toBe(DEFAULT_JEV_CONCURRENCY);
|
|
444
|
+
expect(
|
|
445
|
+
resolveJevConcurrency({ ...fakeHoundsmanConfig, concurrency: { jev: 9 } }),
|
|
446
|
+
).toBe(9);
|
|
447
|
+
});
|
|
448
|
+
});
|
|
449
|
+
|
|
309
450
|
// ---------------------------------------------------------------------------
|
|
310
451
|
// doc-discoverer
|
|
311
452
|
// ---------------------------------------------------------------------------
|
|
@@ -440,6 +581,79 @@ describe("report-builder", () => {
|
|
|
440
581
|
);
|
|
441
582
|
expect(md).toContain("Watchdog Alpha");
|
|
442
583
|
});
|
|
584
|
+
|
|
585
|
+
test("renderReport agrupa achados por arquivo", () => {
|
|
586
|
+
const merged = [
|
|
587
|
+
{
|
|
588
|
+
rule: "R1",
|
|
589
|
+
severity: "blocking" as const,
|
|
590
|
+
file: "a.ts",
|
|
591
|
+
line: 1,
|
|
592
|
+
message: "m",
|
|
593
|
+
action: "a",
|
|
594
|
+
watchdogs: ["alpha"],
|
|
595
|
+
},
|
|
596
|
+
{
|
|
597
|
+
rule: "R2",
|
|
598
|
+
severity: "blocking" as const,
|
|
599
|
+
file: "b.ts",
|
|
600
|
+
line: 2,
|
|
601
|
+
message: "m",
|
|
602
|
+
action: "a",
|
|
603
|
+
watchdogs: ["alpha"],
|
|
604
|
+
},
|
|
605
|
+
];
|
|
606
|
+
const md = renderReport(
|
|
607
|
+
"main",
|
|
608
|
+
[{ watchdogId: "alpha", reviewedFiles: [], skipped: [], findings: [] }],
|
|
609
|
+
merged,
|
|
610
|
+
{},
|
|
611
|
+
);
|
|
612
|
+
expect(md).toContain("### `a.ts`");
|
|
613
|
+
expect(md).toContain("### `b.ts`");
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
test("renderReport indexa os relatórios por domínio", () => {
|
|
617
|
+
const md = renderReport(
|
|
618
|
+
"main",
|
|
619
|
+
[{ watchdogId: "alpha", reviewedFiles: [], skipped: [], findings: [] }],
|
|
620
|
+
[],
|
|
621
|
+
{},
|
|
622
|
+
undefined,
|
|
623
|
+
undefined,
|
|
624
|
+
undefined,
|
|
625
|
+
[
|
|
626
|
+
{
|
|
627
|
+
id: "alpha",
|
|
628
|
+
name: "Alpha",
|
|
629
|
+
file: "review.alpha.md",
|
|
630
|
+
findings: 2,
|
|
631
|
+
reviewedFiles: 3,
|
|
632
|
+
expectedFiles: 5,
|
|
633
|
+
},
|
|
634
|
+
],
|
|
635
|
+
);
|
|
636
|
+
expect(md).toContain("## Relatórios por domínio");
|
|
637
|
+
expect(md).toContain("review.alpha.md");
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
test("renderDomainReport mostra cobertura e não revisados", () => {
|
|
641
|
+
const md = renderDomainReport(
|
|
642
|
+
"main",
|
|
643
|
+
{
|
|
644
|
+
watchdogId: "alpha",
|
|
645
|
+
reviewedFiles: ["a.ts"],
|
|
646
|
+
skipped: ["b.ts"],
|
|
647
|
+
findings: [],
|
|
648
|
+
},
|
|
649
|
+
[],
|
|
650
|
+
2,
|
|
651
|
+
null,
|
|
652
|
+
);
|
|
653
|
+
expect(md).toContain("1/2");
|
|
654
|
+
expect(md).toContain("Não revisados");
|
|
655
|
+
expect(md).toContain("Nenhum achado");
|
|
656
|
+
});
|
|
443
657
|
});
|
|
444
658
|
|
|
445
659
|
// ---------------------------------------------------------------------------
|
|
@@ -571,6 +785,24 @@ describe("houndsman brief", () => {
|
|
|
571
785
|
expect(brief).toContain("**Objetivo:** Entregar X");
|
|
572
786
|
});
|
|
573
787
|
|
|
788
|
+
test("mostra churn e lista arquivos fora do orçamento", () => {
|
|
789
|
+
const brief = renderBrief(alphaWatchdog, {
|
|
790
|
+
watchdogId: "alpha",
|
|
791
|
+
files: ["src/alpha/keep.ts"],
|
|
792
|
+
overflow: ["src/alpha/skip.ts"],
|
|
793
|
+
churn: { "src/alpha/keep.ts": { additions: 12, deletions: 3 } },
|
|
794
|
+
branch: "feature/x",
|
|
795
|
+
outputPath: "o.json",
|
|
796
|
+
outputFormatPath: OUTPUT_FORMAT,
|
|
797
|
+
docs: [],
|
|
798
|
+
statuses: { "src/alpha/keep.ts": { status: "modified" } },
|
|
799
|
+
deleted: [],
|
|
800
|
+
});
|
|
801
|
+
expect(brief).toContain("+12/−3");
|
|
802
|
+
expect(brief).toContain("Fora do orçamento");
|
|
803
|
+
expect(brief).toContain("src/alpha/skip.ts");
|
|
804
|
+
});
|
|
805
|
+
|
|
574
806
|
test("hook custom de renderBrief é usado no lugar do default", () => {
|
|
575
807
|
const custom = { ...alphaWatchdog, renderBrief: () => ["# Custom"] };
|
|
576
808
|
const brief = renderBrief(custom, {
|
|
@@ -691,6 +923,72 @@ describe("jev-client", () => {
|
|
|
691
923
|
});
|
|
692
924
|
});
|
|
693
925
|
|
|
926
|
+
describe("jev-client — validateCredential", () => {
|
|
927
|
+
/** Erro no formato do axios (isAxiosError + response.status). */
|
|
928
|
+
function axiosError(status: number): Error {
|
|
929
|
+
return Object.assign(new Error(`HTTP ${status}`), {
|
|
930
|
+
isAxiosError: true,
|
|
931
|
+
response: { status, data: "unauthorized" },
|
|
932
|
+
});
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
test("sem chave → unreachable e não faz chamada", async () => {
|
|
936
|
+
let called = false;
|
|
937
|
+
const http = {
|
|
938
|
+
post: async () => {
|
|
939
|
+
called = true;
|
|
940
|
+
return { data: { model: "x", answers: {} } };
|
|
941
|
+
},
|
|
942
|
+
} as unknown as AxiosInstance;
|
|
943
|
+
const check = await new JevClient({ apiKey: "", http }).validateCredential();
|
|
944
|
+
expect(check.ok).toBe(false);
|
|
945
|
+
if (!check.ok) expect(check.kind).toBe("unreachable");
|
|
946
|
+
expect(called).toBe(false);
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
test("2xx → credencial válida com o modelo", async () => {
|
|
950
|
+
const http = {
|
|
951
|
+
post: async () => ({ data: { model: "jev-test", answers: {} } }),
|
|
952
|
+
} as unknown as AxiosInstance;
|
|
953
|
+
const check = await new JevClient({ apiKey: "k", http }).validateCredential();
|
|
954
|
+
expect(check).toEqual({ ok: true, model: "jev-test" });
|
|
955
|
+
});
|
|
956
|
+
|
|
957
|
+
test("401 → credencial inválida", async () => {
|
|
958
|
+
const http = {
|
|
959
|
+
post: async () => {
|
|
960
|
+
throw axiosError(401);
|
|
961
|
+
},
|
|
962
|
+
} as unknown as AxiosInstance;
|
|
963
|
+
const check = await new JevClient({ apiKey: "k", http }).validateCredential();
|
|
964
|
+
expect(check.ok).toBe(false);
|
|
965
|
+
if (!check.ok) expect(check.kind).toBe("invalid");
|
|
966
|
+
});
|
|
967
|
+
|
|
968
|
+
test("falha de rede → indeterminada (unreachable)", async () => {
|
|
969
|
+
const http = {
|
|
970
|
+
post: async () => {
|
|
971
|
+
throw new Error("network down");
|
|
972
|
+
},
|
|
973
|
+
} as unknown as AxiosInstance;
|
|
974
|
+
const check = await new JevClient({ apiKey: "k", http }).validateCredential();
|
|
975
|
+
expect(check.ok).toBe(false);
|
|
976
|
+
if (!check.ok) expect(check.kind).toBe("unreachable");
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
test("evaluate preserva o status HTTP no JevError", async () => {
|
|
980
|
+
const http = {
|
|
981
|
+
post: async () => {
|
|
982
|
+
throw axiosError(403);
|
|
983
|
+
},
|
|
984
|
+
} as unknown as AxiosInstance;
|
|
985
|
+
const client = new JevClient({ apiKey: "k", http });
|
|
986
|
+
await expect(
|
|
987
|
+
client.evaluate({ state: "s", questions: {} }),
|
|
988
|
+
).rejects.toMatchObject({ status: 403 });
|
|
989
|
+
});
|
|
990
|
+
});
|
|
991
|
+
|
|
694
992
|
// ---------------------------------------------------------------------------
|
|
695
993
|
// Jev — detector (fail-open)
|
|
696
994
|
// ---------------------------------------------------------------------------
|
|
@@ -699,6 +997,7 @@ describe("jev-detector", () => {
|
|
|
699
997
|
const classification: ClassificationResult = {
|
|
700
998
|
watchdogs: { alpha: ["src/alpha/x.ts"] },
|
|
701
999
|
files: { "src/alpha/x.ts": { status: "modified" } },
|
|
1000
|
+
churn: {},
|
|
702
1001
|
unclassified: [],
|
|
703
1002
|
excluded: [],
|
|
704
1003
|
deleted: [],
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import type { FindingState, FindingsStateFile } from "../core/findings-state";
|
|
6
|
+
import {
|
|
7
|
+
pingFindingsStatus,
|
|
8
|
+
readRunStatus,
|
|
9
|
+
statusAfterFindingsChange,
|
|
10
|
+
writeRunStatus,
|
|
11
|
+
} from "../core/run-status";
|
|
12
|
+
|
|
13
|
+
function findingState(partial: Partial<FindingState>): FindingState {
|
|
14
|
+
return {
|
|
15
|
+
id: partial.id ?? "id",
|
|
16
|
+
fingerprint: partial.fingerprint ?? "fp",
|
|
17
|
+
watchdogIds: partial.watchdogIds ?? ["alpha"],
|
|
18
|
+
rule: partial.rule ?? "r",
|
|
19
|
+
severity: partial.severity ?? "attention",
|
|
20
|
+
file: partial.file ?? "a.ts",
|
|
21
|
+
line: partial.line ?? 1,
|
|
22
|
+
message: partial.message ?? "m",
|
|
23
|
+
action: partial.action ?? "a",
|
|
24
|
+
evidence: partial.evidence,
|
|
25
|
+
anchor: null,
|
|
26
|
+
count: partial.count ?? 1,
|
|
27
|
+
present: partial.present ?? true,
|
|
28
|
+
decision: partial.decision ?? "execute",
|
|
29
|
+
comment: partial.comment,
|
|
30
|
+
execState: partial.execState ?? "pending",
|
|
31
|
+
createdAt: "",
|
|
32
|
+
updatedAt: "",
|
|
33
|
+
history: [],
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function stateOf(findings: FindingState[]): FindingsStateFile {
|
|
38
|
+
return {
|
|
39
|
+
version: 2,
|
|
40
|
+
branch: "feature/x",
|
|
41
|
+
slug: "feature-x",
|
|
42
|
+
updatedAt: "",
|
|
43
|
+
order: findings.map((item) => item.id),
|
|
44
|
+
findings: Object.fromEntries(findings.map((item) => [item.id, item])),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Diretório temporário isolado. */
|
|
49
|
+
function withTempDir(fn: (dir: string) => void): void {
|
|
50
|
+
const dir = mkdtempSync(join(tmpdir(), "houndsman-run-status-"));
|
|
51
|
+
try {
|
|
52
|
+
fn(dir);
|
|
53
|
+
} finally {
|
|
54
|
+
rmSync(dir, { recursive: true, force: true });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe("run-status — arquivo", () => {
|
|
59
|
+
test("grava, lê e preserva/restaura o startedAt", () => {
|
|
60
|
+
withTempDir((dir) => {
|
|
61
|
+
writeRunStatus(dir, {
|
|
62
|
+
branch: "feature/x",
|
|
63
|
+
status: "reviewing",
|
|
64
|
+
resetStartedAt: true,
|
|
65
|
+
now: "2026-01-01T00:00:00.000Z",
|
|
66
|
+
});
|
|
67
|
+
const first = readRunStatus(dir);
|
|
68
|
+
expect(first?.status).toBe("reviewing");
|
|
69
|
+
expect(first?.slug).toBe("feature-x");
|
|
70
|
+
expect(first?.startedAt).toBe("2026-01-01T00:00:00.000Z");
|
|
71
|
+
|
|
72
|
+
writeRunStatus(dir, {
|
|
73
|
+
branch: "feature/x",
|
|
74
|
+
status: "review_done",
|
|
75
|
+
now: "2026-01-01T00:10:00.000Z",
|
|
76
|
+
});
|
|
77
|
+
const second = readRunStatus(dir);
|
|
78
|
+
expect(second?.status).toBe("review_done");
|
|
79
|
+
expect(second?.startedAt).toBe("2026-01-01T00:00:00.000Z");
|
|
80
|
+
expect(second?.updatedAt).toBe("2026-01-01T00:10:00.000Z");
|
|
81
|
+
|
|
82
|
+
writeRunStatus(dir, {
|
|
83
|
+
branch: "feature/x",
|
|
84
|
+
status: "reviewing",
|
|
85
|
+
resetStartedAt: true,
|
|
86
|
+
now: "2026-01-01T00:20:00.000Z",
|
|
87
|
+
});
|
|
88
|
+
expect(readRunStatus(dir)?.startedAt).toBe("2026-01-01T00:20:00.000Z");
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("readRunStatus devolve null sem arquivo", () => {
|
|
93
|
+
withTempDir((dir) => {
|
|
94
|
+
expect(readRunStatus(dir)).toBeNull();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("statusAfterFindingsChange", () => {
|
|
100
|
+
test("in_progress → fixing", () => {
|
|
101
|
+
const state = stateOf([findingState({ execState: "in_progress" })]);
|
|
102
|
+
expect(statusAfterFindingsChange("review_done", state)).toBe("fixing");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("review_done + fila (triado, não iniciado) → mantém review_done", () => {
|
|
106
|
+
const state = stateOf([
|
|
107
|
+
findingState({ decision: "execute", execState: "pending" }),
|
|
108
|
+
]);
|
|
109
|
+
expect(statusAfterFindingsChange("review_done", state)).toBe("review_done");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("fixing + fila → fixing", () => {
|
|
113
|
+
const state = stateOf([
|
|
114
|
+
findingState({ id: "a", execState: "done" }),
|
|
115
|
+
findingState({ id: "b", execState: "pending" }),
|
|
116
|
+
]);
|
|
117
|
+
expect(statusAfterFindingsChange("fixing", state)).toBe("fixing");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("fixing + fila vazia → fix_done", () => {
|
|
121
|
+
const state = stateOf([findingState({ execState: "done" })]);
|
|
122
|
+
expect(statusAfterFindingsChange("fixing", state)).toBe("fix_done");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("done sem nunca ter iniciado → fix_done", () => {
|
|
126
|
+
const state = stateOf([findingState({ execState: "done" })]);
|
|
127
|
+
expect(statusAfterFindingsChange("review_done", state)).toBe("fix_done");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("sem run.json e sem fix → fallback review_done", () => {
|
|
131
|
+
const state = stateOf([
|
|
132
|
+
findingState({ decision: "pending", execState: "pending" }),
|
|
133
|
+
]);
|
|
134
|
+
expect(statusAfterFindingsChange(null, state)).toBe("review_done");
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("pingFindingsStatus", () => {
|
|
139
|
+
test("deriva do estado e grava o run.json", () => {
|
|
140
|
+
withTempDir((dir) => {
|
|
141
|
+
const state = stateOf([findingState({ execState: "in_progress" })]);
|
|
142
|
+
const file = pingFindingsStatus(dir, state);
|
|
143
|
+
expect(file.status).toBe("fixing");
|
|
144
|
+
expect(readRunStatus(dir)?.status).toBe("fixing");
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { createScaffoldContext, syncGeneratedFiles } from "../core/commands/scaffold";
|
|
6
|
+
|
|
7
|
+
/** Cria um diretório temporário, roda `fn` e limpa ao final. */
|
|
8
|
+
function withTempDir(fn: (cwd: string) => void): void {
|
|
9
|
+
const cwd = mkdtempSync(join(tmpdir(), "houndsman-scaffold-"));
|
|
10
|
+
try {
|
|
11
|
+
fn(cwd);
|
|
12
|
+
} finally {
|
|
13
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe("scaffold — ponteiros de skill", () => {
|
|
18
|
+
test("gera 1 skill 'houndsman' por harness e os docs de capability", () => {
|
|
19
|
+
withTempDir((cwd) => {
|
|
20
|
+
syncGeneratedFiles(createScaffoldContext(cwd, [], false));
|
|
21
|
+
|
|
22
|
+
for (const harness of ["agents", "claude"]) {
|
|
23
|
+
expect(
|
|
24
|
+
existsSync(join(cwd, `.${harness}/skills/houndsman/SKILL.md`)),
|
|
25
|
+
).toBe(true);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
for (const name of ["hunt.md", "train.md", "fix.md"]) {
|
|
29
|
+
expect(existsSync(join(cwd, ".houndsman/core/skill", name))).toBe(true);
|
|
30
|
+
}
|
|
31
|
+
// O entry point é o SKILL.md do harness — não um doc da camada.
|
|
32
|
+
expect(existsSync(join(cwd, ".houndsman/core/skill/SKILL.md"))).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("o SKILL.md descreve as capabilities e resolve o projectName", () => {
|
|
37
|
+
withTempDir((cwd) => {
|
|
38
|
+
syncGeneratedFiles(createScaffoldContext(cwd, ["--name", "acme"], false));
|
|
39
|
+
|
|
40
|
+
const skill = readFileSync(
|
|
41
|
+
join(cwd, ".agents/skills/houndsman/SKILL.md"),
|
|
42
|
+
"utf8",
|
|
43
|
+
);
|
|
44
|
+
for (const capability of ["hunt", "train", "fix"]) {
|
|
45
|
+
expect(skill).toContain(capability);
|
|
46
|
+
}
|
|
47
|
+
expect(skill).toContain("acme");
|
|
48
|
+
expect(skill).not.toContain("{{projectName}}");
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("--harness none não gera ponteiros de harness", () => {
|
|
53
|
+
withTempDir((cwd) => {
|
|
54
|
+
syncGeneratedFiles(createScaffoldContext(cwd, ["--harness", "none"], false));
|
|
55
|
+
expect(existsSync(join(cwd, ".agents"))).toBe(false);
|
|
56
|
+
expect(existsSync(join(cwd, ".claude"))).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
});
|