@payconductor/houndsman 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +195 -3
  3. package/assets/houndsman.png +0 -0
  4. package/package.json +63 -5
  5. package/src/cli/index.ts +210 -0
  6. package/src/core/assets.ts +20 -0
  7. package/src/core/brief.ts +162 -0
  8. package/src/core/commands/context.ts +39 -0
  9. package/src/core/commands/review.ts +50 -0
  10. package/src/core/commands/scaffold.ts +373 -0
  11. package/src/core/commands/setup.ts +75 -0
  12. package/src/core/commands/test.ts +20 -0
  13. package/src/core/commands/update.ts +30 -0
  14. package/src/core/commands/watchdog.ts +105 -0
  15. package/src/core/config-loader.ts +87 -0
  16. package/src/core/config.ts +59 -0
  17. package/src/core/doc-discoverer.ts +33 -0
  18. package/src/core/file-classifier.ts +247 -0
  19. package/src/core/jev/index.ts +12 -0
  20. package/src/core/jev/jev-candidates.ts +105 -0
  21. package/src/core/jev/jev-client.ts +99 -0
  22. package/src/core/jev/jev-detector.ts +203 -0
  23. package/src/core/jev/jev-questions.ts +92 -0
  24. package/src/core/jev/jev-types.ts +91 -0
  25. package/src/core/orchestrator.ts +444 -0
  26. package/src/core/paths.ts +30 -0
  27. package/src/core/pr-context.ts +271 -0
  28. package/src/core/registry-check.ts +145 -0
  29. package/src/core/report-builder.ts +357 -0
  30. package/src/core/types.ts +137 -0
  31. package/src/core/watchdog-registry.ts +43 -0
  32. package/src/index.ts +56 -0
  33. package/src/skill/AUDIT-PROTOCOL.md +96 -0
  34. package/src/skill/OUTPUT-FORMAT.md +70 -0
  35. package/src/skill/README.md +116 -0
  36. package/src/skill/SETUP.md +99 -0
  37. package/src/skill/example-review.md +58 -0
  38. package/src/skill/houndsman-update.md +251 -0
  39. package/src/skill/houndsman.md +239 -0
  40. package/src/tests/fixtures/fake-project.ts +64 -0
  41. package/src/tests/review-scripts.test.ts +851 -0
@@ -0,0 +1,851 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import type { AxiosInstance } from "axios";
6
+ import { renderBrief } from "../core/brief";
7
+ import { parseReviewArgs } from "../core/commands/review";
8
+ import { renderWatchdogContext } from "../core/commands/watchdog";
9
+ import { discoverDocs } from "../core/doc-discoverer";
10
+ import {
11
+ type ClassificationResult,
12
+ classifyEntries,
13
+ isExcludedPath,
14
+ matchWatchdogs,
15
+ parseEntries,
16
+ } from "../core/file-classifier";
17
+ import {
18
+ type JevCandidatesFile,
19
+ JevCandidatesStore,
20
+ JevClient,
21
+ JevDetector,
22
+ JevError,
23
+ JevQuestionFactory,
24
+ } from "../core/jev";
25
+ import { checkRegistry } from "../core/registry-check";
26
+ import {
27
+ dedupeFindings,
28
+ parseContextSummary,
29
+ parseWatchdogReport,
30
+ renderReport,
31
+ sanitizeBranch,
32
+ type WatchdogReport,
33
+ } from "../core/report-builder";
34
+ import type { WatchdogMeta } from "../core/types";
35
+ import { defineWatchdog } from "../core/types";
36
+ import { findWatchdog, getWatchdogContext } from "../core/watchdog-registry";
37
+
38
+ import {
39
+ alphaWatchdog,
40
+ FAKE_WATCHDOG_DEFINITIONS,
41
+ fakeHoundsmanConfig,
42
+ } from "./fixtures/fake-project";
43
+
44
+ /** Registry do projeto fake, para injetar nos helpers genéricos. */
45
+ const REGISTRY = FAKE_WATCHDOG_DEFINITIONS as Record<string, WatchdogMeta>;
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Config / registry
49
+ // ---------------------------------------------------------------------------
50
+
51
+ describe("config", () => {
52
+ test("deriva watchdogIds das chaves de watchdogs", () => {
53
+ expect(fakeHoundsmanConfig.watchdogIds).toEqual(["alpha", "beta"]);
54
+ });
55
+
56
+ test("todo watchdog tem metadados completos", () => {
57
+ for (const id of fakeHoundsmanConfig.watchdogIds) {
58
+ const def = fakeHoundsmanConfig.watchdogs[id];
59
+ expect(def.name.length).toBeGreaterThan(0);
60
+ expect(def.scope.length).toBeGreaterThan(0);
61
+ expect(def.rules.length).toBeGreaterThan(0);
62
+ expect(def.classify?.length ?? 0).toBeGreaterThan(0);
63
+ }
64
+ });
65
+
66
+ test("findWatchdog resolve por id e devolve null p/ inexistente", () => {
67
+ expect(findWatchdog(fakeHoundsmanConfig, "alpha")?.name).toBe("Alpha");
68
+ expect(findWatchdog(fakeHoundsmanConfig, "nao-existe")).toBeNull();
69
+ });
70
+
71
+ test("getWatchdogContext expõe metadados sem regras", () => {
72
+ const ctx = getWatchdogContext(fakeHoundsmanConfig, "beta");
73
+ expect(ctx.id).toBe("beta");
74
+ expect(ctx.contextFiles).toContain("README.md");
75
+ expect("rules" in ctx).toBe(false);
76
+ });
77
+ });
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // checkRegistry (anti-drift) — roda sobre o project fake
81
+ // ---------------------------------------------------------------------------
82
+
83
+ describe("registry-check", () => {
84
+ test("config fake é consistente (0 problemas)", () => {
85
+ const { problems } = checkRegistry(fakeHoundsmanConfig);
86
+ expect(problems).toEqual([]);
87
+ });
88
+
89
+ test("falha quando um watchdog não declara 'classify'", () => {
90
+ const broken = { ...alphaWatchdog, classify: undefined };
91
+ const config = {
92
+ ...fakeHoundsmanConfig,
93
+ watchdogs: { ...fakeHoundsmanConfig.watchdogs, alpha: broken },
94
+ };
95
+ const { problems } = checkRegistry(config as typeof fakeHoundsmanConfig);
96
+ expect(problems.some((p) => p.includes("'alpha'"))).toBe(true);
97
+ });
98
+
99
+ test("regra sem polaridade explícita gera aviso (não problema)", () => {
100
+ const { problems, warnings } = checkRegistry(fakeHoundsmanConfig);
101
+ expect(problems).toEqual([]);
102
+ expect(warnings.length).toBeGreaterThan(0);
103
+ expect(warnings.some((w) => w.includes("polaridade"))).toBe(true);
104
+ });
105
+
106
+ test("regras com 'Promover:'/'Evitar:'/'Não'/'Nunca' não geram aviso", () => {
107
+ const config = {
108
+ ...fakeHoundsmanConfig,
109
+ watchdogs: {
110
+ ...fakeHoundsmanConfig.watchdogs,
111
+ alpha: {
112
+ ...alphaWatchdog,
113
+ rules: [
114
+ {
115
+ severity: "blocking" as const,
116
+ text: "Promover: validar a entrada.",
117
+ },
118
+ { severity: "attention" as const, text: "Evitar console.log." },
119
+ {
120
+ severity: "nit" as const,
121
+ text: "Não acessar o banco em rotas.",
122
+ },
123
+ { severity: "nit" as const, text: "Nunca usar `any`." },
124
+ ],
125
+ },
126
+ },
127
+ };
128
+ const { warnings } = checkRegistry(config as typeof fakeHoundsmanConfig);
129
+ expect(warnings).toEqual([]);
130
+ });
131
+ });
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // file-classifier
135
+ // ---------------------------------------------------------------------------
136
+
137
+ describe("parseEntries", () => {
138
+ test("parseia name-status com A/M/D/R", () => {
139
+ const raw = [
140
+ "A\tsrc/new.ts",
141
+ "M\tsrc/mod.ts",
142
+ "D\tsrc/old.ts",
143
+ "R100\tsrc/before.ts\tsrc/after.ts",
144
+ ].join("\n");
145
+
146
+ const entries = parseEntries(raw);
147
+ expect(entries).toHaveLength(4);
148
+ expect(entries[0]).toEqual({ path: "src/new.ts", status: "added" });
149
+ expect(entries[1]).toEqual({ path: "src/mod.ts", status: "modified" });
150
+ expect(entries[2]).toEqual({ path: "src/old.ts", status: "deleted" });
151
+ expect(entries[3]).toEqual({
152
+ path: "src/after.ts",
153
+ oldPath: "src/before.ts",
154
+ status: "renamed",
155
+ });
156
+ });
157
+
158
+ test("parseia caminhos sem status como modified", () => {
159
+ const entries = parseEntries("src/a.ts\nsrc/b.ts");
160
+ expect(entries).toHaveLength(2);
161
+ expect(entries[0]).toEqual({ path: "src/a.ts", status: "modified" });
162
+ });
163
+ });
164
+
165
+ describe("matchWatchdogs", () => {
166
+ test("une os watchdogs cujo classify casa", () => {
167
+ expect(
168
+ matchWatchdogs(
169
+ "src/alpha/x.ts",
170
+ fakeHoundsmanConfig.watchdogs,
171
+ fakeHoundsmanConfig.watchdogIds,
172
+ ),
173
+ ).toEqual(["alpha"]);
174
+ });
175
+
176
+ test("casa por padrão de nome de arquivo (sem casar sufixos)", () => {
177
+ expect(
178
+ matchWatchdogs(
179
+ "lib/special.ts",
180
+ fakeHoundsmanConfig.watchdogs,
181
+ fakeHoundsmanConfig.watchdogIds,
182
+ ),
183
+ ).toEqual(["beta"]);
184
+ expect(
185
+ matchWatchdogs(
186
+ "lib/special-extra.ts",
187
+ fakeHoundsmanConfig.watchdogs,
188
+ fakeHoundsmanConfig.watchdogIds,
189
+ ),
190
+ ).toEqual([]);
191
+ });
192
+
193
+ test("casa pelo conteúdo do arquivo quando type é 'content'", () => {
194
+ const probe = defineWatchdog({
195
+ id: "probe",
196
+ name: "Probe",
197
+ description: "Watchdog por conteúdo.",
198
+ scope: "qualquer arquivo",
199
+ classify: [{ type: "content", value: "NEEDLE" }],
200
+ rules: [],
201
+ contextFiles: [],
202
+ });
203
+ const watchdogs = { probe };
204
+
205
+ expect(matchWatchdogs("any/file.ts", watchdogs, ["probe"], "// NEEDLE")).toEqual([
206
+ "probe",
207
+ ]);
208
+ expect(matchWatchdogs("any/file.ts", watchdogs, ["probe"], "sem alvo")).toEqual(
209
+ [],
210
+ );
211
+ // Sem conteúdo (arquivo não lido) a regra de conteúdo não casa.
212
+ expect(matchWatchdogs("any/file.ts", watchdogs, ["probe"])).toEqual([]);
213
+ });
214
+
215
+ test("arquivo sem padrão não casa nenhum watchdog", () => {
216
+ expect(
217
+ matchWatchdogs(
218
+ "scripts/foo.ts",
219
+ fakeHoundsmanConfig.watchdogs,
220
+ fakeHoundsmanConfig.watchdogIds,
221
+ ),
222
+ ).toEqual([]);
223
+ });
224
+ });
225
+
226
+ describe("classifyEntries", () => {
227
+ test("atribui ao watchdog dono do padrão", () => {
228
+ const result = classifyEntries(
229
+ parseEntries("M\tsrc/alpha/x.ts"),
230
+ fakeHoundsmanConfig,
231
+ );
232
+ expect(result.watchdogs.alpha).toEqual(["src/alpha/x.ts"]);
233
+ });
234
+
235
+ test("arquivo de teste casa o watchdog de testes", () => {
236
+ const result = classifyEntries(
237
+ parseEntries("M\tsrc/beta/x.test.ts"),
238
+ fakeHoundsmanConfig,
239
+ );
240
+ expect(result.watchdogs.beta).toEqual(["src/beta/x.test.ts"]);
241
+ });
242
+
243
+ test("arquivo sem match fica unclassified (sem fallback)", () => {
244
+ const scripts = classifyEntries(
245
+ parseEntries("M\tscripts/foo.ts"),
246
+ fakeHoundsmanConfig,
247
+ );
248
+ expect(scripts.unclassified).toEqual(["scripts/foo.ts"]);
249
+
250
+ const gamma = classifyEntries(
251
+ parseEntries("M\tsrc/gamma/x.ts"),
252
+ fakeHoundsmanConfig,
253
+ );
254
+ expect(gamma.unclassified).toEqual(["src/gamma/x.ts"]);
255
+ });
256
+
257
+ test("casa por padrão de nome de arquivo", () => {
258
+ const hit = classifyEntries(
259
+ parseEntries("M\tlib/special.ts"),
260
+ fakeHoundsmanConfig,
261
+ );
262
+ expect(hit.watchdogs.beta).toEqual(["lib/special.ts"]);
263
+
264
+ const miss = classifyEntries(
265
+ parseEntries("M\tlib/special-extra.ts"),
266
+ fakeHoundsmanConfig,
267
+ );
268
+ expect(miss.unclassified).toEqual(["lib/special-extra.ts"]);
269
+ });
270
+
271
+ test("excluídos não entram em watchdogs", () => {
272
+ const result = classifyEntries(
273
+ parseEntries("M\tdist/x.js\nM\tsrc/alpha/y.ts"),
274
+ fakeHoundsmanConfig,
275
+ );
276
+ expect(result.excluded).toEqual(["dist/x.js"]);
277
+ expect(result.watchdogs.alpha).toEqual(["src/alpha/y.ts"]);
278
+ });
279
+
280
+ test("deletados não entram em watchdogs mas ficam em deleted", () => {
281
+ const result = classifyEntries(
282
+ parseEntries("D\tsrc/alpha/old.ts"),
283
+ fakeHoundsmanConfig,
284
+ );
285
+ expect(result.deleted).toEqual(["src/alpha/old.ts"]);
286
+ expect(result.watchdogs.alpha).toBeUndefined();
287
+ expect(result.files["src/alpha/old.ts"]).toEqual({ status: "deleted" });
288
+ });
289
+
290
+ test("stats refletem o número de arquivos por watchdog", () => {
291
+ const result = classifyEntries(
292
+ parseEntries("M\tsrc/alpha/a.ts\nM\tsrc/alpha/b.ts"),
293
+ fakeHoundsmanConfig,
294
+ );
295
+ expect(result.stats.alpha).toBe(2);
296
+ });
297
+ });
298
+
299
+ describe("isExcludedPath", () => {
300
+ test("casa os padrões de exclusão", () => {
301
+ expect(isExcludedPath("dist/x.js", fakeHoundsmanConfig.exclusions)).toBe(true);
302
+ expect(isExcludedPath("bun.lock", fakeHoundsmanConfig.exclusions)).toBe(true);
303
+ expect(isExcludedPath("src/alpha/x.ts", fakeHoundsmanConfig.exclusions)).toBe(
304
+ false,
305
+ );
306
+ });
307
+ });
308
+
309
+ // ---------------------------------------------------------------------------
310
+ // doc-discoverer
311
+ // ---------------------------------------------------------------------------
312
+
313
+ describe("discoverDocs", () => {
314
+ test("sempre inclui os docs de alwaysIncludeDocs", () => {
315
+ expect(discoverDocs(["src/alpha/x.ts"], fakeHoundsmanConfig)).toContain(
316
+ "README.md",
317
+ );
318
+ });
319
+
320
+ test("aplica os docMappings do projeto", () => {
321
+ expect(discoverDocs(["src/beta/y.ts"], fakeHoundsmanConfig)).toContain(
322
+ "README.md",
323
+ );
324
+ });
325
+ });
326
+
327
+ // ---------------------------------------------------------------------------
328
+ // report-builder
329
+ // ---------------------------------------------------------------------------
330
+
331
+ describe("report-builder", () => {
332
+ test("sanitizeBranch substitui barras", () => {
333
+ expect(sanitizeBranch("feature/add-webhook")).toBe("feature-add-webhook");
334
+ expect(sanitizeBranch("Feature/Add")).toBe("feature-add");
335
+ expect(sanitizeBranch("main")).toBe("main");
336
+ });
337
+
338
+ test("parseWatchdogReport rejeita severidade inválida", () => {
339
+ const report = parseWatchdogReport(
340
+ {
341
+ watchdogId: "alpha",
342
+ findings: [
343
+ {
344
+ rule: "x",
345
+ severity: "critical",
346
+ file: "a.ts",
347
+ message: "m",
348
+ action: "a",
349
+ },
350
+ ],
351
+ },
352
+ "test",
353
+ );
354
+ expect(report?.findings).toHaveLength(0);
355
+ });
356
+
357
+ test("dedupeFindings mescla watchdogs e mantém a severidade mais grave", () => {
358
+ const reports: WatchdogReport[] = [
359
+ {
360
+ watchdogId: "alpha",
361
+ reviewedFiles: ["a.ts"],
362
+ skipped: [],
363
+ findings: [
364
+ {
365
+ rule: "Regra compartilhada",
366
+ severity: "attention",
367
+ file: "a.ts",
368
+ line: 10,
369
+ message: "menor",
370
+ action: "a",
371
+ },
372
+ ],
373
+ },
374
+ {
375
+ watchdogId: "beta",
376
+ reviewedFiles: ["a.ts"],
377
+ skipped: [],
378
+ findings: [
379
+ {
380
+ rule: "Regra compartilhada",
381
+ severity: "blocking",
382
+ file: "a.ts",
383
+ line: 10,
384
+ message: "maior",
385
+ action: "a",
386
+ },
387
+ ],
388
+ },
389
+ ];
390
+
391
+ const merged = dedupeFindings(reports);
392
+ expect(merged).toHaveLength(1);
393
+ expect(merged[0].watchdogs).toHaveLength(2);
394
+ expect(merged[0].severity).toBe("blocking");
395
+ expect(merged[0].message).toBe("maior");
396
+ });
397
+
398
+ test("renderReport marca status conforme severidade", () => {
399
+ const reports: WatchdogReport[] = [
400
+ { watchdogId: "alpha", reviewedFiles: ["s.ts"], skipped: [], findings: [] },
401
+ ];
402
+
403
+ expect(renderReport("main", reports, [], {})).toContain("✅ Aprovado");
404
+
405
+ const blockers = renderReport(
406
+ "main",
407
+ reports,
408
+ [
409
+ {
410
+ rule: "Regra bloqueante",
411
+ severity: "blocking",
412
+ file: "s.ts",
413
+ message: "m",
414
+ action: "a",
415
+ watchdogs: ["alpha"],
416
+ },
417
+ ],
418
+ {},
419
+ );
420
+ expect(blockers).toContain("❌ Mudanças Necessárias");
421
+ expect(blockers).toContain("## ❌ blocking");
422
+ });
423
+
424
+ test("renderReport mostra cobertura pendente", () => {
425
+ const reports: WatchdogReport[] = [
426
+ { watchdogId: "alpha", reviewedFiles: ["s.ts"], skipped: [], findings: [] },
427
+ ];
428
+ const md = renderReport("main", reports, [], {
429
+ classified: { alpha: ["s.ts", "t.ts"] },
430
+ });
431
+ expect(md).toContain("faltam 1");
432
+ });
433
+
434
+ test("renderReport usa o resolver de nome injetado", () => {
435
+ const reports: WatchdogReport[] = [
436
+ { watchdogId: "alpha", reviewedFiles: [], skipped: [], findings: [] },
437
+ ];
438
+ const md = renderReport("main", reports, [], {}, undefined, undefined, (id) =>
439
+ id === "alpha" ? "Watchdog Alpha" : id,
440
+ );
441
+ expect(md).toContain("Watchdog Alpha");
442
+ });
443
+ });
444
+
445
+ // ---------------------------------------------------------------------------
446
+ // context-summary (contexto da PR)
447
+ // ---------------------------------------------------------------------------
448
+
449
+ describe("context-summary", () => {
450
+ test("parseContextSummary aceita resumo válido", () => {
451
+ const summary = parseContextSummary({
452
+ available: true,
453
+ source: { number: 12, title: "T", url: "u", base: "main", head: "f/x" },
454
+ objective: "Fazer algo",
455
+ motivation: "Porque sim",
456
+ changeSummary: ["a", "", "b"],
457
+ });
458
+ expect(summary?.objective).toBe("Fazer algo");
459
+ expect(summary?.changeSummary).toEqual(["a", "b"]);
460
+ expect(summary?.source?.number).toBe(12);
461
+ });
462
+
463
+ test("parseContextSummary rejeita indisponível ou vazio", () => {
464
+ expect(parseContextSummary({ available: false })).toBeNull();
465
+ expect(parseContextSummary({ available: true })).toBeNull();
466
+ expect(parseContextSummary(null)).toBeNull();
467
+ });
468
+
469
+ test("parseContextSummary enxuga resumo longo", () => {
470
+ const summary = parseContextSummary({
471
+ available: true,
472
+ objective: "x".repeat(500),
473
+ motivation: "y".repeat(500),
474
+ changeSummary: Array.from(
475
+ { length: 10 },
476
+ (_, i) => `item ${i} ${"z".repeat(300)}`,
477
+ ),
478
+ });
479
+ expect(summary?.objective?.length).toBeLessThanOrEqual(200);
480
+ expect(summary?.objective?.endsWith("…")).toBe(true);
481
+ expect(summary?.changeSummary).toHaveLength(4);
482
+ });
483
+
484
+ test("renderReport coloca a seção de contexto quando presente", () => {
485
+ const reports: WatchdogReport[] = [
486
+ { watchdogId: "alpha", reviewedFiles: [], skipped: [], findings: [] },
487
+ ];
488
+ const md = renderReport(
489
+ "main",
490
+ reports,
491
+ [],
492
+ {},
493
+ {
494
+ available: true,
495
+ objective: "Entregar X",
496
+ motivation: "Reduzir Y",
497
+ changeSummary: ["Faz A"],
498
+ },
499
+ );
500
+ expect(md).toContain("## Contexto da PR");
501
+ expect(md).toContain("**Objetivo:** Entregar X");
502
+ });
503
+
504
+ test("renderReport omite a seção sem contexto", () => {
505
+ const reports: WatchdogReport[] = [
506
+ { watchdogId: "alpha", reviewedFiles: [], skipped: [], findings: [] },
507
+ ];
508
+ expect(renderReport("main", reports, [], {}, null)).not.toContain(
509
+ "## Contexto da PR",
510
+ );
511
+ });
512
+ });
513
+
514
+ // ---------------------------------------------------------------------------
515
+ // brief
516
+ // ---------------------------------------------------------------------------
517
+
518
+ describe("houndsman brief", () => {
519
+ const OUTPUT_FORMAT = ".houndsman/core/skill/OUTPUT-FORMAT.md";
520
+
521
+ test("inclui regras, arquivos e output do watchdog", () => {
522
+ const brief = renderBrief(alphaWatchdog, {
523
+ watchdogId: "alpha",
524
+ files: ["src/alpha/x.ts"],
525
+ branch: "feature/x",
526
+ outputPath: "tmp/fake-review/feature-x/findings/alpha.json",
527
+ outputFormatPath: OUTPUT_FORMAT,
528
+ docs: ["README.md"],
529
+ statuses: { "src/alpha/x.ts": { status: "modified" } },
530
+ deleted: [],
531
+ });
532
+ expect(brief).toContain("alpha");
533
+ expect(brief).toContain("findings/alpha.json");
534
+ expect(brief).toContain("Nunca usar `any`");
535
+ expect(brief).toContain("Arquivos do seu domínio (1)");
536
+ });
537
+
538
+ test("seção Deletados aparece só com `breakingChange`", () => {
539
+ const args = {
540
+ watchdogId: "alpha",
541
+ files: [],
542
+ branch: "main",
543
+ outputPath: "o.json",
544
+ outputFormatPath: OUTPUT_FORMAT,
545
+ docs: [],
546
+ statuses: {},
547
+ deleted: ["src/alpha/old.ts"],
548
+ };
549
+ const breakingDef = { ...alphaWatchdog, breakingChange: true };
550
+ expect(renderBrief(breakingDef, args)).toContain("Deletados");
551
+ expect(renderBrief(alphaWatchdog, args)).not.toContain("Deletados");
552
+ });
553
+
554
+ test("injeta o contexto da PR quando presente", () => {
555
+ const brief = renderBrief(alphaWatchdog, {
556
+ watchdogId: "alpha",
557
+ files: ["a.ts"],
558
+ branch: "feature/x",
559
+ outputPath: "o.json",
560
+ outputFormatPath: OUTPUT_FORMAT,
561
+ docs: [],
562
+ statuses: {},
563
+ deleted: [],
564
+ context: {
565
+ objective: "Entregar X",
566
+ motivation: "Reduzir Y",
567
+ changeSummary: ["Faz A"],
568
+ },
569
+ });
570
+ expect(brief).toContain("## Contexto da PR");
571
+ expect(brief).toContain("**Objetivo:** Entregar X");
572
+ });
573
+
574
+ test("hook custom de renderBrief é usado no lugar do default", () => {
575
+ const custom = { ...alphaWatchdog, renderBrief: () => ["# Custom"] };
576
+ const brief = renderBrief(custom, {
577
+ watchdogId: "alpha",
578
+ files: [],
579
+ branch: "main",
580
+ outputPath: "o.json",
581
+ outputFormatPath: OUTPUT_FORMAT,
582
+ docs: [],
583
+ statuses: {},
584
+ deleted: [],
585
+ });
586
+ expect(brief).toBe("# Custom");
587
+ });
588
+ });
589
+
590
+ // ---------------------------------------------------------------------------
591
+ // Jev — perguntas derivadas
592
+ // ---------------------------------------------------------------------------
593
+
594
+ describe("jev-questions", () => {
595
+ const factory = new JevQuestionFactory(REGISTRY);
596
+
597
+ test("spec noul sem instructions cita a regra", () => {
598
+ const q = factory.fromSpec("Regra X", { kind: "noul" });
599
+ expect(q?.type).toBe("noul");
600
+ expect(JSON.stringify(q)).toContain("Regra X");
601
+ });
602
+
603
+ test("instructions customizada é preservada", () => {
604
+ const q = factory.fromSpec("Regra X", {
605
+ kind: "noul",
606
+ instructions: "A pergunta customizada?",
607
+ });
608
+ expect(q).toEqual({ type: "noul", instructions: "A pergunta customizada?" });
609
+ });
610
+
611
+ test("choice exige criteria em objeto (senão null)", () => {
612
+ expect(factory.fromSpec("r", { kind: "choice" })).toBeNull();
613
+ expect(factory.fromSpec("r", { kind: "choice", criteria: {} })).toBeNull();
614
+ expect(factory.fromSpec("r", { kind: "choice", criteria: [] })).toBeNull();
615
+ expect(
616
+ factory.fromSpec("r", { kind: "choice", criteria: { a: "A" } }),
617
+ ).toMatchObject({
618
+ type: "choice",
619
+ });
620
+ });
621
+
622
+ test("score exige array de critérios (senão null)", () => {
623
+ expect(factory.fromSpec("r", { kind: "score", criteria: [] })).toBeNull();
624
+ expect(factory.fromSpec("r", { kind: "score", criteria: ["a"] })).toMatchObject({
625
+ type: "score",
626
+ });
627
+ });
628
+
629
+ test("derive só emite regras com spec e preserva a descrição", () => {
630
+ const derived = factory.derive("alpha");
631
+ expect(derived.length).toBeGreaterThan(0);
632
+ for (const d of derived) {
633
+ expect(d.key.startsWith("alpha::")).toBe(true);
634
+ expect(d.rule.length).toBeGreaterThan(0);
635
+ expect(d.question.instructions.length).toBeGreaterThan(0);
636
+ }
637
+ });
638
+
639
+ test("derive de id desconhecido devolve vazio", () => {
640
+ expect(factory.derive("nao-existe")).toEqual([]);
641
+ });
642
+
643
+ test("build agrega watchdogs sem chaves duplicadas", () => {
644
+ const all = factory.build(["alpha", "beta"]);
645
+ const keys = all.map((q) => q.key);
646
+ expect(new Set(keys).size).toBe(keys.length);
647
+ });
648
+ });
649
+
650
+ // ---------------------------------------------------------------------------
651
+ // Jev — cliente axios
652
+ // ---------------------------------------------------------------------------
653
+
654
+ describe("jev-client", () => {
655
+ test("isConfigured reflete a chave", () => {
656
+ expect(new JevClient({ apiKey: "" }).isConfigured()).toBe(false);
657
+ expect(new JevClient({ apiKey: "k" }).isConfigured()).toBe(true);
658
+ });
659
+
660
+ test("evaluate exige chave", async () => {
661
+ const client = new JevClient({ apiKey: "" });
662
+ await expect(
663
+ client.evaluate({ state: "s", questions: {} }),
664
+ ).rejects.toBeInstanceOf(JevError);
665
+ });
666
+
667
+ test("evaluate posta e retorna a resposta tipada", async () => {
668
+ let captured: { model?: string } | undefined;
669
+ const http = {
670
+ post: async (_url: string, body: { model?: string }) => {
671
+ captured = body;
672
+ return { data: { model: "jev-test", answers: {} } };
673
+ },
674
+ } as unknown as AxiosInstance;
675
+ const client = new JevClient({ apiKey: "k", model: "jev-x", http });
676
+ const res = await client.evaluate({ state: "s", questions: {} });
677
+ expect(res.model).toBe("jev-test");
678
+ expect(captured?.model).toBe("jev-x");
679
+ });
680
+
681
+ test("erro HTTP vira JevError", async () => {
682
+ const http = {
683
+ post: async () => {
684
+ throw new Error("boom");
685
+ },
686
+ } as unknown as AxiosInstance;
687
+ const client = new JevClient({ apiKey: "k", http });
688
+ await expect(
689
+ client.evaluate({ state: "s", questions: {} }),
690
+ ).rejects.toBeInstanceOf(JevError);
691
+ });
692
+ });
693
+
694
+ // ---------------------------------------------------------------------------
695
+ // Jev — detector (fail-open)
696
+ // ---------------------------------------------------------------------------
697
+
698
+ describe("jev-detector", () => {
699
+ const classification: ClassificationResult = {
700
+ watchdogs: { alpha: ["src/alpha/x.ts"] },
701
+ files: { "src/alpha/x.ts": { status: "modified" } },
702
+ unclassified: [],
703
+ excluded: [],
704
+ deleted: [],
705
+ stats: {},
706
+ excludedCount: 0,
707
+ };
708
+
709
+ function stubClient(noul: number): JevClient {
710
+ return {
711
+ evaluate: async (req: { questions: Record<string, unknown> }) => {
712
+ const answers: Record<string, unknown> = {};
713
+ for (const key of Object.keys(req.questions))
714
+ answers[key] = { type: "noul", noul };
715
+ return { model: "jev-stub", answers };
716
+ },
717
+ } as unknown as JevClient;
718
+ }
719
+
720
+ test("gera candidatos acima do limiar", async () => {
721
+ const result = await new JevDetector(stubClient(0.9), REGISTRY).detect(
722
+ classification,
723
+ "main",
724
+ "HEAD",
725
+ );
726
+ expect(result.candidates.length).toBeGreaterThan(0);
727
+ for (const c of result.candidates) {
728
+ expect(c.confidence).toBe(0.9);
729
+ expect(c.file).toBe("src/alpha/x.ts");
730
+ }
731
+ });
732
+
733
+ test("ignora respostas abaixo do limiar", async () => {
734
+ const result = await new JevDetector(stubClient(0.1), REGISTRY).detect(
735
+ classification,
736
+ "main",
737
+ "HEAD",
738
+ );
739
+ expect(result.candidates).toHaveLength(0);
740
+ expect(result.analyzedFiles).toBe(1);
741
+ });
742
+
743
+ test("falha por arquivo vira skipped (fail-open)", async () => {
744
+ const failing = {
745
+ evaluate: async () => {
746
+ throw new Error("timeout");
747
+ },
748
+ } as unknown as JevClient;
749
+ const result = await new JevDetector(failing, REGISTRY).detect(
750
+ classification,
751
+ "main",
752
+ "HEAD",
753
+ );
754
+ expect(result.candidates).toHaveLength(0);
755
+ expect(result.skipped).toHaveLength(1);
756
+ expect(result.skipped[0].reason).toContain("timeout");
757
+ });
758
+ });
759
+
760
+ // ---------------------------------------------------------------------------
761
+ // Jev — artefato de candidatos
762
+ // ---------------------------------------------------------------------------
763
+
764
+ describe("jev-candidates", () => {
765
+ test("round-trip de candidates.json", () => {
766
+ const file = join(tmpdir(), `jev-cand-${Date.now()}.json`);
767
+ const data: JevCandidatesFile = {
768
+ available: true,
769
+ model: "jev-test",
770
+ threshold: JevCandidatesStore.THRESHOLD,
771
+ analyzedFiles: 1,
772
+ candidates: [
773
+ {
774
+ watchdogId: "alpha",
775
+ rule: "R",
776
+ severity: "blocking",
777
+ file: "a.ts",
778
+ confidence: 0.8,
779
+ questionKey: "alpha::anti-0",
780
+ },
781
+ ],
782
+ };
783
+ try {
784
+ new JevCandidatesStore(file, fakeHoundsmanConfig.watchdogIds).write(data);
785
+ const read = new JevCandidatesStore(
786
+ file,
787
+ fakeHoundsmanConfig.watchdogIds,
788
+ ).read();
789
+ expect(read?.available).toBe(true);
790
+ expect(read?.candidates).toHaveLength(1);
791
+ expect(read?.candidates[0].rule).toBe("R");
792
+ } finally {
793
+ rmSync(file, { force: true });
794
+ }
795
+ });
796
+
797
+ test("read retorna null para arquivo ausente", () => {
798
+ const store = new JevCandidatesStore(
799
+ join(tmpdir(), `missing-${Date.now()}.json`),
800
+ fakeHoundsmanConfig.watchdogIds,
801
+ );
802
+ expect(store.read()).toBeNull();
803
+ });
804
+
805
+ test("unavailable marca indisponível e sem candidatos", () => {
806
+ const empty = JevCandidatesStore.unavailable("sem chave");
807
+ expect(empty.available).toBe(false);
808
+ expect(empty.candidates).toEqual([]);
809
+ });
810
+ });
811
+
812
+ // ---------------------------------------------------------------------------
813
+ // commands
814
+ // ---------------------------------------------------------------------------
815
+
816
+ describe("parseReviewArgs", () => {
817
+ test("lê flags de valor e booleanas", () => {
818
+ const opts = parseReviewArgs(
819
+ ["--base", "develop", "--branch", "feature/x", "--finalize", "--clean"],
820
+ "tmp/artifacts",
821
+ );
822
+ expect(opts.base).toBe("develop");
823
+ expect(opts.branch).toBe("feature/x");
824
+ expect(opts.dir).toBe("tmp/artifacts");
825
+ expect(opts.finalize).toBe(true);
826
+ expect(opts.clean).toBe(true);
827
+ expect(opts.failOnBlocking).toBe(false);
828
+ });
829
+ });
830
+
831
+ describe("renderWatchdogContext", () => {
832
+ test("markdown inclui nome e escopo", () => {
833
+ const md = renderWatchdogContext(fakeHoundsmanConfig, "alpha");
834
+ expect(md).toContain("# Alpha");
835
+ expect(md).toContain("**Escopo:**");
836
+ });
837
+
838
+ test("json inclui id; com --rules inclui regras", () => {
839
+ const json = renderWatchdogContext(fakeHoundsmanConfig, "alpha", {
840
+ json: true,
841
+ withRules: true,
842
+ });
843
+ const parsed = JSON.parse(json ?? "{}");
844
+ expect(parsed.id).toBe("alpha");
845
+ expect(parsed.rules).toBeDefined();
846
+ });
847
+
848
+ test("id inexistente devolve null", () => {
849
+ expect(renderWatchdogContext(fakeHoundsmanConfig, "nao-existe")).toBeNull();
850
+ });
851
+ });