@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.
@@ -5,6 +5,7 @@
5
5
  * O contexto da run entra no construtor; sem estado ambiente.
6
6
  */
7
7
 
8
+ import { GENERAL_WATCHDOG_ID } from "../builtins";
8
9
  import { summarizeBusiness } from "../business-summary";
9
10
  import type { HoundsmanConfig } from "../config";
10
11
  import { buildStateFromFindings } from "../findings-state";
@@ -22,6 +23,8 @@ import { type ResumeDomain, renderReviewResume } from "../renderers/resume-rende
22
23
  import { dedupeFindings, type WatchdogReport } from "../report-builder";
23
24
  import { writeRunStatus } from "../run-status";
24
25
  import { systemClock } from "../services/clock";
26
+ import { anchorReports, severityAnchoringMessages } from "../severity";
27
+ import type { Rule } from "../types";
25
28
  import { findWatchdog } from "../watchdog-registry";
26
29
  import { mergeCarryover } from "./carryover";
27
30
  import type { ReviewContext } from "./review-context";
@@ -160,8 +163,15 @@ export class FinalizePipeline {
160
163
  const activeIds = Object.keys(classification.watchdogs);
161
164
 
162
165
  const messages: string[] = [];
163
- const { reports, pending, warnings } = ctx.work.readFindings(activeIds);
166
+ const ingested = ctx.work.readFindings(activeIds);
167
+ const { pending, warnings } = ingested;
164
168
  messages.push(...warnings);
169
+ // Severidade é derivada (regra declarada ou classe de impacto), nunca a escolhida
170
+ // pelo watchdog: o mesmo código não pode sair `blocking` numa run e `attention`
171
+ // na seguinte. Divergências são corrigidas aqui e reportadas abaixo.
172
+ const anchored = anchorReports(ingested.reports, this.declaredRules());
173
+ messages.push(...severityAnchoringMessages(anchored));
174
+ const reports = anchored.reports;
165
175
  const jev = this.artifacts.candidates(config);
166
176
  const extras = this.artifacts.manifestExtras();
167
177
  const business = this.artifacts.persistBusiness();
@@ -326,6 +336,20 @@ export class FinalizePipeline {
326
336
  return { reportPath: location, blockers, attention, messages };
327
337
  }
328
338
 
339
+ /**
340
+ * União das regras declaradas (todos os watchdogs ativos + o piso embutido do
341
+ * `general`). É a fonte da severidade dos findings de regra.
342
+ */
343
+ private declaredRules(): Rule[] {
344
+ const ids = new Set<string>([...this.config.watchdogIds, GENERAL_WATCHDOG_ID]);
345
+ const rules: Rule[] = [];
346
+ for (const id of ids) {
347
+ const definition = findWatchdog(this.config, id);
348
+ if (definition) rules.push(...definition.rules);
349
+ }
350
+ return rules;
351
+ }
352
+
329
353
  /** Grava o manifesto da run (delegado ao `ManifestWriter`). */
330
354
  private writeManifest(
331
355
  classification: RunClassification,
@@ -21,6 +21,7 @@ import {
21
21
  import { type FindingBasis, type MergedFinding, sanitizeBranch } from "./report-builder";
22
22
  import { systemClock } from "./services/clock";
23
23
  import { type FileReader, ProjectFiles } from "./services/project-files";
24
+ import { isImpactClass } from "./severity";
24
25
  import { SEVERITY_ORDER, type Severity, severityRank } from "./types";
25
26
 
26
27
  export type { FileReader };
@@ -367,12 +368,19 @@ export function buildStateFromFindings(
367
368
  const prev = previous
368
369
  ? Object.values(previous.findings).find((f) => f.fingerprint === fingerprint)
369
370
  : undefined;
371
+ // Emergente sem `impactClass` (relatório legado) mantém a severidade já triada
372
+ // para o mesmo fingerprint: o código é o mesmo, então o valor não pode oscilar
373
+ // entre reviews. Regra e `impactClass` produzem valor determinístico e vencem.
374
+ const sticky =
375
+ (finding.basis ?? "rule") !== "rule" &&
376
+ !isImpactClass(finding.impactClass) &&
377
+ prev !== undefined;
370
378
  const created: FindingState = {
371
379
  id: prev?.id ?? newFindingId(),
372
380
  fingerprint,
373
381
  watchdogIds: [...finding.watchdogs],
374
382
  rule: finding.rule,
375
- severity: finding.severity,
383
+ severity: sticky && prev ? prev.severity : finding.severity,
376
384
  file: finding.file,
377
385
  line: anchor ? anchor.line : null,
378
386
  message: finding.message,
@@ -0,0 +1,346 @@
1
+ /**
2
+ * Golden set — avaliação de uma run contra defeitos conhecidos (funções puras).
3
+ *
4
+ * Contagem de findings não mede profundidade: variantes diferentes amostram
5
+ * pontos diferentes do mesmo PR. O golden set congela os defeitos que já foram
6
+ * confirmados no código e transforma a comparação em **recall/precisão**, que
7
+ * é o número capaz de dizer se um ajuste de prompt/harness melhorou ou piorou.
8
+ *
9
+ * O formato é genérico: o arquivo é fornecido pelo consumidor (não vive no
10
+ * motor). Cada expectativa é uma âncora — arquivo + faixa de linha + trecho —
11
+ * nunca um fingerprint, para não acoplar a avaliação à identidade interna.
12
+ */
13
+
14
+ import { normalizePath } from "./hunt/review-checks";
15
+ import { SEVERITY_ORDER, type Severity, severityRank } from "./types";
16
+
17
+ /** Defeito congelado (verificado manualmente no código). */
18
+ export type GoldenExpectation = {
19
+ /** Identidade curta, estável e legível (`seller-api-key`). */
20
+ id: string;
21
+ /** Caminho relativo à raiz da revisão. */
22
+ file: string;
23
+ /** Linha de referência do defeito. */
24
+ line?: number;
25
+ /** Tolerância em linhas (default: 30). */
26
+ tolerance?: number;
27
+ /** Trecho que precisa aparecer em `message`/`rule`/`evidence` (case-insensitive). */
28
+ match?: string;
29
+ /** Severidade mínima aceita para contar como encontrado. */
30
+ severity?: Severity;
31
+ /** Contexto para quem lê o relatório. */
32
+ note?: string;
33
+ };
34
+
35
+ /** Arquivo de golden set. */
36
+ export type GoldenSet = {
37
+ version: 1;
38
+ title?: string;
39
+ /** Commit em que os defeitos foram congelados. */
40
+ commit?: string;
41
+ expected: GoldenExpectation[];
42
+ };
43
+
44
+ /** Finding normalizado para a avaliação. */
45
+ export type GoldenFinding = {
46
+ fingerprint: string;
47
+ file: string;
48
+ line?: number;
49
+ rule: string;
50
+ severity: Severity;
51
+ message: string;
52
+ evidence?: string;
53
+ watchdogs: readonly string[];
54
+ };
55
+
56
+ /** Expectativa casada com o finding que a comprova. */
57
+ export type GoldenMatch = {
58
+ expectation: GoldenExpectation;
59
+ finding: GoldenFinding;
60
+ };
61
+
62
+ /** Resultado da avaliação de uma run. */
63
+ export type GoldenScore = {
64
+ /** Defeitos congelados. */
65
+ total: number;
66
+ matched: GoldenMatch[];
67
+ missed: GoldenExpectation[];
68
+ /** Findings que não casam com nenhuma expectativa (ruído). */
69
+ unexpected: GoldenFinding[];
70
+ /** Defeitos distintos cobertos (mesmo defeito = mesma âncora). */
71
+ issues: number;
72
+ recall: number;
73
+ precision: number;
74
+ };
75
+
76
+ /** Parse tolerante: aceita `{ expected: [...] }` ou a lista direta. */
77
+ export function parseGoldenSet(raw: unknown): {
78
+ set: GoldenSet | null;
79
+ warnings: string[];
80
+ } {
81
+ const warnings: string[] = [];
82
+ const list = Array.isArray(raw)
83
+ ? raw
84
+ : typeof raw === "object" && raw !== null
85
+ ? (raw as Record<string, unknown>).expected
86
+ : null;
87
+ if (!Array.isArray(list)) {
88
+ return { set: null, warnings: ["golden set sem lista `expected`."] };
89
+ }
90
+
91
+ const expected: GoldenExpectation[] = [];
92
+ for (const [index, item] of list.entries()) {
93
+ if (typeof item !== "object" || item === null) {
94
+ warnings.push(`item ${index}: não é objeto.`);
95
+ continue;
96
+ }
97
+ const record = item as Record<string, unknown>;
98
+ const id = typeof record.id === "string" ? record.id.trim() : "";
99
+ const file = typeof record.file === "string" ? record.file.trim() : "";
100
+ if (!id || !file) {
101
+ warnings.push(`item ${index}: \`id\` e \`file\` são obrigatórios.`);
102
+ continue;
103
+ }
104
+ const severity =
105
+ typeof record.severity === "string" &&
106
+ SEVERITY_ORDER.includes(record.severity as Severity)
107
+ ? (record.severity as Severity)
108
+ : undefined;
109
+ expected.push({
110
+ id,
111
+ file: normalizePath(file),
112
+ line: typeof record.line === "number" ? record.line : undefined,
113
+ tolerance:
114
+ typeof record.tolerance === "number" ? record.tolerance : undefined,
115
+ match: typeof record.match === "string" ? record.match : undefined,
116
+ severity,
117
+ note: typeof record.note === "string" ? record.note : undefined,
118
+ });
119
+ }
120
+
121
+ if (expected.length === 0) {
122
+ return {
123
+ set: null,
124
+ warnings: [...warnings, "golden set sem expectativas válidas."],
125
+ };
126
+ }
127
+
128
+ const header = Array.isArray(raw) ? {} : (raw as Record<string, unknown>);
129
+ return {
130
+ set: {
131
+ version: 1,
132
+ title: typeof header.title === "string" ? header.title : undefined,
133
+ commit: typeof header.commit === "string" ? header.commit : undefined,
134
+ expected,
135
+ },
136
+ warnings,
137
+ };
138
+ }
139
+
140
+ /** Tolerância efetiva de uma expectativa. */
141
+ const DEFAULT_TOLERANCE = 30;
142
+
143
+ /** `true` quando o finding comprova a expectativa. */
144
+ function satisfies(expectation: GoldenExpectation, finding: GoldenFinding): boolean {
145
+ if (normalizePath(finding.file) !== expectation.file) return false;
146
+ if (expectation.line !== undefined) {
147
+ if (finding.line === undefined) return false;
148
+ const tolerance = expectation.tolerance ?? DEFAULT_TOLERANCE;
149
+ if (Math.abs(finding.line - expectation.line) > tolerance) return false;
150
+ }
151
+ if (expectation.severity) {
152
+ if (severityRank(finding.severity) > severityRank(expectation.severity)) {
153
+ return false;
154
+ }
155
+ }
156
+ if (expectation.match) {
157
+ const haystack = `${finding.message} ${finding.rule} ${finding.evidence ?? ""}`;
158
+ if (!haystack.toLowerCase().includes(expectation.match.toLowerCase())) {
159
+ return false;
160
+ }
161
+ }
162
+ return true;
163
+ }
164
+
165
+ /** Distância de linha usada para escolher o melhor candidato. */
166
+ function distance(expectation: GoldenExpectation, finding: GoldenFinding): number {
167
+ if (expectation.line === undefined || finding.line === undefined) return 0;
168
+ return Math.abs(finding.line - expectation.line);
169
+ }
170
+
171
+ /**
172
+ * Avalia os findings de uma run.
173
+ *
174
+ * Greedy por expectativa, escolhendo o finding mais próximo da âncora — cada
175
+ * finding comprova no máximo um defeito, para a precisão não ser inflada por
176
+ * três achados na mesma linha.
177
+ */
178
+ export function scoreGolden(
179
+ set: GoldenSet,
180
+ findings: readonly GoldenFinding[],
181
+ ): GoldenScore {
182
+ const used = new Set<number>();
183
+ const matched: GoldenMatch[] = [];
184
+ const missed: GoldenExpectation[] = [];
185
+
186
+ for (const expectation of set.expected) {
187
+ let bestIndex = -1;
188
+ for (const [index, finding] of findings.entries()) {
189
+ if (used.has(index) || !satisfies(expectation, finding)) continue;
190
+ if (
191
+ bestIndex < 0 ||
192
+ distance(expectation, finding) <
193
+ distance(expectation, findings[bestIndex])
194
+ ) {
195
+ bestIndex = index;
196
+ }
197
+ }
198
+ if (bestIndex < 0) {
199
+ missed.push(expectation);
200
+ continue;
201
+ }
202
+ used.add(bestIndex);
203
+ matched.push({ expectation, finding: findings[bestIndex] });
204
+ }
205
+
206
+ const unexpected = findings.filter((_, index) => !used.has(index));
207
+ const anchors = new Set(
208
+ findings.map((finding) => `${normalizePath(finding.file)}:${finding.line ?? 0}`),
209
+ );
210
+ return {
211
+ total: set.expected.length,
212
+ matched,
213
+ missed,
214
+ unexpected,
215
+ issues: anchors.size,
216
+ recall: set.expected.length === 0 ? 0 : matched.length / set.expected.length,
217
+ precision: findings.length === 0 ? 0 : matched.length / findings.length,
218
+ };
219
+ }
220
+
221
+ /** Avaliação de uma run (uma linha da tabela). */
222
+ export type GoldenRunScore = {
223
+ slug: string;
224
+ label: string;
225
+ variant?: string;
226
+ scope: string;
227
+ /** Commit revisado pela run (quando conhecido). */
228
+ commit?: string;
229
+ /**
230
+ * `true` quando a run revisou outro commit que não o congelado.
231
+ *
232
+ * Não é erro: as âncoras de linha continuam válidas enquanto o arquivo não
233
+ * se mover, mas o resultado passa a ser **suspeito** — a maior parte das
234
+ * "faltas" pode ser deslocamento de linha, não perda de profundidade.
235
+ */
236
+ commitMismatch?: boolean;
237
+ findings: number;
238
+ score: GoldenScore;
239
+ };
240
+
241
+ /** `true` quando o commit avaliado é o congelado no golden (compara 7 chars). */
242
+ export function commitMatches(
243
+ expected: string | undefined,
244
+ head: string | undefined,
245
+ ): boolean {
246
+ // Sem referência de um dos lados não há como afirmar divergência.
247
+ if (!expected || !head) return true;
248
+ return expected.slice(0, 7).toLowerCase() === head.slice(0, 7).toLowerCase();
249
+ }
250
+
251
+ /** Percentual legível (`62%`). */
252
+ function percent(value: number): string {
253
+ return `${Math.round(value * 100)}%`;
254
+ }
255
+
256
+ /**
257
+ * Renderiza a avaliação em Markdown.
258
+ *
259
+ * A tabela é o artefato de decisão: uma linha por run/variante. O detalhe de
260
+ * cada run lista o que faltou — é o insumo para melhorar o brief/harness.
261
+ */
262
+ export function renderGoldenReport(
263
+ set: GoldenSet,
264
+ runs: readonly GoldenRunScore[],
265
+ ): string {
266
+ const title = set.title ? ` — \`${set.title}\`` : "";
267
+ const drifted = runs.filter((run) => run.commitMismatch);
268
+ const lines = [
269
+ `# Golden set${title}`,
270
+ "",
271
+ `**Defeitos congelados:** ${set.expected.length}` +
272
+ (set.commit
273
+ ? ` · **commit de referência:** \`${set.commit.slice(0, 7)}\``
274
+ : ""),
275
+ "",
276
+ ...(drifted.length > 0
277
+ ? [
278
+ `> ⚠️ **Commit divergente em ${drifted.length} run(s).** O golden foi congelado em`,
279
+ `> \`${set.commit?.slice(0, 7)}\` e a run revisou outro commit (${drifted
280
+ .slice(0, 3)
281
+ .map(
282
+ (run) => `\`${run.label}@${run.commit?.slice(0, 7) ?? "?"}\``,
283
+ )
284
+ .join(", ")}${drifted.length > 3 ? ", …" : ""}).`,
285
+ ">",
286
+ "> Âncoras de linha podem ter se deslocado: revalide o golden antes de tratar",
287
+ "> a diferença como perda de profundidade.",
288
+ "",
289
+ ]
290
+ : []),
291
+ "> Recall mede o que a revisão **encontrou**; precisão mede quanto do que ela",
292
+ "> reportou corresponde a um defeito congelado. O resto pode ser achado legítimo",
293
+ "> ainda não triado — confirme antes de tratar como ruído.",
294
+ "",
295
+ "| Run | Escopo | Findings | Problemas | Recall | Precisão | Faltaram |",
296
+ "| --- | --- | --- | --- | --- | --- | --- |",
297
+ ...runs.map(
298
+ (run) =>
299
+ `| \`${run.slug}\` | ${run.scope} | ${run.findings} | ${run.score.issues} | ` +
300
+ `${percent(run.score.recall)} (${run.score.matched.length}/${run.score.total}) | ` +
301
+ `${percent(run.score.precision)} | ${run.score.missed.length} |`,
302
+ ),
303
+ "",
304
+ ];
305
+
306
+ for (const run of runs) {
307
+ const missed = run.score.missed;
308
+ lines.push(
309
+ `## ${run.label} (${percent(run.score.recall)})`,
310
+ "",
311
+ ...(run.commitMismatch
312
+ ? [
313
+ `> ⚠️ revisou \`${run.commit?.slice(0, 7) ?? "?"}\`, não o commit congelado`,
314
+ `\`${set.commit?.slice(0, 7)}\`.`,
315
+ "",
316
+ ]
317
+ : []),
318
+ missed.length === 0
319
+ ? "_Nenhum defeito congelado faltando._"
320
+ : "**Faltaram:**",
321
+ ...missed.map((expectation) => {
322
+ const line = expectation.line !== undefined ? `:${expectation.line}` : "";
323
+ const note = expectation.note ? ` — ${expectation.note}` : "";
324
+ return `- \`${expectation.id}\` · \`${expectation.file}${line}\`${note}`;
325
+ }),
326
+ "",
327
+ );
328
+ const unexpected = run.score.unexpected.slice(0, 10);
329
+ if (unexpected.length > 0) {
330
+ lines.push(
331
+ `**Fora do golden (${run.score.unexpected.length}):**`,
332
+ ...unexpected.map((finding) => {
333
+ const line = finding.line !== undefined ? `:${finding.line}` : "";
334
+ return `- **${finding.severity}** · \`${finding.file}${line}\` — ${finding.message.slice(0, 140)}`;
335
+ }),
336
+ "",
337
+ );
338
+ }
339
+ }
340
+ return `${lines.join("\n").trimEnd()}\n`;
341
+ }
342
+
343
+ /** JSON estável da avaliação (automação/CI). */
344
+ export function goldenToJson(set: GoldenSet, runs: readonly GoldenRunScore[]): string {
345
+ return `${JSON.stringify({ set, runs }, null, 2)}\n`;
346
+ }