@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.
Files changed (61) hide show
  1. package/README.md +68 -89
  2. package/assets/logo-legacy.png +0 -0
  3. package/assets/logo.png +0 -0
  4. package/package.json +3 -2
  5. package/src/cli/index.ts +101 -10
  6. package/src/core/assets.ts +11 -0
  7. package/src/core/brief.ts +27 -5
  8. package/src/core/commands/findings.ts +618 -0
  9. package/src/core/commands/review.ts +2 -0
  10. package/src/core/commands/scaffold.ts +33 -44
  11. package/src/core/config.ts +42 -0
  12. package/src/core/diff-source.ts +140 -0
  13. package/src/core/file-classifier.ts +50 -2
  14. package/src/core/finding-anchor.ts +147 -0
  15. package/src/core/findings-state.ts +556 -0
  16. package/src/core/fix-batch.ts +236 -0
  17. package/src/core/git-revision.ts +105 -0
  18. package/src/core/jev/index.ts +1 -0
  19. package/src/core/jev/jev-client.ts +72 -11
  20. package/src/core/jev/jev-detector.ts +21 -19
  21. package/src/core/jev/jev-verify.ts +178 -0
  22. package/src/core/orchestrator.ts +362 -23
  23. package/src/core/paths.ts +9 -0
  24. package/src/core/registry-check.ts +15 -0
  25. package/src/core/report-builder.ts +118 -9
  26. package/src/core/run-status.ts +123 -0
  27. package/src/core/types.ts +10 -0
  28. package/src/core/verify.ts +175 -0
  29. package/src/core/watchdog-registry.ts +1 -2
  30. package/src/index.ts +78 -2
  31. package/src/server/artifacts.ts +67 -0
  32. package/src/server/client/App.tsx +158 -0
  33. package/src/server/client/ArtifactsPanel.tsx +48 -0
  34. package/src/server/client/Filters.tsx +83 -0
  35. package/src/server/client/FindingCard.tsx +122 -0
  36. package/src/server/client/FindingsList.tsx +54 -0
  37. package/src/server/client/RunList.tsx +52 -0
  38. package/src/server/client/SummaryBar.tsx +21 -0
  39. package/src/server/client/api.ts +37 -0
  40. package/src/server/client/constants.ts +94 -0
  41. package/src/server/client/index.tsx +12 -0
  42. package/src/server/client/types.ts +52 -0
  43. package/src/server/client-build.ts +47 -0
  44. package/src/server/index.ts +376 -0
  45. package/src/skill/AUDIT-PROTOCOL.md +2 -2
  46. package/src/skill/README.md +43 -11
  47. package/src/skill/SETUP.md +16 -4
  48. package/src/skill/SKILL.md +48 -0
  49. package/src/skill/fix.md +206 -0
  50. package/src/skill/{houndsman.md → hunt.md} +83 -33
  51. package/src/skill/{houndsman-update.md → train.md} +12 -12
  52. package/src/skill/verify.md +70 -0
  53. package/src/tests/findings-anchor.test.ts +120 -0
  54. package/src/tests/findings-commands.test.ts +476 -0
  55. package/src/tests/findings-state.test.ts +325 -0
  56. package/src/tests/fix-batch.test.ts +257 -0
  57. package/src/tests/review-scripts.test.ts +299 -0
  58. package/src/tests/run-status.test.ts +147 -0
  59. package/src/tests/scaffold.test.ts +59 -0
  60. package/src/tests/server.test.ts +242 -0
  61. 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
- for (const severity of SEVERITY_ORDER) {
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
- * 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
@@ -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 { HoundsmanConfig, HoundsmanConfigInput } from "./core/config";
11
- export { DEFAULT_ALWAYS_INCLUDE_DOCS, defineHoundsmanConfig } from "./core/config";
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
+ }