@payconductor/houndsman 0.14.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.
@@ -49,6 +49,7 @@ import { findWatchdog, getWatchdogContext } from "../core/watchdog-registry";
49
49
 
50
50
  import {
51
51
  alphaWatchdog,
52
+ betaWatchdog,
52
53
  FAKE_WATCHDOG_DEFINITIONS,
53
54
  fakeHoundsmanConfig,
54
55
  } from "./fixtures/fake-project";
@@ -159,6 +160,24 @@ describe("registry-check", () => {
159
160
  expect(warnings.some((w) => w.includes("duplicada"))).toBe(true);
160
161
  });
161
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
+
162
181
  test("texto de regra em reviewFocus/contextFiles gera aviso", () => {
163
182
  const config = {
164
183
  ...fakeHoundsmanConfig,
@@ -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
+ });