@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.
- package/README.md +6 -1
- package/package.json +1 -1
- package/src/cli/commands/compare-command.ts +112 -0
- package/src/cli/commands/context-command.ts +1 -0
- package/src/cli/commands/findings-command.ts +11 -1
- package/src/cli/commands/golden-command.ts +92 -0
- package/src/cli/commands/hunt-command.ts +105 -4
- package/src/cli/commands/review-command.ts +38 -0
- package/src/cli/commands/status-command.ts +3 -2
- package/src/cli/help.ts +6 -1
- package/src/cli/index.ts +5 -1
- package/src/cli/progress.ts +7 -2
- package/src/core/ai/ai-client.ts +38 -2
- package/src/core/ai/features/brief-enricher.ts +1 -1
- package/src/core/ai/features/business-analyzer.ts +61 -0
- package/src/core/ai/features/business-summarizer.ts +44 -0
- package/src/core/ai/index.ts +8 -0
- package/src/core/ai/prompts/brief.ts +33 -7
- package/src/core/ai/prompts/business.ts +76 -0
- package/src/core/ai/provider-capabilities.ts +12 -0
- package/src/core/ai/schemas.ts +70 -2
- package/src/core/ai/usage-recorder.ts +53 -1
- package/src/core/brief.ts +278 -16
- package/src/core/builtins.ts +108 -0
- package/src/core/business-summary.ts +583 -0
- package/src/core/commands/compare.ts +266 -0
- package/src/core/commands/context.ts +6 -2
- package/src/core/commands/findings.ts +38 -30
- package/src/core/commands/golden.ts +176 -0
- package/src/core/commands/review.ts +6 -0
- package/src/core/commands/scaffold.ts +29 -0
- package/src/core/commands/status.ts +9 -4
- package/src/core/compare.ts +310 -0
- package/src/core/concurrency.ts +30 -0
- package/src/core/config.ts +41 -3
- package/src/core/defaults.ts +50 -4
- package/src/core/domain/budget.ts +62 -4
- package/src/core/domain/classification.ts +12 -2
- package/src/core/engine/carryover.ts +4 -0
- package/src/core/engine/finalize-pipeline.ts +371 -0
- package/src/core/engine/prepare-pipeline.ts +1294 -0
- package/src/core/engine/review-context.ts +51 -0
- package/src/core/engine/review-options.ts +47 -0
- package/src/core/engine/review-scope.ts +14 -1
- package/src/core/engine/review-session.ts +11 -13
- package/src/core/engine/run-artifacts.ts +196 -0
- package/src/core/engine/run-format.ts +4 -0
- package/src/core/findings-state.ts +22 -2
- package/src/core/github-review.ts +5 -2
- package/src/core/golden.ts +346 -0
- package/src/core/hunt/hunt-runner.ts +621 -34
- package/src/core/hunt/index.ts +40 -1
- package/src/core/hunt/maki-intent-prompt.ts +50 -0
- package/src/core/hunt/maki-prompt.ts +298 -5
- package/src/core/hunt/maki-runner.ts +93 -16
- package/src/core/hunt/pool.ts +4 -26
- package/src/core/hunt/review-checks.ts +460 -0
- package/src/core/logging/index.ts +6 -1
- package/src/core/logging/progress.ts +85 -1
- package/src/core/orchestrator.ts +33 -1289
- package/src/core/paths.ts +6 -3
- package/src/core/pr-context.ts +2 -4
- package/src/core/prepare-report.ts +61 -0
- package/src/core/project-config.ts +18 -1
- package/src/core/propagation.ts +127 -0
- package/src/core/registry-check.ts +90 -27
- package/src/core/renderers/manifest-writer.ts +13 -0
- package/src/core/renderers/resume-renderer.ts +151 -11
- package/src/core/report-builder.ts +116 -278
- package/src/core/severity.ts +245 -0
- package/src/core/storage/file/file-storage.ts +7 -3
- package/src/core/storage/sqlite/mappers.ts +10 -0
- package/src/core/storage/sqlite/schema.ts +104 -23
- package/src/core/storage/sqlite/sqlite-storage.ts +22 -9
- package/src/core/storage/types.ts +17 -4
- package/src/core/storage/work-area.ts +59 -1
- package/src/core/types.ts +31 -0
- package/src/core/watchdog-registry.ts +21 -3
- package/src/index.ts +51 -4
- package/src/server/client/ArtifactsPanel.tsx +4 -2
- package/src/server/client/FindingCard.tsx +21 -1
- package/src/server/client/RunsSection.tsx +5 -0
- package/src/server/client/utils/constants.ts +21 -11
- package/src/server/client/utils/markdown.ts +1 -1
- package/src/server/client/utils/types.ts +14 -2
- package/src/server/run-repository.ts +35 -14
- package/src/server/styles.css +2 -0
- package/src/skill/OUTPUT-FORMAT.md +55 -10
- package/src/skill/README.md +19 -14
- package/src/skill/SETUP.md +1 -0
- package/src/skill/SKILL.md +13 -4
- package/src/skill/example-resume.md +43 -0
- package/src/skill/hunt.md +205 -58
- package/src/skill/train.md +3 -0
- package/src/tests/__snapshots__/baseline.test.ts.snap +208 -184
- package/src/tests/ai-core.test.ts +46 -0
- package/src/tests/ai-features.test.ts +95 -4
- package/src/tests/baseline.test.ts +29 -121
- package/src/tests/brief-scope.test.ts +250 -0
- package/src/tests/business-summary.test.ts +258 -0
- package/src/tests/compare.test.ts +256 -0
- package/src/tests/findings-state.test.ts +82 -0
- package/src/tests/fixtures/fake-model-provider.ts +2 -1
- package/src/tests/golden.test.ts +195 -0
- package/src/tests/hunt-maki.test.ts +710 -2
- package/src/tests/markdown.test.ts +3 -3
- package/src/tests/progress.test.ts +29 -1
- package/src/tests/propagation.test.ts +119 -0
- package/src/tests/review-resume.test.ts +60 -1
- package/src/tests/review-scope.test.ts +38 -29
- package/src/tests/review-scripts.test.ts +125 -218
- package/src/tests/server.test.ts +7 -4
- package/src/tests/severity.test.ts +235 -0
- package/src/tests/storage.test.ts +194 -9
- package/src/core/renderers/report-renderer.ts +0 -66
- package/src/skill/example-review.md +0 -66
|
@@ -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
|
+
}
|
|
@@ -16,4 +16,9 @@ export type {
|
|
|
16
16
|
} from "./logger";
|
|
17
17
|
export { Logger, SilentLogger } from "./logger";
|
|
18
18
|
export type { ProgressBoardOptions } from "./progress";
|
|
19
|
-
export {
|
|
19
|
+
export {
|
|
20
|
+
formatDuration,
|
|
21
|
+
ParallelPhaseReporter,
|
|
22
|
+
PhaseReporter,
|
|
23
|
+
ProgressBoard,
|
|
24
|
+
} from "./progress";
|
|
@@ -83,13 +83,97 @@ export class PhaseReporter {
|
|
|
83
83
|
}
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Relógio de fases **concorrentes** (prepare com etapas em paralelo).
|
|
88
|
+
*
|
|
89
|
+
* No TTY mantém um único spinner com a etapa mais recente e a contagem das
|
|
90
|
+
* demais; em pipe preserva o formato `→`/`✔` por etapa do `PhaseReporter`.
|
|
91
|
+
*/
|
|
92
|
+
export class ParallelPhaseReporter {
|
|
93
|
+
private readonly logger: LoggerLike;
|
|
94
|
+
private readonly active = new Map<string, string>();
|
|
95
|
+
private spinner: SpinnerLike | null = null;
|
|
96
|
+
|
|
97
|
+
constructor(logger: LoggerLike) {
|
|
98
|
+
this.logger = logger;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Abre uma fase (pode haver várias ativas ao mesmo tempo). */
|
|
102
|
+
begin(stage: string, message: string): void {
|
|
103
|
+
try {
|
|
104
|
+
this.active.set(stage, message);
|
|
105
|
+
if (this.spinner) {
|
|
106
|
+
this.restart();
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const spinner = this.logger.spinner(this.summary());
|
|
110
|
+
if (spinner) {
|
|
111
|
+
this.spinner = spinner.start();
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
this.logger.step(message);
|
|
115
|
+
} catch {
|
|
116
|
+
// Progresso nunca derruba a revisão.
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Fecha uma fase com a duração medida. */
|
|
121
|
+
end(stage: string, message: string, durationMs: number): void {
|
|
122
|
+
try {
|
|
123
|
+
this.active.delete(stage);
|
|
124
|
+
const text = `${message} (${formatDuration(durationMs)})`;
|
|
125
|
+
if (this.spinner) {
|
|
126
|
+
this.spinner.stop();
|
|
127
|
+
this.spinner = null;
|
|
128
|
+
this.logger.success(text);
|
|
129
|
+
this.startSpinner();
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
this.logger.success(text);
|
|
133
|
+
} catch {
|
|
134
|
+
// Progresso nunca derruba a revisão.
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Encerra o spinner pendente (sem mensagem de sucesso). */
|
|
139
|
+
stop(): void {
|
|
140
|
+
try {
|
|
141
|
+
this.spinner?.stop();
|
|
142
|
+
} catch {
|
|
143
|
+
// Progresso nunca derruba a revisão.
|
|
144
|
+
}
|
|
145
|
+
this.spinner = null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Texto agregado das fases ativas. */
|
|
149
|
+
private summary(): string {
|
|
150
|
+
const messages = [...this.active.values()];
|
|
151
|
+
if (messages.length <= 1) return messages[0] ?? "";
|
|
152
|
+
return `${messages[0]} (+${messages.length - 1} em paralelo)`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Reinicia o spinner com o texto agregado atualizado. */
|
|
156
|
+
private restart(): void {
|
|
157
|
+
if (!this.spinner) return;
|
|
158
|
+
this.spinner.stop();
|
|
159
|
+
this.spinner = null;
|
|
160
|
+
this.startSpinner();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** (Re)abre o spinner quando ainda há fases ativas. */
|
|
164
|
+
private startSpinner(): void {
|
|
165
|
+
if (this.active.size === 0) return;
|
|
166
|
+
const spinner = this.logger.spinner(this.summary());
|
|
167
|
+
this.spinner = spinner ? spinner.start() : null;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
86
171
|
/** Tarefa concorrente exibida pelo board. */
|
|
87
172
|
type BoardTask = {
|
|
88
173
|
id: string;
|
|
89
174
|
detail: string;
|
|
90
175
|
startedMs: number;
|
|
91
176
|
};
|
|
92
|
-
|
|
93
177
|
export type ProgressBoardOptions = {
|
|
94
178
|
logger: LoggerLike;
|
|
95
179
|
/** Intervalo de repintura do loader em TTY (ms). */
|