@payconductor/houndsman 0.1.1 → 0.1.4

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 (44) hide show
  1. package/README.md +32 -14
  2. package/package.json +1 -1
  3. package/src/cli/index.ts +22 -11
  4. package/src/core/assets.ts +3 -0
  5. package/src/core/brief.ts +2 -3
  6. package/src/core/commands/findings.ts +232 -4
  7. package/src/core/commands/review.ts +2 -0
  8. package/src/core/commands/scaffold.ts +13 -44
  9. package/src/core/diff-source.ts +4 -4
  10. package/src/core/findings-state.ts +20 -2
  11. package/src/core/fix-batch.ts +236 -0
  12. package/src/core/git-revision.ts +105 -0
  13. package/src/core/jev/index.ts +1 -0
  14. package/src/core/jev/jev-detector.ts +2 -2
  15. package/src/core/jev/jev-verify.ts +178 -0
  16. package/src/core/orchestrator.ts +202 -15
  17. package/src/core/paths.ts +6 -0
  18. package/src/core/registry-check.ts +53 -0
  19. package/src/core/report-builder.ts +5 -0
  20. package/src/core/run-status.ts +123 -0
  21. package/src/core/verify.ts +175 -0
  22. package/src/core/watchdog-registry.ts +1 -2
  23. package/src/index.ts +1 -1
  24. package/src/server/artifacts.ts +1 -0
  25. package/src/server/client/RunList.tsx +22 -13
  26. package/src/server/client/constants.ts +32 -1
  27. package/src/server/client/types.ts +4 -0
  28. package/src/server/index.ts +12 -1
  29. package/src/skill/AUDIT-PROTOCOL.md +7 -2
  30. package/src/skill/README.md +32 -6
  31. package/src/skill/SETUP.md +11 -2
  32. package/src/skill/SKILL.md +48 -0
  33. package/src/skill/fix.md +206 -0
  34. package/src/skill/{houndsman.md → hunt.md} +42 -68
  35. package/src/skill/{houndsman-update.md → train.md} +38 -17
  36. package/src/skill/verify.md +70 -0
  37. package/src/tests/findings-commands.test.ts +252 -2
  38. package/src/tests/findings-state.test.ts +52 -1
  39. package/src/tests/fix-batch.test.ts +257 -0
  40. package/src/tests/review-scripts.test.ts +36 -0
  41. package/src/tests/run-status.test.ts +147 -0
  42. package/src/tests/scaffold.test.ts +59 -0
  43. package/src/tests/server.test.ts +30 -0
  44. package/src/tests/verify.test.ts +170 -0
@@ -1,8 +1,15 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import {
3
+ existsSync,
4
+ mkdirSync,
5
+ mkdtempSync,
6
+ readFileSync,
7
+ rmSync,
8
+ writeFileSync,
9
+ } from "node:fs";
3
10
  import { tmpdir } from "node:os";
4
11
  import { join } from "node:path";
5
- import { runFindingsCommand } from "../core/commands/findings";
12
+ import { runFindingsCommand, runVerifyCommand } from "../core/commands/findings";
6
13
  import { fakeHoundsmanConfig } from "./fixtures/fake-project";
7
14
 
8
15
  /** Run fake em disco: classification.json + findings/alpha.json. */
@@ -223,4 +230,247 @@ describe("findings command", () => {
223
230
  run0.cleanup();
224
231
  }
225
232
  });
233
+
234
+ test("batch agrupa a fila e devolve o grupo atual + outros grupos", () => {
235
+ const dir = mkdtempSync(join(tmpdir(), "houndsman-batch-"));
236
+ const outDir = join(dir, "main");
237
+ mkdirSync(join(outDir, "findings"), { recursive: true });
238
+ writeFileSync(
239
+ join(outDir, "classification.json"),
240
+ JSON.stringify({ watchdogs: { alpha: ["src/a.ts"], beta: ["src/b.ts"] } }),
241
+ );
242
+ writeFileSync(
243
+ join(outDir, "findings", "alpha.json"),
244
+ JSON.stringify({
245
+ watchdogId: "alpha",
246
+ reviewedFiles: ["src/a.ts"],
247
+ skipped: [],
248
+ findings: [
249
+ {
250
+ rule: "R1",
251
+ severity: "blocking",
252
+ file: "src/a.ts",
253
+ line: 1,
254
+ message: "m1",
255
+ action: "a",
256
+ },
257
+ {
258
+ rule: "R2",
259
+ severity: "attention",
260
+ file: "src/a.ts",
261
+ line: 2,
262
+ message: "m2",
263
+ action: "a",
264
+ },
265
+ {
266
+ rule: "R3",
267
+ severity: "attention",
268
+ file: "src/a.ts",
269
+ line: 3,
270
+ message: "m3",
271
+ action: "a",
272
+ },
273
+ {
274
+ rule: "R4",
275
+ severity: "nit",
276
+ file: "src/a.ts",
277
+ line: 4,
278
+ message: "m4",
279
+ action: "a",
280
+ },
281
+ ],
282
+ }),
283
+ );
284
+ writeFileSync(
285
+ join(outDir, "findings", "beta.json"),
286
+ JSON.stringify({
287
+ watchdogId: "beta",
288
+ reviewedFiles: ["src/b.ts"],
289
+ skipped: [],
290
+ findings: [
291
+ {
292
+ rule: "R5",
293
+ severity: "attention",
294
+ file: "src/b.ts",
295
+ line: 1,
296
+ message: "m5",
297
+ action: "a",
298
+ },
299
+ {
300
+ rule: "R6",
301
+ severity: "suggestion",
302
+ file: "src/b.ts",
303
+ line: 2,
304
+ message: "m6",
305
+ action: "a",
306
+ },
307
+ ],
308
+ }),
309
+ );
310
+ try {
311
+ const list = JSON.parse(
312
+ run(["list", "--dir", dir, "--branch", "main", "--json"]).output,
313
+ );
314
+ for (const item of list) {
315
+ run([
316
+ "set",
317
+ item.id,
318
+ "--decision",
319
+ "execute",
320
+ "--dir",
321
+ dir,
322
+ "--branch",
323
+ "main",
324
+ ]);
325
+ }
326
+
327
+ const result = run(["batch", "--dir", dir, "--branch", "main", "--json"]);
328
+ expect(result.exitCode).toBe(0);
329
+ const parsed = JSON.parse(result.output);
330
+ expect(parsed.dimension).toBe("file");
331
+ expect(parsed.totals.queue).toBe(6);
332
+ expect(parsed.totals.groups).toBe(2);
333
+ expect(parsed.current.key).toBe("file:src/a.ts");
334
+ expect(parsed.current.findings).toHaveLength(4);
335
+ expect(parsed.others).toEqual([
336
+ expect.objectContaining({ key: "file:src/b.ts", count: 2 }),
337
+ ]);
338
+
339
+ const markdown = run(["batch", "--dir", dir, "--branch", "main"]);
340
+ expect(markdown.output).toContain("Grupo atual");
341
+ expect(markdown.output).toContain("Outros grupos");
342
+
343
+ const scoped = run([
344
+ "batch",
345
+ "--group",
346
+ "file:src/b.ts",
347
+ "--dir",
348
+ dir,
349
+ "--branch",
350
+ "main",
351
+ "--json",
352
+ ]);
353
+ expect(JSON.parse(scoped.output).current.key).toBe("file:src/b.ts");
354
+ } finally {
355
+ rmSync(dir, { recursive: true, force: true });
356
+ }
357
+ });
358
+
359
+ test("mutações fazem ping no run.json (status)", () => {
360
+ const run0 = makeRun(ONE_FINDING);
361
+ try {
362
+ const id = JSON.parse(
363
+ run(["list", "--dir", run0.dir, "--branch", "main", "--json"]).output,
364
+ )[0].id;
365
+ const runJson = join(run0.dir, "main", "run.json");
366
+
367
+ run([
368
+ "set",
369
+ id,
370
+ "--decision",
371
+ "execute",
372
+ "--dir",
373
+ run0.dir,
374
+ "--branch",
375
+ "main",
376
+ ]);
377
+ expect(JSON.parse(readFileSync(runJson, "utf8")).status).toBe("review_done");
378
+
379
+ run([
380
+ "set",
381
+ id,
382
+ "--exec",
383
+ "in_progress",
384
+ "--dir",
385
+ run0.dir,
386
+ "--branch",
387
+ "main",
388
+ ]);
389
+ expect(JSON.parse(readFileSync(runJson, "utf8")).status).toBe("fixing");
390
+
391
+ run(["done", id, "--dir", run0.dir, "--branch", "main"]);
392
+ expect(JSON.parse(readFileSync(runJson, "utf8")).status).toBe("fix_done");
393
+ } finally {
394
+ run0.cleanup();
395
+ }
396
+ });
397
+ });
398
+
399
+ describe("findings verify", () => {
400
+ const FILE = "src/tests/fixtures/fake-project.ts";
401
+
402
+ /** Run fake apontando para um arquivo real (relativo ao cwd → âncora resolvível). */
403
+ function makeVerifyRun(): { dir: string; outDir: string; cleanup: () => void } {
404
+ const dir = mkdtempSync(join(tmpdir(), "houndsman-verify-"));
405
+ const outDir = join(dir, "main");
406
+ mkdirSync(join(outDir, "findings"), { recursive: true });
407
+ writeFileSync(
408
+ join(outDir, "classification.json"),
409
+ JSON.stringify({ watchdogs: { alpha: [FILE] } }),
410
+ );
411
+ writeFileSync(
412
+ join(outDir, "findings", "alpha.json"),
413
+ JSON.stringify({
414
+ watchdogId: "alpha",
415
+ reviewedFiles: [FILE],
416
+ skipped: [],
417
+ findings: [
418
+ {
419
+ rule: "regra",
420
+ severity: "attention",
421
+ file: FILE,
422
+ line: 1,
423
+ message: "msg",
424
+ action: "ação",
425
+ },
426
+ ],
427
+ }),
428
+ );
429
+ return {
430
+ dir,
431
+ outDir,
432
+ cleanup: () => rmSync(dir, { recursive: true, force: true }),
433
+ };
434
+ }
435
+
436
+ test("reporta persisting e grava o verify.md (tier local)", async () => {
437
+ const run0 = makeVerifyRun();
438
+ try {
439
+ const result = await runVerifyCommand(fakeHoundsmanConfig, [
440
+ "verify",
441
+ "--dir",
442
+ run0.dir,
443
+ "--branch",
444
+ "main",
445
+ "--json",
446
+ ]);
447
+ expect(result.exitCode).toBe(0);
448
+ const parsed = JSON.parse(result.output);
449
+ expect(parsed.summary.persisting).toBe(1);
450
+ expect(parsed.entries[0].basis).toBe("anchor");
451
+ expect(existsSync(join(run0.outDir, "verify.md"))).toBe(true);
452
+ } finally {
453
+ run0.cleanup();
454
+ }
455
+ });
456
+
457
+ test("sem --apply não altera o estado", async () => {
458
+ const run0 = makeVerifyRun();
459
+ try {
460
+ await runVerifyCommand(fakeHoundsmanConfig, [
461
+ "verify",
462
+ "--dir",
463
+ run0.dir,
464
+ "--branch",
465
+ "main",
466
+ ]);
467
+ const state = JSON.parse(
468
+ readFileSync(join(run0.outDir, "state.json"), "utf8"),
469
+ ) as { findings: Record<string, { execState: string }> };
470
+ const only = Object.values(state.findings)[0];
471
+ expect(only.execState).toBe("pending");
472
+ } finally {
473
+ run0.cleanup();
474
+ }
475
+ });
226
476
  });
@@ -267,8 +267,59 @@ describe("FindingsStateStore", () => {
267
267
 
268
268
  test("emptyState cria um estado vazio válido", () => {
269
269
  const state = emptyState("feature/x");
270
- expect(state.version).toBe(1);
270
+ expect(state.version).toBe(2);
271
271
  expect(state.slug).toBe("feature-x");
272
272
  expect(state.findings).toEqual({});
273
273
  });
274
274
  });
275
+
276
+ describe("rastreio de revisão (incremental)", () => {
277
+ test("carimba reviewed e foundAtSha nos findings do ciclo", () => {
278
+ const project = makeProject({ "src/f.ts": DUPLICATED });
279
+ try {
280
+ const state = buildStateFromFindings(
281
+ [merged({ file: "src/f.ts", rule: "r", line: 2 })],
282
+ {
283
+ branch: "main",
284
+ readFile: fileReader(project.cwd),
285
+ headSha: "abc1234",
286
+ reviewed: { headSha: "abc1234", at: "2026-01-01T00:00:00.000Z" },
287
+ },
288
+ );
289
+ expect(state.reviewed?.headSha).toBe("abc1234");
290
+ const only = Object.values(state.findings)[0];
291
+ expect(only.foundAtSha).toBe("abc1234");
292
+ } finally {
293
+ project.cleanup();
294
+ }
295
+ });
296
+
297
+ test("carry-over preserva o foundAtSha original", () => {
298
+ const project = makeProject({ "src/f.ts": DUPLICATED });
299
+ try {
300
+ const state = buildStateFromFindings(
301
+ [
302
+ // Novo/re-reportado: recebe o head atual.
303
+ merged({ file: "src/f.ts", rule: "r", line: 2 }),
304
+ // Carry-over: carrega o `foundAtSha` de quando foi observado.
305
+ {
306
+ ...merged({ file: "src/f.ts", rule: "r2", line: 5 }),
307
+ foundAtSha: "old0001",
308
+ },
309
+ ],
310
+ {
311
+ branch: "main",
312
+ readFile: fileReader(project.cwd),
313
+ headSha: "new0002",
314
+ reviewed: { headSha: "new0002", at: "2026-01-02T00:00:00.000Z" },
315
+ },
316
+ );
317
+ const byRule = (rule: string): string | undefined =>
318
+ Object.values(state.findings).find((f) => f.rule === rule)?.foundAtSha;
319
+ expect(byRule("r")).toBe("new0002");
320
+ expect(byRule("r2")).toBe("old0001");
321
+ } finally {
322
+ project.cleanup();
323
+ }
324
+ });
325
+ });
@@ -0,0 +1,257 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { FindingState, FindingsStateFile } from "../core/findings-state";
3
+ import {
4
+ buildFixBatch,
5
+ chooseGrouping,
6
+ fixQueue,
7
+ groupFindings,
8
+ } from "../core/fix-batch";
9
+ import type { Severity } from "../core/types";
10
+
11
+ let seq = 0;
12
+
13
+ /** Finding de teste com defaults sensatos. */
14
+ function f(
15
+ partial: Partial<FindingState> & { file: string; rule: string; severity: Severity },
16
+ ): FindingState {
17
+ seq++;
18
+ return {
19
+ id: partial.id ?? `id${seq}`,
20
+ fingerprint: partial.fingerprint ?? `fp${seq}`,
21
+ watchdogIds: partial.watchdogIds ?? ["alpha"],
22
+ rule: partial.rule,
23
+ severity: partial.severity,
24
+ file: partial.file,
25
+ line: partial.line ?? 1,
26
+ message: partial.message ?? "m",
27
+ action: partial.action ?? "a",
28
+ evidence: partial.evidence,
29
+ anchor: null,
30
+ count: partial.count ?? 1,
31
+ present: partial.present ?? true,
32
+ decision: partial.decision ?? "execute",
33
+ comment: partial.comment,
34
+ execState: partial.execState ?? "pending",
35
+ createdAt: "",
36
+ updatedAt: "",
37
+ history: [],
38
+ };
39
+ }
40
+
41
+ function stateOf(findings: FindingState[]): FindingsStateFile {
42
+ return {
43
+ version: 2,
44
+ branch: "feature/x",
45
+ slug: "feature-x",
46
+ updatedAt: "",
47
+ order: findings.map((item) => item.id),
48
+ findings: Object.fromEntries(findings.map((item) => [item.id, item])),
49
+ };
50
+ }
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // chooseGrouping (heurística determinística)
54
+ // ---------------------------------------------------------------------------
55
+
56
+ describe("chooseGrouping", () => {
57
+ test("prefere arquivo quando há poucos arquivos com coesão", () => {
58
+ const findings = ["f1", "f2", "f3"].flatMap((file, i) => [
59
+ f({
60
+ file,
61
+ rule: `r${i * 2 + 1}`,
62
+ severity: "attention",
63
+ watchdogIds: [`w${i}`],
64
+ }),
65
+ f({ file, rule: `r${i * 2 + 2}`, severity: "nit", watchdogIds: [`w${i}`] }),
66
+ ]);
67
+ expect(chooseGrouping(findings)).toBe("file");
68
+ });
69
+
70
+ test("prefere regra quando uma regra domina vários arquivos", () => {
71
+ const findings = ["f1", "f2", "f3", "f4", "f5", "f6"].map((file, i) =>
72
+ f({
73
+ file,
74
+ rule: i < 3 ? "r1" : "r2",
75
+ severity: "attention",
76
+ watchdogIds: [`w${i}`],
77
+ }),
78
+ );
79
+ expect(chooseGrouping(findings)).toBe("rule");
80
+ });
81
+
82
+ test("cai em watchdog quando nem arquivo nem regra têm coesão", () => {
83
+ const findings = ["f1", "f2", "f3", "f4", "f5", "f6"].map((file, i) =>
84
+ f({
85
+ file,
86
+ rule: `r${Math.min(i, 4)}`,
87
+ severity: "attention",
88
+ watchdogIds: [i < 3 ? "w1" : "w2"],
89
+ }),
90
+ );
91
+ expect(chooseGrouping(findings)).toBe("watchdog");
92
+ });
93
+
94
+ test("sem elegíveis (tudo singleton), cai em file", () => {
95
+ const findings = ["f1", "f2"].map((file, i) =>
96
+ f({ file, rule: `r${i}`, severity: "nit", watchdogIds: [`w${i}`] }),
97
+ );
98
+ expect(chooseGrouping(findings)).toBe("file");
99
+ });
100
+
101
+ test("fila vazia devolve file", () => {
102
+ expect(chooseGrouping([])).toBe("file");
103
+ });
104
+ });
105
+
106
+ describe("groupFindings", () => {
107
+ test("agrupa por arquivo com chaves prefixadas", () => {
108
+ const findings = [
109
+ f({ file: "src/a.ts", rule: "r1", severity: "attention" }),
110
+ f({ file: "src/a.ts", rule: "r2", severity: "nit" }),
111
+ f({ file: "src/b.ts", rule: "r3", severity: "nit" }),
112
+ ];
113
+ const groups = groupFindings(findings, "file");
114
+ expect(groups.size).toBe(2);
115
+ expect(groups.get("file:src/a.ts")).toHaveLength(2);
116
+ expect(groups.get("file:src/b.ts")).toHaveLength(1);
117
+ });
118
+ });
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // fixQueue
122
+ // ---------------------------------------------------------------------------
123
+
124
+ describe("fixQueue", () => {
125
+ test("só execute, presentes e não done; includeDone inclui", () => {
126
+ const done = f({
127
+ file: "a.ts",
128
+ rule: "r1",
129
+ severity: "attention",
130
+ execState: "done",
131
+ });
132
+ const pending = f({ file: "a.ts", rule: "r2", severity: "attention" });
133
+ const ignored = f({
134
+ file: "a.ts",
135
+ rule: "r3",
136
+ severity: "attention",
137
+ decision: "ignore",
138
+ });
139
+ const absent = f({
140
+ file: "a.ts",
141
+ rule: "r4",
142
+ severity: "attention",
143
+ present: false,
144
+ });
145
+ const state = stateOf([done, pending, ignored, absent]);
146
+
147
+ expect(fixQueue(state).map((item) => item.id)).toEqual([pending.id]);
148
+ expect(fixQueue(state, { includeDone: true }).map((item) => item.id)).toEqual([
149
+ done.id,
150
+ pending.id,
151
+ ]);
152
+ });
153
+
154
+ test("filtra por severidade", () => {
155
+ const blocking = f({ file: "a.ts", rule: "r1", severity: "blocking" });
156
+ const nit = f({ file: "a.ts", rule: "r2", severity: "nit" });
157
+ const state = stateOf([blocking, nit]);
158
+ expect(fixQueue(state, { severity: "blocking" }).map((item) => item.id)).toEqual([
159
+ blocking.id,
160
+ ]);
161
+ });
162
+ });
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // buildFixBatch
166
+ // ---------------------------------------------------------------------------
167
+
168
+ /** Fila coesa em 2 arquivos / 2 watchdogs (arquivo e watchdog empatam → file). */
169
+ function coesaState(): FindingsStateFile {
170
+ return stateOf([
171
+ f({ file: "src/a.ts", rule: "r1", severity: "blocking", watchdogIds: ["alpha"] }),
172
+ f({
173
+ file: "src/a.ts",
174
+ rule: "r2",
175
+ severity: "attention",
176
+ watchdogIds: ["alpha"],
177
+ }),
178
+ f({
179
+ file: "src/a.ts",
180
+ rule: "r3",
181
+ severity: "attention",
182
+ watchdogIds: ["alpha"],
183
+ }),
184
+ f({ file: "src/a.ts", rule: "r4", severity: "nit", watchdogIds: ["alpha"] }),
185
+ f({ file: "src/b.ts", rule: "r5", severity: "attention", watchdogIds: ["beta"] }),
186
+ f({
187
+ file: "src/b.ts",
188
+ rule: "r6",
189
+ severity: "suggestion",
190
+ watchdogIds: ["beta"],
191
+ }),
192
+ ]);
193
+ }
194
+
195
+ describe("buildFixBatch", () => {
196
+ test("escolhe o agrupamento e devolve grupo atual + outros grupos", () => {
197
+ const result = buildFixBatch(coesaState());
198
+ expect(result.dimension).toBe("file");
199
+ expect(result.totals).toEqual({ queue: 6, groups: 2, returned: 4 });
200
+ expect(result.current.key).toBe("file:src/a.ts");
201
+ expect(result.current.total).toBe(4);
202
+ expect(result.current.findings).toHaveLength(4);
203
+ expect(result.current.remaining).toBe(0);
204
+ expect(result.others).toHaveLength(1);
205
+ expect(result.others[0].key).toBe("file:src/b.ts");
206
+ expect(result.others[0].count).toBe(2);
207
+ });
208
+
209
+ test("pagina o grupo atual com pageSize", () => {
210
+ const result = buildFixBatch(coesaState(), { pageSize: 2 });
211
+ expect(result.current.returned).toBe(2);
212
+ expect(result.current.remaining).toBe(2);
213
+ expect(result.totals.returned).toBe(2);
214
+ });
215
+
216
+ test("--group foca um grupo específico", () => {
217
+ const result = buildFixBatch(coesaState(), { group: "file:src/b.ts" });
218
+ expect(result.current.key).toBe("file:src/b.ts");
219
+ expect(result.current.total).toBe(2);
220
+ expect(result.others).toHaveLength(1);
221
+ expect(result.others[0].key).toBe("file:src/a.ts");
222
+ });
223
+
224
+ test("--group inexistente lança com as chaves disponíveis", () => {
225
+ expect(() => buildFixBatch(coesaState(), { group: "file:nao-existe" })).toThrow(
226
+ /não encontrado/,
227
+ );
228
+ });
229
+
230
+ test("--severity filtra a fila", () => {
231
+ const result = buildFixBatch(coesaState(), { severity: "blocking" });
232
+ expect(result.totals.queue).toBe(1);
233
+ expect(result.current.key).toBe("file:src/a.ts");
234
+ expect(result.current.total).toBe(1);
235
+ expect(result.others).toHaveLength(0);
236
+ });
237
+
238
+ test("fila vazia devolve lote vazio", () => {
239
+ const state = stateOf([
240
+ f({ file: "a.ts", rule: "r1", severity: "attention", decision: "pending" }),
241
+ ]);
242
+ const result = buildFixBatch(state);
243
+ expect(result.totals).toEqual({ queue: 0, groups: 0, returned: 0 });
244
+ expect(result.current.findings).toHaveLength(0);
245
+ expect(result.others).toHaveLength(0);
246
+ });
247
+
248
+ test("outros grupos trazem o mix de severidade", () => {
249
+ const result = buildFixBatch(coesaState());
250
+ expect(result.others[0].bySeverity).toEqual({
251
+ blocking: 0,
252
+ attention: 1,
253
+ suggestion: 1,
254
+ nit: 0,
255
+ });
256
+ });
257
+ });
@@ -139,6 +139,42 @@ describe("registry-check", () => {
139
139
  const { warnings } = checkRegistry(config as typeof fakeHoundsmanConfig);
140
140
  expect(warnings).toEqual([]);
141
141
  });
142
+
143
+ test("regra duplicada no mesmo watchdog gera aviso", () => {
144
+ const config = {
145
+ ...fakeHoundsmanConfig,
146
+ watchdogs: {
147
+ ...fakeHoundsmanConfig.watchdogs,
148
+ alpha: {
149
+ ...alphaWatchdog,
150
+ rules: [
151
+ { severity: "attention" as const, text: "Evitar console.log." },
152
+ { severity: "blocking" as const, text: "Evitar console.log." },
153
+ ],
154
+ },
155
+ },
156
+ };
157
+ const { warnings } = checkRegistry(config as typeof fakeHoundsmanConfig);
158
+ expect(warnings.some((w) => w.includes("duplicada"))).toBe(true);
159
+ });
160
+
161
+ test("texto de regra em reviewFocus/contextFiles gera aviso", () => {
162
+ const config = {
163
+ ...fakeHoundsmanConfig,
164
+ watchdogs: {
165
+ ...fakeHoundsmanConfig.watchdogs,
166
+ alpha: {
167
+ ...alphaWatchdog,
168
+ reviewFocus: ["Evitar: console.log."],
169
+ contextFiles: ["Promover: validar a entrada."],
170
+ },
171
+ },
172
+ };
173
+ const { warnings } = checkRegistry(config as typeof fakeHoundsmanConfig);
174
+ expect(
175
+ warnings.filter((w) => w.includes("a regra vive só em 'rules'")),
176
+ ).toHaveLength(2);
177
+ });
142
178
  });
143
179
 
144
180
  // ---------------------------------------------------------------------------