@payconductor/houndsman 0.1.1 → 0.1.4
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 +32 -14
- package/package.json +1 -1
- package/src/cli/index.ts +22 -11
- package/src/core/assets.ts +3 -0
- package/src/core/brief.ts +2 -3
- package/src/core/commands/findings.ts +232 -4
- package/src/core/commands/review.ts +2 -0
- package/src/core/commands/scaffold.ts +13 -44
- package/src/core/diff-source.ts +4 -4
- package/src/core/findings-state.ts +20 -2
- 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-detector.ts +2 -2
- package/src/core/jev/jev-verify.ts +178 -0
- package/src/core/orchestrator.ts +202 -15
- package/src/core/paths.ts +6 -0
- package/src/core/registry-check.ts +53 -0
- package/src/core/report-builder.ts +5 -0
- package/src/core/run-status.ts +123 -0
- package/src/core/verify.ts +175 -0
- package/src/core/watchdog-registry.ts +1 -2
- package/src/index.ts +1 -1
- package/src/server/artifacts.ts +1 -0
- package/src/server/client/RunList.tsx +22 -13
- package/src/server/client/constants.ts +32 -1
- package/src/server/client/types.ts +4 -0
- package/src/server/index.ts +12 -1
- package/src/skill/AUDIT-PROTOCOL.md +7 -2
- package/src/skill/README.md +32 -6
- package/src/skill/SETUP.md +11 -2
- package/src/skill/SKILL.md +48 -0
- package/src/skill/fix.md +206 -0
- package/src/skill/{houndsman.md → hunt.md} +42 -68
- package/src/skill/{houndsman-update.md → train.md} +38 -17
- package/src/skill/verify.md +70 -0
- package/src/tests/findings-commands.test.ts +252 -2
- package/src/tests/findings-state.test.ts +52 -1
- package/src/tests/fix-batch.test.ts +257 -0
- package/src/tests/review-scripts.test.ts +36 -0
- package/src/tests/run-status.test.ts +147 -0
- package/src/tests/scaffold.test.ts +59 -0
- package/src/tests/server.test.ts +30 -0
- 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:
|
|
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 =
|
|
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
|
+
}
|
package/src/core/jev/index.ts
CHANGED
|
@@ -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
|
|
134
|
-
*
|
|
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
|
+
}
|