@payconductor/houndsman 0.1.1 → 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 +32 -14
- package/package.json +1 -1
- package/src/cli/index.ts +22 -11
- package/src/core/assets.ts +3 -0
- package/src/core/brief.ts +2 -3
- package/src/core/commands/findings.ts +232 -4
- package/src/core/commands/review.ts +2 -0
- package/src/core/commands/scaffold.ts +13 -44
- package/src/core/diff-source.ts +4 -4
- package/src/core/findings-state.ts +20 -2
- 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-detector.ts +2 -2
- package/src/core/jev/jev-verify.ts +178 -0
- package/src/core/orchestrator.ts +202 -15
- package/src/core/paths.ts +6 -0
- package/src/core/report-builder.ts +5 -0
- package/src/core/run-status.ts +123 -0
- package/src/core/verify.ts +175 -0
- package/src/core/watchdog-registry.ts +1 -2
- package/src/index.ts +1 -1
- package/src/server/artifacts.ts +1 -0
- package/src/server/client/RunList.tsx +22 -13
- package/src/server/client/constants.ts +32 -1
- package/src/server/client/types.ts +4 -0
- package/src/server/index.ts +12 -1
- package/src/skill/AUDIT-PROTOCOL.md +2 -2
- package/src/skill/README.md +29 -5
- package/src/skill/SETUP.md +11 -1
- package/src/skill/SKILL.md +48 -0
- package/src/skill/fix.md +206 -0
- package/src/skill/{houndsman.md → hunt.md} +41 -68
- package/src/skill/{houndsman-update.md → train.md} +11 -11
- package/src/skill/verify.md +70 -0
- package/src/tests/findings-commands.test.ts +252 -2
- package/src/tests/findings-state.test.ts +52 -1
- package/src/tests/fix-batch.test.ts +257 -0
- package/src/tests/run-status.test.ts +147 -0
- package/src/tests/scaffold.test.ts +59 -0
- package/src/tests/server.test.ts +30 -0
- package/src/tests/verify.test.ts +170 -0
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { FindingState, FindingsStateFile } from "../core/findings-state";
|
|
3
|
+
import {
|
|
4
|
+
buildFixBatch,
|
|
5
|
+
chooseGrouping,
|
|
6
|
+
fixQueue,
|
|
7
|
+
groupFindings,
|
|
8
|
+
} from "../core/fix-batch";
|
|
9
|
+
import type { Severity } from "../core/types";
|
|
10
|
+
|
|
11
|
+
let seq = 0;
|
|
12
|
+
|
|
13
|
+
/** Finding de teste com defaults sensatos. */
|
|
14
|
+
function f(
|
|
15
|
+
partial: Partial<FindingState> & { file: string; rule: string; severity: Severity },
|
|
16
|
+
): FindingState {
|
|
17
|
+
seq++;
|
|
18
|
+
return {
|
|
19
|
+
id: partial.id ?? `id${seq}`,
|
|
20
|
+
fingerprint: partial.fingerprint ?? `fp${seq}`,
|
|
21
|
+
watchdogIds: partial.watchdogIds ?? ["alpha"],
|
|
22
|
+
rule: partial.rule,
|
|
23
|
+
severity: partial.severity,
|
|
24
|
+
file: partial.file,
|
|
25
|
+
line: partial.line ?? 1,
|
|
26
|
+
message: partial.message ?? "m",
|
|
27
|
+
action: partial.action ?? "a",
|
|
28
|
+
evidence: partial.evidence,
|
|
29
|
+
anchor: null,
|
|
30
|
+
count: partial.count ?? 1,
|
|
31
|
+
present: partial.present ?? true,
|
|
32
|
+
decision: partial.decision ?? "execute",
|
|
33
|
+
comment: partial.comment,
|
|
34
|
+
execState: partial.execState ?? "pending",
|
|
35
|
+
createdAt: "",
|
|
36
|
+
updatedAt: "",
|
|
37
|
+
history: [],
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function stateOf(findings: FindingState[]): FindingsStateFile {
|
|
42
|
+
return {
|
|
43
|
+
version: 2,
|
|
44
|
+
branch: "feature/x",
|
|
45
|
+
slug: "feature-x",
|
|
46
|
+
updatedAt: "",
|
|
47
|
+
order: findings.map((item) => item.id),
|
|
48
|
+
findings: Object.fromEntries(findings.map((item) => [item.id, item])),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// chooseGrouping (heurística determinística)
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
describe("chooseGrouping", () => {
|
|
57
|
+
test("prefere arquivo quando há poucos arquivos com coesão", () => {
|
|
58
|
+
const findings = ["f1", "f2", "f3"].flatMap((file, i) => [
|
|
59
|
+
f({
|
|
60
|
+
file,
|
|
61
|
+
rule: `r${i * 2 + 1}`,
|
|
62
|
+
severity: "attention",
|
|
63
|
+
watchdogIds: [`w${i}`],
|
|
64
|
+
}),
|
|
65
|
+
f({ file, rule: `r${i * 2 + 2}`, severity: "nit", watchdogIds: [`w${i}`] }),
|
|
66
|
+
]);
|
|
67
|
+
expect(chooseGrouping(findings)).toBe("file");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("prefere regra quando uma regra domina vários arquivos", () => {
|
|
71
|
+
const findings = ["f1", "f2", "f3", "f4", "f5", "f6"].map((file, i) =>
|
|
72
|
+
f({
|
|
73
|
+
file,
|
|
74
|
+
rule: i < 3 ? "r1" : "r2",
|
|
75
|
+
severity: "attention",
|
|
76
|
+
watchdogIds: [`w${i}`],
|
|
77
|
+
}),
|
|
78
|
+
);
|
|
79
|
+
expect(chooseGrouping(findings)).toBe("rule");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("cai em watchdog quando nem arquivo nem regra têm coesão", () => {
|
|
83
|
+
const findings = ["f1", "f2", "f3", "f4", "f5", "f6"].map((file, i) =>
|
|
84
|
+
f({
|
|
85
|
+
file,
|
|
86
|
+
rule: `r${Math.min(i, 4)}`,
|
|
87
|
+
severity: "attention",
|
|
88
|
+
watchdogIds: [i < 3 ? "w1" : "w2"],
|
|
89
|
+
}),
|
|
90
|
+
);
|
|
91
|
+
expect(chooseGrouping(findings)).toBe("watchdog");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("sem elegíveis (tudo singleton), cai em file", () => {
|
|
95
|
+
const findings = ["f1", "f2"].map((file, i) =>
|
|
96
|
+
f({ file, rule: `r${i}`, severity: "nit", watchdogIds: [`w${i}`] }),
|
|
97
|
+
);
|
|
98
|
+
expect(chooseGrouping(findings)).toBe("file");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("fila vazia devolve file", () => {
|
|
102
|
+
expect(chooseGrouping([])).toBe("file");
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("groupFindings", () => {
|
|
107
|
+
test("agrupa por arquivo com chaves prefixadas", () => {
|
|
108
|
+
const findings = [
|
|
109
|
+
f({ file: "src/a.ts", rule: "r1", severity: "attention" }),
|
|
110
|
+
f({ file: "src/a.ts", rule: "r2", severity: "nit" }),
|
|
111
|
+
f({ file: "src/b.ts", rule: "r3", severity: "nit" }),
|
|
112
|
+
];
|
|
113
|
+
const groups = groupFindings(findings, "file");
|
|
114
|
+
expect(groups.size).toBe(2);
|
|
115
|
+
expect(groups.get("file:src/a.ts")).toHaveLength(2);
|
|
116
|
+
expect(groups.get("file:src/b.ts")).toHaveLength(1);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// fixQueue
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
describe("fixQueue", () => {
|
|
125
|
+
test("só execute, presentes e não done; includeDone inclui", () => {
|
|
126
|
+
const done = f({
|
|
127
|
+
file: "a.ts",
|
|
128
|
+
rule: "r1",
|
|
129
|
+
severity: "attention",
|
|
130
|
+
execState: "done",
|
|
131
|
+
});
|
|
132
|
+
const pending = f({ file: "a.ts", rule: "r2", severity: "attention" });
|
|
133
|
+
const ignored = f({
|
|
134
|
+
file: "a.ts",
|
|
135
|
+
rule: "r3",
|
|
136
|
+
severity: "attention",
|
|
137
|
+
decision: "ignore",
|
|
138
|
+
});
|
|
139
|
+
const absent = f({
|
|
140
|
+
file: "a.ts",
|
|
141
|
+
rule: "r4",
|
|
142
|
+
severity: "attention",
|
|
143
|
+
present: false,
|
|
144
|
+
});
|
|
145
|
+
const state = stateOf([done, pending, ignored, absent]);
|
|
146
|
+
|
|
147
|
+
expect(fixQueue(state).map((item) => item.id)).toEqual([pending.id]);
|
|
148
|
+
expect(fixQueue(state, { includeDone: true }).map((item) => item.id)).toEqual([
|
|
149
|
+
done.id,
|
|
150
|
+
pending.id,
|
|
151
|
+
]);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("filtra por severidade", () => {
|
|
155
|
+
const blocking = f({ file: "a.ts", rule: "r1", severity: "blocking" });
|
|
156
|
+
const nit = f({ file: "a.ts", rule: "r2", severity: "nit" });
|
|
157
|
+
const state = stateOf([blocking, nit]);
|
|
158
|
+
expect(fixQueue(state, { severity: "blocking" }).map((item) => item.id)).toEqual([
|
|
159
|
+
blocking.id,
|
|
160
|
+
]);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// buildFixBatch
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
/** Fila coesa em 2 arquivos / 2 watchdogs (arquivo e watchdog empatam → file). */
|
|
169
|
+
function coesaState(): FindingsStateFile {
|
|
170
|
+
return stateOf([
|
|
171
|
+
f({ file: "src/a.ts", rule: "r1", severity: "blocking", watchdogIds: ["alpha"] }),
|
|
172
|
+
f({
|
|
173
|
+
file: "src/a.ts",
|
|
174
|
+
rule: "r2",
|
|
175
|
+
severity: "attention",
|
|
176
|
+
watchdogIds: ["alpha"],
|
|
177
|
+
}),
|
|
178
|
+
f({
|
|
179
|
+
file: "src/a.ts",
|
|
180
|
+
rule: "r3",
|
|
181
|
+
severity: "attention",
|
|
182
|
+
watchdogIds: ["alpha"],
|
|
183
|
+
}),
|
|
184
|
+
f({ file: "src/a.ts", rule: "r4", severity: "nit", watchdogIds: ["alpha"] }),
|
|
185
|
+
f({ file: "src/b.ts", rule: "r5", severity: "attention", watchdogIds: ["beta"] }),
|
|
186
|
+
f({
|
|
187
|
+
file: "src/b.ts",
|
|
188
|
+
rule: "r6",
|
|
189
|
+
severity: "suggestion",
|
|
190
|
+
watchdogIds: ["beta"],
|
|
191
|
+
}),
|
|
192
|
+
]);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
describe("buildFixBatch", () => {
|
|
196
|
+
test("escolhe o agrupamento e devolve grupo atual + outros grupos", () => {
|
|
197
|
+
const result = buildFixBatch(coesaState());
|
|
198
|
+
expect(result.dimension).toBe("file");
|
|
199
|
+
expect(result.totals).toEqual({ queue: 6, groups: 2, returned: 4 });
|
|
200
|
+
expect(result.current.key).toBe("file:src/a.ts");
|
|
201
|
+
expect(result.current.total).toBe(4);
|
|
202
|
+
expect(result.current.findings).toHaveLength(4);
|
|
203
|
+
expect(result.current.remaining).toBe(0);
|
|
204
|
+
expect(result.others).toHaveLength(1);
|
|
205
|
+
expect(result.others[0].key).toBe("file:src/b.ts");
|
|
206
|
+
expect(result.others[0].count).toBe(2);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("pagina o grupo atual com pageSize", () => {
|
|
210
|
+
const result = buildFixBatch(coesaState(), { pageSize: 2 });
|
|
211
|
+
expect(result.current.returned).toBe(2);
|
|
212
|
+
expect(result.current.remaining).toBe(2);
|
|
213
|
+
expect(result.totals.returned).toBe(2);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("--group foca um grupo específico", () => {
|
|
217
|
+
const result = buildFixBatch(coesaState(), { group: "file:src/b.ts" });
|
|
218
|
+
expect(result.current.key).toBe("file:src/b.ts");
|
|
219
|
+
expect(result.current.total).toBe(2);
|
|
220
|
+
expect(result.others).toHaveLength(1);
|
|
221
|
+
expect(result.others[0].key).toBe("file:src/a.ts");
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("--group inexistente lança com as chaves disponíveis", () => {
|
|
225
|
+
expect(() => buildFixBatch(coesaState(), { group: "file:nao-existe" })).toThrow(
|
|
226
|
+
/não encontrado/,
|
|
227
|
+
);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("--severity filtra a fila", () => {
|
|
231
|
+
const result = buildFixBatch(coesaState(), { severity: "blocking" });
|
|
232
|
+
expect(result.totals.queue).toBe(1);
|
|
233
|
+
expect(result.current.key).toBe("file:src/a.ts");
|
|
234
|
+
expect(result.current.total).toBe(1);
|
|
235
|
+
expect(result.others).toHaveLength(0);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("fila vazia devolve lote vazio", () => {
|
|
239
|
+
const state = stateOf([
|
|
240
|
+
f({ file: "a.ts", rule: "r1", severity: "attention", decision: "pending" }),
|
|
241
|
+
]);
|
|
242
|
+
const result = buildFixBatch(state);
|
|
243
|
+
expect(result.totals).toEqual({ queue: 0, groups: 0, returned: 0 });
|
|
244
|
+
expect(result.current.findings).toHaveLength(0);
|
|
245
|
+
expect(result.others).toHaveLength(0);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("outros grupos trazem o mix de severidade", () => {
|
|
249
|
+
const result = buildFixBatch(coesaState());
|
|
250
|
+
expect(result.others[0].bySeverity).toEqual({
|
|
251
|
+
blocking: 0,
|
|
252
|
+
attention: 1,
|
|
253
|
+
suggestion: 1,
|
|
254
|
+
nit: 0,
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
});
|
|
@@ -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
|
+
});
|
package/src/tests/server.test.ts
CHANGED
|
@@ -110,6 +110,36 @@ describe("server API", () => {
|
|
|
110
110
|
}
|
|
111
111
|
});
|
|
112
112
|
|
|
113
|
+
test("PATCH expõe o run status em /api/runs", async () => {
|
|
114
|
+
const env = makeEnv();
|
|
115
|
+
try {
|
|
116
|
+
const handler = createRequestHandler(env.config);
|
|
117
|
+
const before = (await (await get(handler, "/api/runs")).json()) as {
|
|
118
|
+
runs: { status: string | null }[];
|
|
119
|
+
};
|
|
120
|
+
expect(before.runs[0].status).toBeNull();
|
|
121
|
+
|
|
122
|
+
const list = (await (
|
|
123
|
+
await get(handler, "/api/runs/main/findings?all=1")
|
|
124
|
+
).json()) as { findings: { id: string }[] };
|
|
125
|
+
const id = list.findings[0].id;
|
|
126
|
+
await handler(
|
|
127
|
+
new Request(`http://localhost/api/runs/main/findings/${id}`, {
|
|
128
|
+
method: "PATCH",
|
|
129
|
+
headers: { "content-type": "application/json" },
|
|
130
|
+
body: JSON.stringify({ execState: "in_progress" }),
|
|
131
|
+
}),
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
const after = (await (await get(handler, "/api/runs")).json()) as {
|
|
135
|
+
runs: { status: string | null }[];
|
|
136
|
+
};
|
|
137
|
+
expect(after.runs[0].status).toBe("fixing");
|
|
138
|
+
} finally {
|
|
139
|
+
env.cleanup();
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
113
143
|
test("POST sync devolve o resultado da reancoragem", async () => {
|
|
114
144
|
const env = makeEnv();
|
|
115
145
|
try {
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { buildAnchor } from "../core/finding-anchor";
|
|
3
|
+
import {
|
|
4
|
+
emptyState,
|
|
5
|
+
type FindingState,
|
|
6
|
+
type FindingsStateFile,
|
|
7
|
+
} from "../core/findings-state";
|
|
8
|
+
import { type JevClient, JevVerifier } from "../core/jev";
|
|
9
|
+
import {
|
|
10
|
+
applyJev,
|
|
11
|
+
applyResolutions,
|
|
12
|
+
selectVerifiable,
|
|
13
|
+
summarizeVerify,
|
|
14
|
+
verifyLocal,
|
|
15
|
+
} from "../core/verify";
|
|
16
|
+
|
|
17
|
+
const CONTENT = ["function a() {", '\tconsole.log("x");', "}"].join("\n");
|
|
18
|
+
const FIXED = ["function a() {", "}"].join("\n");
|
|
19
|
+
const ANCHOR = buildAnchor(CONTENT, 2);
|
|
20
|
+
|
|
21
|
+
/** Finding mínimo, com overrides. */
|
|
22
|
+
function makeFinding(overrides: Partial<FindingState> = {}): FindingState {
|
|
23
|
+
return {
|
|
24
|
+
id: "f1",
|
|
25
|
+
fingerprint: "fp",
|
|
26
|
+
watchdogIds: ["alpha"],
|
|
27
|
+
rule: "Evitar console.log",
|
|
28
|
+
severity: "attention",
|
|
29
|
+
file: "f.ts",
|
|
30
|
+
line: 2,
|
|
31
|
+
message: "console.log",
|
|
32
|
+
action: "remover",
|
|
33
|
+
anchor: ANCHOR,
|
|
34
|
+
count: 1,
|
|
35
|
+
present: true,
|
|
36
|
+
decision: "execute",
|
|
37
|
+
execState: "in_progress",
|
|
38
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
39
|
+
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
40
|
+
history: [],
|
|
41
|
+
...overrides,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function withFindings(findings: FindingState[]): FindingsStateFile {
|
|
46
|
+
const state = emptyState("main");
|
|
47
|
+
for (const finding of findings) state.findings[finding.id] = finding;
|
|
48
|
+
return state;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe("verifyLocal", () => {
|
|
52
|
+
test("âncora presente ⇒ persisting (definitivo)", () => {
|
|
53
|
+
const [entry] = verifyLocal([makeFinding()], () => CONTENT);
|
|
54
|
+
expect(entry.status).toBe("persisting");
|
|
55
|
+
expect(entry.basis).toBe("anchor");
|
|
56
|
+
expect(entry.ambiguous).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("âncora sumida ⇒ resolved provisório e ambíguo", () => {
|
|
60
|
+
const [entry] = verifyLocal([makeFinding()], () => FIXED);
|
|
61
|
+
expect(entry.status).toBe("resolved");
|
|
62
|
+
expect(entry.ambiguous).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("arquivo ausente ⇒ file-missing", () => {
|
|
66
|
+
const [entry] = verifyLocal([makeFinding()], () => null);
|
|
67
|
+
expect(entry.status).toBe("file-missing");
|
|
68
|
+
expect(entry.ambiguous).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("sem âncora ⇒ uncertain e ambíguo", () => {
|
|
72
|
+
const [entry] = verifyLocal([makeFinding({ anchor: null })], () => CONTENT);
|
|
73
|
+
expect(entry.status).toBe("uncertain");
|
|
74
|
+
expect(entry.ambiguous).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("applyJev", () => {
|
|
79
|
+
test("alta confiança confirma persistência; baixa confirma resolução", () => {
|
|
80
|
+
const entries = verifyLocal(
|
|
81
|
+
[
|
|
82
|
+
makeFinding({ id: "removed", anchor: buildAnchor(CONTENT, 2) }),
|
|
83
|
+
makeFinding({ id: "noanchor", anchor: null }),
|
|
84
|
+
],
|
|
85
|
+
() => FIXED,
|
|
86
|
+
);
|
|
87
|
+
const applied = applyJev(
|
|
88
|
+
entries,
|
|
89
|
+
new Map([
|
|
90
|
+
["removed", 0.9],
|
|
91
|
+
["noanchor", 0.1],
|
|
92
|
+
]),
|
|
93
|
+
0.6,
|
|
94
|
+
);
|
|
95
|
+
expect(applied).toBe(2);
|
|
96
|
+
const byId = (id: string) => entries.find((entry) => entry.id === id);
|
|
97
|
+
expect(byId("removed")?.status).toBe("persisting");
|
|
98
|
+
expect(byId("removed")?.basis).toBe("jev");
|
|
99
|
+
expect(byId("removed")?.confidence).toBe(0.9);
|
|
100
|
+
expect(byId("noanchor")?.status).toBe("resolved");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("id sem resposta mantém o provisório local (fail-open)", () => {
|
|
104
|
+
const entries = verifyLocal([makeFinding()], () => FIXED);
|
|
105
|
+
applyJev(entries, new Map(), 0.6);
|
|
106
|
+
expect(entries[0].status).toBe("resolved");
|
|
107
|
+
expect(entries[0].basis).toBe("anchor");
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("summarizeVerify / applyResolutions", () => {
|
|
112
|
+
test("conta por status e marca os resolved como done", () => {
|
|
113
|
+
const findings = [
|
|
114
|
+
makeFinding({ id: "keeps", file: "keeps.ts" }),
|
|
115
|
+
makeFinding({ id: "fixed", file: "fixed.ts" }),
|
|
116
|
+
makeFinding({ id: "gone", file: "gone.ts" }),
|
|
117
|
+
];
|
|
118
|
+
const reader = (path: string): string | null =>
|
|
119
|
+
path === "gone.ts" ? null : path === "fixed.ts" ? FIXED : CONTENT;
|
|
120
|
+
const entries = verifyLocal(findings, reader);
|
|
121
|
+
|
|
122
|
+
const summary = summarizeVerify(entries);
|
|
123
|
+
expect(summary.persisting).toBe(1);
|
|
124
|
+
expect(summary.resolved).toBe(1);
|
|
125
|
+
expect(summary["file-missing"]).toBe(1);
|
|
126
|
+
|
|
127
|
+
const state = withFindings(findings);
|
|
128
|
+
const changed = applyResolutions(state, entries);
|
|
129
|
+
expect(changed).toEqual(["fixed"]);
|
|
130
|
+
expect(state.findings.fixed.execState).toBe("done");
|
|
131
|
+
expect(state.findings.fixed.history.at(-1)?.by).toBe("verify");
|
|
132
|
+
expect(state.findings.keeps.execState).toBe("in_progress");
|
|
133
|
+
expect(state.findings.gone.execState).toBe("in_progress");
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe("selectVerifiable", () => {
|
|
138
|
+
test("inclui presentes e exclui ignore/ausentes", () => {
|
|
139
|
+
const state = withFindings([
|
|
140
|
+
makeFinding({ id: "p", decision: "pending" }),
|
|
141
|
+
makeFinding({ id: "e", decision: "execute" }),
|
|
142
|
+
makeFinding({ id: "i", decision: "ignore" }),
|
|
143
|
+
makeFinding({ id: "absent", present: false }),
|
|
144
|
+
]);
|
|
145
|
+
expect(
|
|
146
|
+
selectVerifiable(state)
|
|
147
|
+
.map((finding) => finding.id)
|
|
148
|
+
.sort(),
|
|
149
|
+
).toEqual(["e", "p"]);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe("JevVerifier", () => {
|
|
154
|
+
test("sem chave ⇒ nada confirmado (fail-open)", async () => {
|
|
155
|
+
const client = { isConfigured: () => false } as unknown as JevClient;
|
|
156
|
+
const outcome = await new JevVerifier(client).verify([
|
|
157
|
+
{ id: "a", file: "f.ts", rule: "r", foundAtSha: "old" },
|
|
158
|
+
]);
|
|
159
|
+
expect(outcome.checked).toBe(0);
|
|
160
|
+
expect(outcome.skipped).toBe(1);
|
|
161
|
+
expect(outcome.reason).toContain("TYPESAFE_API_KEY");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("sem casos ⇒ vazio", async () => {
|
|
165
|
+
const client = { isConfigured: () => true } as unknown as JevClient;
|
|
166
|
+
const outcome = await new JevVerifier(client).verify([]);
|
|
167
|
+
expect(outcome.checked).toBe(0);
|
|
168
|
+
expect(outcome.skipped).toBe(0);
|
|
169
|
+
});
|
|
170
|
+
});
|