@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,618 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subcomando `findings` — triagem dos findings da revisão (biblioteca).
|
|
3
|
+
*
|
|
4
|
+
* Lê/grava o `state.json` da run (`findings-state.ts`). Devolve o texto pronto
|
|
5
|
+
* (markdown ou JSON) + exit code; o CLI decide o log/exit. O agente do usuário
|
|
6
|
+
* usa `list`/`set`/`done` (e `batch` para lotes de correção) para interagir sem abrir o JSON à mão.
|
|
7
|
+
*
|
|
8
|
+
* Se o `state.json` não existir, é re-construído a partir de
|
|
9
|
+
* `findings/*.json` + `classification.json` (não exige `--finalize`).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { writeFileSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import {
|
|
15
|
+
type HoundsmanConfig,
|
|
16
|
+
resolveArtifactDir,
|
|
17
|
+
resolveJevConcurrency,
|
|
18
|
+
} from "../config";
|
|
19
|
+
import {
|
|
20
|
+
applyFindingUpdate,
|
|
21
|
+
buildStateFromRunDir,
|
|
22
|
+
FINDING_DECISIONS,
|
|
23
|
+
FINDING_EXEC_STATES,
|
|
24
|
+
type FindingDecision,
|
|
25
|
+
type FindingExecState,
|
|
26
|
+
type FindingState,
|
|
27
|
+
type FindingsFilter,
|
|
28
|
+
type FindingsStateFile,
|
|
29
|
+
FindingsStateStore,
|
|
30
|
+
type FindingsSummary,
|
|
31
|
+
type FindingUpdate,
|
|
32
|
+
fileReader,
|
|
33
|
+
filterFindings,
|
|
34
|
+
runDir,
|
|
35
|
+
summarize,
|
|
36
|
+
syncState,
|
|
37
|
+
} from "../findings-state";
|
|
38
|
+
import { buildFixBatch, type FixBatchResult } from "../fix-batch";
|
|
39
|
+
import { JevCandidatesStore, JevClient, JevVerifier } from "../jev";
|
|
40
|
+
import { relFromRoot, VERIFY_FILE } from "../paths";
|
|
41
|
+
import { currentBranch } from "../pr-context";
|
|
42
|
+
import { pingFindingsStatus } from "../run-status";
|
|
43
|
+
import { SEVERITY_EMOJI, SEVERITY_ORDER, type Severity } from "../types";
|
|
44
|
+
import {
|
|
45
|
+
applyJev,
|
|
46
|
+
applyResolutions,
|
|
47
|
+
selectVerifiable,
|
|
48
|
+
summarizeVerify,
|
|
49
|
+
type VerifyEntry,
|
|
50
|
+
type VerifyStatus,
|
|
51
|
+
type VerifySummary,
|
|
52
|
+
verifyLocal,
|
|
53
|
+
} from "../verify";
|
|
54
|
+
|
|
55
|
+
export type FindingsCommandResult = {
|
|
56
|
+
output: string;
|
|
57
|
+
exitCode: number;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const VALUE_FLAGS = new Set([
|
|
61
|
+
"--decision",
|
|
62
|
+
"--exec",
|
|
63
|
+
"--watchdog",
|
|
64
|
+
"--severity",
|
|
65
|
+
"--file",
|
|
66
|
+
"--branch",
|
|
67
|
+
"--dir",
|
|
68
|
+
"--limit",
|
|
69
|
+
"--comment",
|
|
70
|
+
"--by",
|
|
71
|
+
"--offset",
|
|
72
|
+
"--group",
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
type ParsedArgs = {
|
|
76
|
+
sub?: string;
|
|
77
|
+
positionals: string[];
|
|
78
|
+
values: Record<string, string>;
|
|
79
|
+
bools: Set<string>;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** Parse simples: primeiro não-flag = sub; demais = positionais. */
|
|
83
|
+
function parseArgs(args: string[]): ParsedArgs {
|
|
84
|
+
const parsed: ParsedArgs = { positionals: [], values: {}, bools: new Set() };
|
|
85
|
+
for (let i = 0; i < args.length; i++) {
|
|
86
|
+
const arg = args[i];
|
|
87
|
+
if (arg.startsWith("--")) {
|
|
88
|
+
if (VALUE_FLAGS.has(arg)) {
|
|
89
|
+
const value = args[i + 1];
|
|
90
|
+
if (value !== undefined) parsed.values[arg] = value;
|
|
91
|
+
i++;
|
|
92
|
+
} else {
|
|
93
|
+
parsed.bools.add(arg);
|
|
94
|
+
}
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (!parsed.sub) parsed.sub = arg;
|
|
98
|
+
else parsed.positionals.push(arg);
|
|
99
|
+
}
|
|
100
|
+
return parsed;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Diretório da run + branch resolvida. */
|
|
104
|
+
function resolveDir(
|
|
105
|
+
config: HoundsmanConfig,
|
|
106
|
+
parsed: ParsedArgs,
|
|
107
|
+
): { dir: string; branch: string } {
|
|
108
|
+
return {
|
|
109
|
+
dir: parsed.values["--dir"] ?? resolveArtifactDir(config),
|
|
110
|
+
branch: parsed.values["--branch"] ?? currentBranch(),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Lê o estado da run; reconstrói dos findings se ausente. Lança se impossível. */
|
|
115
|
+
function loadState(config: HoundsmanConfig, parsed: ParsedArgs): FindingsStateFile {
|
|
116
|
+
const { dir, branch } = resolveDir(config, parsed);
|
|
117
|
+
const store = FindingsStateStore.at(dir, branch);
|
|
118
|
+
const existing = store.read();
|
|
119
|
+
if (existing) return existing;
|
|
120
|
+
|
|
121
|
+
const built = buildStateFromRunDir(
|
|
122
|
+
runDir(dir, branch),
|
|
123
|
+
branch,
|
|
124
|
+
fileReader(process.cwd()),
|
|
125
|
+
);
|
|
126
|
+
if (!built) {
|
|
127
|
+
throw new Error(
|
|
128
|
+
`Nenhum estado de triagem para '${branch}'. Rode \`houndsman --finalize\` primeiro.`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
store.write(built);
|
|
132
|
+
return built;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
// Render
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
function decisionLabel(decision: FindingDecision): string {
|
|
140
|
+
return decision;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Uma linha compacta por finding (uso no agente). */
|
|
144
|
+
function renderFindingLine(finding: FindingState): string[] {
|
|
145
|
+
const emoji = SEVERITY_EMOJI[finding.severity];
|
|
146
|
+
const location = finding.line ? `${finding.file}:${finding.line}` : finding.file;
|
|
147
|
+
const flags = `${decisionLabel(finding.decision)}/${finding.execState}`;
|
|
148
|
+
const count = finding.count > 1 ? ` ×${finding.count}` : "";
|
|
149
|
+
const lines = [
|
|
150
|
+
`- \`${finding.id}\` ${emoji} **${finding.severity}** \`${location}\` — ${finding.rule}${count}`,
|
|
151
|
+
` ${finding.message}`,
|
|
152
|
+
` _${flags}_ · watchdogs: ${finding.watchdogIds.join(", ") || "—"}`,
|
|
153
|
+
];
|
|
154
|
+
if (finding.comment) lines.push(` **Comentário:** ${finding.comment}`);
|
|
155
|
+
return lines;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function renderList(state: FindingsStateFile, findings: FindingState[]): string {
|
|
159
|
+
if (findings.length === 0) return "Nenhum finding para o filtro informado.";
|
|
160
|
+
const lines = [`# Findings — ${state.branch} (${findings.length})`, ""];
|
|
161
|
+
for (const finding of findings) lines.push(...renderFindingLine(finding));
|
|
162
|
+
return lines.join("\n");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function renderSummary(state: FindingsStateFile, summary: FindingsSummary): string {
|
|
166
|
+
const decisions = FINDING_DECISIONS.map((d) => `${d} ${summary.byDecision[d]}`).join(
|
|
167
|
+
" · ",
|
|
168
|
+
);
|
|
169
|
+
const exec = FINDING_EXEC_STATES.map((e) => `${e} ${summary.byExecState[e]}`).join(
|
|
170
|
+
" · ",
|
|
171
|
+
);
|
|
172
|
+
const severities = SEVERITY_ORDER.map((s) => `${s} ${summary.bySeverity[s]}`).join(
|
|
173
|
+
" · ",
|
|
174
|
+
);
|
|
175
|
+
return [
|
|
176
|
+
`# Triagem — ${state.branch}`,
|
|
177
|
+
"",
|
|
178
|
+
`Total: ${summary.total} (presentes: ${summary.present})`,
|
|
179
|
+
`Decisões: ${decisions}`,
|
|
180
|
+
`Execução: ${exec}`,
|
|
181
|
+
`Severidade: ${severities}`,
|
|
182
|
+
].join("\n");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Mix de severidade compacto (ex.: `❌1 ⚠️2`). */
|
|
186
|
+
function severityBreakdown(bySeverity: Record<Severity, number>): string {
|
|
187
|
+
return SEVERITY_ORDER.filter((severity) => bySeverity[severity] > 0)
|
|
188
|
+
.map((severity) => `${SEVERITY_EMOJI[severity]}${bySeverity[severity]}`)
|
|
189
|
+
.join(" ");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Render do lote de correção: grupo atual paginado + outros grupos. */
|
|
193
|
+
function renderBatch(result: FixBatchResult): string {
|
|
194
|
+
const { dimension, current, others, totals } = result;
|
|
195
|
+
if (totals.queue === 0) return "Nenhum finding pendente de correção.";
|
|
196
|
+
|
|
197
|
+
const lines = [
|
|
198
|
+
`# Lote de correção — agrupado por ${dimension} (${totals.groups} grupo(s))`,
|
|
199
|
+
"",
|
|
200
|
+
`## Grupo atual — ${current.key} (${current.total} finding(s), mostrando ${current.returned})`,
|
|
201
|
+
];
|
|
202
|
+
for (const finding of current.findings) lines.push(...renderFindingLine(finding));
|
|
203
|
+
if (current.remaining > 0) {
|
|
204
|
+
lines.push("", `_… +${current.remaining} neste grupo (use \`--offset\`)._`);
|
|
205
|
+
}
|
|
206
|
+
if (others.length > 0) {
|
|
207
|
+
lines.push("", "## Outros grupos disponíveis");
|
|
208
|
+
for (const group of others) {
|
|
209
|
+
lines.push(
|
|
210
|
+
`- ${group.key} — ${group.count} (${severityBreakdown(group.bySeverity)})`,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
lines.push(
|
|
215
|
+
"",
|
|
216
|
+
`fila: ${totals.queue} · retornados: ${totals.returned} · grupos: ${totals.groups}`,
|
|
217
|
+
);
|
|
218
|
+
return lines.join("\n");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
// Execução
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
function parseDecision(raw: string | undefined): FindingDecision | undefined {
|
|
226
|
+
if (!raw) return undefined;
|
|
227
|
+
if (!FINDING_DECISIONS.includes(raw as FindingDecision)) {
|
|
228
|
+
throw new Error(
|
|
229
|
+
`--decision inválido: '${raw}'. Use: ${FINDING_DECISIONS.join(", ")}.`,
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
return raw as FindingDecision;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function parseExec(raw: string | undefined): FindingExecState | undefined {
|
|
236
|
+
if (!raw) return undefined;
|
|
237
|
+
if (!FINDING_EXEC_STATES.includes(raw as FindingExecState)) {
|
|
238
|
+
throw new Error(
|
|
239
|
+
`--exec inválido: '${raw}'. Use: ${FINDING_EXEC_STATES.join(", ")}.`,
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
return raw as FindingExecState;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function filterFrom(parsed: ParsedArgs): FindingsFilter {
|
|
246
|
+
const severity = parsed.values["--severity"];
|
|
247
|
+
return {
|
|
248
|
+
decision: parseDecision(parsed.values["--decision"]),
|
|
249
|
+
execState: parseExec(parsed.values["--exec"]),
|
|
250
|
+
watchdog: parsed.values["--watchdog"],
|
|
251
|
+
severity: severity ? (severity as Severity) : undefined,
|
|
252
|
+
file: parsed.values["--file"],
|
|
253
|
+
includeAbsent: parsed.bools.has("--all"),
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Faz ping no `run.json` (status derivado do estado atual) após uma mutação. */
|
|
258
|
+
function pingStatus(config: HoundsmanConfig, parsed: ParsedArgs): void {
|
|
259
|
+
const { dir, branch } = resolveDir(config, parsed);
|
|
260
|
+
const state = FindingsStateStore.at(dir, branch).read();
|
|
261
|
+
if (state) pingFindingsStatus(runDir(dir, branch), state);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Aplica uma atualização a um finding e persiste. */
|
|
265
|
+
function updateOne(
|
|
266
|
+
config: HoundsmanConfig,
|
|
267
|
+
parsed: ParsedArgs,
|
|
268
|
+
id: string,
|
|
269
|
+
update: FindingUpdate,
|
|
270
|
+
): FindingState {
|
|
271
|
+
const { dir, branch } = resolveDir(config, parsed);
|
|
272
|
+
const store = FindingsStateStore.at(dir, branch);
|
|
273
|
+
let updated: FindingState | undefined;
|
|
274
|
+
store.update((state) => {
|
|
275
|
+
const finding = state.findings[id];
|
|
276
|
+
if (!finding) return;
|
|
277
|
+
updated = applyFindingUpdate(finding, update);
|
|
278
|
+
});
|
|
279
|
+
if (!updated) throw new Error(`Finding '${id}' não encontrado.`);
|
|
280
|
+
return updated;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Aplica a mesma atualização a vários ids (done/reset). */
|
|
284
|
+
function updateMany(
|
|
285
|
+
config: HoundsmanConfig,
|
|
286
|
+
parsed: ParsedArgs,
|
|
287
|
+
ids: string[],
|
|
288
|
+
update: FindingUpdate,
|
|
289
|
+
): FindingState[] {
|
|
290
|
+
const { dir, branch } = resolveDir(config, parsed);
|
|
291
|
+
const store = FindingsStateStore.at(dir, branch);
|
|
292
|
+
const updated: FindingState[] = [];
|
|
293
|
+
store.update((state) => {
|
|
294
|
+
for (const id of ids) {
|
|
295
|
+
const finding = state.findings[id];
|
|
296
|
+
if (!finding) continue;
|
|
297
|
+
updated.push(applyFindingUpdate(finding, update));
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
const missing = ids.filter((id) => !updated.some((f) => f.id === id));
|
|
301
|
+
if (missing.length > 0) {
|
|
302
|
+
throw new Error(`Findings não encontrados: ${missing.join(", ")}.`);
|
|
303
|
+
}
|
|
304
|
+
return updated;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Executa um subcomando de `findings` e devolve o texto + exit code. */
|
|
308
|
+
export function runFindingsCommand(
|
|
309
|
+
config: HoundsmanConfig,
|
|
310
|
+
args: string[],
|
|
311
|
+
): FindingsCommandResult {
|
|
312
|
+
const parsed = parseArgs(args);
|
|
313
|
+
try {
|
|
314
|
+
return execute(config, parsed);
|
|
315
|
+
} catch (error) {
|
|
316
|
+
return {
|
|
317
|
+
output: error instanceof Error ? error.message : String(error),
|
|
318
|
+
exitCode: 1,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function execute(config: HoundsmanConfig, parsed: ParsedArgs): FindingsCommandResult {
|
|
324
|
+
switch (parsed.sub ?? "list") {
|
|
325
|
+
case "list": {
|
|
326
|
+
const state = loadState(config, parsed);
|
|
327
|
+
const filtered = filterFindings(state, filterFrom(parsed));
|
|
328
|
+
const limit = Number(parsed.values["--limit"]);
|
|
329
|
+
const findings =
|
|
330
|
+
Number.isFinite(limit) && limit > 0 ? filtered.slice(0, limit) : filtered;
|
|
331
|
+
return {
|
|
332
|
+
output: parsed.bools.has("--json")
|
|
333
|
+
? JSON.stringify(findings, null, 2)
|
|
334
|
+
: renderList(state, findings),
|
|
335
|
+
exitCode: 0,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
case "show": {
|
|
339
|
+
const state = loadState(config, parsed);
|
|
340
|
+
const id = parsed.positionals[0];
|
|
341
|
+
if (!id) throw new Error("Informe o id: houndsman findings show <id>");
|
|
342
|
+
const finding = state.findings[id];
|
|
343
|
+
if (!finding) throw new Error(`Finding '${id}' não encontrado.`);
|
|
344
|
+
return {
|
|
345
|
+
output: parsed.bools.has("--json")
|
|
346
|
+
? JSON.stringify(finding, null, 2)
|
|
347
|
+
: renderList(state, [finding]),
|
|
348
|
+
exitCode: 0,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
case "set": {
|
|
352
|
+
const id = parsed.positionals[0];
|
|
353
|
+
if (!id) throw new Error("Informe o id: houndsman findings set <id> [flags]");
|
|
354
|
+
const update = updateFrom(parsed);
|
|
355
|
+
if (!hasUpdate(update)) {
|
|
356
|
+
throw new Error(
|
|
357
|
+
"Nada para atualizar (use --decision, --exec ou --comment).",
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
const finding = updateOne(config, parsed, id, update);
|
|
361
|
+
pingStatus(config, parsed);
|
|
362
|
+
return {
|
|
363
|
+
output: parsed.bools.has("--json")
|
|
364
|
+
? JSON.stringify(finding, null, 2)
|
|
365
|
+
: `Atualizado: \`${finding.id}\` → ${finding.decision}/${finding.execState}.`,
|
|
366
|
+
exitCode: 0,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
case "done": {
|
|
370
|
+
const ids = requireIds(parsed, "done");
|
|
371
|
+
const updated = updateMany(config, parsed, ids, { execState: "done" });
|
|
372
|
+
pingStatus(config, parsed);
|
|
373
|
+
return {
|
|
374
|
+
output: `Marcados como done: ${updated.map((f) => f.id).join(", ")}.`,
|
|
375
|
+
exitCode: 0,
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
case "reset": {
|
|
379
|
+
const ids = requireIds(parsed, "reset");
|
|
380
|
+
const updated = updateMany(config, parsed, ids, {
|
|
381
|
+
decision: "pending",
|
|
382
|
+
execState: "pending",
|
|
383
|
+
comment: "",
|
|
384
|
+
});
|
|
385
|
+
pingStatus(config, parsed);
|
|
386
|
+
return {
|
|
387
|
+
output: `Triagem reiniciada: ${updated.map((f) => f.id).join(", ")}.`,
|
|
388
|
+
exitCode: 0,
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
case "sync": {
|
|
392
|
+
const { dir, branch } = resolveDir(config, parsed);
|
|
393
|
+
const store = FindingsStateStore.at(dir, branch);
|
|
394
|
+
const state = loadState(config, parsed);
|
|
395
|
+
const result = syncState(state, fileReader(process.cwd()));
|
|
396
|
+
store.write(state);
|
|
397
|
+
pingStatus(config, parsed);
|
|
398
|
+
const output = parsed.bools.has("--json")
|
|
399
|
+
? JSON.stringify(result, null, 2)
|
|
400
|
+
: [
|
|
401
|
+
`Sync — ${branch}: ${result.ok} ok, ${result.moved.length} movido(s), ${result.stale.length} stale.`,
|
|
402
|
+
result.moved.length > 0
|
|
403
|
+
? `Movidos: ${result.moved.join(", ")}`
|
|
404
|
+
: "",
|
|
405
|
+
result.stale.length > 0
|
|
406
|
+
? `Stale: ${result.stale.join(", ")}`
|
|
407
|
+
: "",
|
|
408
|
+
]
|
|
409
|
+
.filter(Boolean)
|
|
410
|
+
.join("\n");
|
|
411
|
+
return { output, exitCode: 0 };
|
|
412
|
+
}
|
|
413
|
+
case "summary": {
|
|
414
|
+
const state = loadState(config, parsed);
|
|
415
|
+
const summary = summarize(state);
|
|
416
|
+
return {
|
|
417
|
+
output: parsed.bools.has("--json")
|
|
418
|
+
? JSON.stringify(summary, null, 2)
|
|
419
|
+
: renderSummary(state, summary),
|
|
420
|
+
exitCode: 0,
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
case "path": {
|
|
424
|
+
const { dir, branch } = resolveDir(config, parsed);
|
|
425
|
+
const path = FindingsStateStore.at(dir, branch).filePath;
|
|
426
|
+
return { output: relFromRoot(path), exitCode: 0 };
|
|
427
|
+
}
|
|
428
|
+
case "batch": {
|
|
429
|
+
const state = loadState(config, parsed);
|
|
430
|
+
const severity = parsed.values["--severity"];
|
|
431
|
+
const offset = Number(parsed.values["--offset"]);
|
|
432
|
+
const result = buildFixBatch(state, {
|
|
433
|
+
severity: severity ? (severity as Severity) : undefined,
|
|
434
|
+
includeDone: parsed.bools.has("--include-done"),
|
|
435
|
+
offset: Number.isFinite(offset) ? offset : undefined,
|
|
436
|
+
group: parsed.values["--group"],
|
|
437
|
+
});
|
|
438
|
+
return {
|
|
439
|
+
output: parsed.bools.has("--json")
|
|
440
|
+
? JSON.stringify(result, null, 2)
|
|
441
|
+
: renderBatch(result),
|
|
442
|
+
exitCode: 0,
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
default:
|
|
446
|
+
return {
|
|
447
|
+
output: `Subcomando '${parsed.sub}' desconhecido. Use: list, show, set, done, reset, sync, summary, batch, path.`,
|
|
448
|
+
exitCode: 1,
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function updateFrom(parsed: ParsedArgs): FindingUpdate {
|
|
454
|
+
return {
|
|
455
|
+
decision: parseDecision(parsed.values["--decision"]),
|
|
456
|
+
execState: parseExec(parsed.values["--exec"]),
|
|
457
|
+
comment: parsed.values["--comment"],
|
|
458
|
+
by: parsed.values["--by"],
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function hasUpdate(update: FindingUpdate): boolean {
|
|
463
|
+
return (
|
|
464
|
+
update.decision !== undefined ||
|
|
465
|
+
update.execState !== undefined ||
|
|
466
|
+
update.comment !== undefined
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function requireIds(parsed: ParsedArgs, sub: string): string[] {
|
|
471
|
+
if (parsed.positionals.length === 0) {
|
|
472
|
+
throw new Error(`Informe ao menos um id: houndsman findings ${sub} <id...>`);
|
|
473
|
+
}
|
|
474
|
+
return parsed.positionals;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// ---------------------------------------------------------------------------
|
|
478
|
+
// verify — as violações foram resolvidas? (sem re-revisar a PR)
|
|
479
|
+
// ---------------------------------------------------------------------------
|
|
480
|
+
|
|
481
|
+
/** Rótulo curto de cada status de verificação. */
|
|
482
|
+
const VERIFY_LABEL: Record<VerifyStatus, string> = {
|
|
483
|
+
resolved: "✅ resolvido",
|
|
484
|
+
persisting: "❌ persiste",
|
|
485
|
+
uncertain: "⚠️ incerto",
|
|
486
|
+
"file-missing": "🗑️ arquivo ausente",
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
/** Render da verificação: contagem por status + uma linha por finding. */
|
|
490
|
+
function renderVerify(
|
|
491
|
+
state: FindingsStateFile,
|
|
492
|
+
entries: VerifyEntry[],
|
|
493
|
+
summary: VerifySummary,
|
|
494
|
+
meta: { jevChecked: number; jevReason?: string },
|
|
495
|
+
): string {
|
|
496
|
+
if (entries.length === 0) return "Nenhum finding para verificar.";
|
|
497
|
+
const lines = [
|
|
498
|
+
`# Verificação — ${state.branch} (${entries.length})`,
|
|
499
|
+
"",
|
|
500
|
+
`Resolvidos: ${summary.resolved} · Persistem: ${summary.persisting} · Incertos: ${summary.uncertain} · Arquivo ausente: ${summary["file-missing"]}`,
|
|
501
|
+
];
|
|
502
|
+
if (meta.jevChecked > 0) lines.push(`Confirmados por Jev: ${meta.jevChecked}.`);
|
|
503
|
+
else if (meta.jevReason) lines.push(`Jev: pulado (${meta.jevReason}).`);
|
|
504
|
+
lines.push("");
|
|
505
|
+
for (const entry of entries) {
|
|
506
|
+
const location = entry.line ? `${entry.file}:${entry.line}` : entry.file;
|
|
507
|
+
const via =
|
|
508
|
+
entry.basis === "jev" ? ` · Jev ${(entry.confidence ?? 0).toFixed(2)}` : "";
|
|
509
|
+
lines.push(
|
|
510
|
+
`- \`${entry.id}\` ${VERIFY_LABEL[entry.status]} — **${entry.severity}** \`${location}\` — ${entry.rule}${via}`,
|
|
511
|
+
` ${entry.message}`,
|
|
512
|
+
);
|
|
513
|
+
if (entry.detail) lines.push(` _${entry.detail}_`);
|
|
514
|
+
}
|
|
515
|
+
return lines.join("\n");
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Subcomando `findings verify` — checa se as violações foram resolvidas, sem
|
|
520
|
+
* re-revisar a PR. Tier local (grátis) + confirmação Jev dos casos ambíguos
|
|
521
|
+
* (fail-open). `--apply` persiste os `resolved` como `done`.
|
|
522
|
+
*/
|
|
523
|
+
export async function runVerifyCommand(
|
|
524
|
+
config: HoundsmanConfig,
|
|
525
|
+
args: string[],
|
|
526
|
+
): Promise<FindingsCommandResult> {
|
|
527
|
+
const parsed = parseArgs(args);
|
|
528
|
+
try {
|
|
529
|
+
return await executeVerify(config, parsed);
|
|
530
|
+
} catch (error) {
|
|
531
|
+
return {
|
|
532
|
+
output: error instanceof Error ? error.message : String(error),
|
|
533
|
+
exitCode: 1,
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
async function executeVerify(
|
|
539
|
+
config: HoundsmanConfig,
|
|
540
|
+
parsed: ParsedArgs,
|
|
541
|
+
): Promise<FindingsCommandResult> {
|
|
542
|
+
const { dir, branch } = resolveDir(config, parsed);
|
|
543
|
+
const store = FindingsStateStore.at(dir, branch);
|
|
544
|
+
const state = loadState(config, parsed);
|
|
545
|
+
|
|
546
|
+
const findings = selectVerifiable(state, filterFrom(parsed));
|
|
547
|
+
const entries = verifyLocal(findings, fileReader(process.cwd()));
|
|
548
|
+
|
|
549
|
+
// Tier 1 — Jev só nos ambíguos (fail-open: sem chave/erro mantém o provisório).
|
|
550
|
+
let jevChecked = 0;
|
|
551
|
+
let jevReason: string | undefined;
|
|
552
|
+
const ambiguous = entries.filter((entry) => entry.ambiguous);
|
|
553
|
+
if (ambiguous.length > 0) {
|
|
554
|
+
const verifier = new JevVerifier(new JevClient());
|
|
555
|
+
if (parsed.bools.has("--no-jev")) {
|
|
556
|
+
jevReason = "desativado (--no-jev)";
|
|
557
|
+
} else if (!verifier.isConfigured()) {
|
|
558
|
+
jevReason = "TYPESAFE_API_KEY não configurada";
|
|
559
|
+
} else {
|
|
560
|
+
const byId = new Map(findings.map((finding) => [finding.id, finding]));
|
|
561
|
+
const outcome = await verifier.verify(
|
|
562
|
+
ambiguous.map((entry) => ({
|
|
563
|
+
id: entry.id,
|
|
564
|
+
file: entry.file,
|
|
565
|
+
rule: entry.rule,
|
|
566
|
+
foundAtSha: byId.get(entry.id)?.foundAtSha,
|
|
567
|
+
})),
|
|
568
|
+
{
|
|
569
|
+
headSha: state.reviewed?.headSha,
|
|
570
|
+
concurrency: resolveJevConcurrency(config),
|
|
571
|
+
},
|
|
572
|
+
);
|
|
573
|
+
applyJev(entries, outcome.confidence, JevCandidatesStore.THRESHOLD);
|
|
574
|
+
jevChecked = outcome.checked;
|
|
575
|
+
jevReason = outcome.reason;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const summary = summarizeVerify(entries);
|
|
580
|
+
const reportPath = join(runDir(dir, branch), VERIFY_FILE);
|
|
581
|
+
writeFileSync(
|
|
582
|
+
reportPath,
|
|
583
|
+
`${renderVerify(state, entries, summary, { jevChecked, jevReason })}\n`,
|
|
584
|
+
);
|
|
585
|
+
|
|
586
|
+
let applied: string[] = [];
|
|
587
|
+
if (parsed.bools.has("--apply")) {
|
|
588
|
+
store.update((current) => {
|
|
589
|
+
applied = applyResolutions(current, entries);
|
|
590
|
+
});
|
|
591
|
+
pingStatus(config, parsed);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const output = parsed.bools.has("--json")
|
|
595
|
+
? JSON.stringify(
|
|
596
|
+
{ branch, summary, jevChecked, jevReason, applied, entries },
|
|
597
|
+
null,
|
|
598
|
+
2,
|
|
599
|
+
)
|
|
600
|
+
: [
|
|
601
|
+
renderVerify(state, entries, summary, { jevChecked, jevReason }),
|
|
602
|
+
"",
|
|
603
|
+
`Relatório: ${relFromRoot(reportPath)}`,
|
|
604
|
+
parsed.bools.has("--apply")
|
|
605
|
+
? `Aplicados (done): ${applied.length > 0 ? applied.join(", ") : "—"}.`
|
|
606
|
+
: "",
|
|
607
|
+
]
|
|
608
|
+
.filter(Boolean)
|
|
609
|
+
.join("\n");
|
|
610
|
+
|
|
611
|
+
const failOnPersisting =
|
|
612
|
+
parsed.bools.has("--fail-on-persisting") &&
|
|
613
|
+
entries.some(
|
|
614
|
+
(entry) => entry.status === "persisting" && entry.severity === "blocking",
|
|
615
|
+
);
|
|
616
|
+
|
|
617
|
+
return { output, exitCode: failOnPersisting ? 1 : 0 };
|
|
618
|
+
}
|
|
@@ -14,6 +14,7 @@ const BOOLEAN_FLAGS = new Set([
|
|
|
14
14
|
"--fail-on-blocking",
|
|
15
15
|
"--clean",
|
|
16
16
|
"--no-jev",
|
|
17
|
+
"--full",
|
|
17
18
|
]);
|
|
18
19
|
|
|
19
20
|
function applyValue(opts: ReviewOptions, flag: string, value: string | undefined): void {
|
|
@@ -29,6 +30,7 @@ function applyBoolean(opts: ReviewOptions, flag: string): void {
|
|
|
29
30
|
else if (flag === "--fail-on-blocking") opts.failOnBlocking = true;
|
|
30
31
|
else if (flag === "--clean") opts.clean = true;
|
|
31
32
|
else if (flag === "--no-jev") opts.noJev = true;
|
|
33
|
+
else if (flag === "--full") opts.full = true;
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
/** Converte argv (sem o subcomando) nas opções de prepare/finalize. */
|