@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.
@@ -0,0 +1,460 @@
1
+ /**
2
+ * Checklist de revisão do harness local (`houndsman hunt`).
3
+ *
4
+ * O agente headless tende a "ler o diff e concluir" — cobertura alta no papel e
5
+ * profundidade baixa. Aqui ficam as funções puras que transformam o modelo de
6
+ * intenção (fluxos + invariantes) em itens verificáveis, comparam o que o
7
+ * watchdog declarou e mesclam duas passadas do mesmo brief.
8
+ *
9
+ * Nada disto vira contrato do `--finalize`: é diagnóstico do harness, gravado
10
+ * em `hunt-report.json`.
11
+ */
12
+
13
+ import { GENERAL_WATCHDOG_ID } from "../builtins";
14
+ import type { BusinessSummary } from "../business-summary";
15
+ import type { Finding, WatchdogReport } from "../report-builder";
16
+ import { severityRank } from "../types";
17
+
18
+ /** Resultado declarado de uma verificação. */
19
+ export type CheckVerdict = "ok" | "gap" | "n/a";
20
+
21
+ /** Verificação de um item do modelo de intenção. */
22
+ export type ReviewCheck = {
23
+ /** Texto do fluxo/invariante (copiado do brief). */
24
+ item: string;
25
+ verdict: CheckVerdict;
26
+ /** Onde foi verificado (`arquivo:linha` ou `arquivo · símbolo`). */
27
+ at?: string;
28
+ };
29
+
30
+ /** Regra declarada no brief (numerada para o checklist). */
31
+ export type ReviewRule = {
32
+ /** Número exibido no brief (identidade compacta no JSON). */
33
+ id: number;
34
+ severity: string;
35
+ text: string;
36
+ };
37
+
38
+ /** Veredito de uma regra declarada. */
39
+ export type RuleCheck = {
40
+ id: number;
41
+ verdict: CheckVerdict;
42
+ /** Onde a regra foi (ou não) honrada. */
43
+ at?: string;
44
+ };
45
+
46
+ /** Cobertura declarada por um watchdog. */
47
+ export type CheckCoverage = {
48
+ expected: number;
49
+ /**
50
+ * Itens **com prova**: `ok`/`gap` com `at` resolvido para arquivo/linha
51
+ * existentes. `n/a` não conta — é ausência de verificação, não cobertura.
52
+ */
53
+ verified: number;
54
+ /** Itens sem veredito declarado. */
55
+ missing: number;
56
+ /** Itens declarados `n/a` (não se aplicam ao domínio). */
57
+ notApplicable: number;
58
+ /** Vereditos `gap` (violação declarada). */
59
+ gaps: number;
60
+ };
61
+
62
+ /**
63
+ * Resolve se um `at` aponta para código existente.
64
+ *
65
+ * O runner injeta a checagem real (arquivo existe + linha dentro do arquivo);
66
+ * testes e análises puras usam o default permissivo.
67
+ */
68
+ export type AtResolver = (file: string, line?: number) => boolean;
69
+
70
+ /** Localização declarada (`arquivo:linha`, `arquivo:12-20` ou `arquivo · símbolo`). */
71
+ export type CheckLocation = { file: string; line?: number };
72
+
73
+ /** Extrai arquivo/linha de um `at` declarado pelo modelo. */
74
+ export function parseLocation(at: string | undefined): CheckLocation | null {
75
+ const value = at?.trim();
76
+ if (!value) return null;
77
+
78
+ // `arquivo · símbolo` — sem linha, mas com arquivo explícito.
79
+ const [head] = value.split("\u00b7");
80
+ const trimmed = head.trim();
81
+ const match = trimmed.match(/^(.+?):(\d+)(?:\s*-\s*\d+)?$/);
82
+ if (match) return { file: normalizePath(match[1]), line: Number(match[2]) };
83
+
84
+ // Sem `:linha`, aceita apenas o que parece caminho de arquivo.
85
+ if (/[\\/]/.test(trimmed) && /\.[A-Za-z0-9]{1,5}$/.test(trimmed)) {
86
+ return { file: normalizePath(trimmed) };
87
+ }
88
+ return null;
89
+ }
90
+
91
+ /** `true` quando a verificação tem prova (veredito com localização válida). */
92
+ export function isCheckVerified(
93
+ check: { verdict: CheckVerdict; at?: string },
94
+ resolve: AtResolver,
95
+ ): boolean {
96
+ if (check.verdict === "n/a") return false;
97
+ const location = parseLocation(check.at);
98
+ if (!location) return false;
99
+ return resolve(location.file, location.line);
100
+ }
101
+
102
+ /** Normaliza caminho de arquivo (`.\a\b` → `a/b`). */
103
+ export function normalizePath(value: string): string {
104
+ return value.replace(/\\/g, "/").replace(/^\.\//, "").trim();
105
+ }
106
+
107
+ /** Normaliza um item para comparação (sem acento, caixa e pontuação). */
108
+ function normalizeItem(value: string): string {
109
+ return value
110
+ .normalize("NFD")
111
+ .replace(/[\u0300-\u036f]/g, "")
112
+ .toLowerCase()
113
+ .replace(/[^a-z0-9]+/g, " ")
114
+ .trim();
115
+ }
116
+
117
+ /**
118
+ * Arquivos do domínio declarados pelo brief.
119
+ *
120
+ * Lê a seção `## Arquivos do seu domínio` (lista em code-span); `[]` quando o
121
+ * brief não a traz.
122
+ */
123
+ export function briefDomainFiles(brief: string): string[] {
124
+ const lines = brief.split(/\r?\n/);
125
+ const files: string[] = [];
126
+ let inside = false;
127
+ for (const line of lines) {
128
+ if (/^##\s+Arquivos do seu dom/i.test(line)) {
129
+ inside = true;
130
+ continue;
131
+ }
132
+ if (inside) {
133
+ if (/^##\s+/.test(line)) break;
134
+ const match = line.match(/^-\s+`([^`]+)`/);
135
+ if (match) files.push(normalizePath(match[1]));
136
+ }
137
+ }
138
+ return files;
139
+ }
140
+
141
+ /** Normaliza a lista de verificações devolvida pelo JSON do watchdog. */
142
+ export function parseReviewChecks(raw: unknown): ReviewCheck[] {
143
+ if (typeof raw !== "object" || raw === null) return [];
144
+ const entries = (raw as Record<string, unknown>).checks;
145
+ if (!Array.isArray(entries)) return [];
146
+ const checks: ReviewCheck[] = [];
147
+ for (const entry of entries) {
148
+ if (typeof entry === "string") {
149
+ const item = entry.trim();
150
+ if (item) checks.push({ item, verdict: "n/a" });
151
+ continue;
152
+ }
153
+ if (typeof entry !== "object" || entry === null) continue;
154
+ const record = entry as Record<string, unknown>;
155
+ const item =
156
+ typeof record.item === "string"
157
+ ? record.item.trim()
158
+ : typeof record.check === "string"
159
+ ? record.check.trim()
160
+ : "";
161
+ if (!item) continue;
162
+ const verdict = verdictOf(record.verdict);
163
+ const at = typeof record.at === "string" ? record.at.trim() : undefined;
164
+ checks.push({ item, verdict, at });
165
+ }
166
+ return checks;
167
+ }
168
+
169
+ /** Veredito tolerante a variações de grafia do modelo. */
170
+ function verdictOf(value: unknown): CheckVerdict {
171
+ if (typeof value !== "string") return "n/a";
172
+ const text = value.trim().toLowerCase();
173
+ if (text === "ok" || text === "pass" || text === "passed") return "ok";
174
+ if (text === "gap" || text === "fail" || text === "failed" || text === "missing") {
175
+ return "gap";
176
+ }
177
+ return "n/a";
178
+ }
179
+
180
+ /**
181
+ * Regras declaradas no brief (`## Regras (por severidade)`), já numeradas.
182
+ *
183
+ * O número é a identidade compacta usada no checklist: o watchdog devolve
184
+ * `{ id, verdict }` por regra, sem repetir o texto (que pode ter 200 chars).
185
+ */
186
+ export function briefRules(brief: string): ReviewRule[] {
187
+ const lines = brief.split(/\r?\n/);
188
+ const rules: ReviewRule[] = [];
189
+ let inside = false;
190
+ for (const line of lines) {
191
+ if (/^##\s+Regras\b/i.test(line)) {
192
+ inside = true;
193
+ continue;
194
+ }
195
+ if (inside) {
196
+ if (/^##\s+/.test(line)) break;
197
+ const match = line.match(
198
+ /^-\s+(\d+)\.\s+.*?\*\*(blocking|attention|suggestion|nit)\*\*\s*[—–-]\s*(.+)$/,
199
+ );
200
+ if (!match) continue;
201
+ rules.push({
202
+ id: Number.parseInt(match[1], 10),
203
+ severity: match[2],
204
+ text: match[3].trim(),
205
+ });
206
+ }
207
+ }
208
+ return rules;
209
+ }
210
+
211
+ /** Normaliza os vereditos das regras devolvidos pelo JSON do watchdog. */
212
+ export function parseRuleChecks(raw: unknown): RuleCheck[] {
213
+ if (typeof raw !== "object" || raw === null) return [];
214
+ const entries = (raw as Record<string, unknown>).rules;
215
+ if (!Array.isArray(entries)) return [];
216
+ const checks: RuleCheck[] = [];
217
+ for (const entry of entries) {
218
+ if (typeof entry === "string") {
219
+ const match = entry.match(/^\s*(\d+)\s*[:=-]?\s*(ok|gap|n\/a|pass|fail)?/i);
220
+ if (!match) continue;
221
+ checks.push({
222
+ id: Number.parseInt(match[1], 10),
223
+ verdict: verdictOf(match[2]),
224
+ });
225
+ continue;
226
+ }
227
+ if (typeof entry !== "object" || entry === null) continue;
228
+ const record = entry as Record<string, unknown>;
229
+ const rawId = record.id ?? record.rule ?? record.number;
230
+ const id =
231
+ typeof rawId === "number"
232
+ ? rawId
233
+ : Number.parseInt(String(rawId ?? "").replace(/\D/g, ""), 10);
234
+ if (!Number.isFinite(id) || id <= 0) continue;
235
+ const at = typeof record.at === "string" ? record.at.trim() : undefined;
236
+ checks.push({ id, verdict: verdictOf(record.verdict), at });
237
+ }
238
+ return checks;
239
+ }
240
+
241
+ /** Regras declaradas sem verificação. */
242
+ export function missingRuleChecks(
243
+ rules: readonly ReviewRule[],
244
+ checks: readonly RuleCheck[],
245
+ ): number[] {
246
+ const seen = new Set(checks.map((check) => check.id));
247
+ return rules.filter((rule) => !seen.has(rule.id)).map((rule) => rule.id);
248
+ }
249
+
250
+ /** Resumo da cobertura das regras declaradas. */
251
+ export function summarizeRuleChecks(
252
+ rules: readonly ReviewRule[],
253
+ checks: readonly RuleCheck[],
254
+ resolve: AtResolver = TRUST_LOCATIONS,
255
+ ): CheckCoverage {
256
+ const byRule = rules.map((rule) => checks.find((check) => check.id === rule.id));
257
+ const declared = byRule.filter((check): check is RuleCheck => check !== undefined);
258
+ return {
259
+ expected: rules.length,
260
+ verified: declared.filter((check) => isCheckVerified(check, resolve)).length,
261
+ missing: rules.length - declared.length,
262
+ notApplicable: declared.filter((check) => check.verdict === "n/a").length,
263
+ gaps: declared.filter((check) => check.verdict === "gap").length,
264
+ };
265
+ }
266
+
267
+ /** Mescla dois checklists de regras (o mais recente vence no mesmo id). */
268
+ export function mergeRuleChecks(
269
+ first: readonly RuleCheck[],
270
+ second: readonly RuleCheck[],
271
+ ): RuleCheck[] {
272
+ const merged = new Map(first.map((check) => [check.id, check]));
273
+ for (const check of second) merged.set(check.id, check);
274
+ return [...merged.values()].sort((a, b) => a.id - b.id);
275
+ }
276
+
277
+ /**
278
+ * Itens que o watchdog deveria verificar neste brief.
279
+ *
280
+ * Fluxos são recortados pelos arquivos do domínio (o brief já faz o mesmo
281
+ * recorte); invariantes valem para todos — são o contrato de negócio da PR.
282
+ */
283
+ export function expectedReviewItems(
284
+ brief: string,
285
+ business: BusinessSummary | null | undefined,
286
+ watchdogId: string,
287
+ ): string[] {
288
+ if (!business) return [];
289
+ const isGeneral = watchdogId === GENERAL_WATCHDOG_ID;
290
+ const own = new Set(briefDomainFiles(brief));
291
+ const flows = business.flows
292
+ .filter(
293
+ (flow) =>
294
+ isGeneral ||
295
+ !flow.files ||
296
+ flow.files.length === 0 ||
297
+ flow.files.some((file) => own.has(normalizePath(file))),
298
+ )
299
+ .map((flow) => flow.name);
300
+ const items = [...flows, ...business.invariants];
301
+ const seen = new Set<string>();
302
+ const unique: string[] = [];
303
+ for (const item of items) {
304
+ const key = normalizeItem(item);
305
+ if (!key || seen.has(key)) continue;
306
+ seen.add(key);
307
+ unique.push(item);
308
+ }
309
+ return unique;
310
+ }
311
+
312
+ /** `true` quando a verificação declarada corresponde ao item esperado. */
313
+ export function matchesItem(check: ReviewCheck, expected: string): boolean {
314
+ const left = normalizeItem(check.item);
315
+ const right = normalizeItem(expected);
316
+ if (!left || !right) return false;
317
+ if (left === right) return true;
318
+ // O modelo às vezes resume o item; aceita contenção para não gerar passada
319
+ // extra só por diferença de redação.
320
+ const shortest = Math.min(left.length, right.length);
321
+ return shortest >= 16 && (left.includes(right) || right.includes(left));
322
+ }
323
+
324
+ /** Itens esperados sem verificação declarada. */
325
+ export function missingReviewItems(
326
+ expected: readonly string[],
327
+ checks: readonly ReviewCheck[],
328
+ ): string[] {
329
+ return expected.filter((item) => !checks.some((check) => matchesItem(check, item)));
330
+ }
331
+
332
+ /** Resolver default: aceita qualquer localização (análises puras/testes). */
333
+ const TRUST_LOCATIONS: AtResolver = () => true;
334
+
335
+ /** Resumo da cobertura do checklist. */
336
+ export function summarizeChecks(
337
+ expected: readonly string[],
338
+ checks: readonly ReviewCheck[],
339
+ resolve: AtResolver = TRUST_LOCATIONS,
340
+ ): CheckCoverage {
341
+ const byItem = expected.map((item) =>
342
+ checks.find((check) => matchesItem(check, item)),
343
+ );
344
+ const declared = byItem.filter((check): check is ReviewCheck => check !== undefined);
345
+ const notApplicable = declared.filter((check) => check.verdict === "n/a").length;
346
+ return {
347
+ expected: expected.length,
348
+ verified: declared.filter((check) => isCheckVerified(check, resolve)).length,
349
+ missing: expected.length - declared.length,
350
+ notApplicable,
351
+ gaps: declared.filter((check) => check.verdict === "gap").length,
352
+ };
353
+ }
354
+
355
+ /** Mescla dois checklists (o mais recente vence no mesmo item). */
356
+ export function mergeChecks(
357
+ first: readonly ReviewCheck[],
358
+ second: readonly ReviewCheck[],
359
+ ): ReviewCheck[] {
360
+ const merged = [...first];
361
+ for (const check of second) {
362
+ const index = merged.findIndex((item) => matchesItem(item, check.item));
363
+ if (index >= 0) merged[index] = check;
364
+ else merged.push(check);
365
+ }
366
+ return merged;
367
+ }
368
+
369
+ /** Primeira frase do texto (para inferir a identidade de um finding emergente). */
370
+ function firstSentence(message: string, max = 160): string {
371
+ const text = message.trim().replace(/\s+/g, " ");
372
+ if (text.length === 0) return "achado sem descrição";
373
+ const cut = text.search(/[.!?](\s|$)/);
374
+ const sentence = cut > 0 ? text.slice(0, cut) : text;
375
+ return sentence.length > max ? `${sentence.slice(0, max - 1)}…` : sentence;
376
+ }
377
+
378
+ /**
379
+ * Garante `rule` não vazio (identidade de exibição/triagem do finding).
380
+ *
381
+ * O modelo às vezes descreve o achado só em `message` e deixa `rule: ""`.
382
+ * Para emergente, o prefixo `Lógica:`/`Domínio:` preserva a semântica do
383
+ * `basis` e permite a derivação de severidade por `impactClass`.
384
+ */
385
+ export function withInferredRule(report: WatchdogReport): WatchdogReport {
386
+ const findings = report.findings.map((finding) => {
387
+ if (finding.rule.trim().length > 0) return finding;
388
+ const prefix = (finding.basis ?? "logic") === "domain" ? "Domínio: " : "Lógica: ";
389
+ return { ...finding, rule: `${prefix}${firstSentence(finding.message)}` };
390
+ });
391
+ return { ...report, findings };
392
+ }
393
+
394
+ /** Chave de dedupe de findings entre passadas do mesmo watchdog. */
395
+ function findingKey(finding: Finding): string {
396
+ return `${normalizePath(finding.file)}|${finding.line ?? 0}|${normalizeItem(finding.rule)}`;
397
+ }
398
+
399
+ /** Une findings de duas passadas, preservando o de maior severidade. */
400
+ export function mergeFindings(
401
+ first: readonly Finding[],
402
+ second: readonly Finding[],
403
+ ): Finding[] {
404
+ const merged = [...first];
405
+ const index = new Map(
406
+ merged.map((finding, position) => [findingKey(finding), position]),
407
+ );
408
+ for (const finding of second) {
409
+ const key = findingKey(finding);
410
+ const position = index.get(key);
411
+ if (position === undefined) {
412
+ index.set(key, merged.length);
413
+ merged.push(finding);
414
+ continue;
415
+ }
416
+ const current = merged[position];
417
+ if (severityRank(finding.severity) < severityRank(current.severity)) {
418
+ merged[position] = finding;
419
+ }
420
+ }
421
+ return merged;
422
+ }
423
+
424
+ /** Pontuação de riqueza do modelo de intenção (fluxos e contratos pesam mais). */
425
+ export function businessScore(summary: BusinessSummary | null | undefined): number {
426
+ if (!summary) return 0;
427
+ return (
428
+ (summary.intent ? 1 : 0) +
429
+ summary.flows.length * 3 +
430
+ summary.invariants.length +
431
+ summary.contracts.length * 2 +
432
+ summary.states.length
433
+ );
434
+ }
435
+
436
+ /** Decisão do gate de substituição do modelo de intenção. */
437
+ export type BusinessDecision = {
438
+ /** Modelo que deve permanecer no staging. */
439
+ summary: BusinessSummary | null;
440
+ accepted: boolean;
441
+ };
442
+
443
+ /**
444
+ * Decide se o modelo devolvido pelo revisor de fluxo substitui o existente.
445
+ *
446
+ * O `general` reconstrói a intenção sem enxergar o JSON original inteiro; sem
447
+ * este gate ele já sobrescreveu um modelo rico da IA por um resumo de 4
448
+ * invariantes e 0 fluxos, e o relatório final perdeu os fluxos.
449
+ */
450
+ export function chooseBusinessSummary(
451
+ existing: BusinessSummary | null | undefined,
452
+ incoming: BusinessSummary | null | undefined,
453
+ ): BusinessDecision {
454
+ if (!incoming) return { summary: existing ?? null, accepted: false };
455
+ if (!existing || existing.source === "deterministic") {
456
+ return { summary: incoming, accepted: true };
457
+ }
458
+ const accepted = businessScore(incoming) > businessScore(existing);
459
+ return { summary: accepted ? incoming : existing, accepted };
460
+ }
@@ -207,6 +207,16 @@ export class ProjectConfig<TId extends string = string> {
207
207
  const value = this.hunt.model?.trim();
208
208
  return value && value.length > 0 ? value : this.aiModel;
209
209
  }
210
+
211
+ /** Passada de checklist do harness (default: ligada). */
212
+ get huntChecklist(): boolean {
213
+ return this.hunt.checklist !== false;
214
+ }
215
+
216
+ /** Passada adversarial do `general` (default: ligada). */
217
+ get huntSecondOpinion(): boolean {
218
+ return this.hunt.secondOpinion !== false;
219
+ }
210
220
  }
211
221
 
212
222
  /** Inteiro positivo ou o default. */
@@ -12,7 +12,8 @@ import { JevQuestionFactory } from "./jev";
12
12
  import { renderReviewResume } from "./renderers/resume-renderer";
13
13
  import { sanitizeBranch } from "./report-builder";
14
14
  import { hasExplicitPolarity } from "./rule-text";
15
- import type { WatchdogMeta } from "./types";
15
+ import { normalizeRuleText } from "./severity";
16
+ import type { Severity, WatchdogMeta } from "./types";
16
17
 
17
18
  /** `id` da definição bate com a chave; `description` não vazio. */
18
19
  function checkDefinitions(config: HoundsmanConfig, problems: string[]): void {
@@ -202,11 +203,6 @@ function checkRulePolarity(config: HoundsmanConfig, warnings: string[]): void {
202
203
  }
203
204
  }
204
205
 
205
- /** Normaliza o texto da regra para detectar duplicatas (case/espaços). */
206
- function normalizeRuleText(text: string): string {
207
- return text.trim().toLowerCase().replace(/\s+/g, " ");
208
- }
209
-
210
206
  /**
211
207
  * Aviso para regra repetida (texto normalizado) no mesmo watchdog — a regra deve
212
208
  * existir uma única vez em `rules` (§ Convenções de escrita, item 10).
@@ -228,6 +224,33 @@ function checkRuleDuplication(config: HoundsmanConfig, warnings: string[]): void
228
224
  }
229
225
  }
230
226
 
227
+ /**
228
+ * Aviso quando o mesmo texto de regra aparece em watchdogs diferentes com
229
+ * severidades diferentes: a ancoragem usaria a mais grave e o resultado
230
+ * dependeria de qual watchdog reportou.
231
+ */
232
+ function checkRuleSeverityConflicts(config: HoundsmanConfig, warnings: string[]): void {
233
+ const byText = new Map<string, { id: string; severity: Severity }[]>();
234
+ for (const id of config.watchdogIds) {
235
+ const def = config.watchdogs[id];
236
+ if (!def) continue;
237
+ for (const rule of def.rules) {
238
+ const key = normalizeRuleText(rule.text);
239
+ if (!key) continue;
240
+ const entries = byText.get(key) ?? [];
241
+ entries.push({ id, severity: rule.severity });
242
+ byText.set(key, entries);
243
+ }
244
+ }
245
+ for (const entries of byText.values()) {
246
+ if (entries.length < 2) continue;
247
+ const severities = new Set(entries.map((entry) => entry.severity));
248
+ if (severities.size < 2) continue;
249
+ const detail = entries.map((entry) => `${entry.id}=${entry.severity}`).join(", ");
250
+ warnings.push(`Regra com severidades divergentes entre watchdogs: ${detail}.`);
251
+ }
252
+ }
253
+
231
254
  /** Campos de metadados que NÃO devem conter texto de regra. */
232
255
  const RULE_BEARING_FIELDS = ["reviewFocus", "contextFiles", "exclude"] as const;
233
256
 
@@ -373,6 +396,7 @@ export function checkRegistry(config: HoundsmanConfig): {
373
396
  checkRendering(problems);
374
397
  checkRulePolarity(config, warnings);
375
398
  checkRuleDuplication(config, warnings);
399
+ checkRuleSeverityConflicts(config, warnings);
376
400
  checkMetadataPrecision(config, warnings);
377
401
  checkBudgets(config, warnings);
378
402
  checkPriorities(config, problems);
@@ -30,6 +30,12 @@ export type Finding = {
30
30
  confidence?: number;
31
31
  /** Origem do achado; inferida pelo prefixo `Lógica:`/`Domínio:` quando ausente. */
32
32
  basis?: FindingBasis;
33
+ /**
34
+ * Classe de impacto de um finding emergente (`flow-break`/`contract-drift`/
35
+ * `degradation`/`cosmetic`). Define a severidade fora de regra — ver
36
+ * `core/severity.ts`; o valor emitido em `severity` é ignorado quando presente.
37
+ */
38
+ impactClass?: string;
33
39
  /**
34
40
  * Commit em que o achado foi observado (interno). Preenchido nos carry-over
35
41
  * do review incremental — preserva a referência para a verificação (verify).
@@ -146,7 +152,8 @@ function isFinding(value: unknown): value is Finding {
146
152
  (f.basis === undefined ||
147
153
  f.basis === "rule" ||
148
154
  f.basis === "logic" ||
149
- f.basis === "domain")
155
+ f.basis === "domain") &&
156
+ (f.impactClass === undefined || typeof f.impactClass === "string")
150
157
  );
151
158
  }
152
159
 
@@ -290,6 +297,7 @@ export function dedupeFindings(reports: WatchdogReport[]): MergedFinding[] {
290
297
  );
291
298
  }
292
299
  existing.basis = existing.basis ?? finding.basis;
300
+ existing.impactClass = existing.impactClass ?? finding.impactClass;
293
301
  continue;
294
302
  }
295
303
  map.set(key, { ...finding, watchdogs: [report.watchdogId] });