@payconductor/houndsman 0.3.0 → 0.4.0

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 (69) hide show
  1. package/README.md +18 -6
  2. package/package.json +1 -1
  3. package/src/cli/commands/context-command.ts +11 -9
  4. package/src/cli/commands/doctor-command.ts +5 -0
  5. package/src/cli/commands/findings-command.ts +13 -3
  6. package/src/cli/commands/review-command.ts +1 -2
  7. package/src/cli/help.ts +7 -3
  8. package/src/cli/index.ts +2 -2
  9. package/src/core/commands/context.ts +29 -13
  10. package/src/core/commands/findings.ts +155 -120
  11. package/src/core/commands/review.ts +7 -8
  12. package/src/core/commands/scaffold.ts +15 -16
  13. package/src/core/config.ts +16 -7
  14. package/src/core/engine/houndsman-engine.ts +15 -1
  15. package/src/core/engine/review-session.ts +37 -9
  16. package/src/core/findings-state.ts +32 -71
  17. package/src/core/jev/jev-candidates.ts +49 -35
  18. package/src/core/orchestrator.ts +175 -128
  19. package/src/core/paths.ts +23 -17
  20. package/src/core/pr-context.ts +19 -5
  21. package/src/core/project-config.ts +4 -5
  22. package/src/core/renderers/manifest-writer.ts +29 -18
  23. package/src/core/run-layout.ts +4 -104
  24. package/src/core/run-status.ts +28 -53
  25. package/src/core/services/git-repository.ts +16 -0
  26. package/src/core/services/project-files.ts +13 -1
  27. package/src/core/storage/factory.ts +24 -0
  28. package/src/core/storage/file/file-storage.ts +269 -0
  29. package/src/core/storage/paths.ts +64 -0
  30. package/src/core/storage/repo-identity.ts +70 -0
  31. package/src/core/storage/sqlite/mappers.ts +160 -0
  32. package/src/core/storage/sqlite/schema.ts +108 -0
  33. package/src/core/storage/sqlite/sqlite-storage.ts +427 -0
  34. package/src/core/storage/types.ts +143 -0
  35. package/src/core/storage/work-area.ts +191 -0
  36. package/src/index.ts +27 -6
  37. package/src/server/artifacts.ts +8 -61
  38. package/src/server/client/ArtifactsPanel.tsx +137 -23
  39. package/src/server/client/FindingCard.tsx +37 -6
  40. package/src/server/client/MarkdownText.tsx +101 -0
  41. package/src/server/client/SubmitModal.tsx +9 -4
  42. package/src/server/client/code-highlight.ts +422 -0
  43. package/src/server/client/constants.ts +130 -24
  44. package/src/server/client/json-highlight.ts +55 -0
  45. package/src/server/client/markdown.ts +153 -0
  46. package/src/server/client/types.ts +23 -0
  47. package/src/server/index.ts +95 -15
  48. package/src/server/run-repository.ts +144 -136
  49. package/src/skill/OUTPUT-FORMAT.md +6 -4
  50. package/src/skill/README.md +22 -14
  51. package/src/skill/SETUP.md +4 -4
  52. package/src/skill/fix.md +5 -5
  53. package/src/skill/hunt.md +27 -18
  54. package/src/skill/train.md +1 -1
  55. package/src/skill/verify.md +5 -5
  56. package/src/tests/__snapshots__/baseline.test.ts.snap +44 -33
  57. package/src/tests/baseline.test.ts +76 -34
  58. package/src/tests/cli.test.ts +40 -19
  59. package/src/tests/code-highlight.test.ts +123 -0
  60. package/src/tests/domain.test.ts +1 -1
  61. package/src/tests/findings-commands.test.ts +153 -131
  62. package/src/tests/findings-state.test.ts +6 -3
  63. package/src/tests/fixtures/fake-project.ts +0 -1
  64. package/src/tests/infrastructure.test.ts +33 -38
  65. package/src/tests/markdown.test.ts +89 -0
  66. package/src/tests/review-scripts.test.ts +8 -5
  67. package/src/tests/run-status.test.ts +33 -23
  68. package/src/tests/server.test.ts +108 -75
  69. package/src/tests/storage.test.ts +386 -0
@@ -13,6 +13,9 @@ import { FileClassifier } from "../services/file-classifier";
13
13
  import { FixBatchBuilder } from "../services/fix-batch-builder";
14
14
  import { GitRepository } from "../services/git-repository";
15
15
  import { Verifier } from "../services/verifier";
16
+ import { createStorage } from "../storage/factory";
17
+ import { resolveRepoIdentity } from "../storage/repo-identity";
18
+ import type { RepoIdentity, StorageProvider } from "../storage/types";
16
19
  import { ReviewSession } from "./review-session";
17
20
 
18
21
  /** Colaboradores injetáveis (testes/CLI). */
@@ -21,6 +24,8 @@ export type EngineDeps = {
21
24
  classifier?: FileClassifier;
22
25
  verifier?: Verifier;
23
26
  fixBatch?: FixBatchBuilder;
27
+ storage?: StorageProvider;
28
+ repo?: RepoIdentity;
24
29
  };
25
30
 
26
31
  /** Composition root do motor. */
@@ -30,6 +35,8 @@ export class HoundsmanEngine {
30
35
  readonly classifier: FileClassifier;
31
36
  readonly verifier: Verifier;
32
37
  readonly fixBatch: FixBatchBuilder;
38
+ readonly storage: StorageProvider;
39
+ readonly repo: RepoIdentity;
33
40
 
34
41
  constructor(raw: HoundsmanConfig, deps: EngineDeps = {}) {
35
42
  this.project = new ProjectConfig(raw);
@@ -37,6 +44,8 @@ export class HoundsmanEngine {
37
44
  this.classifier = deps.classifier ?? new FileClassifier(raw);
38
45
  this.verifier = deps.verifier ?? new Verifier();
39
46
  this.fixBatch = deps.fixBatch ?? new FixBatchBuilder();
47
+ this.storage = deps.storage ?? createStorage(raw);
48
+ this.repo = deps.repo ?? resolveRepoIdentity(this.git);
40
49
  }
41
50
 
42
51
  /** Configuração crua (contrato do consumidor). */
@@ -46,6 +55,11 @@ export class HoundsmanEngine {
46
55
 
47
56
  /** Abre uma sessão de revisão (prepare/finalize). */
48
57
  session(options: ReviewOptions): ReviewSession {
49
- return ReviewSession.from(this.config, options);
58
+ return ReviewSession.from(this.config, options, this.storage, this.repo);
59
+ }
60
+
61
+ /** Libera o provider (fecha o SQLite e faz checkpoint do WAL). */
62
+ close(): void {
63
+ this.storage.close();
50
64
  }
51
65
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Sessão de uma run (engine) — amarra opções, branch e layout.
2
+ * Sessão de uma run (engine) — amarra opções, branch, storage e staging.
3
3
  *
4
4
  * É o ponto de entrada do `prepare`/`finalize` para o CLI e para o engine.
5
5
  */
@@ -10,38 +10,66 @@ import {
10
10
  finalize,
11
11
  type PrepareResult,
12
12
  prepare,
13
+ type ReviewContext,
13
14
  type ReviewOptions,
14
15
  resolveBranch,
16
+ reviewContext,
15
17
  } from "../orchestrator";
16
- import { RunLayout } from "../run-layout";
18
+ import { sanitizeBranch } from "../report-builder";
19
+ import type { RepoIdentity, StorageProvider } from "../storage/types";
17
20
 
18
21
  /** Uma revisão em andamento (prepare ou finalize). */
19
22
  export class ReviewSession {
20
23
  readonly config: HoundsmanConfig;
21
24
  readonly options: ReviewOptions;
22
25
  readonly branch: string;
23
- readonly layout: RunLayout;
26
+ readonly context: ReviewContext;
24
27
 
25
- constructor(config: HoundsmanConfig, options: ReviewOptions, branch: string) {
28
+ constructor(
29
+ config: HoundsmanConfig,
30
+ options: ReviewOptions,
31
+ branch: string,
32
+ storage: StorageProvider,
33
+ repo: RepoIdentity,
34
+ ) {
26
35
  this.config = config;
27
36
  this.options = options;
28
37
  this.branch = branch;
29
- this.layout = new RunLayout(options.dir, branch);
38
+ const review = options.finalize
39
+ ? storage.findReview(repo.key, branch)
40
+ : storage.getOrCreateReview({
41
+ repo,
42
+ branch,
43
+ slug: sanitizeBranch(branch),
44
+ base: options.base,
45
+ head: options.head,
46
+ });
47
+ if (!review) {
48
+ throw new Error(
49
+ `Nenhuma revisão para '${branch}'. Rode \`houndsman\` (preparar) primeiro.`,
50
+ );
51
+ }
52
+ this.context = reviewContext(storage, review);
30
53
  }
31
54
 
32
55
  /** Resolve a branch (opção explícita ou a atual do git). */
33
- static from(config: HoundsmanConfig, options: ReviewOptions): ReviewSession {
34
- return new ReviewSession(config, options, resolveBranch(options));
56
+ static from(
57
+ config: HoundsmanConfig,
58
+ options: ReviewOptions,
59
+ storage: StorageProvider,
60
+ repo: RepoIdentity,
61
+ ): ReviewSession {
62
+ return new ReviewSession(config, options, resolveBranch(options), storage, repo);
35
63
  }
36
64
 
37
65
  /** Passo 1 — preparar. */
38
66
  prepare(): Promise<PrepareResult> {
39
- return prepare(this.options, this.branch, this.config, this.layout);
67
+ return prepare(this.options, this.branch, this.config, this.context);
40
68
  }
41
69
 
42
70
  /** Passo 3 — finalizar. */
43
71
  finalize(): FinalizeResult {
44
- return finalize(this.options, this.branch, this.config, this.layout);
72
+ return finalize(this.options, this.branch, this.config, this.context);
45
73
  }
46
74
 
47
75
  /** Executa o passo escolhido em `--finalize`. */
@@ -1,18 +1,16 @@
1
1
  /**
2
2
  * Estado de triagem dos findings (biblioteca).
3
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.
4
+ * O dump (`state.json`) é um detalhe do provider de arquivos; no SQLite o
5
+ * estado é reconstruído de `findings` + `finding_history`. Cada finding tem um
6
+ * `id` curto e um `fingerprint` de conteúdo: nas re-execuções do `--finalize`
7
+ * o estado é preservado casando por fingerprint.
8
8
  *
9
9
  * A `line` é cache — `syncState` a recalcula após edições (auto-fix) e marca
10
10
  * `stale` quando a âncora some.
11
11
  */
12
12
 
13
13
  import { randomUUID } from "node:crypto";
14
- import { existsSync, readFileSync } from "node:fs";
15
- import { join } from "node:path";
16
14
  import {
17
15
  buildAnchor,
18
16
  type FindingAnchor,
@@ -20,14 +18,7 @@ import {
20
18
  locateAnchor,
21
19
  reanchor,
22
20
  } from "./finding-anchor";
23
- import {
24
- dedupeFindings,
25
- type MergedFinding,
26
- parseWatchdogReport,
27
- sanitizeBranch,
28
- type WatchdogReport,
29
- } from "./report-builder";
30
- import { RunLayout } from "./run-layout";
21
+ import { type MergedFinding, sanitizeBranch } from "./report-builder";
31
22
  import { systemClock } from "./services/clock";
32
23
  import { type FileReader, ProjectFiles } from "./services/project-files";
33
24
  import { SEVERITY_ORDER, type Severity, severityRank } from "./types";
@@ -142,16 +133,6 @@ export const FINDINGS_STATE_VERSION = 2;
142
133
  // Caminhos
143
134
  // ---------------------------------------------------------------------------
144
135
 
145
- /** Diretório de artefatos da run (branch-slug). */
146
- export function runDir(artifactDir: string, branch: string): string {
147
- return new RunLayout(artifactDir, branch).outDir;
148
- }
149
-
150
- /** Caminho do `state.json` da run. */
151
- export function stateFile(artifactDir: string, branch: string): string {
152
- return new RunLayout(artifactDir, branch).stateFile;
153
- }
154
-
155
136
  /** Leitor padrão a partir do cwd (null se ausente/ilegível). */
156
137
  export function fileReader(cwd: string): FileReader {
157
138
  return new ProjectFiles(cwd).reader();
@@ -264,6 +245,32 @@ export function filterFindings(
264
245
  .sort(compareFindings);
265
246
  }
266
247
 
248
+ /** Resultado da resolução de um id (completo ou prefixo único). */
249
+ export type FindingIdResolution =
250
+ | { status: "found"; id: string }
251
+ | { status: "ambiguous"; matches: string[] }
252
+ | { status: "missing" };
253
+
254
+ /**
255
+ * Resolve um id de finding: exato primeiro, depois prefixo único.
256
+ * Ambíguo devolve os candidatos para a mensagem de erro.
257
+ */
258
+ export function resolveFindingId(
259
+ state: FindingsStateFile,
260
+ input: string,
261
+ ): FindingIdResolution {
262
+ const needle = input.trim();
263
+ if (needle.length === 0) return { status: "missing" };
264
+ if (state.findings[needle]) return { status: "found", id: needle };
265
+
266
+ const matches = Object.keys(state.findings).filter((id) =>
267
+ id.toLowerCase().startsWith(needle.toLowerCase()),
268
+ );
269
+ if (matches.length === 1) return { status: "found", id: matches[0] };
270
+ if (matches.length > 1) return { status: "ambiguous", matches };
271
+ return { status: "missing" };
272
+ }
273
+
267
274
  // ---------------------------------------------------------------------------
268
275
  // Construção (finalize)
269
276
  // ---------------------------------------------------------------------------
@@ -377,47 +384,6 @@ export function buildStateFromFindings(
377
384
  return state;
378
385
  }
379
386
 
380
- /**
381
- * Reconstrói o estado a partir dos artefatos da run (`classification.json` +
382
- * `findings/<id>.json`), sem exigir `--finalize`. Devolve null se não houver
383
- * findings gravados.
384
- */
385
- export function buildStateFromRunDir(
386
- outDir: string,
387
- branch: string,
388
- readFile: FileReader,
389
- ): FindingsStateFile | null {
390
- const classificationPath = join(outDir, "classification.json");
391
- if (!existsSync(classificationPath)) return null;
392
-
393
- let activeIds: string[] = [];
394
- try {
395
- const classification = JSON.parse(readFileSync(classificationPath, "utf8")) as {
396
- watchdogs?: Record<string, string[]>;
397
- };
398
- activeIds = Object.keys(classification.watchdogs ?? {});
399
- } catch {
400
- return null;
401
- }
402
-
403
- const reports: WatchdogReport[] = [];
404
- for (const id of activeIds) {
405
- const path = join(outDir, "findings", `${id}.json`);
406
- if (!existsSync(path)) continue;
407
- try {
408
- const report = parseWatchdogReport(
409
- JSON.parse(readFileSync(path, "utf8")),
410
- path,
411
- );
412
- if (report) reports.push(report);
413
- } catch {
414
- // Ignora findings ilegíveis — reconstrução best-effort.
415
- }
416
- }
417
- if (reports.length === 0) return null;
418
-
419
- return buildStateFromFindings(dedupeFindings(reports), { branch, readFile });
420
- }
421
387
  // ---------------------------------------------------------------------------
422
388
 
423
389
  export type FindingUpdate = {
@@ -574,7 +540,7 @@ export class Finding {
574
540
  }
575
541
  }
576
542
 
577
- /** Coleção de triagem de uma run — superfície OO sobre o `state.json`. */
543
+ /** Coleção de triagem de uma run — superfície OO sobre o dump persistido. */
578
544
  export class FindingsState {
579
545
  readonly file: FindingsStateFile;
580
546
 
@@ -671,11 +637,6 @@ export class FindingsRepository {
671
637
  this.branch = branch;
672
638
  }
673
639
 
674
- /** Repository para a run de uma branch. */
675
- static at(artifactDir: string, branch: string): FindingsRepository {
676
- return new FindingsRepository(stateFile(artifactDir, branch), branch);
677
- }
678
-
679
640
  get filePath(): string {
680
641
  return this.path;
681
642
  }
@@ -36,6 +36,50 @@ export type JevCandidatesFile = {
36
36
 
37
37
  const SEVERITY_SET = new Set<string>(SEVERITY_ORDER);
38
38
 
39
+ /** Serializa o artefato `candidates.json` (sempre com quebra de linha final). */
40
+ export function serializeCandidates(data: JevCandidatesFile): string {
41
+ return `${JSON.stringify(data, null, 2)}\n`;
42
+ }
43
+
44
+ /** Valida/parseia o JSON bruto do artefato; null se inválido. */
45
+ export function parseCandidates(
46
+ raw: unknown,
47
+ validIds: readonly string[] = [],
48
+ ): JevCandidatesFile | null {
49
+ if (typeof raw !== "object" || raw === null) return null;
50
+ const value = raw as Record<string, unknown>;
51
+ const idSet = validIds.length > 0 ? new Set(validIds) : null;
52
+ const isCandidate = (entry: unknown): entry is JevCandidate => {
53
+ if (typeof entry !== "object" || entry === null) return false;
54
+ const c = entry as Record<string, unknown>;
55
+ return (
56
+ typeof c.watchdogId === "string" &&
57
+ (idSet === null || idSet.has(c.watchdogId)) &&
58
+ typeof c.rule === "string" &&
59
+ typeof c.severity === "string" &&
60
+ SEVERITY_SET.has(c.severity) &&
61
+ typeof c.file === "string" &&
62
+ typeof c.confidence === "number" &&
63
+ typeof c.questionKey === "string"
64
+ );
65
+ };
66
+ const candidates = Array.isArray(value.candidates)
67
+ ? value.candidates.filter(isCandidate)
68
+ : [];
69
+ return {
70
+ available: value.available === true,
71
+ reason: typeof value.reason === "string" ? value.reason : undefined,
72
+ model: typeof value.model === "string" ? value.model : undefined,
73
+ threshold: typeof value.threshold === "number" ? value.threshold : undefined,
74
+ analyzedFiles:
75
+ typeof value.analyzedFiles === "number" ? value.analyzedFiles : undefined,
76
+ candidates,
77
+ skipped: Array.isArray(value.skipped)
78
+ ? (value.skipped as JevSkippedFile[])
79
+ : undefined,
80
+ };
81
+ }
82
+
39
83
  /** Persistência do artefato `candidates.json` para um diretório de review. */
40
84
  export class JevCandidatesStore {
41
85
  /** Limiar padrão de confiança (`noul`) para virar candidato. */
@@ -57,49 +101,19 @@ export class JevCandidatesStore {
57
101
 
58
102
  /** Grava o `candidates.json` (sempre com candidatos válidos apenas). */
59
103
  write(data: JevCandidatesFile): void {
60
- writeFileSync(this.filePath, `${JSON.stringify(data, null, 2)}\n`);
104
+ writeFileSync(this.filePath, serializeCandidates(data));
61
105
  }
62
106
 
63
107
  /** Lê o `candidates.json`; null se ausente/inválido. */
64
108
  read(): JevCandidatesFile | null {
65
109
  if (!existsSync(this.filePath)) return null;
66
110
  try {
67
- const raw = JSON.parse(readFileSync(this.filePath, "utf8")) as Record<
68
- string,
69
- unknown
70
- >;
71
- const candidates = Array.isArray(raw.candidates)
72
- ? raw.candidates.filter((c) => this.isCandidate(c))
73
- : [];
74
- return {
75
- available: raw.available === true,
76
- reason: typeof raw.reason === "string" ? raw.reason : undefined,
77
- model: typeof raw.model === "string" ? raw.model : undefined,
78
- threshold: typeof raw.threshold === "number" ? raw.threshold : undefined,
79
- analyzedFiles:
80
- typeof raw.analyzedFiles === "number" ? raw.analyzedFiles : undefined,
81
- candidates,
82
- skipped: Array.isArray(raw.skipped)
83
- ? (raw.skipped as JevSkippedFile[])
84
- : undefined,
85
- };
111
+ return parseCandidates(
112
+ JSON.parse(readFileSync(this.filePath, "utf8")),
113
+ this.watchdogIdSet ? [...this.watchdogIdSet] : [],
114
+ );
86
115
  } catch {
87
116
  return null;
88
117
  }
89
118
  }
90
-
91
- private isCandidate(value: unknown): value is JevCandidate {
92
- if (typeof value !== "object" || value === null) return false;
93
- const c = value as Record<string, unknown>;
94
- return (
95
- typeof c.watchdogId === "string" &&
96
- (this.watchdogIdSet === null || this.watchdogIdSet.has(c.watchdogId)) &&
97
- typeof c.rule === "string" &&
98
- typeof c.severity === "string" &&
99
- SEVERITY_SET.has(c.severity) &&
100
- typeof c.file === "string" &&
101
- typeof c.confidence === "number" &&
102
- typeof c.questionKey === "string"
103
- );
104
- }
105
119
  }