@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.
- package/package.json +1 -1
- package/src/cli/commands/golden-command.ts +92 -0
- package/src/cli/commands/hunt-command.ts +85 -1
- package/src/cli/help.ts +3 -0
- package/src/cli/index.ts +2 -0
- package/src/core/brief.ts +35 -7
- package/src/core/commands/compare.ts +33 -0
- package/src/core/commands/golden.ts +176 -0
- package/src/core/commands/scaffold.ts +2 -0
- package/src/core/compare.ts +67 -0
- package/src/core/config.ts +10 -0
- package/src/core/engine/finalize-pipeline.ts +25 -1
- package/src/core/findings-state.ts +9 -1
- package/src/core/golden.ts +346 -0
- package/src/core/hunt/hunt-runner.ts +546 -49
- package/src/core/hunt/index.ts +38 -1
- package/src/core/hunt/maki-prompt.ts +289 -8
- package/src/core/hunt/maki-runner.ts +93 -16
- package/src/core/hunt/review-checks.ts +460 -0
- package/src/core/project-config.ts +10 -0
- package/src/core/registry-check.ts +30 -6
- package/src/core/report-builder.ts +9 -1
- package/src/core/severity.ts +245 -0
- package/src/core/storage/types.ts +1 -0
- package/src/index.ts +22 -0
- package/src/skill/OUTPUT-FORMAT.md +20 -1
- package/src/skill/README.md +1 -0
- package/src/skill/hunt.md +60 -0
- package/src/skill/train.md +3 -0
- package/src/tests/__snapshots__/baseline.test.ts.snap +24 -9
- package/src/tests/compare.test.ts +23 -0
- package/src/tests/findings-state.test.ts +82 -0
- package/src/tests/golden.test.ts +195 -0
- package/src/tests/hunt-maki.test.ts +652 -2
- package/src/tests/review-scripts.test.ts +19 -0
- package/src/tests/severity.test.ts +235 -0
|
@@ -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
|
|
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
|
+
}
|