@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
|
@@ -16,6 +16,8 @@ export type WatchdogNameResolver = (watchdogId: string) => string;
|
|
|
16
16
|
const identityName: WatchdogNameResolver = (id) => id;
|
|
17
17
|
|
|
18
18
|
export type Finding = {
|
|
19
|
+
/** Id curto do finding (preenchido no `--finalize`; usado pela triagem). */
|
|
20
|
+
id?: string;
|
|
19
21
|
rule: string;
|
|
20
22
|
severity: Severity;
|
|
21
23
|
file: string;
|
|
@@ -23,6 +25,11 @@ export type Finding = {
|
|
|
23
25
|
message: string;
|
|
24
26
|
action: string;
|
|
25
27
|
evidence?: string;
|
|
28
|
+
/**
|
|
29
|
+
* Commit em que o achado foi observado (interno). Preenchido nos carry-over
|
|
30
|
+
* do review incremental — preserva a referência para a verificação (verify).
|
|
31
|
+
*/
|
|
32
|
+
foundAtSha?: string;
|
|
26
33
|
};
|
|
27
34
|
|
|
28
35
|
export type WatchdogReport = {
|
|
@@ -206,8 +213,9 @@ function watchdogName(nameOf: WatchdogNameResolver, id: string): string {
|
|
|
206
213
|
function renderFinding(finding: MergedFinding, nameOf: WatchdogNameResolver): string[] {
|
|
207
214
|
const location = finding.line ? `${finding.file}:${finding.line}` : finding.file;
|
|
208
215
|
const watchdogs = finding.watchdogs.map((r) => watchdogName(nameOf, r)).join(", ");
|
|
216
|
+
const id = finding.id ? ` · \`${finding.id}\`` : "";
|
|
209
217
|
const lines = [
|
|
210
|
-
`- **${finding.rule}** — \`${location}\` · _${watchdogs}_`,
|
|
218
|
+
`- **${finding.rule}** — \`${location}\` · _${watchdogs}_${id}`,
|
|
211
219
|
` ${finding.message}`,
|
|
212
220
|
` **Ação:** ${finding.action}`,
|
|
213
221
|
];
|
|
@@ -284,6 +292,112 @@ function renderConclusion(counts: Record<Severity, number>): string[] {
|
|
|
284
292
|
];
|
|
285
293
|
}
|
|
286
294
|
|
|
295
|
+
/**
|
|
296
|
+
* Agrupa os achados por arquivo (navegabilidade em PRs grandes) e renderiza cada
|
|
297
|
+
* bloco. O `merged` já vem ordenado por severidade/arquivo/linha.
|
|
298
|
+
*/
|
|
299
|
+
function renderFindingsByFile(
|
|
300
|
+
findings: readonly MergedFinding[],
|
|
301
|
+
nameOf: WatchdogNameResolver,
|
|
302
|
+
): string[] {
|
|
303
|
+
const byFile = new Map<string, MergedFinding[]>();
|
|
304
|
+
for (const finding of findings) {
|
|
305
|
+
const list = byFile.get(finding.file);
|
|
306
|
+
if (list) list.push(finding);
|
|
307
|
+
else byFile.set(finding.file, [finding]);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const lines: string[] = [];
|
|
311
|
+
for (const [file, items] of byFile) {
|
|
312
|
+
lines.push(`### \`${file}\``, "");
|
|
313
|
+
for (const item of items) lines.push(...renderFinding(item, nameOf));
|
|
314
|
+
}
|
|
315
|
+
return lines;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Seções por severidade (❌ > ⚠️ > 💡 > 🔎), com achados agrupados por arquivo. */
|
|
319
|
+
function renderFindingsSections(
|
|
320
|
+
merged: readonly MergedFinding[],
|
|
321
|
+
nameOf: WatchdogNameResolver,
|
|
322
|
+
): string[] {
|
|
323
|
+
const lines: string[] = [];
|
|
324
|
+
for (const severity of SEVERITY_ORDER) {
|
|
325
|
+
const items = merged.filter((f) => f.severity === severity);
|
|
326
|
+
if (items.length === 0) continue;
|
|
327
|
+
lines.push(`## ${SEVERITY_EMOJI[severity]} ${severity}`, "");
|
|
328
|
+
lines.push(...renderFindingsByFile(items, nameOf));
|
|
329
|
+
}
|
|
330
|
+
return lines;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Relatório de domínio gravado à parte (um por watchdog com achados). */
|
|
334
|
+
export type DomainReport = {
|
|
335
|
+
id: string;
|
|
336
|
+
name: string;
|
|
337
|
+
/** Nome do arquivo do relatório (relativo ao diretório de artefatos). */
|
|
338
|
+
file: string;
|
|
339
|
+
findings: number;
|
|
340
|
+
reviewedFiles: number;
|
|
341
|
+
expectedFiles: number;
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
/** Índice dos relatórios por domínio (seção do `review.md` consolidado). */
|
|
345
|
+
function renderDomainIndex(domains: readonly DomainReport[]): string[] {
|
|
346
|
+
if (domains.length === 0) return [];
|
|
347
|
+
const lines = [
|
|
348
|
+
"## Relatórios por domínio",
|
|
349
|
+
"",
|
|
350
|
+
"| Watchdog | Achados | Revisados | Relatório |",
|
|
351
|
+
"|---|---|---|---|",
|
|
352
|
+
];
|
|
353
|
+
for (const domain of domains) {
|
|
354
|
+
lines.push(
|
|
355
|
+
`| ${domain.name} | ${domain.findings} | ${domain.reviewedFiles}/${domain.expectedFiles} | [\`${domain.file}\`](${domain.file}) |`,
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
lines.push("");
|
|
359
|
+
return lines;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Relatório isolado de um watchdog — mantém o `review.md` enxuto em PRs grandes.
|
|
364
|
+
*/
|
|
365
|
+
export function renderDomainReport(
|
|
366
|
+
branch: string,
|
|
367
|
+
report: WatchdogReport,
|
|
368
|
+
merged: MergedFinding[],
|
|
369
|
+
expectedFiles: number,
|
|
370
|
+
context?: ContextSummary | null,
|
|
371
|
+
nameOf: WatchdogNameResolver = identityName,
|
|
372
|
+
): string {
|
|
373
|
+
const now = new Date().toISOString().slice(0, 10);
|
|
374
|
+
const name = watchdogName(nameOf, report.watchdogId);
|
|
375
|
+
const lines: string[] = [
|
|
376
|
+
`# Review — ${name} (\`${report.watchdogId}\`) — ${branch}`,
|
|
377
|
+
"",
|
|
378
|
+
`**Data:** ${now}`,
|
|
379
|
+
`**Arquivos revisados:** ${report.reviewedFiles.length}/${expectedFiles}`,
|
|
380
|
+
`**Achados:** ${merged.length}`,
|
|
381
|
+
"",
|
|
382
|
+
];
|
|
383
|
+
|
|
384
|
+
if (context?.available) lines.push(...renderContext(context));
|
|
385
|
+
|
|
386
|
+
lines.push(...renderFindingsSections(merged, nameOf));
|
|
387
|
+
if (merged.length === 0) lines.push("Nenhum achado. ✅", "");
|
|
388
|
+
|
|
389
|
+
if (report.skipped.length > 0) {
|
|
390
|
+
lines.push(
|
|
391
|
+
"## Não revisados",
|
|
392
|
+
"",
|
|
393
|
+
...report.skipped.map((item) => `- \`${item}\``),
|
|
394
|
+
"",
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
return lines.join("\n");
|
|
399
|
+
}
|
|
400
|
+
|
|
287
401
|
/** Seção de contexto da PR (Objetivo / Motivação / Resumo das alterações). */
|
|
288
402
|
function renderContext(context: ContextSummary): string[] {
|
|
289
403
|
const lines = ["## Contexto da PR", ""];
|
|
@@ -313,6 +427,7 @@ export function renderReport(
|
|
|
313
427
|
context?: ContextSummary | null,
|
|
314
428
|
candidates?: JevCandidate[],
|
|
315
429
|
nameOf: WatchdogNameResolver = identityName,
|
|
430
|
+
domains: readonly DomainReport[] = [],
|
|
316
431
|
): string {
|
|
317
432
|
const now = new Date().toISOString().slice(0, 10);
|
|
318
433
|
const counts = {
|
|
@@ -333,14 +448,7 @@ export function renderReport(
|
|
|
333
448
|
|
|
334
449
|
if (context) lines.push(...renderContext(context));
|
|
335
450
|
|
|
336
|
-
|
|
337
|
-
const items = merged.filter((f) => f.severity === severity);
|
|
338
|
-
if (items.length === 0) continue;
|
|
339
|
-
lines.push(`## ${SEVERITY_EMOJI[severity]} ${severity}`, "");
|
|
340
|
-
for (const finding of items) {
|
|
341
|
-
lines.push(...renderFinding(finding, nameOf));
|
|
342
|
-
}
|
|
343
|
-
}
|
|
451
|
+
lines.push(...renderFindingsSections(merged, nameOf));
|
|
344
452
|
|
|
345
453
|
if (merged.length === 0) {
|
|
346
454
|
lines.push("Nenhum achado. ✅", "");
|
|
@@ -350,6 +458,7 @@ export function renderReport(
|
|
|
350
458
|
lines.push(...renderUnconfirmedCandidates(candidates, merged));
|
|
351
459
|
}
|
|
352
460
|
|
|
461
|
+
lines.push(...renderDomainIndex(domains));
|
|
353
462
|
lines.push(...renderCoverage(reports, coverage, nameOf));
|
|
354
463
|
lines.push(...renderConclusion(counts));
|
|
355
464
|
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Status do run (biblioteca) — persistido em `.houndsman/.runs/<slug>/run.json`.
|
|
3
|
+
*
|
|
4
|
+
* Diz à UI em que pé está uma run: `reviewing` (revisando) → `review_done`
|
|
5
|
+
* (revisão finalizada) → `fixing` (corrigindo) → `fix_done` (correção finalizada).
|
|
6
|
+
*
|
|
7
|
+
* Toda ação que muda o estado faz um **ping** no `updatedAt`, mantendo o arquivo
|
|
8
|
+
* "quente". Timeout é **display-only** (a UI compara `updatedAt` com o agora) —
|
|
9
|
+
* não é um status persistido.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import type { FindingsStateFile } from "./findings-state";
|
|
15
|
+
import { RUN_STATUS_FILE } from "./paths";
|
|
16
|
+
import { sanitizeBranch } from "./report-builder";
|
|
17
|
+
|
|
18
|
+
/** Estados de execução de uma run (persistidos). */
|
|
19
|
+
export type RunStatus = "reviewing" | "review_done" | "fixing" | "fix_done";
|
|
20
|
+
|
|
21
|
+
/** Valores canônicos de status (ordem de exibição/lifecycle). */
|
|
22
|
+
export const RUN_STATUSES: readonly RunStatus[] = [
|
|
23
|
+
"reviewing",
|
|
24
|
+
"review_done",
|
|
25
|
+
"fixing",
|
|
26
|
+
"fix_done",
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
export const RUN_STATUS_VERSION = 1;
|
|
30
|
+
|
|
31
|
+
/** Conteúdo do `run.json`. */
|
|
32
|
+
export type RunStatusFile = {
|
|
33
|
+
version: 1;
|
|
34
|
+
slug: string;
|
|
35
|
+
branch: string;
|
|
36
|
+
status: RunStatus;
|
|
37
|
+
/** Início do ciclo de revisão atual (reiniciado a cada `prepare`). */
|
|
38
|
+
startedAt: string;
|
|
39
|
+
/** Última ação importante (ping). */
|
|
40
|
+
updatedAt: string;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** Caminho do `run.json` dentro da pasta da run. */
|
|
44
|
+
export function runStatusPath(runDir: string): string {
|
|
45
|
+
return join(runDir, RUN_STATUS_FILE);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Lê o `run.json` da run (null se ausente/inválido). */
|
|
49
|
+
export function readRunStatus(runDir: string): RunStatusFile | null {
|
|
50
|
+
const path = runStatusPath(runDir);
|
|
51
|
+
if (!existsSync(path)) return null;
|
|
52
|
+
try {
|
|
53
|
+
return JSON.parse(readFileSync(path, "utf8")) as RunStatusFile;
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type WriteRunStatusOptions = {
|
|
60
|
+
branch: string;
|
|
61
|
+
status: RunStatus;
|
|
62
|
+
/** Reinicia o `startedAt` — usado no início de uma nova revisão. */
|
|
63
|
+
resetStartedAt?: boolean;
|
|
64
|
+
now?: string;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** Grava/atualiza o `run.json` (ping no `updatedAt`). */
|
|
68
|
+
export function writeRunStatus(
|
|
69
|
+
runDir: string,
|
|
70
|
+
options: WriteRunStatusOptions,
|
|
71
|
+
): RunStatusFile {
|
|
72
|
+
const now = options.now ?? new Date().toISOString();
|
|
73
|
+
const previous = readRunStatus(runDir);
|
|
74
|
+
const file: RunStatusFile = {
|
|
75
|
+
version: RUN_STATUS_VERSION,
|
|
76
|
+
slug: sanitizeBranch(options.branch),
|
|
77
|
+
branch: options.branch,
|
|
78
|
+
status: options.status,
|
|
79
|
+
startedAt: options.resetStartedAt ? now : (previous?.startedAt ?? now),
|
|
80
|
+
updatedAt: now,
|
|
81
|
+
};
|
|
82
|
+
mkdirSync(runDir, { recursive: true });
|
|
83
|
+
writeFileSync(runStatusPath(runDir), `${JSON.stringify(file, null, 2)}\n`);
|
|
84
|
+
return file;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Deriva o status após uma mudança nos findings (triagem/correção).
|
|
89
|
+
* `fixing` exige correção em andamento (`in_progress`) ou retomada de um fix;
|
|
90
|
+
* `fix_done` quando o fix começou e a fila (`execute` não `done`) esvaziou.
|
|
91
|
+
*/
|
|
92
|
+
export function statusAfterFindingsChange(
|
|
93
|
+
previous: RunStatus | null,
|
|
94
|
+
state: FindingsStateFile,
|
|
95
|
+
): RunStatus {
|
|
96
|
+
const findings = Object.values(state.findings);
|
|
97
|
+
const active = findings.filter((finding) => finding.present);
|
|
98
|
+
|
|
99
|
+
if (active.some((finding) => finding.execState === "in_progress")) return "fixing";
|
|
100
|
+
|
|
101
|
+
const queueRemaining = active.some(
|
|
102
|
+
(finding) => finding.decision === "execute" && finding.execState !== "done",
|
|
103
|
+
);
|
|
104
|
+
const anyDone = active.some(
|
|
105
|
+
(finding) => finding.decision === "execute" && finding.execState === "done",
|
|
106
|
+
);
|
|
107
|
+
const fixStarted = previous === "fixing" || previous === "fix_done" || anyDone;
|
|
108
|
+
if (fixStarted) return queueRemaining ? "fixing" : "fix_done";
|
|
109
|
+
|
|
110
|
+
return previous ?? "review_done";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Ping: deriva o status da mudança nos findings e grava o `run.json`. */
|
|
114
|
+
export function pingFindingsStatus(
|
|
115
|
+
runDir: string,
|
|
116
|
+
state: FindingsStateFile,
|
|
117
|
+
): RunStatusFile {
|
|
118
|
+
const previous = readRunStatus(runDir)?.status ?? null;
|
|
119
|
+
return writeRunStatus(runDir, {
|
|
120
|
+
branch: state.branch,
|
|
121
|
+
status: statusAfterFindingsChange(previous, state),
|
|
122
|
+
});
|
|
123
|
+
}
|
package/src/core/types.ts
CHANGED
|
@@ -79,6 +79,12 @@ export type FileStatus =
|
|
|
79
79
|
/** Status por arquivo, conforme o `classification.json`. */
|
|
80
80
|
export type FileStatusMap = Record<string, { status: FileStatus; oldPath?: string }>;
|
|
81
81
|
|
|
82
|
+
/** Linhas adicionadas/removidas de um arquivo no diff (churn). */
|
|
83
|
+
export type FileChurn = { additions: number; deletions: number };
|
|
84
|
+
|
|
85
|
+
/** Churn por caminho de arquivo (chave = caminho novo). */
|
|
86
|
+
export type ChurnMap = Record<string, FileChurn>;
|
|
87
|
+
|
|
82
88
|
/** Contexto da PR injetado no brief (estrutural — sem dependência de runtime). */
|
|
83
89
|
export type WatchdogRenderContext = {
|
|
84
90
|
objective?: string;
|
|
@@ -99,6 +105,10 @@ export type WatchdogRenderArgs = {
|
|
|
99
105
|
watchdogId: string;
|
|
100
106
|
files: readonly string[];
|
|
101
107
|
statuses: FileStatusMap;
|
|
108
|
+
/** Churn por arquivo — insumo para priorizar a leitura (PRs grandes). */
|
|
109
|
+
churn?: ChurnMap;
|
|
110
|
+
/** Arquivos do domínio fora do orçamento — NÃO revisar (registrar em `skipped`). */
|
|
111
|
+
overflow?: readonly string[];
|
|
102
112
|
deleted: readonly string[];
|
|
103
113
|
branch: string;
|
|
104
114
|
outputPath: string;
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verificação de findings (biblioteca) — "as violações foram resolvidas?".
|
|
3
|
+
*
|
|
4
|
+
* Checagem barata, **sem re-revisar a PR**:
|
|
5
|
+
* - Tier 0 (local, grátis): reancora a âncora de conteúdo contra o working tree.
|
|
6
|
+
* - Tier 1 (Jev, opcional): confirma/refuta os casos ambíguos (âncora sumida ou
|
|
7
|
+
* inexistente) via LLM, sobre um diff focado do arquivo (`jev-verify.ts`).
|
|
8
|
+
*
|
|
9
|
+
* `verifyLocal` é puro (recebe o leitor de arquivos); o comando renderiza e, com
|
|
10
|
+
* `--apply`, persiste `resolved` → `execState: done`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { reanchor } from "./finding-anchor";
|
|
14
|
+
import {
|
|
15
|
+
applyFindingUpdate,
|
|
16
|
+
type FileReader,
|
|
17
|
+
type FindingState,
|
|
18
|
+
type FindingsFilter,
|
|
19
|
+
type FindingsStateFile,
|
|
20
|
+
filterFindings,
|
|
21
|
+
} from "./findings-state";
|
|
22
|
+
import type { Severity } from "./types";
|
|
23
|
+
|
|
24
|
+
/** Resultado da verificação de um finding. */
|
|
25
|
+
export type VerifyStatus = "resolved" | "persisting" | "uncertain" | "file-missing";
|
|
26
|
+
|
|
27
|
+
/** Mecanismo que decidiu o status (reancoragem local ou LLM). */
|
|
28
|
+
export type VerifyBasis = "anchor" | "jev";
|
|
29
|
+
|
|
30
|
+
/** Valores canônicos de status (ordem de exibição). */
|
|
31
|
+
export const VERIFY_STATUSES: readonly VerifyStatus[] = [
|
|
32
|
+
"resolved",
|
|
33
|
+
"persisting",
|
|
34
|
+
"uncertain",
|
|
35
|
+
"file-missing",
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
/** Um finding verificado. */
|
|
39
|
+
export type VerifyEntry = {
|
|
40
|
+
id: string;
|
|
41
|
+
file: string;
|
|
42
|
+
rule: string;
|
|
43
|
+
severity: Severity;
|
|
44
|
+
message: string;
|
|
45
|
+
line: number | null;
|
|
46
|
+
status: VerifyStatus;
|
|
47
|
+
basis: VerifyBasis;
|
|
48
|
+
/** Confiança do Jev (0–1) quando `basis: "jev"`. */
|
|
49
|
+
confidence?: number;
|
|
50
|
+
detail?: string;
|
|
51
|
+
/** Ambíguo no tier local — candidato à confirmação do Jev. */
|
|
52
|
+
ambiguous: boolean;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** Fila de verificação: presentes, excluindo `ignore` (descartados pelo usuário). */
|
|
56
|
+
export function selectVerifiable(
|
|
57
|
+
state: FindingsStateFile,
|
|
58
|
+
filter: FindingsFilter = {},
|
|
59
|
+
): FindingState[] {
|
|
60
|
+
return filterFindings(state, filter).filter(
|
|
61
|
+
(finding) => finding.decision !== "ignore",
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Tier 0 — checagem local por conteúdo. Marca `ambiguous: true` quando a âncora
|
|
67
|
+
* sumiu (corrigida ou trecho reescrito) ou não existe; nesses casos o Jev pode
|
|
68
|
+
* confirmar. Âncora presente (`ok`/`moved`) ⇒ `persisting`, definitivo.
|
|
69
|
+
*/
|
|
70
|
+
export function verifyLocal(
|
|
71
|
+
findings: readonly FindingState[],
|
|
72
|
+
readFile: FileReader,
|
|
73
|
+
): VerifyEntry[] {
|
|
74
|
+
return findings.map((finding) => {
|
|
75
|
+
const base = {
|
|
76
|
+
id: finding.id,
|
|
77
|
+
file: finding.file,
|
|
78
|
+
rule: finding.rule,
|
|
79
|
+
severity: finding.severity,
|
|
80
|
+
message: finding.message,
|
|
81
|
+
line: finding.line,
|
|
82
|
+
};
|
|
83
|
+
const content = readFile(finding.file);
|
|
84
|
+
if (content === null) {
|
|
85
|
+
return {
|
|
86
|
+
...base,
|
|
87
|
+
status: "file-missing" as const,
|
|
88
|
+
basis: "anchor" as const,
|
|
89
|
+
ambiguous: false,
|
|
90
|
+
detail: "arquivo ausente no working tree",
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
if (!finding.anchor) {
|
|
94
|
+
return {
|
|
95
|
+
...base,
|
|
96
|
+
status: "uncertain" as const,
|
|
97
|
+
basis: "anchor" as const,
|
|
98
|
+
ambiguous: true,
|
|
99
|
+
detail: "finding sem âncora de conteúdo",
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
const { status } = reanchor(content, finding.anchor, finding.line);
|
|
103
|
+
if (status === "stale") {
|
|
104
|
+
return {
|
|
105
|
+
...base,
|
|
106
|
+
status: "resolved" as const,
|
|
107
|
+
basis: "anchor" as const,
|
|
108
|
+
ambiguous: true,
|
|
109
|
+
detail: "âncora ausente (trecho corrigido?)",
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
...base,
|
|
114
|
+
status: "persisting" as const,
|
|
115
|
+
basis: "anchor" as const,
|
|
116
|
+
ambiguous: false,
|
|
117
|
+
detail: status === "moved" ? "âncora deslocada" : undefined,
|
|
118
|
+
};
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Tier 1 — aplica a confirmação do Jev aos casos ambíguos.
|
|
124
|
+
* `confidence[id] >= threshold` ⇒ a violação persiste; abaixo ⇒ resolvida.
|
|
125
|
+
* Ids sem resposta mantêm o provisório local (fail-open). Devolve o nº aplicado.
|
|
126
|
+
*/
|
|
127
|
+
export function applyJev(
|
|
128
|
+
entries: VerifyEntry[],
|
|
129
|
+
confidence: Map<string, number>,
|
|
130
|
+
threshold: number,
|
|
131
|
+
): number {
|
|
132
|
+
let applied = 0;
|
|
133
|
+
for (const entry of entries) {
|
|
134
|
+
if (!entry.ambiguous) continue;
|
|
135
|
+
const value = confidence.get(entry.id);
|
|
136
|
+
if (value === undefined) continue;
|
|
137
|
+
entry.basis = "jev";
|
|
138
|
+
entry.confidence = value;
|
|
139
|
+
entry.status = value >= threshold ? "persisting" : "resolved";
|
|
140
|
+
applied++;
|
|
141
|
+
}
|
|
142
|
+
return applied;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export type VerifySummary = Record<VerifyStatus, number>;
|
|
146
|
+
|
|
147
|
+
/** Contagem por status. */
|
|
148
|
+
export function summarizeVerify(entries: readonly VerifyEntry[]): VerifySummary {
|
|
149
|
+
const summary = Object.fromEntries(
|
|
150
|
+
VERIFY_STATUSES.map((status) => [status, 0]),
|
|
151
|
+
) as VerifySummary;
|
|
152
|
+
for (const entry of entries) summary[entry.status]++;
|
|
153
|
+
return summary;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Persiste os `resolved` como `done` (acao `verify`). Devolve os ids alterados.
|
|
158
|
+
* Muta o estado em memória (o caller persiste).
|
|
159
|
+
*/
|
|
160
|
+
export function applyResolutions(
|
|
161
|
+
state: FindingsStateFile,
|
|
162
|
+
entries: readonly VerifyEntry[],
|
|
163
|
+
now: string = new Date().toISOString(),
|
|
164
|
+
): string[] {
|
|
165
|
+
const changed: string[] = [];
|
|
166
|
+
for (const entry of entries) {
|
|
167
|
+
if (entry.status !== "resolved") continue;
|
|
168
|
+
const finding = state.findings[entry.id];
|
|
169
|
+
if (!finding || finding.execState === "done") continue;
|
|
170
|
+
applyFindingUpdate(finding, { execState: "done", by: "verify" }, now);
|
|
171
|
+
changed.push(finding.id);
|
|
172
|
+
}
|
|
173
|
+
if (changed.length > 0) state.updatedAt = now;
|
|
174
|
+
return changed;
|
|
175
|
+
}
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Acesso tipado aos watchdogs de um `HoundsmanConfig` (biblioteca).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* tudo chega via `config`.
|
|
4
|
+
* O motor não conhece ids concretos — tudo chega via `config`.
|
|
6
5
|
*/
|
|
7
6
|
|
|
8
7
|
import type { HoundsmanConfig } from "./config";
|
package/src/index.ts
CHANGED
|
@@ -7,8 +7,22 @@
|
|
|
7
7
|
|
|
8
8
|
export { assetPath, OUTPUT_FORMAT_FILE } from "./core/assets";
|
|
9
9
|
export { defaultRenderBrief, PROCEDURE, renderBrief } from "./core/brief";
|
|
10
|
-
export type {
|
|
11
|
-
export {
|
|
10
|
+
export type { FindingsCommandResult } from "./core/commands/findings";
|
|
11
|
+
export { runFindingsCommand, runVerifyCommand } from "./core/commands/findings";
|
|
12
|
+
export type {
|
|
13
|
+
HoundsmanBudgets,
|
|
14
|
+
HoundsmanConcurrency,
|
|
15
|
+
HoundsmanConfig,
|
|
16
|
+
HoundsmanConfigInput,
|
|
17
|
+
} from "./core/config";
|
|
18
|
+
export {
|
|
19
|
+
DEFAULT_ALWAYS_INCLUDE_DOCS,
|
|
20
|
+
DEFAULT_JEV_CONCURRENCY,
|
|
21
|
+
DEFAULT_MAX_FILES_PER_BRIEF,
|
|
22
|
+
defineHoundsmanConfig,
|
|
23
|
+
resolveJevConcurrency,
|
|
24
|
+
resolveMaxFilesPerBrief,
|
|
25
|
+
} from "./core/config";
|
|
12
26
|
export type { LoadedConfig } from "./core/config-loader";
|
|
13
27
|
export {
|
|
14
28
|
CONFIG_FILE_CANDIDATES,
|
|
@@ -16,12 +30,67 @@ export {
|
|
|
16
30
|
resolveConfigPath,
|
|
17
31
|
} from "./core/config-loader";
|
|
18
32
|
export {
|
|
33
|
+
collectChurn,
|
|
34
|
+
collectDiff,
|
|
35
|
+
type DiffSource,
|
|
36
|
+
parseDiffIndex,
|
|
37
|
+
parseNumstat,
|
|
38
|
+
} from "./core/diff-source";
|
|
39
|
+
export {
|
|
40
|
+
churnOf,
|
|
19
41
|
classifyEntries,
|
|
20
42
|
classifyRange,
|
|
21
43
|
isExcludedPath,
|
|
22
44
|
matchWatchdogs,
|
|
23
45
|
parseEntries,
|
|
46
|
+
selectByBudget,
|
|
47
|
+
sortByRisk,
|
|
24
48
|
} from "./core/file-classifier";
|
|
49
|
+
export type {
|
|
50
|
+
AnchorLocation,
|
|
51
|
+
FindingAnchor,
|
|
52
|
+
ReanchorStatus,
|
|
53
|
+
} from "./core/finding-anchor";
|
|
54
|
+
export {
|
|
55
|
+
buildAnchor,
|
|
56
|
+
fingerprintOf,
|
|
57
|
+
hashKey,
|
|
58
|
+
locateAnchor,
|
|
59
|
+
normalizeLine,
|
|
60
|
+
reanchor,
|
|
61
|
+
} from "./core/finding-anchor";
|
|
62
|
+
export type {
|
|
63
|
+
BuildStateOptions,
|
|
64
|
+
FileReader,
|
|
65
|
+
FindingDecision,
|
|
66
|
+
FindingExecState,
|
|
67
|
+
FindingHistoryEntry,
|
|
68
|
+
FindingState,
|
|
69
|
+
FindingsFilter,
|
|
70
|
+
FindingsStateFile,
|
|
71
|
+
FindingsSummary,
|
|
72
|
+
FindingUpdate,
|
|
73
|
+
SyncResult,
|
|
74
|
+
} from "./core/findings-state";
|
|
75
|
+
export {
|
|
76
|
+
applyFindingUpdate,
|
|
77
|
+
buildStateFromFindings,
|
|
78
|
+
buildStateFromRunDir,
|
|
79
|
+
compareFindings,
|
|
80
|
+
emptyState,
|
|
81
|
+
FINDING_DECISIONS,
|
|
82
|
+
FINDING_EXEC_STATES,
|
|
83
|
+
FINDINGS_STATE_VERSION,
|
|
84
|
+
FindingsStateStore,
|
|
85
|
+
fileReader,
|
|
86
|
+
filterFindings,
|
|
87
|
+
newFindingId,
|
|
88
|
+
reorder,
|
|
89
|
+
runDir,
|
|
90
|
+
stateFile,
|
|
91
|
+
summarize,
|
|
92
|
+
syncState,
|
|
93
|
+
} from "./core/findings-state";
|
|
25
94
|
export {
|
|
26
95
|
ARTIFACT_DIR,
|
|
27
96
|
CORE_DIR,
|
|
@@ -29,10 +98,15 @@ export {
|
|
|
29
98
|
OUTPUT_FORMAT_PATH,
|
|
30
99
|
PROJECT_DIR,
|
|
31
100
|
relFromRoot,
|
|
101
|
+
STATE_FILE,
|
|
32
102
|
} from "./core/paths";
|
|
33
103
|
export { checkRegistry } from "./core/registry-check";
|
|
104
|
+
export type { DomainReport } from "./core/report-builder";
|
|
105
|
+
export { renderDomainReport } from "./core/report-builder";
|
|
34
106
|
export type {
|
|
107
|
+
ChurnMap,
|
|
35
108
|
ClassifyRule,
|
|
109
|
+
FileChurn,
|
|
36
110
|
FileStatus,
|
|
37
111
|
FileStatusMap,
|
|
38
112
|
Rule,
|
|
@@ -54,3 +128,5 @@ export {
|
|
|
54
128
|
getWatchdog,
|
|
55
129
|
getWatchdogContext,
|
|
56
130
|
} from "./core/watchdog-registry";
|
|
131
|
+
export type { ServerOptions } from "./server";
|
|
132
|
+
export { createRequestHandler, startServer } from "./server";
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Descoberta de runs e leitura segura de artefatos (servidor).
|
|
3
|
+
*
|
|
4
|
+
* Toda leitura é restrita à pasta da run — `readRunFile` barra path traversal.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
8
|
+
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
9
|
+
|
|
10
|
+
/** Arquivos que marcam uma pasta como "run" do houndsman. */
|
|
11
|
+
const RUN_MARKERS = [
|
|
12
|
+
"manifest.json",
|
|
13
|
+
"state.json",
|
|
14
|
+
"run.json",
|
|
15
|
+
"classification.json",
|
|
16
|
+
"review.md",
|
|
17
|
+
] as const;
|
|
18
|
+
|
|
19
|
+
/** Slugs (nomes de pasta) das runs sob o `artifactDir`. */
|
|
20
|
+
export function listRunSlugs(artifactDir: string): string[] {
|
|
21
|
+
if (!existsSync(artifactDir)) return [];
|
|
22
|
+
try {
|
|
23
|
+
return readdirSync(artifactDir, { withFileTypes: true })
|
|
24
|
+
.filter((entry) => entry.isDirectory())
|
|
25
|
+
.map((entry) => entry.name)
|
|
26
|
+
.filter((slug) =>
|
|
27
|
+
RUN_MARKERS.some((marker) => existsSync(join(artifactDir, slug, marker))),
|
|
28
|
+
)
|
|
29
|
+
.sort();
|
|
30
|
+
} catch {
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Diretório absoluto de uma run; null se o slug for inválido/inexistente. */
|
|
36
|
+
export function runDirForSlug(artifactDir: string, slug: string): string | null {
|
|
37
|
+
if (!/^[a-zA-Z0-9._-]+$/.test(slug)) return null;
|
|
38
|
+
const dir = join(artifactDir, slug);
|
|
39
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory()) return null;
|
|
40
|
+
return dir;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Lê um arquivo DENTRO da run; null se ausente ou fora do diretório. */
|
|
44
|
+
export function readRunFile(runDir: string, name: string): string | null {
|
|
45
|
+
const root = resolve(runDir);
|
|
46
|
+
const target = resolve(root, name);
|
|
47
|
+
const rel = relative(root, target);
|
|
48
|
+
if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) return null;
|
|
49
|
+
try {
|
|
50
|
+
return existsSync(target) && statSync(target).isFile()
|
|
51
|
+
? readFileSync(target, "utf8")
|
|
52
|
+
: null;
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Lê e faz parse de um JSON da run (null se ausente/inválido). */
|
|
59
|
+
export function readRunJson<T>(runDir: string, name: string): T | null {
|
|
60
|
+
const raw = readRunFile(runDir, name);
|
|
61
|
+
if (raw === null) return null;
|
|
62
|
+
try {
|
|
63
|
+
return JSON.parse(raw) as T;
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|