@payconductor/houndsman 0.1.0 → 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 (61) hide show
  1. package/README.md +68 -89
  2. package/assets/logo-legacy.png +0 -0
  3. package/assets/logo.png +0 -0
  4. package/package.json +3 -2
  5. package/src/cli/index.ts +101 -10
  6. package/src/core/assets.ts +11 -0
  7. package/src/core/brief.ts +27 -5
  8. package/src/core/commands/findings.ts +618 -0
  9. package/src/core/commands/review.ts +2 -0
  10. package/src/core/commands/scaffold.ts +33 -44
  11. package/src/core/config.ts +42 -0
  12. package/src/core/diff-source.ts +140 -0
  13. package/src/core/file-classifier.ts +50 -2
  14. package/src/core/finding-anchor.ts +147 -0
  15. package/src/core/findings-state.ts +556 -0
  16. package/src/core/fix-batch.ts +236 -0
  17. package/src/core/git-revision.ts +105 -0
  18. package/src/core/jev/index.ts +1 -0
  19. package/src/core/jev/jev-client.ts +72 -11
  20. package/src/core/jev/jev-detector.ts +21 -19
  21. package/src/core/jev/jev-verify.ts +178 -0
  22. package/src/core/orchestrator.ts +362 -23
  23. package/src/core/paths.ts +9 -0
  24. package/src/core/registry-check.ts +15 -0
  25. package/src/core/report-builder.ts +118 -9
  26. package/src/core/run-status.ts +123 -0
  27. package/src/core/types.ts +10 -0
  28. package/src/core/verify.ts +175 -0
  29. package/src/core/watchdog-registry.ts +1 -2
  30. package/src/index.ts +78 -2
  31. package/src/server/artifacts.ts +67 -0
  32. package/src/server/client/App.tsx +158 -0
  33. package/src/server/client/ArtifactsPanel.tsx +48 -0
  34. package/src/server/client/Filters.tsx +83 -0
  35. package/src/server/client/FindingCard.tsx +122 -0
  36. package/src/server/client/FindingsList.tsx +54 -0
  37. package/src/server/client/RunList.tsx +52 -0
  38. package/src/server/client/SummaryBar.tsx +21 -0
  39. package/src/server/client/api.ts +37 -0
  40. package/src/server/client/constants.ts +94 -0
  41. package/src/server/client/index.tsx +12 -0
  42. package/src/server/client/types.ts +52 -0
  43. package/src/server/client-build.ts +47 -0
  44. package/src/server/index.ts +376 -0
  45. package/src/skill/AUDIT-PROTOCOL.md +2 -2
  46. package/src/skill/README.md +43 -11
  47. package/src/skill/SETUP.md +16 -4
  48. package/src/skill/SKILL.md +48 -0
  49. package/src/skill/fix.md +206 -0
  50. package/src/skill/{houndsman.md → hunt.md} +83 -33
  51. package/src/skill/{houndsman-update.md → train.md} +12 -12
  52. package/src/skill/verify.md +70 -0
  53. package/src/tests/findings-anchor.test.ts +120 -0
  54. package/src/tests/findings-commands.test.ts +476 -0
  55. package/src/tests/findings-state.test.ts +325 -0
  56. package/src/tests/fix-batch.test.ts +257 -0
  57. package/src/tests/review-scripts.test.ts +299 -0
  58. package/src/tests/run-status.test.ts +147 -0
  59. package/src/tests/scaffold.test.ts +59 -0
  60. package/src/tests/server.test.ts +242 -0
  61. package/src/tests/verify.test.ts +170 -0
@@ -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";
@@ -17,16 +17,27 @@ import type { JevEvaluateRequest, JevEvaluateResponse } from "./jev-types";
17
17
  const DEFAULT_BASE_URL = "https://api.typesafe.ai";
18
18
  const DEFAULT_MODEL = "jev-latest";
19
19
  const DEFAULT_TIMEOUT_MS = 20_000;
20
- /** Caminho fixo do endpoint System One. */
20
+ /** Timeout da verificação de credencial do `doctor` (mais curto que o de review). */
21
+ const DEFAULT_PING_TIMEOUT_MS = 10_000;
22
+ /** Caminho fixo do endpoinft System One. */
21
23
  const JEV_PATH = "/v1/systemone";
22
24
 
23
25
  export class JevError extends Error {
24
- constructor(message: string) {
26
+ /** Status HTTP da resposta, quando houver (indefinido em falha de rede). */
27
+ readonly status?: number;
28
+
29
+ constructor(message: string, status?: number) {
25
30
  super(message);
26
31
  this.name = "JevError";
32
+ this.status = status;
27
33
  }
28
34
  }
29
35
 
36
+ /** Resultado da verificação de credencial (`doctor`) — não lança, devolve dados. */
37
+ export type JevCredentialCheck =
38
+ | { ok: true; model: string }
39
+ | { ok: false; kind: "invalid" | "unreachable"; detail: string };
40
+
30
41
  export type JevClientOptions = {
31
42
  /** Chave de API (default: `process.env.TYPESAFE_API_KEY`). */
32
43
  apiKey?: string;
@@ -78,22 +89,72 @@ export class JevClient {
78
89
  );
79
90
  return data;
80
91
  } catch (error) {
81
- throw new JevError(JevClient.describeError(error));
92
+ const { message, status } = JevClient.parseError(error);
93
+ throw new JevError(message, status);
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Verifica a credencial com uma chamada mínima (usada pelo `doctor`).
99
+ *
100
+ * Não lança: devolve o resultado tipado para o chamador decidir (fail-open).
101
+ * 2xx → válida; 401/403 → inválida; demais → indeterminada (rede/timeout).
102
+ */
103
+ async validateCredential(): Promise<JevCredentialCheck> {
104
+ if (!this.apiKey) {
105
+ return {
106
+ ok: false,
107
+ kind: "unreachable",
108
+ detail: "TYPESAFE_API_KEY não configurada.",
109
+ };
110
+ }
111
+ const request: JevEvaluateRequest = {
112
+ state: "Ping de verificação de credencial do houndsman.",
113
+ questions: {
114
+ ping: {
115
+ type: "noul",
116
+ instructions: 'A seguinte afirmação é verdadeira? "Este é um ping."',
117
+ },
118
+ },
119
+ };
120
+ try {
121
+ const { data } = await this.http.post<JevEvaluateResponse>(
122
+ JEV_PATH,
123
+ { ...request, model: this.model },
124
+ {
125
+ headers: { Authorization: `Bearer ${this.apiKey}` },
126
+ timeout: DEFAULT_PING_TIMEOUT_MS,
127
+ },
128
+ );
129
+ return { ok: true, model: data.model ?? this.model };
130
+ } catch (error) {
131
+ const { message, status } = JevClient.parseError(error);
132
+ const kind = status === 401 || status === 403 ? "invalid" : "unreachable";
133
+ return { ok: false, kind, detail: message };
82
134
  }
83
135
  }
84
136
 
85
- /** Normaliza o erro do axios em mensagem legível (sem expor o payload bruto). */
86
- private static describeError(error: unknown): string {
137
+ /** Normaliza o erro do axios em `{ message, status }` (sem expor o payload bruto). */
138
+ private static parseError(error: unknown): {
139
+ message: string;
140
+ status?: number;
141
+ } {
87
142
  if (isAxiosError(error)) {
88
143
  const status = error.response?.status;
144
+ const responseData = error.response?.data;
89
145
  const detail =
90
- typeof error.response?.data === "string"
91
- ? error.response.data
92
- : error.response?.data
93
- ? JSON.stringify(error.response.data)
146
+ typeof responseData === "string"
147
+ ? responseData
148
+ : responseData
149
+ ? responseData?.detail?.message || JSON.stringify(responseData)
94
150
  : error.message;
95
- return status ? `HTTP ${status}: ${detail}` : detail;
151
+ return {
152
+ message: status ? `HTTP ${status}: ${detail}` : detail,
153
+ status,
154
+ };
96
155
  }
97
- return error instanceof Error ? error.message : String(error);
156
+ return {
157
+ message: error instanceof Error ? error.message : String(error),
158
+ };
98
159
  }
99
160
  }
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import { readFileSync } from "node:fs";
12
+ import { collectDiff, type DiffSource } from "../diff-source";
12
13
  import type { ClassificationResult } from "../file-classifier";
13
14
  import type { WatchdogMeta } from "../types";
14
15
  import {
@@ -25,6 +26,8 @@ export type DetectOptions = {
25
26
  threshold?: number;
26
27
  /** Nº de arquivos avaliados em paralelo. */
27
28
  concurrency?: number;
29
+ /** Diff já coletado (evita novo `git diff`); ausente → coleta aqui. */
30
+ diff?: DiffSource;
28
31
  };
29
32
 
30
33
  export type DetectResult = {
@@ -60,6 +63,7 @@ export class JevDetector {
60
63
  ): Promise<DetectResult> {
61
64
  const threshold = options.threshold ?? JevCandidatesStore.THRESHOLD;
62
65
  const concurrency = options.concurrency ?? JevDetector.DEFAULT_CONCURRENCY;
66
+ const diff = this.resolveDiff(base, head, options.diff);
63
67
 
64
68
  const jobs = this.buildJobs(classification);
65
69
  const questionByKey = new Map<string, DerivedQuestion>();
@@ -76,7 +80,7 @@ export class JevDetector {
76
80
  for (const dq of job.questions) questions[dq.key] = dq.question;
77
81
 
78
82
  const status = classification.files[job.file]?.status ?? "modified";
79
- const state = this.buildState(job.file, status, base, head);
83
+ const state = this.buildState(job.file, status, diff);
80
84
 
81
85
  try {
82
86
  const response = await this.client.evaluate({ state, questions });
@@ -125,15 +129,24 @@ export class JevDetector {
125
129
  .filter((job) => job.questions.length > 0);
126
130
  }
127
131
 
132
+ /**
133
+ * Diff para o `state`: reaproveita o injetado ou coleta **uma vez**. Tolerante
134
+ * a falha — o Jev é opcional/fail-open.
135
+ */
136
+ private resolveDiff(base: string, head: string, provided?: DiffSource): DiffSource {
137
+ if (provided) return provided;
138
+ try {
139
+ return collectDiff(base, head);
140
+ } catch {
141
+ return { byFile: new Map<string, string>(), churn: new Map() };
142
+ }
143
+ }
144
+
128
145
  /** Monta o `state` de um arquivo: cabeçalho + hunk do diff, com cap de tamanho. */
129
- private buildState(
130
- filePath: string,
131
- status: string,
132
- base: string,
133
- head: string,
134
- ): string {
146
+ private buildState(filePath: string, status: string, diff: DiffSource): string {
135
147
  const header = this.readHeader(filePath);
136
- const hunk = this.readHunk(base, head, filePath);
148
+ const hunk =
149
+ diff.byFile.get(filePath)?.slice(0, JevDetector.MAX_DIFF_CHARS) ?? "";
137
150
  const parts = [`Arquivo: ${filePath} (${status})`];
138
151
  if (header) {
139
152
  parts.push("--- topo do arquivo (imports/declarações) ---", header);
@@ -153,17 +166,6 @@ export class JevDetector {
153
166
  }
154
167
  }
155
168
 
156
- private readHunk(base: string, head: string, filePath: string): string {
157
- const result = Bun.spawnSync(
158
- ["git", "diff", `${base}...${head}`, "--", filePath],
159
- { stdout: "pipe", stderr: "pipe" },
160
- );
161
- if (result.exitCode !== 0) return "";
162
- return new TextDecoder()
163
- .decode(result.stdout)
164
- .slice(0, JevDetector.MAX_DIFF_CHARS);
165
- }
166
-
167
169
  /** Arquivo → watchdogs que o cobrem. */
168
170
  private static mapFilesToWatchdogs(
169
171
  classification: ClassificationResult,