@payconductor/houndsman 0.1.1 → 0.1.3

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.
Files changed (42) hide show
  1. package/README.md +32 -14
  2. package/package.json +1 -1
  3. package/src/cli/index.ts +22 -11
  4. package/src/core/assets.ts +3 -0
  5. package/src/core/brief.ts +2 -3
  6. package/src/core/commands/findings.ts +232 -4
  7. package/src/core/commands/review.ts +2 -0
  8. package/src/core/commands/scaffold.ts +13 -44
  9. package/src/core/diff-source.ts +4 -4
  10. package/src/core/findings-state.ts +20 -2
  11. package/src/core/fix-batch.ts +236 -0
  12. package/src/core/git-revision.ts +105 -0
  13. package/src/core/jev/index.ts +1 -0
  14. package/src/core/jev/jev-detector.ts +2 -2
  15. package/src/core/jev/jev-verify.ts +178 -0
  16. package/src/core/orchestrator.ts +202 -15
  17. package/src/core/paths.ts +6 -0
  18. package/src/core/report-builder.ts +5 -0
  19. package/src/core/run-status.ts +123 -0
  20. package/src/core/verify.ts +175 -0
  21. package/src/core/watchdog-registry.ts +1 -2
  22. package/src/index.ts +1 -1
  23. package/src/server/artifacts.ts +1 -0
  24. package/src/server/client/RunList.tsx +22 -13
  25. package/src/server/client/constants.ts +32 -1
  26. package/src/server/client/types.ts +4 -0
  27. package/src/server/index.ts +12 -1
  28. package/src/skill/AUDIT-PROTOCOL.md +2 -2
  29. package/src/skill/README.md +29 -5
  30. package/src/skill/SETUP.md +11 -1
  31. package/src/skill/SKILL.md +48 -0
  32. package/src/skill/fix.md +206 -0
  33. package/src/skill/{houndsman.md → hunt.md} +41 -68
  34. package/src/skill/{houndsman-update.md → train.md} +11 -11
  35. package/src/skill/verify.md +70 -0
  36. package/src/tests/findings-commands.test.ts +252 -2
  37. package/src/tests/findings-state.test.ts +52 -1
  38. package/src/tests/fix-batch.test.ts +257 -0
  39. package/src/tests/run-status.test.ts +147 -0
  40. package/src/tests/scaffold.test.ts +59 -0
  41. package/src/tests/server.test.ts +30 -0
  42. package/src/tests/verify.test.ts +170 -0
@@ -67,6 +67,13 @@ export type FindingHistoryEntry = {
67
67
  by?: string;
68
68
  };
69
69
 
70
+ /** Marco da revisão que originou o estado: commit revisado + quando. */
71
+ export type FindingsReviewed = {
72
+ /** SHA do commit revisado (head do diff no `--finalize`). */
73
+ headSha: string;
74
+ at: string;
75
+ };
76
+
70
77
  /** Um finding triável, com o estado das duas dimensões. */
71
78
  export type FindingState = {
72
79
  id: string;
@@ -80,6 +87,8 @@ export type FindingState = {
80
87
  action: string;
81
88
  evidence?: string;
82
89
  anchor: FindingAnchor | null;
90
+ /** Commit em que o achado foi observado (base do diff focado do verify). */
91
+ foundAtSha?: string;
83
92
  /** Ocorrências idênticas colapsadas neste fingerprint (>1 = grupo). */
84
93
  count: number;
85
94
  /** Ainda presente no último relatório consolidado. */
@@ -94,16 +103,18 @@ export type FindingState = {
94
103
 
95
104
  /** Conteúdo do `state.json`. */
96
105
  export type FindingsStateFile = {
97
- version: 1;
106
+ version: 2;
98
107
  branch: string;
99
108
  slug: string;
100
109
  updatedAt: string;
101
110
  /** Ids na ordem de exibição (estável). */
102
111
  order: string[];
112
+ /** Commit revisado — habilita o review incremental e o verify por diff. */
113
+ reviewed?: FindingsReviewed;
103
114
  findings: Record<string, FindingState>;
104
115
  };
105
116
 
106
- export const FINDINGS_STATE_VERSION = 1;
117
+ export const FINDINGS_STATE_VERSION = 2;
107
118
 
108
119
  // ---------------------------------------------------------------------------
109
120
  // Caminhos
@@ -249,6 +260,10 @@ export type BuildStateOptions = {
249
260
  branch: string;
250
261
  readFile: FileReader;
251
262
  previous?: FindingsStateFile | null;
263
+ /** Commit revisado — carimbado nos findings e no `reviewed` do estado. */
264
+ reviewed?: FindingsReviewed;
265
+ /** SHA usado para carimbar `foundAtSha` de findings novos/re-reportados. */
266
+ headSha?: string;
252
267
  now?: string;
253
268
  };
254
269
 
@@ -320,6 +335,8 @@ export function buildStateFromFindings(
320
335
  action: finding.action,
321
336
  evidence: finding.evidence,
322
337
  anchor,
338
+ // Carry-over preserva o `foundAtSha` original; novos/re-reportados usam o head atual.
339
+ foundAtSha: finding.foundAtSha ?? options.headSha ?? prev?.foundAtSha,
323
340
  count: 1,
324
341
  present: true,
325
342
  decision: prev?.decision ?? "pending",
@@ -340,6 +357,7 @@ export function buildStateFromFindings(
340
357
  slug: sanitizeBranch(options.branch),
341
358
  updatedAt: now,
342
359
  order: [],
360
+ reviewed: options.reviewed ?? previous?.reviewed,
343
361
  findings: result,
344
362
  };
345
363
  reorder(state);
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Montagem do **lote de correção** dos findings (biblioteca).
3
+ *
4
+ * O `findings batch` precisa entregar, com o mínimo de decisão da LLM, a "melhor
5
+ * unidade de trabalho" para corrigir achados: um grupo coeso (arquivo, regra ou
6
+ * watchdog) já paginado, mais a lista dos outros grupos disponíveis.
7
+ *
8
+ * A escolha do agrupamento é **determinística** — não vem do consumidor. Para a
9
+ * fila atual (findings `decision: execute` ainda não concluídos):
10
+ *
11
+ * 1. Candidatas, em ordem de prioridade: `file` → `rule` → `watchdog`.
12
+ * 2. "Elegível" = média de itens por grupo ≥ 2 (há coesão real).
13
+ * 3. Vence a elegível com **menos grupos** (escopo mais coeso); empate mantém
14
+ * a prioridade. Sem elegíveis (tudo singleton), cai em `file`.
15
+ *
16
+ * Assim o lote se adapta à run: poucos arquivos → corrige arquivo a arquivo;
17
+ * uma regra espalhada por muitos arquivos → corrige regra a regra; muitos
18
+ * arquivos/regras → agrupa por watchdog.
19
+ */
20
+
21
+ import {
22
+ compareFindings,
23
+ type FindingState,
24
+ type FindingsStateFile,
25
+ filterFindings,
26
+ } from "./findings-state";
27
+ import { SEVERITY_ORDER, type Severity, severityRank } from "./types";
28
+
29
+ /** Eixos possíveis de agrupamento do lote (ordem = prioridade de desempate). */
30
+ export type GroupingDimension = "file" | "rule" | "watchdog";
31
+
32
+ const GROUPING_DIMENSIONS: readonly GroupingDimension[] = ["file", "rule", "watchdog"];
33
+
34
+ /** Tamanho de página padrão dentro do grupo atual. */
35
+ export const FIX_BATCH_PAGE_SIZE = 8;
36
+
37
+ const DIMENSION_PREFIX = /^(file|rule|watchdog):/;
38
+
39
+ export type FixBatchOptions = {
40
+ /** Severidade alvo; ausente = todas. */
41
+ severity?: Severity;
42
+ /** Inclui achados já concluídos (default: só os pendentes). */
43
+ includeDone?: boolean;
44
+ /** Deslocamento dentro do grupo atual (default 0). */
45
+ offset?: number;
46
+ /** Chave do grupo alvo (default: o primeiro grupo). */
47
+ group?: string;
48
+ /** Tamanho da página dentro do grupo atual (default `FIX_BATCH_PAGE_SIZE`). */
49
+ pageSize?: number;
50
+ };
51
+
52
+ /** Referência a um grupo na lista dos "outros grupos". */
53
+ export type FixBatchGroupRef = {
54
+ key: string;
55
+ label: string;
56
+ count: number;
57
+ bySeverity: Record<Severity, number>;
58
+ };
59
+
60
+ export type FixBatchCurrent = {
61
+ key: string;
62
+ label: string;
63
+ findings: FindingState[];
64
+ total: number;
65
+ offset: number;
66
+ returned: number;
67
+ /** Restantes no grupo atual após a página. */
68
+ remaining: number;
69
+ };
70
+
71
+ export type FixBatchResult = {
72
+ dimension: GroupingDimension;
73
+ current: FixBatchCurrent;
74
+ others: FixBatchGroupRef[];
75
+ totals: {
76
+ /** Itens na fila de correção. */
77
+ queue: number;
78
+ /** Total de grupos. */
79
+ groups: number;
80
+ /** Itens retornados na página do grupo atual. */
81
+ returned: number;
82
+ };
83
+ };
84
+
85
+ /**
86
+ * Fila de correção: findings `decision: execute`, presentes, excluindo `done`
87
+ * (salvo `includeDone`). Ordenados por severidade/arquivo/linha.
88
+ */
89
+ export function fixQueue(
90
+ state: FindingsStateFile,
91
+ options: FixBatchOptions = {},
92
+ ): FindingState[] {
93
+ const filtered = filterFindings(state, {
94
+ decision: "execute",
95
+ severity: options.severity,
96
+ });
97
+ return options.includeDone
98
+ ? filtered
99
+ : filtered.filter((finding) => finding.execState !== "done");
100
+ }
101
+
102
+ /** Escolhe a dimensão de agrupamento com melhor coesão para a fila. */
103
+ export function chooseGrouping(findings: FindingState[]): GroupingDimension {
104
+ let best: { dimension: GroupingDimension; groups: number } | undefined;
105
+ for (const dimension of GROUPING_DIMENSIONS) {
106
+ const groups = groupFindings(findings, dimension).size;
107
+ if (groups === 0) continue;
108
+ if (findings.length / groups < 2) continue;
109
+ if (!best || groups < best.groups) best = { dimension, groups };
110
+ }
111
+ return best?.dimension ?? "file";
112
+ }
113
+
114
+ /** Agrupa os findings pela dimensão informada (chaves prefixadas pela dimensão). */
115
+ export function groupFindings(
116
+ findings: readonly FindingState[],
117
+ dimension: GroupingDimension,
118
+ ): Map<string, FindingState[]> {
119
+ const groups = new Map<string, FindingState[]>();
120
+ for (const finding of findings) {
121
+ const key = groupKey(finding, dimension);
122
+ const list = groups.get(key);
123
+ if (list) list.push(finding);
124
+ else groups.set(key, [finding]);
125
+ }
126
+ return groups;
127
+ }
128
+
129
+ function groupKey(finding: FindingState, dimension: GroupingDimension): string {
130
+ if (dimension === "file") return `file:${finding.file}`;
131
+ if (dimension === "rule") return `rule:${finding.rule}`;
132
+ return `watchdog:${finding.watchdogIds[0] ?? "—"}`;
133
+ }
134
+
135
+ function labelOf(key: string): string {
136
+ return key.replace(DIMENSION_PREFIX, "");
137
+ }
138
+
139
+ function countBySeverity(findings: readonly FindingState[]): Record<Severity, number> {
140
+ const counts = Object.fromEntries(SEVERITY_ORDER.map((s) => [s, 0])) as Record<
141
+ Severity,
142
+ number
143
+ >;
144
+ for (const finding of findings) counts[finding.severity]++;
145
+ return counts;
146
+ }
147
+
148
+ function groupSeverityRank(findings: readonly FindingState[]): number {
149
+ let rank = Number.POSITIVE_INFINITY;
150
+ for (const finding of findings) rank = Math.min(rank, severityRank(finding.severity));
151
+ return rank;
152
+ }
153
+
154
+ /** Ordena os grupos: severidade mais grave primeiro → tamanho desc → chave. */
155
+ function compareGroups(
156
+ a: readonly [string, FindingState[]],
157
+ b: readonly [string, FindingState[]],
158
+ ): number {
159
+ const bySeverity = groupSeverityRank(a[1]) - groupSeverityRank(b[1]);
160
+ if (bySeverity !== 0) return bySeverity;
161
+ if (a[1].length !== b[1].length) return b[1].length - a[1].length;
162
+ return a[0].localeCompare(b[0]);
163
+ }
164
+
165
+ /**
166
+ * Monta o lote de correção: escolhe o agrupamento, seleciona o grupo atual
167
+ * (primeiro, ou o `group` informado) paginado, e lista os demais grupos.
168
+ * Lança se o `group` informado não existir.
169
+ */
170
+ export function buildFixBatch(
171
+ state: FindingsStateFile,
172
+ options: FixBatchOptions = {},
173
+ ): FixBatchResult {
174
+ const queue = fixQueue(state, options);
175
+ const dimension = chooseGrouping(queue);
176
+ const ordered = [...groupFindings(queue, dimension).entries()].sort(compareGroups);
177
+
178
+ if (ordered.length === 0) {
179
+ return {
180
+ dimension,
181
+ current: {
182
+ key: "",
183
+ label: "",
184
+ findings: [],
185
+ total: 0,
186
+ offset: 0,
187
+ returned: 0,
188
+ remaining: 0,
189
+ },
190
+ others: [],
191
+ totals: { queue: 0, groups: 0, returned: 0 },
192
+ };
193
+ }
194
+
195
+ if (options.group !== undefined && !ordered.some(([key]) => key === options.group)) {
196
+ const available = ordered.map(([key]) => key);
197
+ const preview = available.slice(0, 5).join(", ");
198
+ throw new Error(
199
+ `Grupo '${options.group}' não encontrado. Disponíveis: ${preview}${
200
+ available.length > 5 ? ", …" : ""
201
+ }.`,
202
+ );
203
+ }
204
+
205
+ const [key, members] = (
206
+ options.group ? ordered.find(([k]) => k === options.group) : ordered[0]
207
+ ) as [string, FindingState[]];
208
+ const sorted = [...members].sort(compareFindings);
209
+ const offset = Math.max(0, options.offset ?? 0);
210
+ const pageSize = Math.max(1, options.pageSize ?? FIX_BATCH_PAGE_SIZE);
211
+ const page = sorted.slice(offset, offset + pageSize);
212
+
213
+ const others = ordered
214
+ .filter(([otherKey]) => otherKey !== key)
215
+ .map(([otherKey, otherMembers]) => ({
216
+ key: otherKey,
217
+ label: labelOf(otherKey),
218
+ count: otherMembers.length,
219
+ bySeverity: countBySeverity(otherMembers),
220
+ }));
221
+
222
+ return {
223
+ dimension,
224
+ current: {
225
+ key,
226
+ label: labelOf(key),
227
+ findings: page,
228
+ total: sorted.length,
229
+ offset,
230
+ returned: page.length,
231
+ remaining: Math.max(0, sorted.length - (offset + page.length)),
232
+ },
233
+ others,
234
+ totals: { queue: queue.length, groups: ordered.length, returned: page.length },
235
+ };
236
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Operações de revisão do git (biblioteca).
3
+ *
4
+ * Resolução de refs, ancestralidade, leitura de arquivo por commit e diffs por
5
+ * intervalo (`from..to`) — a base do **review incremental** e da **verificação
6
+ * de findings**, que comparam contra um commit fixado (ex.: o `headSha` da
7
+ * última revisão) em vez do working tree.
8
+ *
9
+ * Genérico: nenhum path/id de consumidor aqui. Reusa os parsers de diff de
10
+ * `diff-source.ts` (`parseDiffIndex`/`parseNumstat`) e de `file-classifier.ts`
11
+ * (`parseEntries`) — não reimplementa leitura de diff.
12
+ */
13
+
14
+ import {
15
+ DEFAULT_MAX_PER_FILE_CHARS,
16
+ type DiffSource,
17
+ parseDiffIndex,
18
+ parseNumstat,
19
+ } from "./diff-source";
20
+ import { parseEntries } from "./file-classifier";
21
+ import type { FileChurn } from "./types";
22
+
23
+ /** Executa `git` (paths literais) e devolve o stdout; lança se falhar. */
24
+ export function runGit(args: string[]): string {
25
+ const result = Bun.spawnSync(["git", "-c", "core.quotepath=false", ...args], {
26
+ stdout: "pipe",
27
+ stderr: "pipe",
28
+ });
29
+ if (result.exitCode !== 0) {
30
+ const stderr = new TextDecoder().decode(result.stderr).trim();
31
+ throw new Error(`git ${args.join(" ")} falhou: ${stderr}`);
32
+ }
33
+ return new TextDecoder().decode(result.stdout);
34
+ }
35
+
36
+ /** SHA completo de uma ref (`git rev-parse`). Lança se a ref não resolver. */
37
+ export function resolveSha(ref: string): string {
38
+ return runGit(["rev-parse", ref]).trim();
39
+ }
40
+
41
+ /** SHA do commit atual (`HEAD`). */
42
+ export function currentHeadSha(): string {
43
+ return resolveSha("HEAD");
44
+ }
45
+
46
+ /**
47
+ * `true` se `ancestor` é ancestral de `descendant` — histórico linear/sem
48
+ * rebase, condição para o review incremental. Qualquer outro desfecho (ref
49
+ * desconhecida, exit ≠ 0) devolve `false`: cai no review completo, que é o
50
+ * comportamento seguro.
51
+ */
52
+ export function isAncestor(ancestor: string, descendant: string): boolean {
53
+ const result = Bun.spawnSync(
54
+ ["git", "merge-base", "--is-ancestor", ancestor, descendant],
55
+ { stdout: "ignore", stderr: "ignore" },
56
+ );
57
+ return result.exitCode === 0;
58
+ }
59
+
60
+ /** Conteúdo de um arquivo num commit (`git show sha:path`); null se ausente. */
61
+ export function readFileAt(sha: string, path: string): string | null {
62
+ const result = Bun.spawnSync(
63
+ ["git", "-c", "core.quotepath=false", "show", `${sha}:${path}`],
64
+ { stdout: "pipe", stderr: "ignore" },
65
+ );
66
+ if (result.exitCode !== 0) return null;
67
+ return new TextDecoder().decode(result.stdout);
68
+ }
69
+
70
+ /** Arquivos alterados num intervalo (`git diff range --name-status`). */
71
+ export function diffEntries(range: string): ReturnType<typeof parseEntries> {
72
+ return parseEntries(runGit(["diff", range, "--name-status"]));
73
+ }
74
+
75
+ /** Churn por arquivo de um intervalo (`git diff --numstat -z range`). */
76
+ export function diffNumstat(range: string): Map<string, FileChurn> {
77
+ return parseNumstat(runGit(["diff", "--numstat", "-z", range]));
78
+ }
79
+
80
+ /**
81
+ * Patch por arquivo de um intervalo, opcionalmente restrito a `paths`.
82
+ * `maxPerFileChars` trunca cada arquivo (default: mesmo teto do `collectDiff`).
83
+ */
84
+ export function diffPatch(
85
+ range: string,
86
+ paths?: readonly string[],
87
+ maxPerFileChars: number = DEFAULT_MAX_PER_FILE_CHARS,
88
+ ): Map<string, string> {
89
+ const args = ["diff", range];
90
+ if (paths && paths.length > 0) args.push("--", ...paths);
91
+ return parseDiffIndex(runGit(args), maxPerFileChars);
92
+ }
93
+
94
+ /** Diff (patch + churn) de `from..to` — 2 spawns, base do review incremental. */
95
+ export function collectDiffSince(
96
+ from: string,
97
+ to: string,
98
+ maxPerFileChars: number = DEFAULT_MAX_PER_FILE_CHARS,
99
+ ): DiffSource {
100
+ const range = `${from}..${to}`;
101
+ return {
102
+ byFile: parseDiffIndex(runGit(["diff", range]), maxPerFileChars),
103
+ churn: diffNumstat(range),
104
+ };
105
+ }
@@ -10,3 +10,4 @@ export * from "./jev-client";
10
10
  export * from "./jev-detector";
11
11
  export * from "./jev-questions";
12
12
  export * from "./jev-types";
13
+ export * from "./jev-verify";
@@ -130,8 +130,8 @@ export class JevDetector {
130
130
  }
131
131
 
132
132
  /**
133
- * Diff para o `state`: reaproveita o injetado ou coleta **uma vez** (antes era
134
- * 1 `git diff` por arquivo). Tolerante a falha — o Jev é opcional/fail-open.
133
+ * Diff para o `state`: reaproveita o injetado ou coleta **uma vez**. Tolerante
134
+ * a falha — o Jev é opcional/fail-open.
135
135
  */
136
136
  private resolveDiff(base: string, head: string, provided?: DiffSource): DiffSource {
137
137
  if (provided) return provided;
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Confirmação Jev dos findings ambíguos (tier 1 do `findings verify`).
3
+ *
4
+ * Por arquivo: monta um `state` pequeno (topo do arquivo + diff focado
5
+ * `foundAtSha..head`) e, numa única chamada, pergunta se cada violação ainda
6
+ * ocorre. **Falha-aberto** — sem chave/erro, nada é confirmado: o tier local
7
+ * decide. O custo é uma chamada por arquivo ambíguo, não uma revisão completa.
8
+ */
9
+
10
+ import { diffPatch, readFileAt } from "../git-revision";
11
+ import type { JevClient } from "./jev-client";
12
+ import type { JevAnswer, JevQuestion } from "./jev-types";
13
+
14
+ /** Caso ambíguo a confirmar com o Jev. */
15
+ export type JevVerifyCase = {
16
+ id: string;
17
+ file: string;
18
+ rule: string;
19
+ /** Commit em que o achado foi observado (base do diff focado). */
20
+ foundAtSha?: string;
21
+ };
22
+
23
+ export type JevVerifyOptions = {
24
+ /** Commit atual (head) para o diff focado. */
25
+ headSha?: string;
26
+ /** Nº de arquivos avaliados em paralelo. */
27
+ concurrency?: number;
28
+ };
29
+
30
+ export type JevVerifyOutcome = {
31
+ /** id → confiança (0–1) de a violação ainda ocorrer. */
32
+ confidence: Map<string, number>;
33
+ checked: number;
34
+ skipped: number;
35
+ reason?: string;
36
+ };
37
+
38
+ /** Linhas iniciais do arquivo incluídas no `state` (contexto de imports). */
39
+ const HEADER_LINES = 60;
40
+ const MAX_STATE_CHARS = 8_000;
41
+ const MAX_DIFF_CHARS = 4_000;
42
+ const DEFAULT_CONCURRENCY = 5;
43
+
44
+ /** Início do diff do grupo: o `foundAtSha` mais antigo informado. */
45
+ function referenceOf(
46
+ group: readonly JevVerifyCase[],
47
+ headSha: string | undefined,
48
+ ): { from: string; to: string } | null {
49
+ const from = group.find((item) => item.foundAtSha)?.foundAtSha;
50
+ if (!from || !headSha || from === headSha) return null;
51
+ return { from, to: headSha };
52
+ }
53
+
54
+ /** Arquivo → casos daquele arquivo (uma chamada por arquivo). */
55
+ function groupByFile(cases: readonly JevVerifyCase[]): Map<string, JevVerifyCase[]> {
56
+ const map = new Map<string, JevVerifyCase[]>();
57
+ for (const item of cases) {
58
+ const list = map.get(item.file);
59
+ if (list) list.push(item);
60
+ else map.set(item.file, [item]);
61
+ }
62
+ return map;
63
+ }
64
+
65
+ /** Executa `fn` sobre `items` com concorrência limitada (ordem irrelevante). */
66
+ async function mapWithConcurrency<T>(
67
+ items: readonly T[],
68
+ limit: number,
69
+ fn: (item: T) => Promise<void>,
70
+ ): Promise<void> {
71
+ let cursor = 0;
72
+ const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
73
+ while (cursor < items.length) {
74
+ const current = items[cursor++];
75
+ await fn(current);
76
+ }
77
+ });
78
+ await Promise.all(workers);
79
+ }
80
+
81
+ /** Confirma com o Jev se as violações ambíguas ainda ocorrem. */
82
+ export class JevVerifier {
83
+ private readonly client: JevClient;
84
+
85
+ /** Construtor que recebe o cliente (injeção). */
86
+ constructor(client: JevClient) {
87
+ this.client = client;
88
+ }
89
+
90
+ /** `true` se há chave configurada (sem chave o tier 1 é pulado). */
91
+ isConfigured(): boolean {
92
+ return this.client.isConfigured();
93
+ }
94
+
95
+ /** Avalia os casos ambíguos e devolve a confiança por id. */
96
+ async verify(
97
+ cases: readonly JevVerifyCase[],
98
+ options: JevVerifyOptions = {},
99
+ ): Promise<JevVerifyOutcome> {
100
+ const confidence = new Map<string, number>();
101
+ if (cases.length === 0) return { confidence, checked: 0, skipped: 0 };
102
+ if (!this.client.isConfigured()) {
103
+ return {
104
+ confidence,
105
+ checked: 0,
106
+ skipped: cases.length,
107
+ reason: "TYPESAFE_API_KEY não configurada",
108
+ };
109
+ }
110
+
111
+ const groups = [...groupByFile(cases).values()];
112
+ const jobs = groups.filter((group) => referenceOf(group, options.headSha));
113
+ const withoutRef =
114
+ cases.length - jobs.reduce((sum, group) => sum + group.length, 0);
115
+ let skipped = withoutRef;
116
+
117
+ await mapWithConcurrency(
118
+ jobs,
119
+ options.concurrency ?? DEFAULT_CONCURRENCY,
120
+ async (group) => {
121
+ try {
122
+ const answers = await this.evaluateGroup(group, options.headSha);
123
+ for (const item of group) {
124
+ const answer = answers[item.id];
125
+ if (answer?.type === "noul") confidence.set(item.id, answer.noul);
126
+ }
127
+ } catch {
128
+ skipped += group.length;
129
+ }
130
+ },
131
+ );
132
+
133
+ return {
134
+ confidence,
135
+ checked: confidence.size,
136
+ skipped,
137
+ reason:
138
+ withoutRef > 0
139
+ ? "sem commit de referência (foundAtSha) em algum arquivo"
140
+ : undefined,
141
+ };
142
+ }
143
+
144
+ /** Uma chamada por arquivo: `state` focado + uma pergunta por caso. */
145
+ private async evaluateGroup(
146
+ group: readonly JevVerifyCase[],
147
+ headSha: string | undefined,
148
+ ): Promise<Record<string, JevAnswer>> {
149
+ const reference = referenceOf(group, headSha);
150
+ if (!reference) throw new Error("sem commit de referência para o diff focado.");
151
+
152
+ const file = group[0].file;
153
+ const patch =
154
+ diffPatch(`${reference.from}..${reference.to}`, [file]).get(file) ?? "";
155
+ const header = (readFileAt(reference.to, file) ?? "")
156
+ .split("\n")
157
+ .slice(0, HEADER_LINES)
158
+ .join("\n");
159
+
160
+ const parts = [`Arquivo: ${file}`];
161
+ if (header) parts.push("--- topo do arquivo ---", header);
162
+ if (patch) {
163
+ parts.push("--- diff desde a revisão ---", patch.slice(0, MAX_DIFF_CHARS));
164
+ }
165
+ const state = parts.join("\n").slice(0, MAX_STATE_CHARS);
166
+
167
+ const questions: Record<string, JevQuestion> = {};
168
+ for (const item of group) {
169
+ questions[item.id] = {
170
+ type: "noul",
171
+ instructions: `No estado fornecido, a violação a seguir ainda ocorre? "${item.rule}"`,
172
+ };
173
+ }
174
+
175
+ const response = await this.client.evaluate({ state, questions });
176
+ return response.answers;
177
+ }
178
+ }