@payconductor/houndsman 0.1.0 → 0.1.1
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 +39 -78
- package/assets/logo-legacy.png +0 -0
- package/assets/logo.png +0 -0
- package/package.json +3 -2
- package/src/cli/index.ts +89 -9
- package/src/core/assets.ts +8 -0
- package/src/core/brief.ts +25 -2
- package/src/core/commands/findings.ts +390 -0
- package/src/core/commands/scaffold.ts +20 -0
- 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 +538 -0
- package/src/core/jev/jev-client.ts +72 -11
- package/src/core/jev/jev-detector.ts +21 -19
- package/src/core/orchestrator.ts +169 -17
- package/src/core/paths.ts +3 -0
- package/src/core/registry-check.ts +15 -0
- package/src/core/report-builder.ts +113 -9
- package/src/core/types.ts +10 -0
- package/src/index.ts +78 -2
- package/src/server/artifacts.ts +66 -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 +43 -0
- package/src/server/client/SummaryBar.tsx +21 -0
- package/src/server/client/api.ts +37 -0
- package/src/server/client/constants.ts +63 -0
- package/src/server/client/index.tsx +12 -0
- package/src/server/client/types.ts +48 -0
- package/src/server/client-build.ts +47 -0
- package/src/server/index.ts +365 -0
- package/src/skill/README.md +14 -6
- package/src/skill/SETUP.md +5 -3
- package/src/skill/houndsman-update.md +1 -1
- package/src/skill/houndsman.md +87 -10
- package/src/tests/findings-anchor.test.ts +120 -0
- package/src/tests/findings-commands.test.ts +226 -0
- package/src/tests/findings-state.test.ts +274 -0
- package/src/tests/review-scripts.test.ts +299 -0
- package/src/tests/server.test.ts +212 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
applyFindingUpdate,
|
|
7
|
+
buildStateFromFindings,
|
|
8
|
+
emptyState,
|
|
9
|
+
FindingsStateStore,
|
|
10
|
+
fileReader,
|
|
11
|
+
filterFindings,
|
|
12
|
+
summarize,
|
|
13
|
+
syncState,
|
|
14
|
+
} from "../core/findings-state";
|
|
15
|
+
import type { MergedFinding } from "../core/report-builder";
|
|
16
|
+
import type { Severity } from "../core/types";
|
|
17
|
+
|
|
18
|
+
/** Projeto em disco temporário (limpo no fim). */
|
|
19
|
+
function makeProject(files: Record<string, string>): {
|
|
20
|
+
cwd: string;
|
|
21
|
+
cleanup: () => void;
|
|
22
|
+
} {
|
|
23
|
+
const cwd = mkdtempSync(join(tmpdir(), "houndsman-state-"));
|
|
24
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
25
|
+
const full = join(cwd, rel);
|
|
26
|
+
mkdirSync(dirname(full), { recursive: true });
|
|
27
|
+
writeFileSync(full, content);
|
|
28
|
+
}
|
|
29
|
+
return { cwd, cleanup: () => rmSync(cwd, { recursive: true, force: true }) };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function merged(
|
|
33
|
+
partial: Partial<MergedFinding> & { file: string; rule: string },
|
|
34
|
+
): MergedFinding {
|
|
35
|
+
return {
|
|
36
|
+
rule: partial.rule,
|
|
37
|
+
severity: (partial.severity ?? "attention") as Severity,
|
|
38
|
+
file: partial.file,
|
|
39
|
+
line: partial.line,
|
|
40
|
+
message: partial.message ?? "msg",
|
|
41
|
+
action: partial.action ?? "ação",
|
|
42
|
+
evidence: partial.evidence,
|
|
43
|
+
watchdogs: partial.watchdogs ?? ["alpha"],
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const DUPLICATED = [
|
|
48
|
+
"function a() {",
|
|
49
|
+
'\tconsole.log("x");',
|
|
50
|
+
"}",
|
|
51
|
+
"function b() {",
|
|
52
|
+
'\tconsole.log("x");',
|
|
53
|
+
"}",
|
|
54
|
+
].join("\n");
|
|
55
|
+
|
|
56
|
+
const AFTER_FIX = [
|
|
57
|
+
"function a() {",
|
|
58
|
+
"}",
|
|
59
|
+
"function b() {",
|
|
60
|
+
'\tconsole.log("x");',
|
|
61
|
+
"}",
|
|
62
|
+
].join("\n");
|
|
63
|
+
|
|
64
|
+
describe("buildStateFromFindings", () => {
|
|
65
|
+
test("cria entradas pendentes com id e ancora a linha", () => {
|
|
66
|
+
const project = makeProject({ "src/f.ts": DUPLICATED });
|
|
67
|
+
try {
|
|
68
|
+
const findings = [
|
|
69
|
+
merged({ file: "src/f.ts", rule: "r", line: 2 }),
|
|
70
|
+
merged({ file: "src/f.ts", rule: "r", line: 5 }),
|
|
71
|
+
];
|
|
72
|
+
const state = buildStateFromFindings(findings, {
|
|
73
|
+
branch: "feature/x",
|
|
74
|
+
readFile: fileReader(project.cwd),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
expect(state.slug).toBe("feature-x");
|
|
78
|
+
expect(Object.keys(state.findings)).toHaveLength(2);
|
|
79
|
+
// Os ids voltam para os findings (usados no relatório).
|
|
80
|
+
expect(findings[0].id).toBeTruthy();
|
|
81
|
+
expect(findings[0].id).not.toBe(findings[1].id);
|
|
82
|
+
for (const finding of Object.values(state.findings)) {
|
|
83
|
+
expect(finding.decision).toBe("pending");
|
|
84
|
+
expect(finding.execState).toBe("pending");
|
|
85
|
+
expect(finding.present).toBe(true);
|
|
86
|
+
expect(finding.line).toBeGreaterThan(0);
|
|
87
|
+
}
|
|
88
|
+
} finally {
|
|
89
|
+
project.cleanup();
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("preserva id e decisão entre execuções (casa por fingerprint)", () => {
|
|
94
|
+
const project = makeProject({ "src/f.ts": DUPLICATED });
|
|
95
|
+
try {
|
|
96
|
+
const reader = fileReader(project.cwd);
|
|
97
|
+
const first = buildStateFromFindings(
|
|
98
|
+
[merged({ file: "src/f.ts", rule: "r", line: 2 })],
|
|
99
|
+
{ branch: "main", readFile: reader },
|
|
100
|
+
);
|
|
101
|
+
const id = Object.keys(first.findings)[0];
|
|
102
|
+
applyFindingUpdate(first.findings[id], { decision: "ignore" });
|
|
103
|
+
|
|
104
|
+
const second = buildStateFromFindings(
|
|
105
|
+
[merged({ file: "src/f.ts", rule: "r", line: 2 })],
|
|
106
|
+
{ branch: "main", readFile: reader, previous: first },
|
|
107
|
+
);
|
|
108
|
+
expect(Object.keys(second.findings)).toEqual([id]);
|
|
109
|
+
expect(second.findings[id].decision).toBe("ignore");
|
|
110
|
+
} finally {
|
|
111
|
+
project.cleanup();
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("finding que sumiu fica ausente e vira done se era 'execute'", () => {
|
|
116
|
+
const project = makeProject({ "src/f.ts": DUPLICATED });
|
|
117
|
+
try {
|
|
118
|
+
const reader = fileReader(project.cwd);
|
|
119
|
+
const first = buildStateFromFindings(
|
|
120
|
+
[merged({ file: "src/f.ts", rule: "r", line: 2 })],
|
|
121
|
+
{ branch: "main", readFile: reader },
|
|
122
|
+
);
|
|
123
|
+
const id = Object.keys(first.findings)[0];
|
|
124
|
+
applyFindingUpdate(first.findings[id], { decision: "execute" });
|
|
125
|
+
|
|
126
|
+
const second = buildStateFromFindings([], {
|
|
127
|
+
branch: "main",
|
|
128
|
+
readFile: reader,
|
|
129
|
+
previous: first,
|
|
130
|
+
});
|
|
131
|
+
expect(second.findings[id].present).toBe(false);
|
|
132
|
+
expect(second.findings[id].execState).toBe("done");
|
|
133
|
+
} finally {
|
|
134
|
+
project.cleanup();
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("ocorrências idênticas (mesmo contexto) viram um grupo com count", () => {
|
|
139
|
+
const content = [
|
|
140
|
+
"const a = 1;",
|
|
141
|
+
'console.log("x");',
|
|
142
|
+
"const a = 1;",
|
|
143
|
+
'console.log("x");',
|
|
144
|
+
"const a = 1;",
|
|
145
|
+
].join("\n");
|
|
146
|
+
const project = makeProject({ "src/g.ts": content });
|
|
147
|
+
try {
|
|
148
|
+
const findings = [
|
|
149
|
+
merged({ file: "src/g.ts", rule: "r", line: 2 }),
|
|
150
|
+
merged({ file: "src/g.ts", rule: "r", line: 4 }),
|
|
151
|
+
];
|
|
152
|
+
const state = buildStateFromFindings(findings, {
|
|
153
|
+
branch: "main",
|
|
154
|
+
readFile: fileReader(project.cwd),
|
|
155
|
+
});
|
|
156
|
+
expect(Object.keys(state.findings)).toHaveLength(1);
|
|
157
|
+
const only = Object.values(state.findings)[0];
|
|
158
|
+
expect(only.count).toBe(2);
|
|
159
|
+
expect(findings[0].id).toBe(findings[1].id);
|
|
160
|
+
} finally {
|
|
161
|
+
project.cleanup();
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe("syncState", () => {
|
|
167
|
+
test("move a sobrevivente de linha e marca a removida como stale/done", () => {
|
|
168
|
+
const before = makeProject({ "src/f.ts": DUPLICATED });
|
|
169
|
+
const after = makeProject({ "src/f.ts": AFTER_FIX });
|
|
170
|
+
try {
|
|
171
|
+
const state = buildStateFromFindings(
|
|
172
|
+
[
|
|
173
|
+
merged({ file: "src/f.ts", rule: "r", line: 2 }),
|
|
174
|
+
merged({ file: "src/f.ts", rule: "r", line: 5 }),
|
|
175
|
+
],
|
|
176
|
+
{ branch: "main", readFile: fileReader(before.cwd) },
|
|
177
|
+
);
|
|
178
|
+
const ids = Object.keys(state.findings);
|
|
179
|
+
// A primeira ocorrência foi marcada para executar.
|
|
180
|
+
const firstId = ids[0];
|
|
181
|
+
applyFindingUpdate(state.findings[firstId], { decision: "execute" });
|
|
182
|
+
|
|
183
|
+
const result = syncState(state, fileReader(after.cwd));
|
|
184
|
+
expect(result.moved).toHaveLength(1);
|
|
185
|
+
expect(result.stale).toHaveLength(1);
|
|
186
|
+
expect(result.stale).toContain(firstId);
|
|
187
|
+
expect(state.findings[firstId].execState).toBe("done");
|
|
188
|
+
|
|
189
|
+
const survivor = state.findings[result.moved[0]];
|
|
190
|
+
expect(survivor.line).toBe(4);
|
|
191
|
+
} finally {
|
|
192
|
+
before.cleanup();
|
|
193
|
+
after.cleanup();
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe("filterFindings / summarize", () => {
|
|
199
|
+
test("filtra por decisão e severity, e resume as contagens", () => {
|
|
200
|
+
const project = makeProject({ "src/f.ts": DUPLICATED });
|
|
201
|
+
try {
|
|
202
|
+
const state = buildStateFromFindings(
|
|
203
|
+
[
|
|
204
|
+
merged({
|
|
205
|
+
file: "src/f.ts",
|
|
206
|
+
rule: "r",
|
|
207
|
+
line: 2,
|
|
208
|
+
severity: "blocking",
|
|
209
|
+
}),
|
|
210
|
+
merged({ file: "src/f.ts", rule: "r2", line: 5, severity: "nit" }),
|
|
211
|
+
],
|
|
212
|
+
{ branch: "main", readFile: fileReader(project.cwd) },
|
|
213
|
+
);
|
|
214
|
+
const ids = Object.keys(state.findings);
|
|
215
|
+
applyFindingUpdate(state.findings[ids[0]], { decision: "execute" });
|
|
216
|
+
|
|
217
|
+
expect(
|
|
218
|
+
filterFindings(state, { decision: "execute" }).map((f) => f.id),
|
|
219
|
+
).toEqual([ids[0]]);
|
|
220
|
+
expect(filterFindings(state, { severity: "nit" }).map((f) => f.id)).toEqual([
|
|
221
|
+
ids[1],
|
|
222
|
+
]);
|
|
223
|
+
|
|
224
|
+
const summary = summarize(state);
|
|
225
|
+
expect(summary.total).toBe(2);
|
|
226
|
+
expect(summary.byDecision.execute).toBe(1);
|
|
227
|
+
expect(summary.byDecision.pending).toBe(1);
|
|
228
|
+
expect(summary.bySeverity.blocking).toBe(1);
|
|
229
|
+
} finally {
|
|
230
|
+
project.cleanup();
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
describe("FindingsStateStore", () => {
|
|
236
|
+
test("grava e lê o state.json de forma atômica", () => {
|
|
237
|
+
const project = makeProject({ "src/f.ts": DUPLICATED });
|
|
238
|
+
const outDir = join(project.cwd, "artifacts");
|
|
239
|
+
try {
|
|
240
|
+
const store = FindingsStateStore.at(outDir, "feature/x");
|
|
241
|
+
const state = buildStateFromFindings(
|
|
242
|
+
[merged({ file: "src/f.ts", rule: "r", line: 2 })],
|
|
243
|
+
{ branch: "feature/x", readFile: fileReader(project.cwd) },
|
|
244
|
+
);
|
|
245
|
+
store.write(state);
|
|
246
|
+
|
|
247
|
+
expect(store.filePath).toBe(join(outDir, "feature-x", "state.json"));
|
|
248
|
+
expect(existsSync(store.filePath)).toBe(true);
|
|
249
|
+
const read = store.read();
|
|
250
|
+
expect(read?.slug).toBe("feature-x");
|
|
251
|
+
expect(Object.keys(read?.findings ?? {})).toHaveLength(1);
|
|
252
|
+
} finally {
|
|
253
|
+
project.cleanup();
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("read devolve null quando o arquivo não existe", () => {
|
|
258
|
+
const project = makeProject({});
|
|
259
|
+
try {
|
|
260
|
+
const store = FindingsStateStore.at(join(project.cwd, "artifacts"), "main");
|
|
261
|
+
expect(store.read()).toBeNull();
|
|
262
|
+
expect(store.update(() => {})).toBeTruthy();
|
|
263
|
+
} finally {
|
|
264
|
+
project.cleanup();
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("emptyState cria um estado vazio válido", () => {
|
|
269
|
+
const state = emptyState("feature/x");
|
|
270
|
+
expect(state.version).toBe(1);
|
|
271
|
+
expect(state.slug).toBe("feature-x");
|
|
272
|
+
expect(state.findings).toEqual({});
|
|
273
|
+
});
|
|
274
|
+
});
|
|
@@ -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: [],
|