@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.
Files changed (45) hide show
  1. package/README.md +39 -78
  2. package/assets/logo-legacy.png +0 -0
  3. package/assets/logo.png +0 -0
  4. package/package.json +3 -2
  5. package/src/cli/index.ts +89 -9
  6. package/src/core/assets.ts +8 -0
  7. package/src/core/brief.ts +25 -2
  8. package/src/core/commands/findings.ts +390 -0
  9. package/src/core/commands/scaffold.ts +20 -0
  10. package/src/core/config.ts +42 -0
  11. package/src/core/diff-source.ts +140 -0
  12. package/src/core/file-classifier.ts +50 -2
  13. package/src/core/finding-anchor.ts +147 -0
  14. package/src/core/findings-state.ts +538 -0
  15. package/src/core/jev/jev-client.ts +72 -11
  16. package/src/core/jev/jev-detector.ts +21 -19
  17. package/src/core/orchestrator.ts +169 -17
  18. package/src/core/paths.ts +3 -0
  19. package/src/core/registry-check.ts +15 -0
  20. package/src/core/report-builder.ts +113 -9
  21. package/src/core/types.ts +10 -0
  22. package/src/index.ts +78 -2
  23. package/src/server/artifacts.ts +66 -0
  24. package/src/server/client/App.tsx +158 -0
  25. package/src/server/client/ArtifactsPanel.tsx +48 -0
  26. package/src/server/client/Filters.tsx +83 -0
  27. package/src/server/client/FindingCard.tsx +122 -0
  28. package/src/server/client/FindingsList.tsx +54 -0
  29. package/src/server/client/RunList.tsx +43 -0
  30. package/src/server/client/SummaryBar.tsx +21 -0
  31. package/src/server/client/api.ts +37 -0
  32. package/src/server/client/constants.ts +63 -0
  33. package/src/server/client/index.tsx +12 -0
  34. package/src/server/client/types.ts +48 -0
  35. package/src/server/client-build.ts +47 -0
  36. package/src/server/index.ts +365 -0
  37. package/src/skill/README.md +14 -6
  38. package/src/skill/SETUP.md +5 -3
  39. package/src/skill/houndsman-update.md +1 -1
  40. package/src/skill/houndsman.md +87 -10
  41. package/src/tests/findings-anchor.test.ts +120 -0
  42. package/src/tests/findings-commands.test.ts +226 -0
  43. package/src/tests/findings-state.test.ts +274 -0
  44. package/src/tests/review-scripts.test.ts +299 -0
  45. 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: [],