@payconductor/houndsman 0.2.0 → 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 (68) 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 +58 -266
  15. package/src/core/arg-parser.ts +48 -0
  16. package/src/core/commands/findings.ts +34 -144
  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 +170 -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 +4 -33
  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/index.ts +64 -234
  64. package/src/server/run-repository.ts +291 -0
  65. package/src/tests/__snapshots__/baseline.test.ts.snap +814 -0
  66. package/src/tests/baseline.test.ts +513 -493
  67. package/src/tests/domain.test.ts +328 -0
  68. package/src/tests/infrastructure.test.ts +210 -0
@@ -1,18 +1,70 @@
1
1
  /**
2
- * Acesso tipado aos watchdogs de um `HoundsmanConfig` (biblioteca).
2
+ * Registry de watchdogs (biblioteca) — acesso tipado ao `HoundsmanConfig`.
3
3
  *
4
- * O motor não conhece ids concretos — tudo chega via `config`.
4
+ * O motor não conhece ids concretos: o registry resolve definições, contexto e
5
+ * casamento de arquivos por domínio. As funções antigas continuam como
6
+ * adaptadores para a API pública.
5
7
  */
6
8
 
7
9
  import type { HoundsmanConfig } from "./config";
10
+ import { Watchdog, type WatchdogContext } from "./domain/watchdog";
8
11
  import type { WatchdogDefinition } from "./types";
9
12
 
13
+ /** Coleção tipada de watchdogs do projeto. */
14
+ export class WatchdogRegistry<TId extends string = string> {
15
+ private readonly config: HoundsmanConfig<TId>;
16
+
17
+ constructor(config: HoundsmanConfig<TId>) {
18
+ this.config = config;
19
+ }
20
+
21
+ /** Ids na ordem de declaração. */
22
+ get ids(): readonly TId[] {
23
+ return this.config.watchdogIds;
24
+ }
25
+
26
+ /** Definição de um watchdog (id string em runtime). */
27
+ get(watchdogId: string): WatchdogDefinition {
28
+ return this.config.watchdogs[watchdogId as TId];
29
+ }
30
+
31
+ /** Busca null-safe por id. */
32
+ find(watchdogId: string): Watchdog | null {
33
+ const definition = this.config.watchdogs[watchdogId as TId];
34
+ return definition ? new Watchdog(definition) : null;
35
+ }
36
+
37
+ /** Nome legível (default: o próprio id). */
38
+ nameOf(watchdogId: string): string {
39
+ return this.find(watchdogId)?.name ?? watchdogId;
40
+ }
41
+
42
+ /** Contexto essencial (sem regras). */
43
+ context(watchdogId: string): WatchdogContext {
44
+ return new Watchdog(this.get(watchdogId)).context();
45
+ }
46
+
47
+ /** Ids dos watchdogs que cobrem o arquivo. */
48
+ matching(filePath: string, content?: string): TId[] {
49
+ return this.config.watchdogIds.filter((id) =>
50
+ new Watchdog(this.config.watchdogs[id]).matches(filePath, content),
51
+ );
52
+ }
53
+
54
+ /** Todos os watchdogs (na ordem de declaração). */
55
+ all(): Watchdog[] {
56
+ return this.config.watchdogIds.map(
57
+ (id) => new Watchdog(this.config.watchdogs[id]),
58
+ );
59
+ }
60
+ }
61
+
10
62
  /** Acesso tipado a um watchdog do projeto (id string em runtime). */
11
63
  export function getWatchdog<TId extends string>(
12
64
  config: HoundsmanConfig<TId>,
13
65
  watchdogId: string,
14
66
  ): WatchdogDefinition {
15
- return config.watchdogs[watchdogId as TId];
67
+ return new WatchdogRegistry(config).get(watchdogId);
16
68
  }
17
69
 
18
70
  /** Busca null-safe por id string (runtime). */
@@ -20,23 +72,13 @@ export function findWatchdog<TId extends string>(
20
72
  config: HoundsmanConfig<TId>,
21
73
  watchdogId: string,
22
74
  ): WatchdogDefinition | null {
23
- return config.watchdogs[watchdogId as TId] ?? null;
75
+ return new WatchdogRegistry(config).find(watchdogId)?.definition ?? null;
24
76
  }
25
77
 
26
78
  /** Contexto "essencial" do watchdog — SEM regras (docs/UI/inspeção). */
27
79
  export function getWatchdogContext<TId extends string>(
28
80
  config: HoundsmanConfig<TId>,
29
81
  watchdogId: string,
30
- ) {
31
- const { id, name, description, scope, reviewFocus, exclude, contextFiles } =
32
- getWatchdog(config, watchdogId);
33
- return {
34
- id,
35
- name,
36
- description,
37
- scope,
38
- reviewFocus,
39
- exclude,
40
- contextFiles,
41
- };
82
+ ): WatchdogContext {
83
+ return new WatchdogRegistry(config).context(watchdogId);
42
84
  }
package/src/index.ts CHANGED
@@ -5,10 +5,12 @@
5
5
  * (`houndsman.config.ts`): watchdogs, tipos e helpers de autoria.
6
6
  */
7
7
 
8
+ export { ArgParser } from "./core/arg-parser";
8
9
  export { assetPath, OUTPUT_FORMAT_FILE } from "./core/assets";
9
10
  export { defaultRenderBrief, PROCEDURE, renderBrief } from "./core/brief";
10
11
  export type { FindingsCommandResult } from "./core/commands/findings";
11
12
  export { runFindingsCommand, runVerifyCommand } from "./core/commands/findings";
13
+ export { ProjectScaffolder } from "./core/commands/scaffold";
12
14
  export type {
13
15
  HoundsmanBudgets,
14
16
  HoundsmanConcurrency,
@@ -36,6 +38,14 @@ export {
36
38
  parseDiffIndex,
37
39
  parseNumstat,
38
40
  } from "./core/diff-source";
41
+ export { DocDiscoverer } from "./core/doc-discoverer";
42
+ export { Classification } from "./core/domain/classification";
43
+ export { Diff } from "./core/domain/diff";
44
+ export { Watchdog } from "./core/domain/watchdog";
45
+ export { buildCarryover, mergeCarryover } from "./core/engine/carryover";
46
+ export { HoundsmanEngine } from "./core/engine/houndsman-engine";
47
+ export { ReviewScopeResolver } from "./core/engine/review-scope";
48
+ export { ReviewSession } from "./core/engine/review-session";
39
49
  export {
40
50
  churnOf,
41
51
  classifyEntries,
@@ -81,6 +91,9 @@ export {
81
91
  FINDING_DECISIONS,
82
92
  FINDING_EXEC_STATES,
83
93
  FINDINGS_STATE_VERSION,
94
+ Finding,
95
+ FindingsRepository,
96
+ FindingsState,
84
97
  FindingsStateStore,
85
98
  fileReader,
86
99
  filterFindings,
@@ -91,6 +104,7 @@ export {
91
104
  summarize,
92
105
  syncState,
93
106
  } from "./core/findings-state";
107
+ export type { GitResult, GitRunner } from "./core/git-runner";
94
108
  export {
95
109
  ARTIFACT_DIR,
96
110
  CORE_DIR,
@@ -100,9 +114,23 @@ export {
100
114
  relFromRoot,
101
115
  STATE_FILE,
102
116
  } from "./core/paths";
117
+ export { ProjectConfig } from "./core/project-config";
103
118
  export { checkRegistry } from "./core/registry-check";
119
+ export { BriefRenderer } from "./core/renderers/brief-renderer";
120
+ export { ManifestWriter } from "./core/renderers/manifest-writer";
121
+ export { ReportRenderer } from "./core/renderers/report-renderer";
104
122
  export type { DomainReport } from "./core/report-builder";
105
123
  export { renderDomainReport } from "./core/report-builder";
124
+ export { RunLayout } from "./core/run-layout";
125
+ export type { Clock } from "./core/services/clock";
126
+ export { systemClock } from "./core/services/clock";
127
+ export { FileClassifier } from "./core/services/file-classifier";
128
+ export { FixBatchBuilder } from "./core/services/fix-batch-builder";
129
+ export type { GhResult, GhRunner } from "./core/services/gh-client";
130
+ export { GhClient } from "./core/services/gh-client";
131
+ export { GitRepository } from "./core/services/git-repository";
132
+ export { ProjectFiles } from "./core/services/project-files";
133
+ export { Verifier } from "./core/services/verifier";
106
134
  export type {
107
135
  ChurnMap,
108
136
  ClassifyRule,
@@ -127,6 +155,8 @@ export {
127
155
  findWatchdog,
128
156
  getWatchdog,
129
157
  getWatchdogContext,
158
+ WatchdogRegistry,
130
159
  } from "./core/watchdog-registry";
131
160
  export type { ServerOptions } from "./server";
132
- export { createRequestHandler, startServer } from "./server";
161
+ export { createRequestHandler, HoundsmanServer, startServer } from "./server";
162
+ export { RunRepository } from "./server/run-repository";
@@ -6,41 +6,16 @@
6
6
  * Bind padrão em `127.0.0.1` — ferramenta local, sem autenticação.
7
7
  */
8
8
 
9
- import { join, resolve } from "node:path";
10
9
  import { mediaPath } from "../core/assets";
11
- import { type HoundsmanConfig, resolveArtifactDir } from "../core/config";
10
+ import type { HoundsmanConfig } from "../core/config";
11
+ import type { FindingUpdate } from "../core/findings-state";
12
12
  import {
13
- applyFindingUpdate,
14
- buildStateFromRunDir,
15
- emptyState,
16
- type FindingDecision,
17
- type FindingExecState,
18
- type FindingState,
19
- type FindingsFilter,
20
- type FindingsStateFile,
21
- FindingsStateStore,
22
- type FindingsSummary,
23
- type FindingUpdate,
24
- fileReader,
25
- filterFindings,
26
- reorder,
27
- type SyncResult,
28
- summarize,
29
- syncState,
30
- } from "../core/findings-state";
31
- import {
32
- buildSubmitPreview,
33
13
  defaultGhRunner,
34
14
  type GhRunner,
35
- resolvePrTarget,
36
15
  type SubmitSelection,
37
- submitFindings,
38
16
  } from "../core/github-review";
39
- import { STATE_FILE } from "../core/paths";
40
- import { pingFindingsStatus, type RunStatus, readRunStatus } from "../core/run-status";
41
- import type { Severity } from "../core/types";
42
- import { listRunSlugs, readRunFile, readRunJson, runDirForSlug } from "./artifacts";
43
17
  import { buildClientBundle } from "./client-build";
18
+ import { RunRepository } from "./run-repository";
44
19
 
45
20
  type BunServer = ReturnType<typeof Bun.serve>;
46
21
 
@@ -77,180 +52,8 @@ function notFound(detail = "não encontrado"): Response {
77
52
  // Estado / dados
78
53
  // ---------------------------------------------------------------------------
79
54
 
80
- /** Item da lista de execuções (sidebar) — já ordenado do mais recente ao antigo. */
81
- type RunListItem = {
82
- slug: string;
83
- branch: string;
84
- updatedAt?: string;
85
- status: RunStatus | null;
86
- summary: FindingsSummary;
87
- };
88
-
89
- function buildForRun(dir: string, slug: string): FindingsStateFile | null {
90
- const manifest = readRunJson<{ branch?: string }>(dir, "manifest.json");
91
- return buildStateFromRunDir(dir, manifest?.branch ?? slug, fileReader(process.cwd()));
92
- }
93
-
94
- /**
95
- * Lê o estado da run. Se ausente, reconstrói dos artefatos e PERSISTE — os ids
96
- * são aleatórios, então precisam ser estáveis entre requisições.
97
- */
98
- function loadState(dir: string, slug: string): FindingsStateFile {
99
- const store = new FindingsStateStore(join(dir, STATE_FILE), slug);
100
- const existing = store.read();
101
- if (existing) return existing;
102
- const built = buildForRun(dir, slug);
103
- if (built) {
104
- store.write(built);
105
- return built;
106
- }
107
- return emptyState(slug);
108
- }
109
-
110
- function listRuns(artifactDir: string): RunListItem[] {
111
- return (
112
- listRunSlugs(artifactDir)
113
- .map((slug) => {
114
- const dir = join(artifactDir, slug);
115
- const state = loadState(dir, slug);
116
- const manifest = readRunJson<{ branch?: string; generatedAt?: string }>(
117
- dir,
118
- "manifest.json",
119
- );
120
- const status = readRunStatus(dir);
121
- return {
122
- slug,
123
- branch: state.branch || manifest?.branch || slug,
124
- updatedAt: status?.updatedAt ?? state.updatedAt,
125
- status: status?.status ?? null,
126
- summary: summarize(state),
127
- };
128
- })
129
- // Mais recentes primeiro (ISO 8601 compara lexicograficamente).
130
- .sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""))
131
- );
132
- }
133
-
134
- /**
135
- * Finding + caminho absoluto. O cliente monta o link `vscode://file/...` e não
136
- * conhece o cwd do servidor — o caminho relativo do relatório não basta.
137
- */
138
- function toClientFinding(finding: FindingState): FindingState & { absFile: string } {
139
- return { ...finding, absFile: resolve(process.cwd(), finding.file) };
140
- }
141
-
142
- function runDetail(dir: string, slug: string): unknown {
143
- const state = loadState(dir, slug);
144
- const classification = readRunJson<{
145
- watchdogs?: Record<string, string[]>;
146
- unclassified?: string[];
147
- deleted?: string[];
148
- }>(dir, "classification.json");
149
- return {
150
- slug,
151
- branch: state.branch || slug,
152
- status: readRunStatus(dir),
153
- manifest: readRunJson(dir, "manifest.json"),
154
- summary: summarize(state),
155
- watchdogs: Object.keys(classification?.watchdogs ?? {}),
156
- unclassified: classification?.unclassified ?? [],
157
- deleted: classification?.deleted ?? [],
158
- };
159
- }
160
-
161
- function findingsFor(dir: string, slug: string, params: URLSearchParams): unknown {
162
- const state = loadState(dir, slug);
163
- const filter: FindingsFilter = {
164
- decision: (params.get("decision") as FindingDecision) || undefined,
165
- execState: (params.get("exec") as FindingExecState) || undefined,
166
- severity: (params.get("severity") as Severity) || undefined,
167
- watchdog: params.get("watchdog") || undefined,
168
- file: params.get("file") || undefined,
169
- includeAbsent: params.get("all") === "1",
170
- };
171
- return {
172
- branch: state.branch,
173
- findings: filterFindings(state, filter).map(toClientFinding),
174
- summary: summarize(state),
175
- };
176
- }
177
-
178
- async function updateFinding(
179
- dir: string,
180
- slug: string,
181
- id: string,
182
- request: Request,
183
- ): Promise<Response> {
184
- const body = (await request.json().catch(() => ({}))) as FindingUpdate;
185
- const store = new FindingsStateStore(join(dir, STATE_FILE), slug);
186
- const state = store.read() ?? buildForRun(dir, slug) ?? emptyState(slug);
187
- const finding = state.findings[id];
188
- if (!finding) return notFound(`finding '${id}' não encontrado`);
189
- applyFindingUpdate(finding, body);
190
- reorder(state);
191
- store.write(state);
192
- pingFindingsStatus(dir, state);
193
- return json({ finding: toClientFinding(finding) });
194
- }
195
-
196
- function syncRun(dir: string, slug: string): Response {
197
- const store = new FindingsStateStore(join(dir, STATE_FILE), slug);
198
- const state = store.read() ?? buildForRun(dir, slug) ?? emptyState(slug);
199
- const result: SyncResult = syncState(state, fileReader(process.cwd()));
200
- store.write(state);
201
- pingFindingsStatus(dir, state);
202
- return json({ result, summary: summarize(state) });
203
- }
204
-
205
- /** Prévia do envio da revisão: candidatos + disponibilidade da PR (via gh). */
206
- function submitPreview(dir: string, slug: string, ghRunner: GhRunner): Response {
207
- const state = loadState(dir, slug);
208
- const target = resolvePrTarget(ghRunner, state.branch || slug);
209
- return json(buildSubmitPreview(state, target));
210
- }
211
-
212
- /**
213
- * Envia os findings selecionados como comentários na PR. Fail-open: sem PR/gh
214
- * devolve `{ available:false, reason }` (a UI só informa). Persiste o vínculo
215
- * (`finding.github`) no `state.json` e atualiza o status da run.
216
- */
217
- async function submitRun(
218
- dir: string,
219
- slug: string,
220
- ghRunner: GhRunner,
221
- request: Request,
222
- ): Promise<Response> {
223
- const state = loadState(dir, slug);
224
- const body = (await request.json().catch(() => ({}))) as SubmitSelection;
225
- const selection: SubmitSelection = {
226
- severities: body.severities,
227
- execStates: body.execStates,
228
- includeSent: body.includeSent,
229
- };
230
-
231
- const target = resolvePrTarget(ghRunner, state.branch || slug);
232
- if (!target.ok) return json({ available: false, reason: target.reason });
233
-
234
- const outcome = submitFindings({
235
- state,
236
- target: target.target,
237
- runner: ghRunner,
238
- selection,
239
- });
240
- new FindingsStateStore(join(dir, STATE_FILE), slug).write(state);
241
- pingFindingsStatus(dir, state);
242
- return json({ available: true, target: target.target, outcome });
243
- }
244
-
245
- function serveArtifact(dir: string, name: string): Response {
246
- if (!/\.(json|md)$/i.test(name)) return notFound("tipo de artefato não servido");
247
- const content = readRunFile(dir, name);
248
- if (content === null) return notFound(`artefato '${name}' não encontrado`);
249
- const type = name.toLowerCase().endsWith(".json")
250
- ? "application/json; charset=utf-8"
251
- : "text/markdown; charset=utf-8";
252
- return text(content, type);
253
- }
55
+ /** Repositório das runs (dados) — ver `run-repository.ts`. */
56
+ type ServerDeps = { ghRunner?: GhRunner };
254
57
 
255
58
  // ---------------------------------------------------------------------------
256
59
  // Rotas
@@ -270,7 +73,7 @@ async function serveLogo(): Promise<Response> {
270
73
 
271
74
  async function route(
272
75
  request: Request,
273
- artifactDir: string,
76
+ runs: RunRepository,
274
77
  ghRunner: GhRunner,
275
78
  ): Promise<Response> {
276
79
  const url = new URL(request.url);
@@ -286,65 +89,77 @@ async function route(
286
89
  if (path === "/api/health") return json({ ok: true });
287
90
 
288
91
  if (path === "/api/runs" && request.method === "GET") {
289
- return json({ runs: listRuns(artifactDir) });
92
+ return json({ runs: runs.list() });
290
93
  }
291
94
 
292
95
  const match = path.match(API_RUN);
293
96
  if (!match) return notFound();
294
97
  const slug = decodeURIComponent(match[1]);
295
98
  const rest = match[2] ?? "";
296
- const dir = runDirForSlug(artifactDir, slug);
297
- if (!dir) return notFound(`run '${slug}' não encontrada`);
99
+ if (!runs.exists(slug)) return notFound(`run '${slug}' não encontrada`);
298
100
 
299
- if (rest === "" || rest === "/") return json(runDetail(dir, slug));
101
+ if (rest === "" || rest === "/") return json(runs.detail(slug));
300
102
 
301
103
  if (rest === "/findings" && request.method === "GET") {
302
- return json(findingsFor(dir, slug, url.searchParams));
104
+ return json(runs.findings(slug, url.searchParams));
303
105
  }
304
- if (rest === "/sync" && request.method === "POST") return syncRun(dir, slug);
106
+ if (rest === "/sync" && request.method === "POST") return json(runs.sync(slug));
305
107
  if (rest === "/submit-preview" && request.method === "GET") {
306
- return submitPreview(dir, slug, ghRunner);
108
+ return json(runs.submitPreview(slug, ghRunner));
307
109
  }
308
110
  if (rest === "/submit" && request.method === "POST") {
309
- return submitRun(dir, slug, ghRunner, request);
111
+ const body = (await request.json().catch(() => ({}))) as SubmitSelection;
112
+ const selection: SubmitSelection = {
113
+ severities: body.severities,
114
+ execStates: body.execStates,
115
+ includeSent: body.includeSent,
116
+ };
117
+ return json(runs.submit(slug, selection, ghRunner));
310
118
  }
311
119
 
312
120
  const findingMatch = rest.match(/^\/findings\/([^/]+)$/);
313
121
  if (findingMatch) {
314
122
  const id = decodeURIComponent(findingMatch[1]);
315
123
  if (request.method === "GET") {
316
- const state = loadState(dir, slug);
317
- const finding = state.findings[id];
124
+ const finding = runs.finding(slug, id);
318
125
  return finding
319
- ? json({ finding: toClientFinding(finding) })
126
+ ? json({ finding })
320
127
  : notFound(`finding '${id}' não encontrado`);
321
128
  }
322
129
  if (request.method === "PATCH" || request.method === "POST") {
323
- return updateFinding(dir, slug, id, request);
130
+ const body = (await request.json().catch(() => ({}))) as FindingUpdate;
131
+ const finding = runs.update(slug, id, body);
132
+ return finding
133
+ ? json({ finding })
134
+ : notFound(`finding '${id}' não encontrado`);
324
135
  }
325
136
  }
326
137
 
327
138
  const artifactMatch = rest.match(/^\/artifacts\/(.+)$/);
328
139
  if (artifactMatch && request.method === "GET") {
329
- return serveArtifact(dir, decodeURIComponent(artifactMatch[1]));
140
+ const name = decodeURIComponent(artifactMatch[1]);
141
+ const artifact = runs.artifact(slug, name);
142
+ if (!artifact) return notFound(`artefato '${name}' não encontrado`);
143
+ return text(artifact.content, artifact.type);
330
144
  }
331
145
 
332
146
  return notFound();
333
147
  }
334
148
 
335
- /** Dependências injetáveis do servidor (testes). */
336
- export type ServerDeps = { ghRunner?: GhRunner };
149
+ /** Servidor local do houndsman (rotas + shell). */
150
+ export class HoundsmanServer {
151
+ private readonly runs: RunRepository;
152
+ private readonly ghRunner: GhRunner;
337
153
 
338
- /** Cria o handler de requisições (testável sem abrir porta). */
339
- export function createRequestHandler(
340
- config: HoundsmanConfig,
341
- deps: ServerDeps = {},
342
- ): (request: Request) => Promise<Response> {
343
- const artifactDir = resolveArtifactDir(config);
344
- const ghRunner = deps.ghRunner ?? defaultGhRunner;
345
- return async (request) => {
154
+ constructor(config: HoundsmanConfig, deps: ServerDeps = {}) {
155
+ this.runs = RunRepository.fromConfig(config);
156
+ this.ghRunner = deps.ghRunner ?? defaultGhRunner;
157
+ }
158
+
159
+ /** Handler de requisições (testável sem abrir porta). */
160
+ readonly handler = async (request: Request): Promise<Response> => {
346
161
  try {
347
- return await route(request, artifactDir, ghRunner);
162
+ return await route(request, this.runs, this.ghRunner);
348
163
  } catch (error) {
349
164
  return json(
350
165
  { error: error instanceof Error ? error.message : String(error) },
@@ -352,6 +167,25 @@ export function createRequestHandler(
352
167
  );
353
168
  }
354
169
  };
170
+
171
+ /** Sobe o servidor e devolve a instância + URL. */
172
+ start(options: { port?: number; host?: string } = {}): {
173
+ server: BunServer;
174
+ url: string;
175
+ } {
176
+ const host = options.host ?? "127.0.0.1";
177
+ const port = options.port ?? 7331;
178
+ const server = Bun.serve({ hostname: host, port, fetch: this.handler });
179
+ return { server, url: `http://${host}:${server.port}` };
180
+ }
181
+ }
182
+
183
+ /** Cria o handler de requisições (testável sem abrir porta). */
184
+ export function createRequestHandler(
185
+ config: HoundsmanConfig,
186
+ deps: ServerDeps = {},
187
+ ): (request: Request) => Promise<Response> {
188
+ return new HoundsmanServer(config, deps).handler;
355
189
  }
356
190
 
357
191
  export type ServerOptions = {
@@ -364,14 +198,10 @@ export type ServerOptions = {
364
198
 
365
199
  /** Sobe o servidor e devolve a instância + URL. */
366
200
  export function startServer(options: ServerOptions): { server: BunServer; url: string } {
367
- const host = options.host ?? "127.0.0.1";
368
- const port = options.port ?? 7331;
369
- const server = Bun.serve({
370
- hostname: host,
371
- port,
372
- fetch: createRequestHandler(options.config, { ghRunner: options.ghRunner }),
201
+ return new HoundsmanServer(options.config, { ghRunner: options.ghRunner }).start({
202
+ port: options.port,
203
+ host: options.host,
373
204
  });
374
- return { server, url: `http://${host}:${server.port}` };
375
205
  }
376
206
 
377
207
  // ---------------------------------------------------------------------------