@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.
- package/README.md +68 -89
- package/assets/logo-legacy.png +0 -0
- package/assets/logo.png +0 -0
- package/package.json +3 -2
- package/src/cli/index.ts +101 -10
- package/src/core/assets.ts +11 -0
- package/src/core/brief.ts +27 -5
- package/src/core/commands/findings.ts +618 -0
- package/src/core/commands/review.ts +2 -0
- package/src/core/commands/scaffold.ts +33 -44
- package/src/core/config.ts +42 -0
- package/src/core/diff-source.ts +140 -0
- package/src/core/file-classifier.ts +50 -2
- package/src/core/finding-anchor.ts +147 -0
- package/src/core/findings-state.ts +556 -0
- package/src/core/fix-batch.ts +236 -0
- package/src/core/git-revision.ts +105 -0
- package/src/core/jev/index.ts +1 -0
- package/src/core/jev/jev-client.ts +72 -11
- package/src/core/jev/jev-detector.ts +21 -19
- package/src/core/jev/jev-verify.ts +178 -0
- package/src/core/orchestrator.ts +362 -23
- package/src/core/paths.ts +9 -0
- package/src/core/registry-check.ts +15 -0
- package/src/core/report-builder.ts +118 -9
- package/src/core/run-status.ts +123 -0
- package/src/core/types.ts +10 -0
- package/src/core/verify.ts +175 -0
- package/src/core/watchdog-registry.ts +1 -2
- package/src/index.ts +78 -2
- package/src/server/artifacts.ts +67 -0
- package/src/server/client/App.tsx +158 -0
- package/src/server/client/ArtifactsPanel.tsx +48 -0
- package/src/server/client/Filters.tsx +83 -0
- package/src/server/client/FindingCard.tsx +122 -0
- package/src/server/client/FindingsList.tsx +54 -0
- package/src/server/client/RunList.tsx +52 -0
- package/src/server/client/SummaryBar.tsx +21 -0
- package/src/server/client/api.ts +37 -0
- package/src/server/client/constants.ts +94 -0
- package/src/server/client/index.tsx +12 -0
- package/src/server/client/types.ts +52 -0
- package/src/server/client-build.ts +47 -0
- package/src/server/index.ts +376 -0
- package/src/skill/AUDIT-PROTOCOL.md +2 -2
- package/src/skill/README.md +43 -11
- package/src/skill/SETUP.md +16 -4
- package/src/skill/SKILL.md +48 -0
- package/src/skill/fix.md +206 -0
- package/src/skill/{houndsman.md → hunt.md} +83 -33
- package/src/skill/{houndsman-update.md → train.md} +12 -12
- package/src/skill/verify.md +70 -0
- package/src/tests/findings-anchor.test.ts +120 -0
- package/src/tests/findings-commands.test.ts +476 -0
- package/src/tests/findings-state.test.ts +325 -0
- package/src/tests/fix-batch.test.ts +257 -0
- package/src/tests/review-scripts.test.ts +299 -0
- package/src/tests/run-status.test.ts +147 -0
- package/src/tests/scaffold.test.ts +59 -0
- package/src/tests/server.test.ts +242 -0
- 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
|
+
}
|
package/src/core/jev/index.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
86
|
-
private static
|
|
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
|
|
91
|
-
?
|
|
92
|
-
:
|
|
93
|
-
? JSON.stringify(
|
|
146
|
+
typeof responseData === "string"
|
|
147
|
+
? responseData
|
|
148
|
+
: responseData
|
|
149
|
+
? responseData?.detail?.message || JSON.stringify(responseData)
|
|
94
150
|
: error.message;
|
|
95
|
-
return
|
|
151
|
+
return {
|
|
152
|
+
message: status ? `HTTP ${status}: ${detail}` : detail,
|
|
153
|
+
status,
|
|
154
|
+
};
|
|
96
155
|
}
|
|
97
|
-
return
|
|
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,
|
|
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 =
|
|
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,
|