@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
@@ -0,0 +1,556 @@
1
+ /**
2
+ * Estado de triagem dos findings (biblioteca).
3
+ *
4
+ * Persistido em `.houndsman/.runs/<slug>/state.json` (local — coberto pelo
5
+ * `.gitignore` gerado, que ignora `.runs/`). Cada finding tem um `id` curto e um
6
+ * `fingerprint` de conteúdo: nas re-execuções do `--finalize` o estado é
7
+ * preservado casando por fingerprint.
8
+ *
9
+ * A `line` é cache — `syncState` a recalcula após edições (auto-fix) e marca
10
+ * `stale` quando a âncora some.
11
+ */
12
+
13
+ import { randomUUID } from "node:crypto";
14
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
15
+ import { dirname, join } from "node:path";
16
+ import {
17
+ buildAnchor,
18
+ type FindingAnchor,
19
+ fingerprintOf,
20
+ locateAnchor,
21
+ reanchor,
22
+ } from "./finding-anchor";
23
+ import { STATE_FILE } from "./paths";
24
+ import {
25
+ dedupeFindings,
26
+ type MergedFinding,
27
+ parseWatchdogReport,
28
+ sanitizeBranch,
29
+ type WatchdogReport,
30
+ } from "./report-builder";
31
+ import { SEVERITY_ORDER, type Severity, severityRank } from "./types";
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Tipos
35
+ // ---------------------------------------------------------------------------
36
+
37
+ /** Decisão de triagem: o que fazer com o finding. */
38
+ export type FindingDecision = "pending" | "execute" | "ignore";
39
+
40
+ /** Estado de execução: em que pé está a ação. */
41
+ export type FindingExecState = "pending" | "in_progress" | "done" | "stale";
42
+
43
+ /** Valores canônicos (na ordem de exibição). */
44
+ export const FINDING_DECISIONS: readonly FindingDecision[] = [
45
+ "pending",
46
+ "execute",
47
+ "ignore",
48
+ ];
49
+
50
+ /** Valores canônicos de `execState`. */
51
+ export const FINDING_EXEC_STATES: readonly FindingExecState[] = [
52
+ "pending",
53
+ "in_progress",
54
+ "done",
55
+ "stale",
56
+ ];
57
+
58
+ const DECISION_SET = new Set<string>(FINDING_DECISIONS);
59
+ const EXEC_SET = new Set<string>(FINDING_EXEC_STATES);
60
+
61
+ /** Entrada de auditoria (quem mudou o quê). */
62
+ export type FindingHistoryEntry = {
63
+ at: string;
64
+ decision: FindingDecision;
65
+ execState: FindingExecState;
66
+ comment?: string;
67
+ by?: string;
68
+ };
69
+
70
+ /** Marco da revisão que originou o estado: commit revisado + quando. */
71
+ export type FindingsReviewed = {
72
+ /** SHA do commit revisado (head do diff no `--finalize`). */
73
+ headSha: string;
74
+ at: string;
75
+ };
76
+
77
+ /** Um finding triável, com o estado das duas dimensões. */
78
+ export type FindingState = {
79
+ id: string;
80
+ fingerprint: string;
81
+ watchdogIds: string[];
82
+ rule: string;
83
+ severity: Severity;
84
+ file: string;
85
+ line: number | null;
86
+ message: string;
87
+ action: string;
88
+ evidence?: string;
89
+ anchor: FindingAnchor | null;
90
+ /** Commit em que o achado foi observado (base do diff focado do verify). */
91
+ foundAtSha?: string;
92
+ /** Ocorrências idênticas colapsadas neste fingerprint (>1 = grupo). */
93
+ count: number;
94
+ /** Ainda presente no último relatório consolidado. */
95
+ present: boolean;
96
+ decision: FindingDecision;
97
+ comment?: string;
98
+ execState: FindingExecState;
99
+ createdAt: string;
100
+ updatedAt: string;
101
+ history: FindingHistoryEntry[];
102
+ };
103
+
104
+ /** Conteúdo do `state.json`. */
105
+ export type FindingsStateFile = {
106
+ version: 2;
107
+ branch: string;
108
+ slug: string;
109
+ updatedAt: string;
110
+ /** Ids na ordem de exibição (estável). */
111
+ order: string[];
112
+ /** Commit revisado — habilita o review incremental e o verify por diff. */
113
+ reviewed?: FindingsReviewed;
114
+ findings: Record<string, FindingState>;
115
+ };
116
+
117
+ export const FINDINGS_STATE_VERSION = 2;
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // Caminhos
121
+ // ---------------------------------------------------------------------------
122
+
123
+ /** Diretório de artefatos da run (branch-slug). */
124
+ export function runDir(artifactDir: string, branch: string): string {
125
+ return join(artifactDir, sanitizeBranch(branch));
126
+ }
127
+
128
+ /** Caminho do `state.json` da run. */
129
+ export function stateFile(artifactDir: string, branch: string): string {
130
+ return join(runDir(artifactDir, branch), STATE_FILE);
131
+ }
132
+
133
+ /** Leitor de arquivos relativos à raiz do projeto (injetável nos testes). */
134
+ export type FileReader = (relativePath: string) => string | null;
135
+
136
+ /** Leitor padrão a partir do cwd (null se ausente/ilegível). */
137
+ export function fileReader(cwd: string): FileReader {
138
+ return (relativePath: string) => {
139
+ try {
140
+ const full = join(cwd, relativePath);
141
+ return existsSync(full) ? readFileSync(full, "utf8") : null;
142
+ } catch {
143
+ return null;
144
+ }
145
+ };
146
+ }
147
+
148
+ /** Id curto aleatório (hex) para um finding novo. */
149
+ export function newFindingId(): string {
150
+ return randomUUID().replace(/-/g, "").slice(0, 10);
151
+ }
152
+
153
+ /** Estado vazio para uma branch. */
154
+ export function emptyState(branch: string): FindingsStateFile {
155
+ return {
156
+ version: FINDINGS_STATE_VERSION,
157
+ branch,
158
+ slug: sanitizeBranch(branch),
159
+ updatedAt: new Date().toISOString(),
160
+ order: [],
161
+ findings: {},
162
+ };
163
+ }
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // Ordenação / resumo
167
+ // ---------------------------------------------------------------------------
168
+
169
+ /** Presentes primeiro; depois severidade, arquivo e linha. */
170
+ export function compareFindings(a: FindingState, b: FindingState): number {
171
+ if (a.present !== b.present) return a.present ? -1 : 1;
172
+ const bySeverity = severityRank(a.severity) - severityRank(b.severity);
173
+ if (bySeverity !== 0) return bySeverity;
174
+ if (a.file !== b.file) return a.file.localeCompare(b.file);
175
+ return (a.line ?? 0) - (b.line ?? 0);
176
+ }
177
+
178
+ /** Ordena os ids do estado (recomputa `order`). */
179
+ export function reorder(state: FindingsStateFile): void {
180
+ state.order = Object.values(state.findings)
181
+ .sort(compareFindings)
182
+ .map((f) => f.id);
183
+ }
184
+
185
+ export type FindingsSummary = {
186
+ total: number;
187
+ present: number;
188
+ byDecision: Record<FindingDecision, number>;
189
+ byExecState: Record<FindingExecState, number>;
190
+ bySeverity: Record<Severity, number>;
191
+ };
192
+
193
+ /** Contagens por decisão, execução e severidade. */
194
+ export function summarize(state: FindingsStateFile): FindingsSummary {
195
+ const byDecision = Object.fromEntries(FINDING_DECISIONS.map((d) => [d, 0])) as Record<
196
+ FindingDecision,
197
+ number
198
+ >;
199
+ const byExecState = Object.fromEntries(
200
+ FINDING_EXEC_STATES.map((e) => [e, 0]),
201
+ ) as Record<FindingExecState, number>;
202
+ const bySeverity = Object.fromEntries(SEVERITY_ORDER.map((s) => [s, 0])) as Record<
203
+ Severity,
204
+ number
205
+ >;
206
+
207
+ let total = 0;
208
+ let present = 0;
209
+ for (const finding of Object.values(state.findings)) {
210
+ total++;
211
+ if (finding.present) present++;
212
+ byDecision[finding.decision]++;
213
+ byExecState[finding.execState]++;
214
+ bySeverity[finding.severity]++;
215
+ }
216
+ return { total, present, byDecision, byExecState, bySeverity };
217
+ }
218
+
219
+ // ---------------------------------------------------------------------------
220
+ // Filtro
221
+ // ---------------------------------------------------------------------------
222
+
223
+ export type FindingsFilter = {
224
+ decision?: FindingDecision;
225
+ execState?: FindingExecState;
226
+ watchdog?: string;
227
+ severity?: Severity;
228
+ /** Substring do caminho (case-insensitive). */
229
+ file?: string;
230
+ /** Inclui findings ausentes do último relatório (default: só presentes). */
231
+ includeAbsent?: boolean;
232
+ };
233
+
234
+ /** Aplica o filtro e devolve os findings ordenados. */
235
+ export function filterFindings(
236
+ state: FindingsStateFile,
237
+ filter: FindingsFilter = {},
238
+ ): FindingState[] {
239
+ const needle = filter.file?.toLowerCase();
240
+ return Object.values(state.findings)
241
+ .filter((finding) => {
242
+ if (!filter.includeAbsent && !finding.present) return false;
243
+ if (filter.decision && finding.decision !== filter.decision) return false;
244
+ if (filter.execState && finding.execState !== filter.execState) return false;
245
+ if (filter.severity && finding.severity !== filter.severity) return false;
246
+ if (filter.watchdog && !finding.watchdogIds.includes(filter.watchdog)) {
247
+ return false;
248
+ }
249
+ if (needle && !finding.file.toLowerCase().includes(needle)) return false;
250
+ return true;
251
+ })
252
+ .sort(compareFindings);
253
+ }
254
+
255
+ // ---------------------------------------------------------------------------
256
+ // Construção (finalize)
257
+ // ---------------------------------------------------------------------------
258
+
259
+ export type BuildStateOptions = {
260
+ branch: string;
261
+ readFile: FileReader;
262
+ previous?: FindingsStateFile | null;
263
+ /** Commit revisado — carimbado nos findings e no `reviewed` do estado. */
264
+ reviewed?: FindingsReviewed;
265
+ /** SHA usado para carimbar `foundAtSha` de findings novos/re-reportados. */
266
+ headSha?: string;
267
+ now?: string;
268
+ };
269
+
270
+ /**
271
+ * Constrói o estado a partir dos findings consolidados, preservando o que já
272
+ * existia (casando por `fingerprint`). Depende de ler o arquivo para ancorar;
273
+ * sem conteúdo (deletado/binário) a âncora fica nula e o fingerprint cai no
274
+ * `message`.
275
+ *
276
+ * Muta cada `finding` recebido com o `id` correspondente (para o relatório).
277
+ */
278
+ export function buildStateFromFindings(
279
+ merged: readonly MergedFinding[],
280
+ options: BuildStateOptions,
281
+ ): FindingsStateFile {
282
+ const now = options.now ?? new Date().toISOString();
283
+ const previous = options.previous ?? null;
284
+
285
+ const result: Record<string, FindingState> = {};
286
+ // 1. Preserva o estado anterior (marca como ausente até provar o contrário).
287
+ for (const prev of Object.values(previous?.findings ?? {})) {
288
+ result[prev.id] = {
289
+ ...prev,
290
+ present: false,
291
+ execState:
292
+ prev.execState === "pending" && prev.decision === "execute"
293
+ ? "done"
294
+ : prev.execState,
295
+ updatedAt: now,
296
+ };
297
+ }
298
+
299
+ // 2. Findings atuais, agrupados por fingerprint.
300
+ const byFingerprint = new Map<string, FindingState>();
301
+ for (const finding of merged) {
302
+ const content = options.readFile(finding.file);
303
+ const anchor = content
304
+ ? buildAnchor(content, finding.line, finding.evidence)
305
+ : null;
306
+ const fingerprint = fingerprintOf(
307
+ finding.file,
308
+ finding.rule,
309
+ anchor,
310
+ finding.message,
311
+ );
312
+
313
+ const group = byFingerprint.get(fingerprint);
314
+ if (group) {
315
+ group.count++;
316
+ for (const w of finding.watchdogs) {
317
+ if (!group.watchdogIds.includes(w)) group.watchdogIds.push(w);
318
+ }
319
+ finding.id = group.id;
320
+ continue;
321
+ }
322
+
323
+ const prev = previous
324
+ ? Object.values(previous.findings).find((f) => f.fingerprint === fingerprint)
325
+ : undefined;
326
+ const created: FindingState = {
327
+ id: prev?.id ?? newFindingId(),
328
+ fingerprint,
329
+ watchdogIds: [...finding.watchdogs],
330
+ rule: finding.rule,
331
+ severity: finding.severity,
332
+ file: finding.file,
333
+ line: anchor ? anchor.line : null,
334
+ message: finding.message,
335
+ action: finding.action,
336
+ evidence: finding.evidence,
337
+ anchor,
338
+ // Carry-over preserva o `foundAtSha` original; novos/re-reportados usam o head atual.
339
+ foundAtSha: finding.foundAtSha ?? options.headSha ?? prev?.foundAtSha,
340
+ count: 1,
341
+ present: true,
342
+ decision: prev?.decision ?? "pending",
343
+ comment: prev?.comment,
344
+ execState: prev?.execState ?? "pending",
345
+ createdAt: prev?.createdAt ?? now,
346
+ updatedAt: now,
347
+ history: prev?.history ?? [],
348
+ };
349
+ byFingerprint.set(fingerprint, created);
350
+ result[created.id] = created;
351
+ finding.id = created.id;
352
+ }
353
+
354
+ const state: FindingsStateFile = {
355
+ version: FINDINGS_STATE_VERSION,
356
+ branch: options.branch,
357
+ slug: sanitizeBranch(options.branch),
358
+ updatedAt: now,
359
+ order: [],
360
+ reviewed: options.reviewed ?? previous?.reviewed,
361
+ findings: result,
362
+ };
363
+ reorder(state);
364
+ return state;
365
+ }
366
+
367
+ /**
368
+ * Reconstrói o estado a partir dos artefatos da run (`classification.json` +
369
+ * `findings/<id>.json`), sem exigir `--finalize`. Devolve null se não houver
370
+ * findings gravados.
371
+ */
372
+ export function buildStateFromRunDir(
373
+ outDir: string,
374
+ branch: string,
375
+ readFile: FileReader,
376
+ ): FindingsStateFile | null {
377
+ const classificationPath = join(outDir, "classification.json");
378
+ if (!existsSync(classificationPath)) return null;
379
+
380
+ let activeIds: string[] = [];
381
+ try {
382
+ const classification = JSON.parse(readFileSync(classificationPath, "utf8")) as {
383
+ watchdogs?: Record<string, string[]>;
384
+ };
385
+ activeIds = Object.keys(classification.watchdogs ?? {});
386
+ } catch {
387
+ return null;
388
+ }
389
+
390
+ const reports: WatchdogReport[] = [];
391
+ for (const id of activeIds) {
392
+ const path = join(outDir, "findings", `${id}.json`);
393
+ if (!existsSync(path)) continue;
394
+ try {
395
+ const report = parseWatchdogReport(
396
+ JSON.parse(readFileSync(path, "utf8")),
397
+ path,
398
+ );
399
+ if (report) reports.push(report);
400
+ } catch {
401
+ // Ignora findings ilegíveis — reconstrução best-effort.
402
+ }
403
+ }
404
+ if (reports.length === 0) return null;
405
+
406
+ return buildStateFromFindings(dedupeFindings(reports), { branch, readFile });
407
+ }
408
+ // ---------------------------------------------------------------------------
409
+
410
+ export type FindingUpdate = {
411
+ decision?: FindingDecision;
412
+ comment?: string;
413
+ execState?: FindingExecState;
414
+ by?: string;
415
+ };
416
+
417
+ /** Aplica uma atualização a um finding (registrando histórico). */
418
+ export function applyFindingUpdate(
419
+ finding: FindingState,
420
+ update: FindingUpdate,
421
+ now: string = new Date().toISOString(),
422
+ ): FindingState {
423
+ if (update.decision && DECISION_SET.has(update.decision)) {
424
+ finding.decision = update.decision;
425
+ }
426
+ if (update.execState && EXEC_SET.has(update.execState)) {
427
+ finding.execState = update.execState;
428
+ }
429
+ if (update.comment !== undefined) finding.comment = update.comment;
430
+ finding.updatedAt = now;
431
+ finding.history.push({
432
+ at: now,
433
+ decision: finding.decision,
434
+ execState: finding.execState,
435
+ comment: finding.comment,
436
+ by: update.by,
437
+ });
438
+ return finding;
439
+ }
440
+
441
+ export type SyncResult = {
442
+ ok: number;
443
+ moved: string[];
444
+ stale: string[];
445
+ };
446
+
447
+ /**
448
+ * Reancora todos os findings presentes lendo os arquivos atuais. Findings cuja
449
+ * âncora sumiu viram `stale` (ou `done` se a decisão era `execute`).
450
+ * Muta o estado em memória (o caller persiste).
451
+ */
452
+ export function syncState(
453
+ state: FindingsStateFile,
454
+ readFile: FileReader,
455
+ now: string = new Date().toISOString(),
456
+ ): SyncResult {
457
+ const moved: string[] = [];
458
+ const stale: string[] = [];
459
+ let ok = 0;
460
+
461
+ for (const finding of Object.values(state.findings)) {
462
+ if (!finding.present) continue;
463
+ const content = readFile(finding.file);
464
+ if (content === null) {
465
+ if (finding.execState !== "done") finding.execState = "stale";
466
+ finding.updatedAt = now;
467
+ stale.push(finding.id);
468
+ continue;
469
+ }
470
+ const { status, line } = reanchor(content, finding.anchor, finding.line);
471
+ if (status === "stale") {
472
+ finding.execState =
473
+ finding.decision === "execute" && finding.execState === "pending"
474
+ ? "done"
475
+ : finding.execState === "done"
476
+ ? "done"
477
+ : "stale";
478
+ finding.updatedAt = now;
479
+ stale.push(finding.id);
480
+ continue;
481
+ }
482
+ if (status === "moved" && line !== null) {
483
+ finding.line = line;
484
+ if (finding.anchor) finding.anchor.line = line;
485
+ finding.updatedAt = now;
486
+ moved.push(finding.id);
487
+ continue;
488
+ }
489
+ ok++;
490
+ }
491
+
492
+ if (moved.length > 0 || stale.length > 0) state.updatedAt = now;
493
+ return { ok, moved, stale };
494
+ }
495
+
496
+ /** Nº de ocorrências da âncora na versão atual do arquivo (diagnóstico). */
497
+ export function occurrenceCount(
498
+ fileContent: string,
499
+ anchor: FindingAnchor | null,
500
+ ): number {
501
+ return locateAnchor(fileContent, anchor).occurrences;
502
+ }
503
+
504
+ // ---------------------------------------------------------------------------
505
+ // Store (I/O atômico)
506
+ // ---------------------------------------------------------------------------
507
+
508
+ /** Persistência do `state.json` — escrita atômica (tmp + rename). */
509
+ export class FindingsStateStore {
510
+ private readonly path: string;
511
+ private readonly branch: string;
512
+
513
+ constructor(path: string, branch: string) {
514
+ this.path = path;
515
+ this.branch = branch;
516
+ }
517
+
518
+ /** Store para a run de uma branch. */
519
+ static at(artifactDir: string, branch: string): FindingsStateStore {
520
+ return new FindingsStateStore(stateFile(artifactDir, branch), branch);
521
+ }
522
+
523
+ get filePath(): string {
524
+ return this.path;
525
+ }
526
+
527
+ /** Lê o estado; null se ausente/inválido. */
528
+ read(): FindingsStateFile | null {
529
+ if (!existsSync(this.path)) return null;
530
+ try {
531
+ const raw = JSON.parse(readFileSync(this.path, "utf8")) as FindingsStateFile;
532
+ if (raw?.version !== FINDINGS_STATE_VERSION || !raw.findings) return null;
533
+ return raw;
534
+ } catch {
535
+ return null;
536
+ }
537
+ }
538
+
539
+ /** Grava o estado de forma atômica. */
540
+ write(state: FindingsStateFile): void {
541
+ mkdirSync(dirname(this.path), { recursive: true });
542
+ const tmp = `${this.path}.${process.pid}.tmp`;
543
+ writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`);
544
+ renameSync(tmp, this.path);
545
+ }
546
+
547
+ /** Lê-modifica-grava; parte de um estado vazio se não existir. */
548
+ update(mutator: (state: FindingsStateFile) => void): FindingsStateFile {
549
+ const state = this.read() ?? emptyState(this.branch);
550
+ mutator(state);
551
+ state.updatedAt = new Date().toISOString();
552
+ reorder(state);
553
+ this.write(state);
554
+ return state;
555
+ }
556
+ }