@payconductor/houndsman 0.0.0-stage → 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 (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +156 -3
  3. package/assets/houndsman.png +0 -0
  4. package/assets/logo-legacy.png +0 -0
  5. package/assets/logo.png +0 -0
  6. package/package.json +64 -5
  7. package/src/cli/index.ts +290 -0
  8. package/src/core/assets.ts +28 -0
  9. package/src/core/brief.ts +185 -0
  10. package/src/core/commands/context.ts +39 -0
  11. package/src/core/commands/findings.ts +390 -0
  12. package/src/core/commands/review.ts +50 -0
  13. package/src/core/commands/scaffold.ts +393 -0
  14. package/src/core/commands/setup.ts +75 -0
  15. package/src/core/commands/test.ts +20 -0
  16. package/src/core/commands/update.ts +30 -0
  17. package/src/core/commands/watchdog.ts +105 -0
  18. package/src/core/config-loader.ts +87 -0
  19. package/src/core/config.ts +101 -0
  20. package/src/core/diff-source.ts +140 -0
  21. package/src/core/doc-discoverer.ts +33 -0
  22. package/src/core/file-classifier.ts +295 -0
  23. package/src/core/finding-anchor.ts +147 -0
  24. package/src/core/findings-state.ts +538 -0
  25. package/src/core/jev/index.ts +12 -0
  26. package/src/core/jev/jev-candidates.ts +105 -0
  27. package/src/core/jev/jev-client.ts +160 -0
  28. package/src/core/jev/jev-detector.ts +205 -0
  29. package/src/core/jev/jev-questions.ts +92 -0
  30. package/src/core/jev/jev-types.ts +91 -0
  31. package/src/core/orchestrator.ts +596 -0
  32. package/src/core/paths.ts +33 -0
  33. package/src/core/pr-context.ts +271 -0
  34. package/src/core/registry-check.ts +160 -0
  35. package/src/core/report-builder.ts +461 -0
  36. package/src/core/types.ts +147 -0
  37. package/src/core/watchdog-registry.ts +43 -0
  38. package/src/index.ts +132 -0
  39. package/src/server/artifacts.ts +66 -0
  40. package/src/server/client/App.tsx +158 -0
  41. package/src/server/client/ArtifactsPanel.tsx +48 -0
  42. package/src/server/client/Filters.tsx +83 -0
  43. package/src/server/client/FindingCard.tsx +122 -0
  44. package/src/server/client/FindingsList.tsx +54 -0
  45. package/src/server/client/RunList.tsx +43 -0
  46. package/src/server/client/SummaryBar.tsx +21 -0
  47. package/src/server/client/api.ts +37 -0
  48. package/src/server/client/constants.ts +63 -0
  49. package/src/server/client/index.tsx +12 -0
  50. package/src/server/client/types.ts +48 -0
  51. package/src/server/client-build.ts +47 -0
  52. package/src/server/index.ts +365 -0
  53. package/src/skill/AUDIT-PROTOCOL.md +96 -0
  54. package/src/skill/OUTPUT-FORMAT.md +70 -0
  55. package/src/skill/README.md +124 -0
  56. package/src/skill/SETUP.md +101 -0
  57. package/src/skill/example-review.md +58 -0
  58. package/src/skill/houndsman-update.md +251 -0
  59. package/src/skill/houndsman.md +316 -0
  60. package/src/tests/findings-anchor.test.ts +120 -0
  61. package/src/tests/findings-commands.test.ts +226 -0
  62. package/src/tests/findings-state.test.ts +274 -0
  63. package/src/tests/fixtures/fake-project.ts +64 -0
  64. package/src/tests/review-scripts.test.ts +1150 -0
  65. package/src/tests/server.test.ts +212 -0
@@ -0,0 +1,1150 @@
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 {
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";
16
+ import { discoverDocs } from "../core/doc-discoverer";
17
+ import {
18
+ type ClassificationResult,
19
+ churnOf,
20
+ classifyEntries,
21
+ isExcludedPath,
22
+ matchWatchdogs,
23
+ parseEntries,
24
+ selectByBudget,
25
+ sortByRisk,
26
+ } from "../core/file-classifier";
27
+ import {
28
+ type JevCandidatesFile,
29
+ JevCandidatesStore,
30
+ JevClient,
31
+ JevDetector,
32
+ JevError,
33
+ JevQuestionFactory,
34
+ } from "../core/jev";
35
+ import { checkRegistry } from "../core/registry-check";
36
+ import {
37
+ dedupeFindings,
38
+ parseContextSummary,
39
+ parseWatchdogReport,
40
+ renderDomainReport,
41
+ renderReport,
42
+ sanitizeBranch,
43
+ type WatchdogReport,
44
+ } from "../core/report-builder";
45
+ import type { WatchdogMeta } from "../core/types";
46
+ import { defineWatchdog } from "../core/types";
47
+ import { findWatchdog, getWatchdogContext } from "../core/watchdog-registry";
48
+
49
+ import {
50
+ alphaWatchdog,
51
+ FAKE_WATCHDOG_DEFINITIONS,
52
+ fakeHoundsmanConfig,
53
+ } from "./fixtures/fake-project";
54
+
55
+ /** Registry do projeto fake, para injetar nos helpers genéricos. */
56
+ const REGISTRY = FAKE_WATCHDOG_DEFINITIONS as Record<string, WatchdogMeta>;
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Config / registry
60
+ // ---------------------------------------------------------------------------
61
+
62
+ describe("config", () => {
63
+ test("deriva watchdogIds das chaves de watchdogs", () => {
64
+ expect(fakeHoundsmanConfig.watchdogIds).toEqual(["alpha", "beta"]);
65
+ });
66
+
67
+ test("todo watchdog tem metadados completos", () => {
68
+ for (const id of fakeHoundsmanConfig.watchdogIds) {
69
+ const def = fakeHoundsmanConfig.watchdogs[id];
70
+ expect(def.name.length).toBeGreaterThan(0);
71
+ expect(def.scope.length).toBeGreaterThan(0);
72
+ expect(def.rules.length).toBeGreaterThan(0);
73
+ expect(def.classify?.length ?? 0).toBeGreaterThan(0);
74
+ }
75
+ });
76
+
77
+ test("findWatchdog resolve por id e devolve null p/ inexistente", () => {
78
+ expect(findWatchdog(fakeHoundsmanConfig, "alpha")?.name).toBe("Alpha");
79
+ expect(findWatchdog(fakeHoundsmanConfig, "nao-existe")).toBeNull();
80
+ });
81
+
82
+ test("getWatchdogContext expõe metadados sem regras", () => {
83
+ const ctx = getWatchdogContext(fakeHoundsmanConfig, "beta");
84
+ expect(ctx.id).toBe("beta");
85
+ expect(ctx.contextFiles).toContain("README.md");
86
+ expect("rules" in ctx).toBe(false);
87
+ });
88
+ });
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // checkRegistry (anti-drift) — roda sobre o project fake
92
+ // ---------------------------------------------------------------------------
93
+
94
+ describe("registry-check", () => {
95
+ test("config fake é consistente (0 problemas)", () => {
96
+ const { problems } = checkRegistry(fakeHoundsmanConfig);
97
+ expect(problems).toEqual([]);
98
+ });
99
+
100
+ test("falha quando um watchdog não declara 'classify'", () => {
101
+ const broken = { ...alphaWatchdog, classify: undefined };
102
+ const config = {
103
+ ...fakeHoundsmanConfig,
104
+ watchdogs: { ...fakeHoundsmanConfig.watchdogs, alpha: broken },
105
+ };
106
+ const { problems } = checkRegistry(config as typeof fakeHoundsmanConfig);
107
+ expect(problems.some((p) => p.includes("'alpha'"))).toBe(true);
108
+ });
109
+
110
+ test("regra sem polaridade explícita gera aviso (não problema)", () => {
111
+ const { problems, warnings } = checkRegistry(fakeHoundsmanConfig);
112
+ expect(problems).toEqual([]);
113
+ expect(warnings.length).toBeGreaterThan(0);
114
+ expect(warnings.some((w) => w.includes("polaridade"))).toBe(true);
115
+ });
116
+
117
+ test("regras com 'Promover:'/'Evitar:'/'Não'/'Nunca' não geram aviso", () => {
118
+ const config = {
119
+ ...fakeHoundsmanConfig,
120
+ watchdogs: {
121
+ ...fakeHoundsmanConfig.watchdogs,
122
+ alpha: {
123
+ ...alphaWatchdog,
124
+ rules: [
125
+ {
126
+ severity: "blocking" as const,
127
+ text: "Promover: validar a entrada.",
128
+ },
129
+ { severity: "attention" as const, text: "Evitar console.log." },
130
+ {
131
+ severity: "nit" as const,
132
+ text: "Não acessar o banco em rotas.",
133
+ },
134
+ { severity: "nit" as const, text: "Nunca usar `any`." },
135
+ ],
136
+ },
137
+ },
138
+ };
139
+ const { warnings } = checkRegistry(config as typeof fakeHoundsmanConfig);
140
+ expect(warnings).toEqual([]);
141
+ });
142
+ });
143
+
144
+ // ---------------------------------------------------------------------------
145
+ // file-classifier
146
+ // ---------------------------------------------------------------------------
147
+
148
+ describe("parseEntries", () => {
149
+ test("parseia name-status com A/M/D/R", () => {
150
+ const raw = [
151
+ "A\tsrc/new.ts",
152
+ "M\tsrc/mod.ts",
153
+ "D\tsrc/old.ts",
154
+ "R100\tsrc/before.ts\tsrc/after.ts",
155
+ ].join("\n");
156
+
157
+ const entries = parseEntries(raw);
158
+ expect(entries).toHaveLength(4);
159
+ expect(entries[0]).toEqual({ path: "src/new.ts", status: "added" });
160
+ expect(entries[1]).toEqual({ path: "src/mod.ts", status: "modified" });
161
+ expect(entries[2]).toEqual({ path: "src/old.ts", status: "deleted" });
162
+ expect(entries[3]).toEqual({
163
+ path: "src/after.ts",
164
+ oldPath: "src/before.ts",
165
+ status: "renamed",
166
+ });
167
+ });
168
+
169
+ test("parseia caminhos sem status como modified", () => {
170
+ const entries = parseEntries("src/a.ts\nsrc/b.ts");
171
+ expect(entries).toHaveLength(2);
172
+ expect(entries[0]).toEqual({ path: "src/a.ts", status: "modified" });
173
+ });
174
+ });
175
+
176
+ describe("matchWatchdogs", () => {
177
+ test("une os watchdogs cujo classify casa", () => {
178
+ expect(
179
+ matchWatchdogs(
180
+ "src/alpha/x.ts",
181
+ fakeHoundsmanConfig.watchdogs,
182
+ fakeHoundsmanConfig.watchdogIds,
183
+ ),
184
+ ).toEqual(["alpha"]);
185
+ });
186
+
187
+ test("casa por padrão de nome de arquivo (sem casar sufixos)", () => {
188
+ expect(
189
+ matchWatchdogs(
190
+ "lib/special.ts",
191
+ fakeHoundsmanConfig.watchdogs,
192
+ fakeHoundsmanConfig.watchdogIds,
193
+ ),
194
+ ).toEqual(["beta"]);
195
+ expect(
196
+ matchWatchdogs(
197
+ "lib/special-extra.ts",
198
+ fakeHoundsmanConfig.watchdogs,
199
+ fakeHoundsmanConfig.watchdogIds,
200
+ ),
201
+ ).toEqual([]);
202
+ });
203
+
204
+ test("casa pelo conteúdo do arquivo quando type é 'content'", () => {
205
+ const probe = defineWatchdog({
206
+ id: "probe",
207
+ name: "Probe",
208
+ description: "Watchdog por conteúdo.",
209
+ scope: "qualquer arquivo",
210
+ classify: [{ type: "content", value: "NEEDLE" }],
211
+ rules: [],
212
+ contextFiles: [],
213
+ });
214
+ const watchdogs = { probe };
215
+
216
+ expect(matchWatchdogs("any/file.ts", watchdogs, ["probe"], "// NEEDLE")).toEqual([
217
+ "probe",
218
+ ]);
219
+ expect(matchWatchdogs("any/file.ts", watchdogs, ["probe"], "sem alvo")).toEqual(
220
+ [],
221
+ );
222
+ // Sem conteúdo (arquivo não lido) a regra de conteúdo não casa.
223
+ expect(matchWatchdogs("any/file.ts", watchdogs, ["probe"])).toEqual([]);
224
+ });
225
+
226
+ test("arquivo sem padrão não casa nenhum watchdog", () => {
227
+ expect(
228
+ matchWatchdogs(
229
+ "scripts/foo.ts",
230
+ fakeHoundsmanConfig.watchdogs,
231
+ fakeHoundsmanConfig.watchdogIds,
232
+ ),
233
+ ).toEqual([]);
234
+ });
235
+ });
236
+
237
+ describe("classifyEntries", () => {
238
+ test("atribui ao watchdog dono do padrão", () => {
239
+ const result = classifyEntries(
240
+ parseEntries("M\tsrc/alpha/x.ts"),
241
+ fakeHoundsmanConfig,
242
+ );
243
+ expect(result.watchdogs.alpha).toEqual(["src/alpha/x.ts"]);
244
+ });
245
+
246
+ test("arquivo de teste casa o watchdog de testes", () => {
247
+ const result = classifyEntries(
248
+ parseEntries("M\tsrc/beta/x.test.ts"),
249
+ fakeHoundsmanConfig,
250
+ );
251
+ expect(result.watchdogs.beta).toEqual(["src/beta/x.test.ts"]);
252
+ });
253
+
254
+ test("arquivo sem match fica unclassified (sem fallback)", () => {
255
+ const scripts = classifyEntries(
256
+ parseEntries("M\tscripts/foo.ts"),
257
+ fakeHoundsmanConfig,
258
+ );
259
+ expect(scripts.unclassified).toEqual(["scripts/foo.ts"]);
260
+
261
+ const gamma = classifyEntries(
262
+ parseEntries("M\tsrc/gamma/x.ts"),
263
+ fakeHoundsmanConfig,
264
+ );
265
+ expect(gamma.unclassified).toEqual(["src/gamma/x.ts"]);
266
+ });
267
+
268
+ test("casa por padrão de nome de arquivo", () => {
269
+ const hit = classifyEntries(
270
+ parseEntries("M\tlib/special.ts"),
271
+ fakeHoundsmanConfig,
272
+ );
273
+ expect(hit.watchdogs.beta).toEqual(["lib/special.ts"]);
274
+
275
+ const miss = classifyEntries(
276
+ parseEntries("M\tlib/special-extra.ts"),
277
+ fakeHoundsmanConfig,
278
+ );
279
+ expect(miss.unclassified).toEqual(["lib/special-extra.ts"]);
280
+ });
281
+
282
+ test("excluídos não entram em watchdogs", () => {
283
+ const result = classifyEntries(
284
+ parseEntries("M\tdist/x.js\nM\tsrc/alpha/y.ts"),
285
+ fakeHoundsmanConfig,
286
+ );
287
+ expect(result.excluded).toEqual(["dist/x.js"]);
288
+ expect(result.watchdogs.alpha).toEqual(["src/alpha/y.ts"]);
289
+ });
290
+
291
+ test("deletados não entram em watchdogs mas ficam em deleted", () => {
292
+ const result = classifyEntries(
293
+ parseEntries("D\tsrc/alpha/old.ts"),
294
+ fakeHoundsmanConfig,
295
+ );
296
+ expect(result.deleted).toEqual(["src/alpha/old.ts"]);
297
+ expect(result.watchdogs.alpha).toBeUndefined();
298
+ expect(result.files["src/alpha/old.ts"]).toEqual({ status: "deleted" });
299
+ });
300
+
301
+ test("stats refletem o número de arquivos por watchdog", () => {
302
+ const result = classifyEntries(
303
+ parseEntries("M\tsrc/alpha/a.ts\nM\tsrc/alpha/b.ts"),
304
+ fakeHoundsmanConfig,
305
+ );
306
+ expect(result.stats.alpha).toBe(2);
307
+ });
308
+ });
309
+
310
+ describe("isExcludedPath", () => {
311
+ test("casa os padrões de exclusão", () => {
312
+ expect(isExcludedPath("dist/x.js", fakeHoundsmanConfig.exclusions)).toBe(true);
313
+ expect(isExcludedPath("bun.lock", fakeHoundsmanConfig.exclusions)).toBe(true);
314
+ expect(isExcludedPath("src/alpha/x.ts", fakeHoundsmanConfig.exclusions)).toBe(
315
+ false,
316
+ );
317
+ });
318
+ });
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
+
450
+ // ---------------------------------------------------------------------------
451
+ // doc-discoverer
452
+ // ---------------------------------------------------------------------------
453
+
454
+ describe("discoverDocs", () => {
455
+ test("sempre inclui os docs de alwaysIncludeDocs", () => {
456
+ expect(discoverDocs(["src/alpha/x.ts"], fakeHoundsmanConfig)).toContain(
457
+ "README.md",
458
+ );
459
+ });
460
+
461
+ test("aplica os docMappings do projeto", () => {
462
+ expect(discoverDocs(["src/beta/y.ts"], fakeHoundsmanConfig)).toContain(
463
+ "README.md",
464
+ );
465
+ });
466
+ });
467
+
468
+ // ---------------------------------------------------------------------------
469
+ // report-builder
470
+ // ---------------------------------------------------------------------------
471
+
472
+ describe("report-builder", () => {
473
+ test("sanitizeBranch substitui barras", () => {
474
+ expect(sanitizeBranch("feature/add-webhook")).toBe("feature-add-webhook");
475
+ expect(sanitizeBranch("Feature/Add")).toBe("feature-add");
476
+ expect(sanitizeBranch("main")).toBe("main");
477
+ });
478
+
479
+ test("parseWatchdogReport rejeita severidade inválida", () => {
480
+ const report = parseWatchdogReport(
481
+ {
482
+ watchdogId: "alpha",
483
+ findings: [
484
+ {
485
+ rule: "x",
486
+ severity: "critical",
487
+ file: "a.ts",
488
+ message: "m",
489
+ action: "a",
490
+ },
491
+ ],
492
+ },
493
+ "test",
494
+ );
495
+ expect(report?.findings).toHaveLength(0);
496
+ });
497
+
498
+ test("dedupeFindings mescla watchdogs e mantém a severidade mais grave", () => {
499
+ const reports: WatchdogReport[] = [
500
+ {
501
+ watchdogId: "alpha",
502
+ reviewedFiles: ["a.ts"],
503
+ skipped: [],
504
+ findings: [
505
+ {
506
+ rule: "Regra compartilhada",
507
+ severity: "attention",
508
+ file: "a.ts",
509
+ line: 10,
510
+ message: "menor",
511
+ action: "a",
512
+ },
513
+ ],
514
+ },
515
+ {
516
+ watchdogId: "beta",
517
+ reviewedFiles: ["a.ts"],
518
+ skipped: [],
519
+ findings: [
520
+ {
521
+ rule: "Regra compartilhada",
522
+ severity: "blocking",
523
+ file: "a.ts",
524
+ line: 10,
525
+ message: "maior",
526
+ action: "a",
527
+ },
528
+ ],
529
+ },
530
+ ];
531
+
532
+ const merged = dedupeFindings(reports);
533
+ expect(merged).toHaveLength(1);
534
+ expect(merged[0].watchdogs).toHaveLength(2);
535
+ expect(merged[0].severity).toBe("blocking");
536
+ expect(merged[0].message).toBe("maior");
537
+ });
538
+
539
+ test("renderReport marca status conforme severidade", () => {
540
+ const reports: WatchdogReport[] = [
541
+ { watchdogId: "alpha", reviewedFiles: ["s.ts"], skipped: [], findings: [] },
542
+ ];
543
+
544
+ expect(renderReport("main", reports, [], {})).toContain("✅ Aprovado");
545
+
546
+ const blockers = renderReport(
547
+ "main",
548
+ reports,
549
+ [
550
+ {
551
+ rule: "Regra bloqueante",
552
+ severity: "blocking",
553
+ file: "s.ts",
554
+ message: "m",
555
+ action: "a",
556
+ watchdogs: ["alpha"],
557
+ },
558
+ ],
559
+ {},
560
+ );
561
+ expect(blockers).toContain("❌ Mudanças Necessárias");
562
+ expect(blockers).toContain("## ❌ blocking");
563
+ });
564
+
565
+ test("renderReport mostra cobertura pendente", () => {
566
+ const reports: WatchdogReport[] = [
567
+ { watchdogId: "alpha", reviewedFiles: ["s.ts"], skipped: [], findings: [] },
568
+ ];
569
+ const md = renderReport("main", reports, [], {
570
+ classified: { alpha: ["s.ts", "t.ts"] },
571
+ });
572
+ expect(md).toContain("faltam 1");
573
+ });
574
+
575
+ test("renderReport usa o resolver de nome injetado", () => {
576
+ const reports: WatchdogReport[] = [
577
+ { watchdogId: "alpha", reviewedFiles: [], skipped: [], findings: [] },
578
+ ];
579
+ const md = renderReport("main", reports, [], {}, undefined, undefined, (id) =>
580
+ id === "alpha" ? "Watchdog Alpha" : id,
581
+ );
582
+ expect(md).toContain("Watchdog Alpha");
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
+ });
657
+ });
658
+
659
+ // ---------------------------------------------------------------------------
660
+ // context-summary (contexto da PR)
661
+ // ---------------------------------------------------------------------------
662
+
663
+ describe("context-summary", () => {
664
+ test("parseContextSummary aceita resumo válido", () => {
665
+ const summary = parseContextSummary({
666
+ available: true,
667
+ source: { number: 12, title: "T", url: "u", base: "main", head: "f/x" },
668
+ objective: "Fazer algo",
669
+ motivation: "Porque sim",
670
+ changeSummary: ["a", "", "b"],
671
+ });
672
+ expect(summary?.objective).toBe("Fazer algo");
673
+ expect(summary?.changeSummary).toEqual(["a", "b"]);
674
+ expect(summary?.source?.number).toBe(12);
675
+ });
676
+
677
+ test("parseContextSummary rejeita indisponível ou vazio", () => {
678
+ expect(parseContextSummary({ available: false })).toBeNull();
679
+ expect(parseContextSummary({ available: true })).toBeNull();
680
+ expect(parseContextSummary(null)).toBeNull();
681
+ });
682
+
683
+ test("parseContextSummary enxuga resumo longo", () => {
684
+ const summary = parseContextSummary({
685
+ available: true,
686
+ objective: "x".repeat(500),
687
+ motivation: "y".repeat(500),
688
+ changeSummary: Array.from(
689
+ { length: 10 },
690
+ (_, i) => `item ${i} ${"z".repeat(300)}`,
691
+ ),
692
+ });
693
+ expect(summary?.objective?.length).toBeLessThanOrEqual(200);
694
+ expect(summary?.objective?.endsWith("…")).toBe(true);
695
+ expect(summary?.changeSummary).toHaveLength(4);
696
+ });
697
+
698
+ test("renderReport coloca a seção de contexto quando presente", () => {
699
+ const reports: WatchdogReport[] = [
700
+ { watchdogId: "alpha", reviewedFiles: [], skipped: [], findings: [] },
701
+ ];
702
+ const md = renderReport(
703
+ "main",
704
+ reports,
705
+ [],
706
+ {},
707
+ {
708
+ available: true,
709
+ objective: "Entregar X",
710
+ motivation: "Reduzir Y",
711
+ changeSummary: ["Faz A"],
712
+ },
713
+ );
714
+ expect(md).toContain("## Contexto da PR");
715
+ expect(md).toContain("**Objetivo:** Entregar X");
716
+ });
717
+
718
+ test("renderReport omite a seção sem contexto", () => {
719
+ const reports: WatchdogReport[] = [
720
+ { watchdogId: "alpha", reviewedFiles: [], skipped: [], findings: [] },
721
+ ];
722
+ expect(renderReport("main", reports, [], {}, null)).not.toContain(
723
+ "## Contexto da PR",
724
+ );
725
+ });
726
+ });
727
+
728
+ // ---------------------------------------------------------------------------
729
+ // brief
730
+ // ---------------------------------------------------------------------------
731
+
732
+ describe("houndsman brief", () => {
733
+ const OUTPUT_FORMAT = ".houndsman/core/skill/OUTPUT-FORMAT.md";
734
+
735
+ test("inclui regras, arquivos e output do watchdog", () => {
736
+ const brief = renderBrief(alphaWatchdog, {
737
+ watchdogId: "alpha",
738
+ files: ["src/alpha/x.ts"],
739
+ branch: "feature/x",
740
+ outputPath: "tmp/fake-review/feature-x/findings/alpha.json",
741
+ outputFormatPath: OUTPUT_FORMAT,
742
+ docs: ["README.md"],
743
+ statuses: { "src/alpha/x.ts": { status: "modified" } },
744
+ deleted: [],
745
+ });
746
+ expect(brief).toContain("alpha");
747
+ expect(brief).toContain("findings/alpha.json");
748
+ expect(brief).toContain("Nunca usar `any`");
749
+ expect(brief).toContain("Arquivos do seu domínio (1)");
750
+ });
751
+
752
+ test("seção Deletados aparece só com `breakingChange`", () => {
753
+ const args = {
754
+ watchdogId: "alpha",
755
+ files: [],
756
+ branch: "main",
757
+ outputPath: "o.json",
758
+ outputFormatPath: OUTPUT_FORMAT,
759
+ docs: [],
760
+ statuses: {},
761
+ deleted: ["src/alpha/old.ts"],
762
+ };
763
+ const breakingDef = { ...alphaWatchdog, breakingChange: true };
764
+ expect(renderBrief(breakingDef, args)).toContain("Deletados");
765
+ expect(renderBrief(alphaWatchdog, args)).not.toContain("Deletados");
766
+ });
767
+
768
+ test("injeta o contexto da PR quando presente", () => {
769
+ const brief = renderBrief(alphaWatchdog, {
770
+ watchdogId: "alpha",
771
+ files: ["a.ts"],
772
+ branch: "feature/x",
773
+ outputPath: "o.json",
774
+ outputFormatPath: OUTPUT_FORMAT,
775
+ docs: [],
776
+ statuses: {},
777
+ deleted: [],
778
+ context: {
779
+ objective: "Entregar X",
780
+ motivation: "Reduzir Y",
781
+ changeSummary: ["Faz A"],
782
+ },
783
+ });
784
+ expect(brief).toContain("## Contexto da PR");
785
+ expect(brief).toContain("**Objetivo:** Entregar X");
786
+ });
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
+
806
+ test("hook custom de renderBrief é usado no lugar do default", () => {
807
+ const custom = { ...alphaWatchdog, renderBrief: () => ["# Custom"] };
808
+ const brief = renderBrief(custom, {
809
+ watchdogId: "alpha",
810
+ files: [],
811
+ branch: "main",
812
+ outputPath: "o.json",
813
+ outputFormatPath: OUTPUT_FORMAT,
814
+ docs: [],
815
+ statuses: {},
816
+ deleted: [],
817
+ });
818
+ expect(brief).toBe("# Custom");
819
+ });
820
+ });
821
+
822
+ // ---------------------------------------------------------------------------
823
+ // Jev — perguntas derivadas
824
+ // ---------------------------------------------------------------------------
825
+
826
+ describe("jev-questions", () => {
827
+ const factory = new JevQuestionFactory(REGISTRY);
828
+
829
+ test("spec noul sem instructions cita a regra", () => {
830
+ const q = factory.fromSpec("Regra X", { kind: "noul" });
831
+ expect(q?.type).toBe("noul");
832
+ expect(JSON.stringify(q)).toContain("Regra X");
833
+ });
834
+
835
+ test("instructions customizada é preservada", () => {
836
+ const q = factory.fromSpec("Regra X", {
837
+ kind: "noul",
838
+ instructions: "A pergunta customizada?",
839
+ });
840
+ expect(q).toEqual({ type: "noul", instructions: "A pergunta customizada?" });
841
+ });
842
+
843
+ test("choice exige criteria em objeto (senão null)", () => {
844
+ expect(factory.fromSpec("r", { kind: "choice" })).toBeNull();
845
+ expect(factory.fromSpec("r", { kind: "choice", criteria: {} })).toBeNull();
846
+ expect(factory.fromSpec("r", { kind: "choice", criteria: [] })).toBeNull();
847
+ expect(
848
+ factory.fromSpec("r", { kind: "choice", criteria: { a: "A" } }),
849
+ ).toMatchObject({
850
+ type: "choice",
851
+ });
852
+ });
853
+
854
+ test("score exige array de critérios (senão null)", () => {
855
+ expect(factory.fromSpec("r", { kind: "score", criteria: [] })).toBeNull();
856
+ expect(factory.fromSpec("r", { kind: "score", criteria: ["a"] })).toMatchObject({
857
+ type: "score",
858
+ });
859
+ });
860
+
861
+ test("derive só emite regras com spec e preserva a descrição", () => {
862
+ const derived = factory.derive("alpha");
863
+ expect(derived.length).toBeGreaterThan(0);
864
+ for (const d of derived) {
865
+ expect(d.key.startsWith("alpha::")).toBe(true);
866
+ expect(d.rule.length).toBeGreaterThan(0);
867
+ expect(d.question.instructions.length).toBeGreaterThan(0);
868
+ }
869
+ });
870
+
871
+ test("derive de id desconhecido devolve vazio", () => {
872
+ expect(factory.derive("nao-existe")).toEqual([]);
873
+ });
874
+
875
+ test("build agrega watchdogs sem chaves duplicadas", () => {
876
+ const all = factory.build(["alpha", "beta"]);
877
+ const keys = all.map((q) => q.key);
878
+ expect(new Set(keys).size).toBe(keys.length);
879
+ });
880
+ });
881
+
882
+ // ---------------------------------------------------------------------------
883
+ // Jev — cliente axios
884
+ // ---------------------------------------------------------------------------
885
+
886
+ describe("jev-client", () => {
887
+ test("isConfigured reflete a chave", () => {
888
+ expect(new JevClient({ apiKey: "" }).isConfigured()).toBe(false);
889
+ expect(new JevClient({ apiKey: "k" }).isConfigured()).toBe(true);
890
+ });
891
+
892
+ test("evaluate exige chave", async () => {
893
+ const client = new JevClient({ apiKey: "" });
894
+ await expect(
895
+ client.evaluate({ state: "s", questions: {} }),
896
+ ).rejects.toBeInstanceOf(JevError);
897
+ });
898
+
899
+ test("evaluate posta e retorna a resposta tipada", async () => {
900
+ let captured: { model?: string } | undefined;
901
+ const http = {
902
+ post: async (_url: string, body: { model?: string }) => {
903
+ captured = body;
904
+ return { data: { model: "jev-test", answers: {} } };
905
+ },
906
+ } as unknown as AxiosInstance;
907
+ const client = new JevClient({ apiKey: "k", model: "jev-x", http });
908
+ const res = await client.evaluate({ state: "s", questions: {} });
909
+ expect(res.model).toBe("jev-test");
910
+ expect(captured?.model).toBe("jev-x");
911
+ });
912
+
913
+ test("erro HTTP vira JevError", async () => {
914
+ const http = {
915
+ post: async () => {
916
+ throw new Error("boom");
917
+ },
918
+ } as unknown as AxiosInstance;
919
+ const client = new JevClient({ apiKey: "k", http });
920
+ await expect(
921
+ client.evaluate({ state: "s", questions: {} }),
922
+ ).rejects.toBeInstanceOf(JevError);
923
+ });
924
+ });
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
+
992
+ // ---------------------------------------------------------------------------
993
+ // Jev — detector (fail-open)
994
+ // ---------------------------------------------------------------------------
995
+
996
+ describe("jev-detector", () => {
997
+ const classification: ClassificationResult = {
998
+ watchdogs: { alpha: ["src/alpha/x.ts"] },
999
+ files: { "src/alpha/x.ts": { status: "modified" } },
1000
+ churn: {},
1001
+ unclassified: [],
1002
+ excluded: [],
1003
+ deleted: [],
1004
+ stats: {},
1005
+ excludedCount: 0,
1006
+ };
1007
+
1008
+ function stubClient(noul: number): JevClient {
1009
+ return {
1010
+ evaluate: async (req: { questions: Record<string, unknown> }) => {
1011
+ const answers: Record<string, unknown> = {};
1012
+ for (const key of Object.keys(req.questions))
1013
+ answers[key] = { type: "noul", noul };
1014
+ return { model: "jev-stub", answers };
1015
+ },
1016
+ } as unknown as JevClient;
1017
+ }
1018
+
1019
+ test("gera candidatos acima do limiar", async () => {
1020
+ const result = await new JevDetector(stubClient(0.9), REGISTRY).detect(
1021
+ classification,
1022
+ "main",
1023
+ "HEAD",
1024
+ );
1025
+ expect(result.candidates.length).toBeGreaterThan(0);
1026
+ for (const c of result.candidates) {
1027
+ expect(c.confidence).toBe(0.9);
1028
+ expect(c.file).toBe("src/alpha/x.ts");
1029
+ }
1030
+ });
1031
+
1032
+ test("ignora respostas abaixo do limiar", async () => {
1033
+ const result = await new JevDetector(stubClient(0.1), REGISTRY).detect(
1034
+ classification,
1035
+ "main",
1036
+ "HEAD",
1037
+ );
1038
+ expect(result.candidates).toHaveLength(0);
1039
+ expect(result.analyzedFiles).toBe(1);
1040
+ });
1041
+
1042
+ test("falha por arquivo vira skipped (fail-open)", async () => {
1043
+ const failing = {
1044
+ evaluate: async () => {
1045
+ throw new Error("timeout");
1046
+ },
1047
+ } as unknown as JevClient;
1048
+ const result = await new JevDetector(failing, REGISTRY).detect(
1049
+ classification,
1050
+ "main",
1051
+ "HEAD",
1052
+ );
1053
+ expect(result.candidates).toHaveLength(0);
1054
+ expect(result.skipped).toHaveLength(1);
1055
+ expect(result.skipped[0].reason).toContain("timeout");
1056
+ });
1057
+ });
1058
+
1059
+ // ---------------------------------------------------------------------------
1060
+ // Jev — artefato de candidatos
1061
+ // ---------------------------------------------------------------------------
1062
+
1063
+ describe("jev-candidates", () => {
1064
+ test("round-trip de candidates.json", () => {
1065
+ const file = join(tmpdir(), `jev-cand-${Date.now()}.json`);
1066
+ const data: JevCandidatesFile = {
1067
+ available: true,
1068
+ model: "jev-test",
1069
+ threshold: JevCandidatesStore.THRESHOLD,
1070
+ analyzedFiles: 1,
1071
+ candidates: [
1072
+ {
1073
+ watchdogId: "alpha",
1074
+ rule: "R",
1075
+ severity: "blocking",
1076
+ file: "a.ts",
1077
+ confidence: 0.8,
1078
+ questionKey: "alpha::anti-0",
1079
+ },
1080
+ ],
1081
+ };
1082
+ try {
1083
+ new JevCandidatesStore(file, fakeHoundsmanConfig.watchdogIds).write(data);
1084
+ const read = new JevCandidatesStore(
1085
+ file,
1086
+ fakeHoundsmanConfig.watchdogIds,
1087
+ ).read();
1088
+ expect(read?.available).toBe(true);
1089
+ expect(read?.candidates).toHaveLength(1);
1090
+ expect(read?.candidates[0].rule).toBe("R");
1091
+ } finally {
1092
+ rmSync(file, { force: true });
1093
+ }
1094
+ });
1095
+
1096
+ test("read retorna null para arquivo ausente", () => {
1097
+ const store = new JevCandidatesStore(
1098
+ join(tmpdir(), `missing-${Date.now()}.json`),
1099
+ fakeHoundsmanConfig.watchdogIds,
1100
+ );
1101
+ expect(store.read()).toBeNull();
1102
+ });
1103
+
1104
+ test("unavailable marca indisponível e sem candidatos", () => {
1105
+ const empty = JevCandidatesStore.unavailable("sem chave");
1106
+ expect(empty.available).toBe(false);
1107
+ expect(empty.candidates).toEqual([]);
1108
+ });
1109
+ });
1110
+
1111
+ // ---------------------------------------------------------------------------
1112
+ // commands
1113
+ // ---------------------------------------------------------------------------
1114
+
1115
+ describe("parseReviewArgs", () => {
1116
+ test("lê flags de valor e booleanas", () => {
1117
+ const opts = parseReviewArgs(
1118
+ ["--base", "develop", "--branch", "feature/x", "--finalize", "--clean"],
1119
+ "tmp/artifacts",
1120
+ );
1121
+ expect(opts.base).toBe("develop");
1122
+ expect(opts.branch).toBe("feature/x");
1123
+ expect(opts.dir).toBe("tmp/artifacts");
1124
+ expect(opts.finalize).toBe(true);
1125
+ expect(opts.clean).toBe(true);
1126
+ expect(opts.failOnBlocking).toBe(false);
1127
+ });
1128
+ });
1129
+
1130
+ describe("renderWatchdogContext", () => {
1131
+ test("markdown inclui nome e escopo", () => {
1132
+ const md = renderWatchdogContext(fakeHoundsmanConfig, "alpha");
1133
+ expect(md).toContain("# Alpha");
1134
+ expect(md).toContain("**Escopo:**");
1135
+ });
1136
+
1137
+ test("json inclui id; com --rules inclui regras", () => {
1138
+ const json = renderWatchdogContext(fakeHoundsmanConfig, "alpha", {
1139
+ json: true,
1140
+ withRules: true,
1141
+ });
1142
+ const parsed = JSON.parse(json ?? "{}");
1143
+ expect(parsed.id).toBe("alpha");
1144
+ expect(parsed.rules).toBeDefined();
1145
+ });
1146
+
1147
+ test("id inexistente devolve null", () => {
1148
+ expect(renderWatchdogContext(fakeHoundsmanConfig, "nao-existe")).toBeNull();
1149
+ });
1150
+ });