@payconductor/houndsman 0.1.4 → 0.3.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 (84) hide show
  1. package/package.json +1 -1
  2. package/src/cli/arg-parser.ts +8 -0
  3. package/src/cli/commands/command.ts +23 -0
  4. package/src/cli/commands/context-command.ts +38 -0
  5. package/src/cli/commands/doctor-command.ts +55 -0
  6. package/src/cli/commands/findings-command.ts +28 -0
  7. package/src/cli/commands/help-command.ts +13 -0
  8. package/src/cli/commands/registry.ts +34 -0
  9. package/src/cli/commands/review-command.ts +22 -0
  10. package/src/cli/commands/server-command.ts +37 -0
  11. package/src/cli/commands/setup-command.ts +34 -0
  12. package/src/cli/commands/watchdog-command.ts +34 -0
  13. package/src/cli/help.ts +43 -0
  14. package/src/cli/index.ts +62 -260
  15. package/src/core/arg-parser.ts +48 -0
  16. package/src/core/commands/findings.ts +163 -147
  17. package/src/core/commands/review.ts +18 -35
  18. package/src/core/commands/scaffold.ts +174 -64
  19. package/src/core/commands/setup.ts +16 -11
  20. package/src/core/commands/update.ts +7 -7
  21. package/src/core/commands/watchdog.ts +8 -80
  22. package/src/core/config.ts +9 -19
  23. package/src/core/defaults.ts +20 -0
  24. package/src/core/diff-source.ts +1 -13
  25. package/src/core/doc-discoverer.ts +25 -15
  26. package/src/core/domain/budget.ts +42 -0
  27. package/src/core/domain/classification.ts +94 -0
  28. package/src/core/domain/diff.ts +53 -0
  29. package/src/core/domain/watchdog.ts +96 -0
  30. package/src/core/engine/carryover.ts +67 -0
  31. package/src/core/engine/houndsman-engine.ts +51 -0
  32. package/src/core/engine/review-scope.ts +95 -0
  33. package/src/core/engine/review-session.ts +51 -0
  34. package/src/core/file-classifier.ts +11 -67
  35. package/src/core/findings-state.ts +189 -38
  36. package/src/core/git-revision.ts +14 -40
  37. package/src/core/git-runner.ts +46 -0
  38. package/src/core/github-review.ts +423 -0
  39. package/src/core/orchestrator.ts +116 -337
  40. package/src/core/paths.ts +9 -0
  41. package/src/core/pr-context.ts +28 -54
  42. package/src/core/project-config.ts +57 -0
  43. package/src/core/renderers/batch-presenter.ts +40 -0
  44. package/src/core/renderers/brief-renderer.ts +23 -0
  45. package/src/core/renderers/findings-presenter.ts +74 -0
  46. package/src/core/renderers/manifest-writer.ts +112 -0
  47. package/src/core/renderers/report-renderer.ts +66 -0
  48. package/src/core/renderers/verify-presenter.ts +50 -0
  49. package/src/core/renderers/watchdog-presenter.ts +100 -0
  50. package/src/core/report-builder.ts +12 -7
  51. package/src/core/run-layout.ts +108 -0
  52. package/src/core/run-status.ts +6 -11
  53. package/src/core/services/clock.ts +12 -0
  54. package/src/core/services/file-classifier.ts +34 -0
  55. package/src/core/services/fix-batch-builder.ts +13 -0
  56. package/src/core/services/gh-client.ts +54 -0
  57. package/src/core/services/git-repository.ts +123 -0
  58. package/src/core/services/project-files.ts +79 -0
  59. package/src/core/services/verifier.ts +58 -0
  60. package/src/core/verify.ts +2 -1
  61. package/src/core/watchdog-registry.ts +58 -16
  62. package/src/index.ts +31 -1
  63. package/src/server/client/App.tsx +174 -49
  64. package/src/server/client/ArtifactsPanel.tsx +2 -2
  65. package/src/server/client/AutoRefresh.tsx +38 -0
  66. package/src/server/client/Filters.tsx +72 -53
  67. package/src/server/client/FindingCard.tsx +212 -71
  68. package/src/server/client/FindingsList.tsx +8 -6
  69. package/src/server/client/RunList.tsx +30 -7
  70. package/src/server/client/SubmitModal.tsx +418 -0
  71. package/src/server/client/SummaryBar.tsx +65 -12
  72. package/src/server/client/constants.ts +9 -12
  73. package/src/server/client/format.ts +40 -0
  74. package/src/server/client/icons.tsx +143 -0
  75. package/src/server/client/types.ts +69 -0
  76. package/src/server/index.ts +316 -222
  77. package/src/server/run-repository.ts +291 -0
  78. package/src/tests/__snapshots__/baseline.test.ts.snap +814 -0
  79. package/src/tests/baseline.test.ts +513 -0
  80. package/src/tests/cli.test.ts +167 -0
  81. package/src/tests/domain.test.ts +328 -0
  82. package/src/tests/github-review.test.ts +320 -0
  83. package/src/tests/infrastructure.test.ts +210 -0
  84. package/src/tests/server.test.ts +117 -0
@@ -0,0 +1,291 @@
1
+ /**
2
+ * Repositório das runs (servidor) — leitura/escrita dos artefatos e estado.
3
+ *
4
+ * Concentra o acesso a disco usado pelas rotas: lista de runs, detalhe,
5
+ * findings, sync, envio à PR e artefatos. A validação de slug (path traversal)
6
+ * continua em `artifacts.ts`.
7
+ */
8
+
9
+ import { join, resolve } from "node:path";
10
+ import { type HoundsmanConfig, resolveArtifactDir } from "../core/config";
11
+ import {
12
+ applyFindingUpdate,
13
+ buildStateFromRunDir,
14
+ emptyState,
15
+ type FindingDecision,
16
+ type FindingExecState,
17
+ type FindingState,
18
+ type FindingsFilter,
19
+ FindingsRepository,
20
+ type FindingsStateFile,
21
+ type FindingsSummary,
22
+ type FindingUpdate,
23
+ fileReader,
24
+ filterFindings,
25
+ reorder,
26
+ type SyncResult,
27
+ summarize,
28
+ syncState,
29
+ } from "../core/findings-state";
30
+ import {
31
+ buildSubmitPreview,
32
+ type GhRunner,
33
+ resolvePrTarget,
34
+ type SubmitSelection,
35
+ submitFindings,
36
+ } from "../core/github-review";
37
+ import { STATE_FILE } from "../core/paths";
38
+ import {
39
+ pingFindingsStatus,
40
+ type RunStatus,
41
+ type RunStatusFile,
42
+ readRunStatus,
43
+ } from "../core/run-status";
44
+ import type { Severity } from "../core/types";
45
+ import { listRunSlugs, readRunFile, readRunJson, runDirForSlug } from "./artifacts";
46
+
47
+ /** Item da lista de execuções (sidebar) — já ordenado do mais recente ao antigo. */
48
+ export type RunListItem = {
49
+ slug: string;
50
+ branch: string;
51
+ updatedAt?: string;
52
+ status: RunStatus | null;
53
+ summary: FindingsSummary;
54
+ };
55
+
56
+ /** Detalhe de uma run (cabeçalho da UI). */
57
+ export type RunDetail = {
58
+ slug: string;
59
+ branch: string;
60
+ status: RunStatusFile | null;
61
+ manifest: unknown;
62
+ summary: FindingsSummary;
63
+ watchdogs: string[];
64
+ unclassified: string[];
65
+ deleted: string[];
66
+ };
67
+
68
+ /** Resultado do envio dos findings à PR. */
69
+ export type SubmitResult =
70
+ | { available: false; reason: string }
71
+ | { available: true; target: unknown; outcome: unknown };
72
+
73
+ /** Acesso às runs de um diretório de artefatos. */
74
+ export class RunRepository {
75
+ private readonly artifactDir: string;
76
+
77
+ constructor(artifactDir: string) {
78
+ this.artifactDir = artifactDir;
79
+ }
80
+
81
+ static fromConfig(config: HoundsmanConfig): RunRepository {
82
+ return new RunRepository(resolveArtifactDir(config));
83
+ }
84
+
85
+ /** `true` se o slug aponta para uma run válida. */
86
+ exists(slug: string): boolean {
87
+ return runDirForSlug(this.artifactDir, slug) !== null;
88
+ }
89
+
90
+ /** Lista as runs com resumo (mais recentes primeiro). */
91
+ list(): RunListItem[] {
92
+ return (
93
+ listRunSlugs(this.artifactDir)
94
+ .map((slug) => {
95
+ const dir = join(this.artifactDir, slug);
96
+ const state = this.loadState(dir, slug);
97
+ const manifest = readRunJson<{
98
+ branch?: string;
99
+ generatedAt?: string;
100
+ }>(dir, "manifest.json");
101
+ const status = readRunStatus(dir);
102
+ return {
103
+ slug,
104
+ branch: state.branch || manifest?.branch || slug,
105
+ updatedAt: status?.updatedAt ?? state.updatedAt,
106
+ status: status?.status ?? null,
107
+ summary: summarize(state),
108
+ };
109
+ })
110
+ // Mais recentes primeiro (ISO 8601 compara lexicograficamente).
111
+ .sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""))
112
+ );
113
+ }
114
+
115
+ /** Detalhe da run (null se o slug for inválido). */
116
+ detail(slug: string): RunDetail | null {
117
+ const dir = runDirForSlug(this.artifactDir, slug);
118
+ if (!dir) return null;
119
+ const state = this.loadState(dir, slug);
120
+ const classification = readRunJson<{
121
+ watchdogs?: Record<string, string[]>;
122
+ unclassified?: string[];
123
+ deleted?: string[];
124
+ }>(dir, "classification.json");
125
+ return {
126
+ slug,
127
+ branch: state.branch || slug,
128
+ status: readRunStatus(dir),
129
+ manifest: readRunJson(dir, "manifest.json"),
130
+ summary: summarize(state),
131
+ watchdogs: Object.keys(classification?.watchdogs ?? {}),
132
+ unclassified: classification?.unclassified ?? [],
133
+ deleted: classification?.deleted ?? [],
134
+ };
135
+ }
136
+
137
+ /** Findings filtrados + resumo (null se o slug for inválido). */
138
+ findings(
139
+ slug: string,
140
+ params: URLSearchParams,
141
+ ): {
142
+ branch: string;
143
+ findings: (FindingState & { absFile: string })[];
144
+ summary: FindingsSummary;
145
+ } | null {
146
+ const dir = runDirForSlug(this.artifactDir, slug);
147
+ if (!dir) return null;
148
+ const state = this.loadState(dir, slug);
149
+ const filter: FindingsFilter = {
150
+ decision: (params.get("decision") as FindingDecision) || undefined,
151
+ execState: (params.get("exec") as FindingExecState) || undefined,
152
+ severity: (params.get("severity") as Severity) || undefined,
153
+ watchdog: params.get("watchdog") || undefined,
154
+ file: params.get("file") || undefined,
155
+ includeAbsent: params.get("all") === "1",
156
+ };
157
+ return {
158
+ branch: state.branch,
159
+ findings: filterFindings(state, filter).map(toClientFinding),
160
+ summary: summarize(state),
161
+ };
162
+ }
163
+
164
+ /** Um finding (null se ausente/inválido). */
165
+ finding(slug: string, id: string): (FindingState & { absFile: string }) | null {
166
+ const dir = runDirForSlug(this.artifactDir, slug);
167
+ if (!dir) return null;
168
+ const finding = this.loadState(dir, slug).findings[id];
169
+ return finding ? toClientFinding(finding) : null;
170
+ }
171
+
172
+ /** Aplica uma atualização a um finding (null se ausente/inválido). */
173
+ update(
174
+ slug: string,
175
+ id: string,
176
+ body: FindingUpdate,
177
+ ): (FindingState & { absFile: string }) | null {
178
+ const dir = runDirForSlug(this.artifactDir, slug);
179
+ if (!dir) return null;
180
+ const store = this.store(dir, slug);
181
+ const state = store.read() ?? this.buildForRun(dir, slug) ?? emptyState(slug);
182
+ const finding = state.findings[id];
183
+ if (!finding) return null;
184
+ applyFindingUpdate(finding, body);
185
+ reorder(state);
186
+ store.write(state);
187
+ pingFindingsStatus(dir, state);
188
+ return toClientFinding(finding);
189
+ }
190
+
191
+ /** Reancora os findings contra os arquivos atuais. */
192
+ sync(slug: string): {
193
+ result: SyncResult;
194
+ summary: FindingsSummary;
195
+ } | null {
196
+ const dir = runDirForSlug(this.artifactDir, slug);
197
+ if (!dir) return null;
198
+ const store = this.store(dir, slug);
199
+ const state = store.read() ?? this.buildForRun(dir, slug) ?? emptyState(slug);
200
+ const result = syncState(state, fileReader(process.cwd()));
201
+ store.write(state);
202
+ pingFindingsStatus(dir, state);
203
+ return { result, summary: summarize(state) };
204
+ }
205
+
206
+ /** Prévia do envio da revisão (candidatos + disponibilidade da PR). */
207
+ submitPreview(slug: string, runner: GhRunner): unknown | null {
208
+ const dir = runDirForSlug(this.artifactDir, slug);
209
+ if (!dir) return null;
210
+ const state = this.loadState(dir, slug);
211
+ const target = resolvePrTarget(runner, state.branch || slug);
212
+ return buildSubmitPreview(state, target);
213
+ }
214
+
215
+ /** Envia os findings selecionados à PR (fail-open). */
216
+ submit(
217
+ slug: string,
218
+ selection: SubmitSelection,
219
+ runner: GhRunner,
220
+ ): SubmitResult | null {
221
+ const dir = runDirForSlug(this.artifactDir, slug);
222
+ if (!dir) return null;
223
+ const state = this.loadState(dir, slug);
224
+ const target = resolvePrTarget(runner, state.branch || slug);
225
+ if (!target.ok) return { available: false, reason: target.reason };
226
+
227
+ const outcome = submitFindings({
228
+ state,
229
+ target: target.target,
230
+ runner,
231
+ selection,
232
+ });
233
+ this.store(dir, slug).write(state);
234
+ pingFindingsStatus(dir, state);
235
+ return { available: true, target: target.target, outcome };
236
+ }
237
+
238
+ /** Conteúdo de um artefato (`json`/`md`) da run (null se não servível). */
239
+ artifact(slug: string, name: string): { content: string; type: string } | null {
240
+ if (!/\.(json|md)$/i.test(name)) return null;
241
+ const dir = runDirForSlug(this.artifactDir, slug);
242
+ if (!dir) return null;
243
+ const content = readRunFile(dir, name);
244
+ if (content === null) return null;
245
+ const type = name.toLowerCase().endsWith(".json")
246
+ ? "application/json; charset=utf-8"
247
+ : "text/markdown; charset=utf-8";
248
+ return { content, type };
249
+ }
250
+
251
+ // -----------------------------------------------------------------------
252
+ // Internos
253
+ // -----------------------------------------------------------------------
254
+
255
+ private store(dir: string, slug: string): FindingsRepository {
256
+ return new FindingsRepository(join(dir, STATE_FILE), slug);
257
+ }
258
+
259
+ private buildForRun(dir: string, slug: string): FindingsStateFile | null {
260
+ const manifest = readRunJson<{ branch?: string }>(dir, "manifest.json");
261
+ return buildStateFromRunDir(
262
+ dir,
263
+ manifest?.branch ?? slug,
264
+ fileReader(process.cwd()),
265
+ );
266
+ }
267
+
268
+ /**
269
+ * Lê o estado da run. Se ausente, reconstrói dos artefatos e PERSISTE — os ids
270
+ * são aleatórios, então precisam ser estáveis entre requisições.
271
+ */
272
+ private loadState(dir: string, slug: string): FindingsStateFile {
273
+ const store = this.store(dir, slug);
274
+ const existing = store.read();
275
+ if (existing) return existing;
276
+ const built = this.buildForRun(dir, slug);
277
+ if (built) {
278
+ store.write(built);
279
+ return built;
280
+ }
281
+ return emptyState(slug);
282
+ }
283
+ }
284
+
285
+ /**
286
+ * Finding + caminho absoluto. O cliente monta o link `vscode://file/...` e não
287
+ * conhece o cwd do servidor — o caminho relativo do relatório não basta.
288
+ */
289
+ function toClientFinding(finding: FindingState): FindingState & { absFile: string } {
290
+ return { ...finding, absFile: resolve(process.cwd(), finding.file) };
291
+ }