@payconductor/houndsman 0.13.0 → 0.16.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 (116) hide show
  1. package/README.md +6 -1
  2. package/package.json +1 -1
  3. package/src/cli/commands/compare-command.ts +112 -0
  4. package/src/cli/commands/context-command.ts +1 -0
  5. package/src/cli/commands/findings-command.ts +11 -1
  6. package/src/cli/commands/golden-command.ts +92 -0
  7. package/src/cli/commands/hunt-command.ts +105 -4
  8. package/src/cli/commands/review-command.ts +38 -0
  9. package/src/cli/commands/status-command.ts +3 -2
  10. package/src/cli/help.ts +6 -1
  11. package/src/cli/index.ts +5 -1
  12. package/src/cli/progress.ts +7 -2
  13. package/src/core/ai/ai-client.ts +38 -2
  14. package/src/core/ai/features/brief-enricher.ts +1 -1
  15. package/src/core/ai/features/business-analyzer.ts +61 -0
  16. package/src/core/ai/features/business-summarizer.ts +44 -0
  17. package/src/core/ai/index.ts +8 -0
  18. package/src/core/ai/prompts/brief.ts +33 -7
  19. package/src/core/ai/prompts/business.ts +76 -0
  20. package/src/core/ai/provider-capabilities.ts +12 -0
  21. package/src/core/ai/schemas.ts +70 -2
  22. package/src/core/ai/usage-recorder.ts +53 -1
  23. package/src/core/brief.ts +278 -16
  24. package/src/core/builtins.ts +108 -0
  25. package/src/core/business-summary.ts +583 -0
  26. package/src/core/commands/compare.ts +266 -0
  27. package/src/core/commands/context.ts +6 -2
  28. package/src/core/commands/findings.ts +38 -30
  29. package/src/core/commands/golden.ts +176 -0
  30. package/src/core/commands/review.ts +6 -0
  31. package/src/core/commands/scaffold.ts +29 -0
  32. package/src/core/commands/status.ts +9 -4
  33. package/src/core/compare.ts +310 -0
  34. package/src/core/concurrency.ts +30 -0
  35. package/src/core/config.ts +41 -3
  36. package/src/core/defaults.ts +50 -4
  37. package/src/core/domain/budget.ts +62 -4
  38. package/src/core/domain/classification.ts +12 -2
  39. package/src/core/engine/carryover.ts +4 -0
  40. package/src/core/engine/finalize-pipeline.ts +371 -0
  41. package/src/core/engine/prepare-pipeline.ts +1294 -0
  42. package/src/core/engine/review-context.ts +51 -0
  43. package/src/core/engine/review-options.ts +47 -0
  44. package/src/core/engine/review-scope.ts +14 -1
  45. package/src/core/engine/review-session.ts +11 -13
  46. package/src/core/engine/run-artifacts.ts +196 -0
  47. package/src/core/engine/run-format.ts +4 -0
  48. package/src/core/findings-state.ts +22 -2
  49. package/src/core/github-review.ts +5 -2
  50. package/src/core/golden.ts +346 -0
  51. package/src/core/hunt/hunt-runner.ts +621 -34
  52. package/src/core/hunt/index.ts +40 -1
  53. package/src/core/hunt/maki-intent-prompt.ts +50 -0
  54. package/src/core/hunt/maki-prompt.ts +298 -5
  55. package/src/core/hunt/maki-runner.ts +93 -16
  56. package/src/core/hunt/pool.ts +4 -26
  57. package/src/core/hunt/review-checks.ts +460 -0
  58. package/src/core/logging/index.ts +6 -1
  59. package/src/core/logging/progress.ts +85 -1
  60. package/src/core/orchestrator.ts +33 -1289
  61. package/src/core/paths.ts +6 -3
  62. package/src/core/pr-context.ts +2 -4
  63. package/src/core/prepare-report.ts +61 -0
  64. package/src/core/project-config.ts +18 -1
  65. package/src/core/propagation.ts +127 -0
  66. package/src/core/registry-check.ts +90 -27
  67. package/src/core/renderers/manifest-writer.ts +13 -0
  68. package/src/core/renderers/resume-renderer.ts +151 -11
  69. package/src/core/report-builder.ts +116 -278
  70. package/src/core/severity.ts +245 -0
  71. package/src/core/storage/file/file-storage.ts +7 -3
  72. package/src/core/storage/sqlite/mappers.ts +10 -0
  73. package/src/core/storage/sqlite/schema.ts +104 -23
  74. package/src/core/storage/sqlite/sqlite-storage.ts +22 -9
  75. package/src/core/storage/types.ts +17 -4
  76. package/src/core/storage/work-area.ts +59 -1
  77. package/src/core/types.ts +31 -0
  78. package/src/core/watchdog-registry.ts +21 -3
  79. package/src/index.ts +51 -4
  80. package/src/server/client/ArtifactsPanel.tsx +4 -2
  81. package/src/server/client/FindingCard.tsx +21 -1
  82. package/src/server/client/RunsSection.tsx +5 -0
  83. package/src/server/client/utils/constants.ts +21 -11
  84. package/src/server/client/utils/markdown.ts +1 -1
  85. package/src/server/client/utils/types.ts +14 -2
  86. package/src/server/run-repository.ts +35 -14
  87. package/src/server/styles.css +2 -0
  88. package/src/skill/OUTPUT-FORMAT.md +55 -10
  89. package/src/skill/README.md +19 -14
  90. package/src/skill/SETUP.md +1 -0
  91. package/src/skill/SKILL.md +13 -4
  92. package/src/skill/example-resume.md +43 -0
  93. package/src/skill/hunt.md +205 -58
  94. package/src/skill/train.md +3 -0
  95. package/src/tests/__snapshots__/baseline.test.ts.snap +208 -184
  96. package/src/tests/ai-core.test.ts +46 -0
  97. package/src/tests/ai-features.test.ts +95 -4
  98. package/src/tests/baseline.test.ts +29 -121
  99. package/src/tests/brief-scope.test.ts +250 -0
  100. package/src/tests/business-summary.test.ts +258 -0
  101. package/src/tests/compare.test.ts +256 -0
  102. package/src/tests/findings-state.test.ts +82 -0
  103. package/src/tests/fixtures/fake-model-provider.ts +2 -1
  104. package/src/tests/golden.test.ts +195 -0
  105. package/src/tests/hunt-maki.test.ts +710 -2
  106. package/src/tests/markdown.test.ts +3 -3
  107. package/src/tests/progress.test.ts +29 -1
  108. package/src/tests/propagation.test.ts +119 -0
  109. package/src/tests/review-resume.test.ts +60 -1
  110. package/src/tests/review-scope.test.ts +38 -29
  111. package/src/tests/review-scripts.test.ts +125 -218
  112. package/src/tests/server.test.ts +7 -4
  113. package/src/tests/severity.test.ts +235 -0
  114. package/src/tests/storage.test.ts +194 -9
  115. package/src/core/renderers/report-renderer.ts +0 -66
  116. package/src/skill/example-review.md +0 -66
@@ -14,6 +14,9 @@ import {
14
14
  } from "../core/config";
15
15
  import { parseDiffIndex, parseNumstat } from "../core/diff-source";
16
16
  import { discoverDocs } from "../core/doc-discoverer";
17
+ import { riskOf } from "../core/domain/budget";
18
+ import { coverageOf, unaccountedFiles } from "../core/engine/finalize-pipeline";
19
+ import { overlapMessage } from "../core/engine/prepare-pipeline";
17
20
  import {
18
21
  type ClassificationResult,
19
22
  churnOf,
@@ -37,8 +40,6 @@ import {
37
40
  dedupeFindings,
38
41
  parseContextSummary,
39
42
  parseWatchdogReport,
40
- renderDomainReport,
41
- renderReport,
42
43
  sanitizeBranch,
43
44
  type WatchdogReport,
44
45
  } from "../core/report-builder";
@@ -48,6 +49,7 @@ import { findWatchdog, getWatchdogContext } from "../core/watchdog-registry";
48
49
 
49
50
  import {
50
51
  alphaWatchdog,
52
+ betaWatchdog,
51
53
  FAKE_WATCHDOG_DEFINITIONS,
52
54
  fakeHoundsmanConfig,
53
55
  } from "./fixtures/fake-project";
@@ -158,6 +160,24 @@ describe("registry-check", () => {
158
160
  expect(warnings.some((w) => w.includes("duplicada"))).toBe(true);
159
161
  });
160
162
 
163
+ test("mesma regra com severidades diferentes entre watchdogs gera aviso", () => {
164
+ const config = {
165
+ ...fakeHoundsmanConfig,
166
+ watchdogs: {
167
+ ...fakeHoundsmanConfig.watchdogs,
168
+ beta: {
169
+ ...betaWatchdog,
170
+ rules: [
171
+ ...betaWatchdog.rules,
172
+ { severity: "suggestion" as const, text: "Nunca usar `any`." },
173
+ ],
174
+ },
175
+ },
176
+ };
177
+ const { warnings } = checkRegistry(config as typeof fakeHoundsmanConfig);
178
+ expect(warnings.some((w) => w.includes("severidades divergentes"))).toBe(true);
179
+ });
180
+
161
181
  test("texto de regra em reviewFocus/contextFiles gera aviso", () => {
162
182
  const config = {
163
183
  ...fakeHoundsmanConfig,
@@ -451,6 +471,83 @@ describe("triage", () => {
451
471
  expect(overflow).toEqual(["a.ts"]);
452
472
  });
453
473
 
474
+ test("prioridade soma ao churn (linhas equivalentes) sem virar tier rígido", () => {
475
+ const priorities = [{ pattern: /^server\/db\//, bonus: 300 }];
476
+ const files = ["client/big.ts", "server/db/small.ts"];
477
+ expect(
478
+ sortByRisk(
479
+ files,
480
+ {
481
+ "client/big.ts": { additions: 200, deletions: 0 },
482
+ "server/db/small.ts": { additions: 10, deletions: 0 },
483
+ },
484
+ priorities,
485
+ ),
486
+ ).toEqual(["server/db/small.ts", "client/big.ts"]);
487
+ // Churn puro continua vencendo quando é muito maior (400 > 10 + 300).
488
+ expect(
489
+ sortByRisk(
490
+ files,
491
+ {
492
+ "client/big.ts": { additions: 400, deletions: 0 },
493
+ "server/db/small.ts": { additions: 10, deletions: 0 },
494
+ },
495
+ priorities,
496
+ ),
497
+ ).toEqual(["client/big.ts", "server/db/small.ts"]);
498
+ // Sem prioridade configurada, comportamento antigo.
499
+ expect(
500
+ sortByRisk(files, {
501
+ "client/big.ts": { additions: 200, deletions: 0 },
502
+ "server/db/small.ts": { additions: 10, deletions: 0 },
503
+ }),
504
+ ).toEqual(["client/big.ts", "server/db/small.ts"]);
505
+ // O orçamento respeita a prioridade.
506
+ expect(
507
+ selectByBudget(
508
+ files,
509
+ {
510
+ "client/big.ts": { additions: 200, deletions: 0 },
511
+ "server/db/small.ts": { additions: 10, deletions: 0 },
512
+ },
513
+ 1,
514
+ priorities,
515
+ ).selected,
516
+ ).toEqual(["server/db/small.ts"]);
517
+ });
518
+
519
+ test("maxRiskFromChurn satura o superchurn de caminho com baixa criticidade", () => {
520
+ const priorities = [
521
+ { pattern: /^server\/db\//, bonus: 300 },
522
+ { pattern: /^client\//, maxRiskFromChurn: 120 },
523
+ ];
524
+ const files = ["client/table.tsx", "server/db/migration.ts"];
525
+ const churn = {
526
+ "client/table.tsx": { additions: 1900, deletions: 100 },
527
+ "server/db/migration.ts": { additions: 20, deletions: 0 },
528
+ };
529
+ // 2.000 linhas de UI "boba" viram 120; 20 linhas de db valem 320.
530
+ expect(riskOf("client/table.tsx", churn, priorities)).toBe(120);
531
+ expect(sortByRisk(files, churn, priorities)).toEqual([
532
+ "server/db/migration.ts",
533
+ "client/table.tsx",
534
+ ]);
535
+ // Abaixo do teto, o caminho mantém o peso natural do churn.
536
+ expect(
537
+ riskOf(
538
+ "client/table.tsx",
539
+ {
540
+ "client/table.tsx": { additions: 15, deletions: 0 },
541
+ },
542
+ priorities,
543
+ ),
544
+ ).toBe(15);
545
+ // Sem teto configurado, o churn puro continua vencendo.
546
+ expect(
547
+ riskOf("client/table.tsx", churn, [{ pattern: /^server\/db\//, bonus: 300 }]),
548
+ ).toBe(2000);
549
+ });
550
+
454
551
  test("maxFiles não-positivo desativa o orçamento", () => {
455
552
  const { selected, overflow } = selectByBudget(["a.ts", "b.ts"], churn, 0);
456
553
  expect(selected).toHaveLength(2);
@@ -572,196 +669,35 @@ describe("report-builder", () => {
572
669
  expect(merged[0].message).toBe("maior");
573
670
  });
574
671
 
575
- test("renderReport marca status conforme severidade", () => {
576
- const reports: WatchdogReport[] = [
577
- { watchdogId: "alpha", reviewedFiles: ["s.ts"], skipped: [], findings: [] },
578
- ];
579
-
580
- expect(renderReport("main", reports, [], {})).toContain("✅ Aprovado");
581
-
582
- const blockers = renderReport(
583
- "main",
584
- reports,
585
- [
586
- {
587
- rule: "Regra bloqueante",
588
- severity: "blocking",
589
- file: "s.ts",
590
- message: "m",
591
- action: "a",
592
- watchdogs: ["alpha"],
593
- },
594
- ],
595
- {},
596
- );
597
- expect(blockers).toContain("❌ Mudanças Necessárias");
598
- expect(blockers).toContain("## ❌ blocking");
599
- });
600
-
601
- test("renderReport mostra cobertura pendente", () => {
602
- const reports: WatchdogReport[] = [
603
- { watchdogId: "alpha", reviewedFiles: ["s.ts"], skipped: [], findings: [] },
604
- ];
605
- const md = renderReport("main", reports, [], {
606
- classified: { alpha: ["s.ts", "t.ts"] },
607
- });
608
- expect(md).toContain("faltam 1");
609
- });
610
-
611
- test("renderReport usa o resolver de nome injetado", () => {
612
- const reports: WatchdogReport[] = [
613
- { watchdogId: "alpha", reviewedFiles: [], skipped: [], findings: [] },
614
- ];
615
- const md = renderReport("main", reports, [], {}, undefined, undefined, (id) =>
616
- id === "alpha" ? "Watchdog Alpha" : id,
617
- );
618
- expect(md).toContain("Watchdog Alpha");
619
- });
620
-
621
- test("renderReport agrupa achados por arquivo", () => {
622
- const merged = [
623
- {
624
- rule: "R1",
625
- severity: "blocking" as const,
626
- file: "a.ts",
627
- line: 1,
628
- message: "m",
629
- action: "a",
630
- watchdogs: ["alpha"],
631
- },
632
- {
633
- rule: "R2",
634
- severity: "blocking" as const,
635
- file: "b.ts",
636
- line: 2,
637
- message: "m",
638
- action: "a",
639
- watchdogs: ["alpha"],
640
- },
641
- ];
642
- const md = renderReport(
643
- "main",
644
- [{ watchdogId: "alpha", reviewedFiles: [], skipped: [], findings: [] }],
645
- merged,
646
- {},
647
- );
648
- expect(md).toContain("### `a.ts`");
649
- expect(md).toContain("### `b.ts`");
650
- });
651
-
652
- test("renderReport indexa os relatórios por domínio", () => {
653
- const md = renderReport(
654
- "main",
655
- [{ watchdogId: "alpha", reviewedFiles: [], skipped: [], findings: [] }],
656
- [],
657
- {},
658
- undefined,
659
- undefined,
660
- undefined,
661
- [
662
- {
663
- id: "alpha",
664
- name: "Alpha",
665
- file: "review.alpha.md",
666
- findings: 2,
667
- reviewedFiles: 3,
668
- expectedFiles: 5,
669
- },
670
- ],
672
+ test("cobertura ignora arquivos declarados fora do escopo", () => {
673
+ const coverage = coverageOf(
674
+ ["src/a.ts", "src/b.ts", "src/outro.ts", "src/a.ts"],
675
+ ["src/a.ts", "src/b.ts", "src/c.ts"],
671
676
  );
672
- expect(md).toContain("## Relatórios por domínio");
673
- expect(md).toContain("review.alpha.md");
677
+ expect(coverage).toEqual({ reviewed: 2, expected: 3 });
674
678
  });
675
679
 
676
- test("renderReport coloca o sumário antes dos achados", () => {
677
- const md = renderReport(
678
- "main",
679
- [{ watchdogId: "alpha", reviewedFiles: ["a.ts"], skipped: [], findings: [] }],
680
- [
681
- {
682
- rule: "R1",
683
- severity: "blocking" as const,
684
- file: "a.ts",
685
- line: 1,
686
- message: "m",
687
- action: "a",
688
- watchdogs: ["alpha"],
689
- },
690
- ],
691
- { classified: { alpha: ["a.ts"] } },
692
- null,
693
- undefined,
694
- undefined,
695
- [
696
- {
697
- id: "alpha",
698
- name: "Alpha",
699
- file: "review.alpha.md",
700
- findings: 1,
701
- reviewedFiles: 1,
702
- expectedFiles: 1,
703
- },
704
- ],
705
- );
706
- const sections = [
707
- "## Relatórios por domínio",
708
- "## Cobertura",
709
- "## Conclusão",
710
- "## ❌ blocking",
711
- ].map((section) => md.indexOf(section));
712
- expect(sections.every((index) => index >= 0)).toBe(true);
713
- expect([...sections].sort((a, b) => a - b)).toEqual(sections);
714
- });
715
-
716
- test("renderReport anexa candidatos Jev depois dos achados", () => {
717
- const md = renderReport(
718
- "main",
719
- [{ watchdogId: "alpha", reviewedFiles: [], skipped: [], findings: [] }],
720
- [
721
- {
722
- rule: "R1",
723
- severity: "blocking" as const,
724
- file: "a.ts",
725
- line: 1,
726
- message: "m",
727
- action: "a",
728
- watchdogs: ["alpha"],
729
- },
730
- ],
731
- {},
732
- null,
733
- [
734
- {
735
- watchdogId: "alpha",
736
- rule: "R2",
737
- severity: "attention",
738
- file: "b.ts",
739
- confidence: 0.6,
740
- questionKey: "q",
741
- },
742
- ],
743
- );
744
- expect(md.indexOf("## ❌ blocking")).toBeLessThan(
745
- md.indexOf("## 🔎 Candidatos não verificados"),
746
- );
680
+ test("lista arquivos sem revisão e sem declaração em skipped", () => {
681
+ expect(
682
+ unaccountedFiles(
683
+ ["src/a.ts", "src/fora.ts"],
684
+ ["src/c.ts"],
685
+ ["src/a.ts", "src/c.ts", "src/b.ts"],
686
+ ),
687
+ ).toEqual(["src/b.ts"]);
747
688
  });
748
689
 
749
- test("renderDomainReport mostra cobertura e não revisados", () => {
750
- const md = renderDomainReport(
751
- "main",
752
- {
753
- watchdogId: "alpha",
754
- reviewedFiles: ["a.ts"],
755
- skipped: ["b.ts"],
756
- findings: [],
757
- },
758
- [],
759
- 2,
760
- null,
761
- );
762
- expect(md).toContain("1/2");
763
- expect(md).toContain("Não revisados");
764
- expect(md).toContain("Nenhum achado");
690
+ test("avisa quando um arquivo entra em vários domínios", () => {
691
+ expect(
692
+ overlapMessage({
693
+ watchdogs: { alpha: ["a.ts", "b.ts"], beta: ["b.ts"], gamma: [] },
694
+ }),
695
+ ).toEqual([
696
+ "Escopo: 1 arquivo(s) em mais de um domínio (máx. 2) — cada domínio revisa o arquivo com o seu recorte.",
697
+ ]);
698
+ expect(
699
+ overlapMessage({ watchdogs: { alpha: ["a.ts"], beta: ["b.ts"] } }),
700
+ ).toEqual([]);
765
701
  });
766
702
  });
767
703
 
@@ -803,35 +739,6 @@ describe("context-summary", () => {
803
739
  expect(summary?.objective?.endsWith("…")).toBe(true);
804
740
  expect(summary?.changeSummary).toHaveLength(6);
805
741
  });
806
-
807
- test("renderReport coloca a seção de contexto quando presente", () => {
808
- const reports: WatchdogReport[] = [
809
- { watchdogId: "alpha", reviewedFiles: [], skipped: [], findings: [] },
810
- ];
811
- const md = renderReport(
812
- "main",
813
- reports,
814
- [],
815
- {},
816
- {
817
- available: true,
818
- objective: "Entregar X",
819
- motivation: "Reduzir Y",
820
- changeSummary: ["Faz A"],
821
- },
822
- );
823
- expect(md).toContain("## Contexto da PR");
824
- expect(md).toContain("**Objetivo:** Entregar X");
825
- });
826
-
827
- test("renderReport omite a seção sem contexto", () => {
828
- const reports: WatchdogReport[] = [
829
- { watchdogId: "alpha", reviewedFiles: [], skipped: [], findings: [] },
830
- ];
831
- expect(renderReport("main", reports, [], {}, null)).not.toContain(
832
- "## Contexto da PR",
833
- );
834
- });
835
742
  });
836
743
 
837
744
  // ---------------------------------------------------------------------------
@@ -894,7 +801,7 @@ describe("houndsman brief", () => {
894
801
  expect(brief).toContain("**Objetivo:** Entregar X");
895
802
  });
896
803
 
897
- test("mostra churn e lista arquivos fora do orçamento", () => {
804
+ test("mostra churn e lista arquivos fora do recorte", () => {
898
805
  const brief = renderBrief(alphaWatchdog, {
899
806
  watchdogId: "alpha",
900
807
  files: ["src/alpha/keep.ts"],
@@ -908,7 +815,7 @@ describe("houndsman brief", () => {
908
815
  deleted: [],
909
816
  });
910
817
  expect(brief).toContain("+12/−3");
911
- expect(brief).toContain("Fora do orçamento");
818
+ expect(brief).toContain("Fora do recorte");
912
819
  expect(brief).toContain("src/alpha/skip.ts");
913
820
  });
914
821
 
@@ -64,8 +64,8 @@ function makeEnv(): {
64
64
  content: JSON.stringify({ watchdogs: { alpha: ["src/alpha/f.ts"] } }),
65
65
  });
66
66
  storage.writeArtifact(review.id, {
67
- name: "review.md",
68
- kind: "review",
67
+ name: "review-resume.md",
68
+ kind: "resume",
69
69
  content: "# Review\n",
70
70
  });
71
71
  storage.writeArtifact(review.id, {
@@ -386,7 +386,7 @@ describe("server API", () => {
386
386
  expect.arrayContaining([
387
387
  "manifest.json",
388
388
  "classification.json",
389
- "review.md",
389
+ "review-resume.md",
390
390
  "briefs/alpha.md",
391
391
  ]),
392
392
  );
@@ -406,7 +406,10 @@ describe("server API", () => {
406
406
  expect(ok.status).toBe(200);
407
407
  expect(await ok.json()).toEqual({ branch: "main" });
408
408
 
409
- const review = await get(handler, "/api/runs/main/artifacts/review.md");
409
+ const review = await get(
410
+ handler,
411
+ "/api/runs/main/artifacts/review-resume.md",
412
+ );
410
413
  expect(review.status).toBe(200);
411
414
  expect(await review.text()).toContain("# Review");
412
415
 
@@ -0,0 +1,235 @@
1
+ /** Severidade determinística: ancoragem por regra e por classe de impacto. */
2
+
3
+ import { describe, expect, test } from "bun:test";
4
+ import type { Finding, WatchdogReport } from "../core/report-builder";
5
+ import {
6
+ anchorReports,
7
+ IMPACT_CLASSES,
8
+ normalizeRuleText,
9
+ resolveDeclaredSeverity,
10
+ SEVERITY_BY_IMPACT,
11
+ severityAnchoringMessages,
12
+ } from "../core/severity";
13
+ import type { Rule } from "../core/types";
14
+ import { FAKE_WATCHDOG_DEFINITIONS } from "./fixtures/fake-project";
15
+
16
+ /** União das regras do projeto fake (o mesmo que o finalize monta). */
17
+ function declaredRules(): Rule[] {
18
+ return Object.values(FAKE_WATCHDOG_DEFINITIONS).flatMap((def) => [...def.rules]);
19
+ }
20
+
21
+ function finding(partial: Partial<Finding> & { rule: string }): Finding {
22
+ return {
23
+ rule: partial.rule,
24
+ severity: partial.severity ?? "attention",
25
+ file: partial.file ?? "src/alpha/a.ts",
26
+ line: partial.line,
27
+ message: partial.message ?? "msg",
28
+ action: partial.action ?? "ação",
29
+ basis: partial.basis,
30
+ impactClass: partial.impactClass,
31
+ evidence: partial.evidence,
32
+ scenario: partial.scenario,
33
+ };
34
+ }
35
+
36
+ function report(findings: Finding[], watchdogId = "alpha"): WatchdogReport {
37
+ return {
38
+ watchdogId,
39
+ reviewedFiles: ["src/alpha/a.ts"],
40
+ skipped: [],
41
+ findings,
42
+ };
43
+ }
44
+
45
+ describe("normalizeRuleText", () => {
46
+ test("remove markdown, emoji, bullet e prefixo de severidade do brief", () => {
47
+ expect(
48
+ normalizeRuleText(
49
+ "- ❌ **blocking** — Promover: todo fluxo tem caminho completo.",
50
+ ),
51
+ ).toBe("promover: todo fluxo tem caminho completo");
52
+ });
53
+
54
+ test("colapsa espaços e ignora caixa", () => {
55
+ expect(normalizeRuleText("Evitar CONSOLE.log")).toBe("evitar console.log");
56
+ });
57
+ });
58
+
59
+ describe("resolveDeclaredSeverity", () => {
60
+ test("casa pelo texto exato normalizado", () => {
61
+ expect(
62
+ resolveDeclaredSeverity("Nunca usar `any`.", declaredRules())?.severity,
63
+ ).toBe("blocking");
64
+ });
65
+
66
+ test("casa quando o watchdog cita um trecho da regra", () => {
67
+ const declared = resolveDeclaredSeverity(
68
+ "Vale para todo handler: validar entrada do usuário",
69
+ declaredRules(),
70
+ );
71
+ expect(declared?.severity).toBe("attention");
72
+ });
73
+
74
+ test("texto sem casamento devolve null", () => {
75
+ expect(
76
+ resolveDeclaredSeverity(
77
+ "Regra inventada que não está no brief.",
78
+ declaredRules(),
79
+ ),
80
+ ).toBeNull();
81
+ });
82
+
83
+ test("mesmo texto em dois watchdogs fica com a severidade mais grave", () => {
84
+ const rules: Rule[] = [
85
+ { severity: "attention", text: "Promover: todo fluxo tem caminho completo." },
86
+ { severity: "blocking", text: "Promover: todo fluxo tem caminho completo." },
87
+ ];
88
+ expect(
89
+ resolveDeclaredSeverity("Promover: todo fluxo tem caminho completo.", rules)
90
+ ?.severity,
91
+ ).toBe("blocking");
92
+ });
93
+ });
94
+
95
+ describe("impactClass", () => {
96
+ test("toda classe tem severidade mapeada", () => {
97
+ expect(IMPACT_CLASSES).toHaveLength(4);
98
+ expect(SEVERITY_BY_IMPACT["flow-break"]).toBe("blocking");
99
+ expect(SEVERITY_BY_IMPACT["contract-drift"]).toBe("attention");
100
+ expect(SEVERITY_BY_IMPACT.degradation).toBe("suggestion");
101
+ expect(SEVERITY_BY_IMPACT.cosmetic).toBe("nit");
102
+ });
103
+ });
104
+
105
+ describe("anchorReports", () => {
106
+ test("finding de regra assume a severidade declarada e registra a divergência", () => {
107
+ const anchoring = anchorReports(
108
+ [report([finding({ rule: "Nunca usar `any`.", severity: "attention" })])],
109
+ declaredRules(),
110
+ );
111
+ expect(anchoring.reports[0]?.findings[0]?.severity).toBe("blocking");
112
+ expect(anchoring.anchored).toBe(1);
113
+ expect(anchoring.diverged).toHaveLength(1);
114
+ expect(anchoring.diverged[0]).toMatchObject({
115
+ emitted: "attention",
116
+ resolved: "blocking",
117
+ source: "rule",
118
+ watchdogId: "alpha",
119
+ });
120
+ });
121
+
122
+ test("finding de regra sem casamento mantém o valor e entra em unmatched", () => {
123
+ const anchoring = anchorReports(
124
+ [
125
+ report([
126
+ finding({
127
+ rule: "Regra parafraseada fora do brief",
128
+ severity: "nit",
129
+ }),
130
+ ]),
131
+ ],
132
+ declaredRules(),
133
+ );
134
+ expect(anchoring.reports[0]?.findings[0]?.severity).toBe("nit");
135
+ expect(anchoring.unmatched).toBe(1);
136
+ expect(anchoring.diverged).toHaveLength(0);
137
+ });
138
+
139
+ test("emergente com flow-break e prova vira blocking", () => {
140
+ const anchoring = anchorReports(
141
+ [
142
+ report([
143
+ finding({
144
+ rule: "Lógica: renovação não fecha o fluxo",
145
+ severity: "suggestion",
146
+ basis: "logic",
147
+ impactClass: "flow-break",
148
+ scenario: "renovação de assinatura",
149
+ evidence: "await renew(data)",
150
+ }),
151
+ ]),
152
+ ],
153
+ declaredRules(),
154
+ );
155
+ expect(anchoring.reports[0]?.findings[0]?.severity).toBe("blocking");
156
+ expect(anchoring.classified).toBe(1);
157
+ expect(anchoring.diverged[0]).toMatchObject({
158
+ source: "impact",
159
+ resolved: "blocking",
160
+ });
161
+ });
162
+
163
+ test("flow-break sem cenário+evidência cai para attention", () => {
164
+ const anchoring = anchorReports(
165
+ [
166
+ report([
167
+ finding({
168
+ rule: "Domínio: estado sem tratamento",
169
+ severity: "suggestion",
170
+ basis: "domain",
171
+ impactClass: "flow-break",
172
+ }),
173
+ ]),
174
+ ],
175
+ declaredRules(),
176
+ );
177
+ expect(anchoring.reports[0]?.findings[0]?.severity).toBe("attention");
178
+ expect(anchoring.diverged[0]?.resolved).toBe("attention");
179
+ });
180
+
181
+ test("emergente sem impactClass mantém o valor emitido", () => {
182
+ const anchoring = anchorReports(
183
+ [
184
+ report([
185
+ finding({
186
+ rule: "Domínio: algo",
187
+ severity: "nit",
188
+ basis: "domain",
189
+ impactClass: "desconhecida",
190
+ }),
191
+ ]),
192
+ ],
193
+ declaredRules(),
194
+ );
195
+ expect(anchoring.reports[0]?.findings[0]?.severity).toBe("nit");
196
+ expect(anchoring.classified).toBe(0);
197
+ expect(anchoring.unmatched).toBe(0);
198
+ expect(anchoring.diverged).toHaveLength(0);
199
+ });
200
+ });
201
+
202
+ describe("severityAnchoringMessages", () => {
203
+ test("resume contagens, exemplos e a falta de casamento", () => {
204
+ const anchoring = anchorReports(
205
+ [
206
+ report([
207
+ finding({ rule: "Nunca usar `any`.", severity: "attention" }),
208
+ finding({
209
+ rule: "Lógica: fluxo não fecha",
210
+ severity: "nit",
211
+ basis: "logic",
212
+ impactClass: "flow-break",
213
+ scenario: "gatilho",
214
+ evidence: "trecho",
215
+ }),
216
+ finding({ rule: "Regra inventada", severity: "nit" }),
217
+ ]),
218
+ ],
219
+ declaredRules(),
220
+ );
221
+ const messages = severityAnchoringMessages(anchoring);
222
+ expect(messages[0]).toContain("1 pela regra");
223
+ expect(messages[0]).toContain("1 pelo impacto");
224
+ expect(messages[0]).toContain("2 divergência(s)");
225
+ expect(messages.some((message) => message.includes("sem casamento"))).toBe(true);
226
+ });
227
+
228
+ test("silencia quando nada é resolvido", () => {
229
+ const anchoring = anchorReports(
230
+ [report([finding({ rule: "Lógica: x", basis: "logic" })])],
231
+ declaredRules(),
232
+ );
233
+ expect(severityAnchoringMessages(anchoring)).toEqual([]);
234
+ });
235
+ });