@payconductor/houndsman 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +32 -14
  2. package/package.json +1 -1
  3. package/src/cli/index.ts +22 -11
  4. package/src/core/assets.ts +3 -0
  5. package/src/core/brief.ts +2 -3
  6. package/src/core/commands/findings.ts +232 -4
  7. package/src/core/commands/review.ts +2 -0
  8. package/src/core/commands/scaffold.ts +13 -44
  9. package/src/core/diff-source.ts +4 -4
  10. package/src/core/findings-state.ts +20 -2
  11. package/src/core/fix-batch.ts +236 -0
  12. package/src/core/git-revision.ts +105 -0
  13. package/src/core/jev/index.ts +1 -0
  14. package/src/core/jev/jev-detector.ts +2 -2
  15. package/src/core/jev/jev-verify.ts +178 -0
  16. package/src/core/orchestrator.ts +202 -15
  17. package/src/core/paths.ts +6 -0
  18. package/src/core/report-builder.ts +5 -0
  19. package/src/core/run-status.ts +123 -0
  20. package/src/core/verify.ts +175 -0
  21. package/src/core/watchdog-registry.ts +1 -2
  22. package/src/index.ts +1 -1
  23. package/src/server/artifacts.ts +1 -0
  24. package/src/server/client/RunList.tsx +22 -13
  25. package/src/server/client/constants.ts +32 -1
  26. package/src/server/client/types.ts +4 -0
  27. package/src/server/index.ts +12 -1
  28. package/src/skill/AUDIT-PROTOCOL.md +2 -2
  29. package/src/skill/README.md +29 -5
  30. package/src/skill/SETUP.md +11 -1
  31. package/src/skill/SKILL.md +48 -0
  32. package/src/skill/fix.md +206 -0
  33. package/src/skill/{houndsman.md → hunt.md} +41 -68
  34. package/src/skill/{houndsman-update.md → train.md} +11 -11
  35. package/src/skill/verify.md +70 -0
  36. package/src/tests/findings-commands.test.ts +252 -2
  37. package/src/tests/findings-state.test.ts +52 -1
  38. package/src/tests/fix-batch.test.ts +257 -0
  39. package/src/tests/run-status.test.ts +147 -0
  40. package/src/tests/scaffold.test.ts +59 -0
  41. package/src/tests/server.test.ts +30 -0
  42. package/src/tests/verify.test.ts +170 -0
@@ -22,15 +22,22 @@ import {
22
22
  resolveJevConcurrency,
23
23
  resolveMaxFilesPerBrief,
24
24
  } from "./config";
25
- import { collectDiff, type DiffSource } from "./diff-source";
25
+ import { churnToRecord, collectDiff, type DiffSource } from "./diff-source";
26
26
  import { discoverDocs } from "./doc-discoverer";
27
27
  import {
28
28
  type BudgetSelection,
29
29
  type ClassificationResult,
30
+ classifyEntries,
30
31
  classifyRange,
31
32
  selectByBudget,
32
33
  } from "./file-classifier";
33
- import { buildStateFromFindings, FindingsStateStore, fileReader } from "./findings-state";
34
+ import {
35
+ buildStateFromFindings,
36
+ type FindingsStateFile,
37
+ FindingsStateStore,
38
+ fileReader,
39
+ } from "./findings-state";
40
+ import { collectDiffSince, diffEntries, isAncestor, resolveSha } from "./git-revision";
34
41
  import {
35
42
  type JevCandidate,
36
43
  type JevCandidatesFile,
@@ -44,13 +51,15 @@ import {
44
51
  type ContextSummary,
45
52
  type DomainReport,
46
53
  dedupeFindings,
54
+ type MergedFinding,
47
55
  parseWatchdogReport,
48
56
  renderDomainReport,
49
57
  renderReport,
50
58
  sanitizeBranch,
51
59
  type WatchdogReport,
52
60
  } from "./report-builder";
53
- import type { WatchdogMeta } from "./types";
61
+ import { writeRunStatus } from "./run-status";
62
+ import { severityRank, type WatchdogMeta } from "./types";
54
63
  import { findWatchdog } from "./watchdog-registry";
55
64
 
56
65
  export type ReviewOptions = {
@@ -62,6 +71,8 @@ export type ReviewOptions = {
62
71
  failOnBlocking: boolean;
63
72
  clean: boolean;
64
73
  noJev: boolean;
74
+ /** Força review completo (ignora o modo incremental). */
75
+ full: boolean;
65
76
  };
66
77
 
67
78
  /** Valores default das opções (o CLI sobrepõe com as flags). */
@@ -74,6 +85,7 @@ export function defaultOptions(artifactDir: string = ARTIFACT_DIR): ReviewOption
74
85
  failOnBlocking: false,
75
86
  clean: false,
76
87
  noJev: false,
88
+ full: false,
77
89
  };
78
90
  }
79
91
 
@@ -109,6 +121,142 @@ function prepareDirs(dirs: Paths, clean: boolean): void {
109
121
  }
110
122
  }
111
123
 
124
+ // ---------------------------------------------------------------------------
125
+ // Escopo da revisão (completo vs incremental)
126
+ // ---------------------------------------------------------------------------
127
+
128
+ /** Classificação enriquecida gravada no `classification.json` (modo + revisão + carry-over). */
129
+ type RunClassification = ClassificationResult & {
130
+ /** Commit revisado (head do diff). */
131
+ headSha?: string;
132
+ /** Modo do prepare: completo ou incremental (delta desde a última revisão). */
133
+ mode: "full" | "incremental";
134
+ /** Commit da última revisão (só no incremental). */
135
+ reviewedFrom?: string;
136
+ /** Findings preservados do review anterior (arquivos inalterados). */
137
+ carryover?: MergedFinding[];
138
+ };
139
+
140
+ /** SHA curto para logs (7 chars, como o git). */
141
+ function shortSha(sha: string): string {
142
+ return sha.slice(0, 7);
143
+ }
144
+
145
+ /** Chave de dedupe de finding: `(file, line, rule)` — igual ao `dedupeFindings`. */
146
+ function findingKey(finding: Pick<MergedFinding, "file" | "line" | "rule">): string {
147
+ return `${finding.file}|${finding.line ?? ""}|${finding.rule}`;
148
+ }
149
+
150
+ /** Aviso no topo do `review.md` quando a revisão foi incremental (senão vazio). */
151
+ function incrementalNote(classification: RunClassification): string {
152
+ if (classification.mode !== "incremental" || !classification.reviewedFrom) return "";
153
+ const carried = classification.carryover?.length ?? 0;
154
+ return (
155
+ `> **Revisão incremental** — desde \`${shortSha(classification.reviewedFrom)}\`; ` +
156
+ `${Object.keys(classification.files).length} arquivo(s) alterado(s), ` +
157
+ `${carried} finding(s) carregado(s) do review anterior.\n\n`
158
+ );
159
+ }
160
+
161
+ /**
162
+ * Findings do review anterior em arquivos inalterados — carregados sem
163
+ * re-revisão (carry-over). Preserva o `foundAtSha` para o diff focado do verify.
164
+ */
165
+ function buildCarryover(
166
+ previous: FindingsStateFile | null,
167
+ changedPaths: Set<string>,
168
+ ): MergedFinding[] {
169
+ if (!previous) return [];
170
+ return Object.values(previous.findings)
171
+ .filter((finding) => finding.present && !changedPaths.has(finding.file))
172
+ .map((finding) => ({
173
+ id: finding.id,
174
+ rule: finding.rule,
175
+ severity: finding.severity,
176
+ file: finding.file,
177
+ line: finding.line ?? undefined,
178
+ message: finding.message,
179
+ action: finding.action,
180
+ evidence: finding.evidence,
181
+ foundAtSha: finding.foundAtSha,
182
+ watchdogs: [...finding.watchdogIds],
183
+ }));
184
+ }
185
+
186
+ /** Une os findings re-reportados com os carry-over (preserva ids do estado anterior). */
187
+ function mergeCarryover(
188
+ merged: MergedFinding[],
189
+ carryover: readonly MergedFinding[],
190
+ ): MergedFinding[] {
191
+ const byKey = new Map(merged.map((finding) => [findingKey(finding), finding]));
192
+ for (const carried of carryover) {
193
+ const existing = byKey.get(findingKey(carried));
194
+ if (existing) {
195
+ for (const watchdog of carried.watchdogs) {
196
+ if (!existing.watchdogs.includes(watchdog))
197
+ existing.watchdogs.push(watchdog);
198
+ }
199
+ continue;
200
+ }
201
+ byKey.set(findingKey(carried), carried);
202
+ merged.push(carried);
203
+ }
204
+ merged.sort((a, b) => {
205
+ const bySeverity = severityRank(a.severity) - severityRank(b.severity);
206
+ if (bySeverity !== 0) return bySeverity;
207
+ if (a.file !== b.file) return a.file.localeCompare(b.file);
208
+ return (a.line ?? 0) - (b.line ?? 0);
209
+ });
210
+ return merged;
211
+ }
212
+
213
+ /**
214
+ * Decide entre review completo e incremental. Incremental só quando há um
215
+ * `reviewed.headSha` anterior, o `HEAD` atual o sucede (sem rebase) e não veio
216
+ * `--full`/`--clean`. Reconstrói só o delta `prevSha..headSha` e carrega os
217
+ * findings de arquivos inalterados do estado anterior.
218
+ */
219
+ function resolveReviewScope(
220
+ opts: ReviewOptions,
221
+ previous: FindingsStateFile | null,
222
+ headSha: string,
223
+ config: HoundsmanConfig,
224
+ ): { classification: RunClassification; diff: DiffSource; reviewedFrom?: string } {
225
+ const prevSha = previous?.reviewed?.headSha;
226
+ if (
227
+ opts.full ||
228
+ opts.clean ||
229
+ !prevSha ||
230
+ prevSha === headSha ||
231
+ !isAncestor(prevSha, headSha)
232
+ ) {
233
+ const diff = collectDiff(opts.base, opts.head);
234
+ return {
235
+ diff,
236
+ classification: {
237
+ ...classifyRange(opts.base, opts.head, config, diff),
238
+ headSha,
239
+ mode: "full",
240
+ },
241
+ };
242
+ }
243
+
244
+ const range = `${prevSha}..${headSha}`;
245
+ const diff = collectDiffSince(prevSha, headSha);
246
+ const entries = diffEntries(range);
247
+ const changedPaths = new Set(entries.map((entry) => entry.path));
248
+ const classification: RunClassification = {
249
+ ...classifyEntries(entries, config, churnToRecord(diff.churn)),
250
+ base: prevSha,
251
+ head: opts.head,
252
+ headSha,
253
+ mode: "incremental",
254
+ reviewedFrom: prevSha,
255
+ carryover: buildCarryover(previous, changedPaths),
256
+ };
257
+ return { classification, diff, reviewedFrom: prevSha };
258
+ }
259
+
112
260
  // ---------------------------------------------------------------------------
113
261
  // Findings
114
262
  // ---------------------------------------------------------------------------
@@ -157,7 +305,7 @@ function writeManifest(
157
305
  outDir: string,
158
306
  opts: ReviewOptions,
159
307
  branch: string,
160
- classification: ClassificationResult,
308
+ classification: RunClassification,
161
309
  activeIds: string[],
162
310
  pending: string[],
163
311
  jev: JevCandidatesFile,
@@ -184,10 +332,19 @@ function writeManifest(
184
332
  slug: sanitizeBranch(branch),
185
333
  base: opts.base,
186
334
  head: opts.head,
335
+ headSha: classification.headSha,
187
336
  generatedAt: new Date().toISOString(),
188
337
  outputFormat: OUTPUT_FORMAT_PATH,
189
338
  budget: { maxFilesPerBrief, filesOverBudget },
190
339
  churn: churnTotals(classification),
340
+ incremental:
341
+ classification.mode === "incremental"
342
+ ? {
343
+ from: classification.reviewedFrom,
344
+ changedFiles: Object.keys(classification.files).length,
345
+ carried: classification.carryover?.length ?? 0,
346
+ }
347
+ : undefined,
191
348
  watchdogs,
192
349
  context: contextManifest(opts, branch),
193
350
  jev: jev.available
@@ -388,9 +545,13 @@ export async function prepare(
388
545
  ): Promise<void> {
389
546
  prepareDirs(dirs, opts.clean);
390
547
 
391
- // Um único diff para toda a execução (churn + patch) — antes: 1 spawn por arquivo.
392
- const diff = collectDiff(opts.base, opts.head);
393
- const classification = classifyRange(opts.base, opts.head, config, diff);
548
+ // Marca o run como "revisando" (reinicia o ciclo) — a UI mostra o status.
549
+ writeRunStatus(dirs.outDir, { branch, status: "reviewing", resetStartedAt: true });
550
+
551
+ // Marca o commit revisado e decide entre review completo e incremental.
552
+ const headSha = resolveSha(opts.head);
553
+ const previous = FindingsStateStore.at(opts.dir, branch).read();
554
+ const { classification, diff } = resolveReviewScope(opts, previous, headSha, config);
394
555
  writeFileSync(
395
556
  join(dirs.outDir, "classification.json"),
396
557
  `${JSON.stringify(classification, null, 2)}\n`,
@@ -400,13 +561,24 @@ export async function prepare(
400
561
 
401
562
  const activeIds = Object.keys(classification.watchdogs);
402
563
  if (Object.keys(classification.files).length === 0) {
403
- console.log(`Nenhuma alteração entre ${opts.base}...${opts.head}.`);
564
+ writeRunStatus(dirs.outDir, { branch, status: "review_done" });
565
+ console.log(
566
+ classification.mode === "incremental"
567
+ ? `Nenhuma alteração desde a última revisão (${shortSha(classification.reviewedFrom ?? "")}).`
568
+ : `Nenhuma alteração entre ${opts.base}...${opts.head}.`,
569
+ );
404
570
  return;
405
571
  }
406
572
  if (activeIds.length === 0) {
573
+ writeRunStatus(dirs.outDir, { branch, status: "review_done" });
407
574
  console.log("Nenhum arquivo revisável (tudo excluído, deletado ou irrelevante).");
408
575
  return;
409
576
  }
577
+ if (classification.mode === "incremental") {
578
+ console.log(
579
+ `Modo incremental: ${Object.keys(classification.files).length} arquivo(s) alterado(s) desde ${shortSha(classification.reviewedFrom ?? "")}; ${classification.carryover?.length ?? 0} finding(s) carregado(s) do review anterior.`,
580
+ );
581
+ }
410
582
 
411
583
  const context = readContextSummary(contextPaths(opts.dir, branch).summary);
412
584
 
@@ -444,7 +616,7 @@ export async function prepare(
444
616
  // ---------------------------------------------------------------------------
445
617
 
446
618
  /** Lê o classification.json gravado pelo passo de preparação (lança se ausente). */
447
- function readClassification(classificationPath: string): ClassificationResult {
619
+ function readClassification(classificationPath: string): RunClassification {
448
620
  if (!existsSync(classificationPath)) {
449
621
  throw new Error(
450
622
  `Não encontrei ${relFromRoot(classificationPath)}. Rode \`bun run houndsman\` (preparar) primeiro.`,
@@ -452,9 +624,7 @@ function readClassification(classificationPath: string): ClassificationResult {
452
624
  }
453
625
  const parsed = JSON.parse(
454
626
  readFileSync(classificationPath, "utf8"),
455
- ) as ClassificationResult;
456
- // Compatível com classification.json gerado antes do churn existir.
457
- parsed.churn ??= {};
627
+ ) as RunClassification;
458
628
  return parsed;
459
629
  }
460
630
 
@@ -542,21 +712,38 @@ export function finalize(
542
712
  );
543
713
  }
544
714
 
545
- const merged = dedupeFindings(reports);
715
+ const now = new Date().toISOString();
716
+ // Findings re-reportados neste ciclo + carry-over (arquivos inalterados).
717
+ const merged = mergeCarryover(
718
+ dedupeFindings(reports),
719
+ classification.carryover ?? [],
720
+ );
721
+ if (classification.mode === "incremental") {
722
+ console.log(
723
+ `Carry-over: ${classification.carryover?.length ?? 0} finding(s) preservado(s) de ${shortSha(classification.reviewedFrom ?? "")}.`,
724
+ );
725
+ }
546
726
 
547
727
  // Estado de triagem: preserva o que já existia (casando por fingerprint) e
548
728
  // anexa o `id` de cada finding aos itens do relatório. A linha é cache —
549
- // recalculada por `houndsman findings sync` após edições.
729
+ // recalculada por `houndsman findings sync` após edições. O commit revisado
730
+ // (`reviewed.headSha`) habilita o review incremental e o diff focado do verify.
550
731
  const store = FindingsStateStore.at(opts.dir, branch);
551
732
  const state = buildStateFromFindings(merged, {
552
733
  branch,
553
734
  readFile: fileReader(process.cwd()),
554
735
  previous: store.read(),
736
+ now,
737
+ headSha: classification.headSha,
738
+ reviewed: classification.headSha
739
+ ? { headSha: classification.headSha, at: now }
740
+ : undefined,
555
741
  });
556
742
  store.write(state);
557
743
  console.log(
558
744
  `Triagem: ${relFromRoot(store.filePath)} — ${Object.keys(state.findings).length} finding(s).`,
559
745
  );
746
+ writeRunStatus(dirs.outDir, { branch, status: "review_done" });
560
747
 
561
748
  const context = readContextSummary(contextPaths(opts.dir, branch).summary);
562
749
  const nameOf = (id: string): string => findWatchdog(config, id)?.name ?? id;
@@ -579,7 +766,7 @@ export function finalize(
579
766
  domains,
580
767
  );
581
768
  const reportPath = join(dirs.outDir, "review.md");
582
- writeFileSync(reportPath, `${markdown}\n`);
769
+ writeFileSync(reportPath, `${incrementalNote(classification)}${markdown}\n`);
583
770
 
584
771
  const blockers = merged.filter((f) => f.severity === "blocking").length;
585
772
  const attention = merged.filter((f) => f.severity === "attention").length;
package/src/core/paths.ts CHANGED
@@ -31,3 +31,9 @@ export const OUTPUT_FORMAT_PATH = `${CORE_SKILL_DIR}/${OUTPUT_FORMAT_FILE}`;
31
31
 
32
32
  /** Estado de triagem dos findings, dentro da pasta da run (`<slug>/state.json`). */
33
33
  export const STATE_FILE = "state.json";
34
+
35
+ /** Status da run (revisão/correção), dentro da pasta da run (`<slug>/run.json`). */
36
+ export const RUN_STATUS_FILE = "run.json";
37
+
38
+ /** Relatório da verificação de findings, dentro da pasta da run (`<slug>/verify.md`). */
39
+ export const VERIFY_FILE = "verify.md";
@@ -25,6 +25,11 @@ export type Finding = {
25
25
  message: string;
26
26
  action: string;
27
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;
28
33
  };
29
34
 
30
35
  export type WatchdogReport = {
@@ -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
+ }
@@ -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
- * Substitui o antigo barrel `registry/`. O motor não conhece ids concretos —
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
@@ -8,7 +8,7 @@
8
8
  export { assetPath, OUTPUT_FORMAT_FILE } from "./core/assets";
9
9
  export { defaultRenderBrief, PROCEDURE, renderBrief } from "./core/brief";
10
10
  export type { FindingsCommandResult } from "./core/commands/findings";
11
- export { runFindingsCommand } from "./core/commands/findings";
11
+ export { runFindingsCommand, runVerifyCommand } from "./core/commands/findings";
12
12
  export type {
13
13
  HoundsmanBudgets,
14
14
  HoundsmanConcurrency,
@@ -11,6 +11,7 @@ import { isAbsolute, join, relative, resolve } from "node:path";
11
11
  const RUN_MARKERS = [
12
12
  "manifest.json",
13
13
  "state.json",
14
+ "run.json",
14
15
  "classification.json",
15
16
  "review.md",
16
17
  ] as const;