@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
@@ -27,7 +27,6 @@ import {
27
27
  type FindingsFilter,
28
28
  type FindingsStateFile,
29
29
  FindingsStateStore,
30
- type FindingsSummary,
31
30
  type FindingUpdate,
32
31
  fileReader,
33
32
  filterFindings,
@@ -35,7 +34,6 @@ import {
35
34
  summarize,
36
35
  syncState,
37
36
  } from "../findings-state";
38
- import { buildFixBatch, type FixBatchResult } from "../fix-batch";
39
37
  import {
40
38
  buildSubmitPreview,
41
39
  defaultGhRunner,
@@ -47,11 +45,13 @@ import {
47
45
  } from "../github-review";
48
46
  import { relFromRoot, VERIFY_FILE } from "../paths";
49
47
  import { currentBranch } from "../pr-context";
48
+ import { BatchPresenter } from "../renderers/batch-presenter";
49
+ import { FindingsPresenter } from "../renderers/findings-presenter";
50
+ import { VerifyPresenter } from "../renderers/verify-presenter";
50
51
  import { pingFindingsStatus } from "../run-status";
51
- import { SEVERITY_EMOJI, SEVERITY_ORDER, type Severity } from "../types";
52
- // Tipos são apagados no transpile (custo zero). Os **valores** de `../verify` e de
53
- // `../jev` (axios) entram por import dinâmico — só o subcomando `verify` os usa.
54
- import type { VerifyEntry, VerifyStatus, VerifySummary } from "../verify";
52
+ import { FixBatchBuilder } from "../services/fix-batch-builder";
53
+ import { Verifier } from "../services/verifier";
54
+ import { SEVERITY_ORDER, type Severity } from "../types";
55
55
 
56
56
  export type FindingsCommandResult = {
57
57
  output: string;
@@ -134,90 +134,16 @@ function loadState(config: HoundsmanConfig, parsed: ParsedArgs): FindingsStateFi
134
134
  }
135
135
 
136
136
  // ---------------------------------------------------------------------------
137
- // Render
137
+ // Apresentação
138
138
  // ---------------------------------------------------------------------------
139
139
 
140
- function decisionLabel(decision: FindingDecision): string {
141
- return decision;
142
- }
143
-
144
- /** Uma linha compacta por finding (uso no agente). */
145
- function renderFindingLine(finding: FindingState): string[] {
146
- const emoji = SEVERITY_EMOJI[finding.severity];
147
- const location = finding.line ? `${finding.file}:${finding.line}` : finding.file;
148
- const flags = `${decisionLabel(finding.decision)}/${finding.execState}`;
149
- const count = finding.count > 1 ? ` ×${finding.count}` : "";
150
- const lines = [
151
- `- \`${finding.id}\` ${emoji} **${finding.severity}** \`${location}\` — ${finding.rule}${count}`,
152
- ` ${finding.message}`,
153
- ` _${flags}_ · watchdogs: ${finding.watchdogIds.join(", ") || "—"}`,
154
- ];
155
- if (finding.comment) lines.push(` **Comentário:** ${finding.comment}`);
156
- return lines;
157
- }
158
-
159
- function renderList(state: FindingsStateFile, findings: FindingState[]): string {
160
- if (findings.length === 0) return "Nenhum finding para o filtro informado.";
161
- const lines = [`# Findings — ${state.branch} (${findings.length})`, ""];
162
- for (const finding of findings) lines.push(...renderFindingLine(finding));
163
- return lines.join("\n");
164
- }
165
-
166
- function renderSummary(state: FindingsStateFile, summary: FindingsSummary): string {
167
- const decisions = FINDING_DECISIONS.map((d) => `${d} ${summary.byDecision[d]}`).join(
168
- " · ",
169
- );
170
- const exec = FINDING_EXEC_STATES.map((e) => `${e} ${summary.byExecState[e]}`).join(
171
- " · ",
172
- );
173
- const severities = SEVERITY_ORDER.map((s) => `${s} ${summary.bySeverity[s]}`).join(
174
- " · ",
175
- );
176
- return [
177
- `# Triagem — ${state.branch}`,
178
- "",
179
- `Total: ${summary.total} (presentes: ${summary.present})`,
180
- `Decisões: ${decisions}`,
181
- `Execução: ${exec}`,
182
- `Severidade: ${severities}`,
183
- ].join("\n");
184
- }
185
-
186
- /** Mix de severidade compacto (ex.: `❌1 ⚠️2`). */
187
- function severityBreakdown(bySeverity: Record<Severity, number>): string {
188
- return SEVERITY_ORDER.filter((severity) => bySeverity[severity] > 0)
189
- .map((severity) => `${SEVERITY_EMOJI[severity]}${bySeverity[severity]}`)
190
- .join(" ");
191
- }
192
-
193
- /** Render do lote de correção: grupo atual paginado + outros grupos. */
194
- function renderBatch(result: FixBatchResult): string {
195
- const { dimension, current, others, totals } = result;
196
- if (totals.queue === 0) return "Nenhum finding pendente de correção.";
140
+ const presenters = {
141
+ findings: new FindingsPresenter(),
142
+ batch: new BatchPresenter(),
143
+ verify: new VerifyPresenter(),
144
+ };
197
145
 
198
- const lines = [
199
- `# Lote de correção — agrupado por ${dimension} (${totals.groups} grupo(s))`,
200
- "",
201
- `## Grupo atual — ${current.key} (${current.total} finding(s), mostrando ${current.returned})`,
202
- ];
203
- for (const finding of current.findings) lines.push(...renderFindingLine(finding));
204
- if (current.remaining > 0) {
205
- lines.push("", `_… +${current.remaining} neste grupo (use \`--offset\`)._`);
206
- }
207
- if (others.length > 0) {
208
- lines.push("", "## Outros grupos disponíveis");
209
- for (const group of others) {
210
- lines.push(
211
- `- ${group.key} — ${group.count} (${severityBreakdown(group.bySeverity)})`,
212
- );
213
- }
214
- }
215
- lines.push(
216
- "",
217
- `fila: ${totals.queue} · retornados: ${totals.returned} · grupos: ${totals.groups}`,
218
- );
219
- return lines.join("\n");
220
- }
146
+ const fixBatchBuilder = new FixBatchBuilder();
221
147
 
222
148
  // ---------------------------------------------------------------------------
223
149
  // Execução
@@ -332,7 +258,7 @@ function execute(config: HoundsmanConfig, parsed: ParsedArgs): FindingsCommandRe
332
258
  return {
333
259
  output: parsed.bools.has("--json")
334
260
  ? JSON.stringify(findings, null, 2)
335
- : renderList(state, findings),
261
+ : presenters.findings.list(state, findings),
336
262
  exitCode: 0,
337
263
  };
338
264
  }
@@ -345,7 +271,7 @@ function execute(config: HoundsmanConfig, parsed: ParsedArgs): FindingsCommandRe
345
271
  return {
346
272
  output: parsed.bools.has("--json")
347
273
  ? JSON.stringify(finding, null, 2)
348
- : renderList(state, [finding]),
274
+ : presenters.findings.list(state, [finding]),
349
275
  exitCode: 0,
350
276
  };
351
277
  }
@@ -417,7 +343,7 @@ function execute(config: HoundsmanConfig, parsed: ParsedArgs): FindingsCommandRe
417
343
  return {
418
344
  output: parsed.bools.has("--json")
419
345
  ? JSON.stringify(summary, null, 2)
420
- : renderSummary(state, summary),
346
+ : presenters.findings.summary(state, summary),
421
347
  exitCode: 0,
422
348
  };
423
349
  }
@@ -430,7 +356,7 @@ function execute(config: HoundsmanConfig, parsed: ParsedArgs): FindingsCommandRe
430
356
  const state = loadState(config, parsed);
431
357
  const severity = parsed.values["--severity"];
432
358
  const offset = Number(parsed.values["--offset"]);
433
- const result = buildFixBatch(state, {
359
+ const result = fixBatchBuilder.build(state, {
434
360
  severity: severity ? (severity as Severity) : undefined,
435
361
  includeDone: parsed.bools.has("--include-done"),
436
362
  offset: Number.isFinite(offset) ? offset : undefined,
@@ -439,7 +365,7 @@ function execute(config: HoundsmanConfig, parsed: ParsedArgs): FindingsCommandRe
439
365
  return {
440
366
  output: parsed.bools.has("--json")
441
367
  ? JSON.stringify(result, null, 2)
442
- : renderBatch(result),
368
+ : presenters.batch.render(result),
443
369
  exitCode: 0,
444
370
  };
445
371
  }
@@ -479,43 +405,6 @@ function requireIds(parsed: ParsedArgs, sub: string): string[] {
479
405
  // verify — as violações foram resolvidas? (sem re-revisar a PR)
480
406
  // ---------------------------------------------------------------------------
481
407
 
482
- /** Rótulo curto de cada status de verificação. */
483
- const VERIFY_LABEL: Record<VerifyStatus, string> = {
484
- resolved: "✅ resolvido",
485
- persisting: "❌ persiste",
486
- uncertain: "⚠️ incerto",
487
- "file-missing": "🗑️ arquivo ausente",
488
- };
489
-
490
- /** Render da verificação: contagem por status + uma linha por finding. */
491
- function renderVerify(
492
- state: FindingsStateFile,
493
- entries: VerifyEntry[],
494
- summary: VerifySummary,
495
- meta: { jevChecked: number; jevReason?: string },
496
- ): string {
497
- if (entries.length === 0) return "Nenhum finding para verificar.";
498
- const lines = [
499
- `# Verificação — ${state.branch} (${entries.length})`,
500
- "",
501
- `Resolvidos: ${summary.resolved} · Persistem: ${summary.persisting} · Incertos: ${summary.uncertain} · Arquivo ausente: ${summary["file-missing"]}`,
502
- ];
503
- if (meta.jevChecked > 0) lines.push(`Confirmados por Jev: ${meta.jevChecked}.`);
504
- else if (meta.jevReason) lines.push(`Jev: pulado (${meta.jevReason}).`);
505
- lines.push("");
506
- for (const entry of entries) {
507
- const location = entry.line ? `${entry.file}:${entry.line}` : entry.file;
508
- const via =
509
- entry.basis === "jev" ? ` · Jev ${(entry.confidence ?? 0).toFixed(2)}` : "";
510
- lines.push(
511
- `- \`${entry.id}\` ${VERIFY_LABEL[entry.status]} — **${entry.severity}** \`${location}\` — ${entry.rule}${via}`,
512
- ` ${entry.message}`,
513
- );
514
- if (entry.detail) lines.push(` _${entry.detail}_`);
515
- }
516
- return lines.join("\n");
517
- }
518
-
519
408
  /**
520
409
  * Subcomando `findings verify` — checa se as violações foram resolvidas, sem
521
410
  * re-revisar a PR. Tier local (grátis) + confirmação Jev dos casos ambíguos
@@ -544,28 +433,26 @@ async function executeVerify(
544
433
  const store = FindingsStateStore.at(dir, branch);
545
434
  const state = loadState(config, parsed);
546
435
 
547
- // Lazy: `../verify` (leve) e `../jev` (axios) só entram no caminho do verify.
548
- const [
549
- { selectVerifiable, verifyLocal, summarizeVerify, applyJev, applyResolutions },
550
- { JevCandidatesStore, JevClient, JevVerifier },
551
- ] = await Promise.all([import("../verify"), import("../jev")]);
436
+ // Lazy: `../jev` (axios) só entra no caminho do verify.
437
+ const verifier = new Verifier();
438
+ const { JevCandidatesStore, JevClient, JevVerifier } = await import("../jev");
552
439
 
553
- const findings = selectVerifiable(state, filterFrom(parsed));
554
- const entries = verifyLocal(findings, fileReader(process.cwd()));
440
+ const findings = verifier.select(state, filterFrom(parsed));
441
+ const entries = verifier.verifyLocal(findings, fileReader(process.cwd()));
555
442
 
556
443
  // Tier 1 — Jev só nos ambíguos (fail-open: sem chave/erro mantém o provisório).
557
444
  let jevChecked = 0;
558
445
  let jevReason: string | undefined;
559
446
  const ambiguous = entries.filter((entry) => entry.ambiguous);
560
447
  if (ambiguous.length > 0) {
561
- const verifier = new JevVerifier(new JevClient());
448
+ const jevVerifier = new JevVerifier(new JevClient());
562
449
  if (parsed.bools.has("--no-jev")) {
563
450
  jevReason = "desativado (--no-jev)";
564
- } else if (!verifier.isConfigured()) {
451
+ } else if (!jevVerifier.isConfigured()) {
565
452
  jevReason = "TYPESAFE_API_KEY não configurada";
566
453
  } else {
567
454
  const byId = new Map(findings.map((finding) => [finding.id, finding]));
568
- const outcome = await verifier.verify(
455
+ const outcome = await jevVerifier.verify(
569
456
  ambiguous.map((entry) => ({
570
457
  id: entry.id,
571
458
  file: entry.file,
@@ -577,23 +464,23 @@ async function executeVerify(
577
464
  concurrency: resolveJevConcurrency(config),
578
465
  },
579
466
  );
580
- applyJev(entries, outcome.confidence, JevCandidatesStore.THRESHOLD);
467
+ verifier.applyJev(entries, outcome.confidence, JevCandidatesStore.THRESHOLD);
581
468
  jevChecked = outcome.checked;
582
469
  jevReason = outcome.reason;
583
470
  }
584
471
  }
585
472
 
586
- const summary = summarizeVerify(entries);
473
+ const summary = verifier.summarize(entries);
587
474
  const reportPath = join(runDir(dir, branch), VERIFY_FILE);
588
475
  writeFileSync(
589
476
  reportPath,
590
- `${renderVerify(state, entries, summary, { jevChecked, jevReason })}\n`,
477
+ `${presenters.verify.render(state, entries, summary, { jevChecked, jevReason })}\n`,
591
478
  );
592
479
 
593
480
  let applied: string[] = [];
594
481
  if (parsed.bools.has("--apply")) {
595
482
  store.update((current) => {
596
- applied = applyResolutions(current, entries);
483
+ applied = verifier.applyResolutions(current, entries);
597
484
  });
598
485
  pingStatus(config, parsed);
599
486
  }
@@ -605,7 +492,10 @@ async function executeVerify(
605
492
  2,
606
493
  )
607
494
  : [
608
- renderVerify(state, entries, summary, { jevChecked, jevReason }),
495
+ presenters.verify.render(state, entries, summary, {
496
+ jevChecked,
497
+ jevReason,
498
+ }),
609
499
  "",
610
500
  `Relatório: ${relFromRoot(reportPath)}`,
611
501
  parsed.bools.has("--apply")
@@ -4,49 +4,32 @@
4
4
  * Biblioteca: NÃO usa `process.argv`/`process.exit` (o CLI decide o exit code).
5
5
  */
6
6
 
7
+ import { ArgParser } from "../arg-parser";
7
8
  import { defaultOptions, type ReviewOptions } from "../orchestrator";
8
9
  import { ARTIFACT_DIR } from "../paths";
9
10
 
10
- const VALUE_FLAGS = new Set(["--base", "--head", "--branch", "--dir"]);
11
- const BOOLEAN_FLAGS = new Set([
12
- "--finalize",
13
- "--report-only",
14
- "--fail-on-blocking",
15
- "--clean",
16
- "--no-jev",
17
- "--full",
18
- ]);
19
-
20
- function applyValue(opts: ReviewOptions, flag: string, value: string | undefined): void {
21
- if (!value) return;
22
- if (flag === "--base") opts.base = value;
23
- else if (flag === "--head") opts.head = value;
24
- else if (flag === "--branch") opts.branch = value;
25
- else if (flag === "--dir") opts.dir = value;
26
- }
27
-
28
- function applyBoolean(opts: ReviewOptions, flag: string): void {
29
- if (flag === "--finalize" || flag === "--report-only") opts.finalize = true;
30
- else if (flag === "--fail-on-blocking") opts.failOnBlocking = true;
31
- else if (flag === "--clean") opts.clean = true;
32
- else if (flag === "--no-jev") opts.noJev = true;
33
- else if (flag === "--full") opts.full = true;
34
- }
35
-
36
11
  /** Converte argv (sem o subcomando) nas opções de prepare/finalize. */
37
12
  export function parseReviewArgs(
38
13
  args: string[],
39
14
  artifactDir: string = ARTIFACT_DIR,
40
15
  ): ReviewOptions {
16
+ const parser = ArgParser.from(args);
41
17
  const opts = defaultOptions(artifactDir);
42
- for (let i = 0; i < args.length; i++) {
43
- const arg = args[i];
44
- if (VALUE_FLAGS.has(arg)) {
45
- applyValue(opts, arg, args[i + 1]);
46
- i++;
47
- } else if (BOOLEAN_FLAGS.has(arg)) {
48
- applyBoolean(opts, arg);
49
- }
50
- }
18
+
19
+ const base = parser.value("--base");
20
+ const head = parser.value("--head");
21
+ const branch = parser.value("--branch");
22
+ const dir = parser.value("--dir");
23
+ if (base) opts.base = base;
24
+ if (head) opts.head = head;
25
+ if (branch) opts.branch = branch;
26
+ if (dir) opts.dir = dir;
27
+
28
+ if (parser.has("--finalize") || parser.has("--report-only")) opts.finalize = true;
29
+ if (parser.has("--fail-on-blocking")) opts.failOnBlocking = true;
30
+ if (parser.has("--clean")) opts.clean = true;
31
+ if (parser.has("--no-jev")) opts.noJev = true;
32
+ if (parser.has("--full")) opts.full = true;
33
+
51
34
  return opts;
52
35
  }
@@ -280,83 +280,193 @@ core/
280
280
  `;
281
281
 
282
282
  // ---------------------------------------------------------------------------
283
- // Escrita
283
+ // Templates (arquivo → conteúdo)
284
284
  // ---------------------------------------------------------------------------
285
285
 
286
- /** Escreve um arquivo do usuário somente se ausente (ou se `force`). */
287
- function writeUserFile(
288
- ctx: ScaffoldContext,
289
- relativePath: string,
290
- content: string,
291
- ): void {
292
- const full = join(ctx.cwd, relativePath);
293
- if (existsSync(full) && !ctx.force) {
294
- ctx.skipped.push(relativePath);
295
- return;
286
+ /** Contexto de render de um template. */
287
+ type TemplateContext = { projectName: string };
288
+
289
+ /** Um arquivo da camada (caminho relativo + conteúdo). */
290
+ interface ScaffoldTemplate {
291
+ readonly path: string;
292
+ render(context: TemplateContext): string;
293
+ }
294
+
295
+ class ConfigTemplate implements ScaffoldTemplate {
296
+ readonly path = "houndsman.config.ts";
297
+
298
+ render(context: TemplateContext): string {
299
+ return CONFIG_TEMPLATE(context.projectName);
296
300
  }
297
- mkdirSync(join(full, ".."), { recursive: true });
298
- writeFileSync(full, content);
299
- ctx.created.push(relativePath);
300
301
  }
301
302
 
302
- /** Escreve/sobrescreve um arquivo auto-gerado. */
303
- function writeGeneratedFile(
304
- ctx: ScaffoldContext,
305
- relativePath: string,
306
- content: string,
307
- ): void {
308
- const full = join(ctx.cwd, relativePath);
309
- mkdirSync(join(full, ".."), { recursive: true });
310
- writeFileSync(full, content);
311
- ctx.generated.push(relativePath);
303
+ class WatchdogExampleTemplate implements ScaffoldTemplate {
304
+ readonly path = join(PROJECT_DIR, "watchdogs", "example.ts");
305
+
306
+ render(): string {
307
+ return WATCHDOG_EXAMPLE;
308
+ }
312
309
  }
313
310
 
314
- // ---------------------------------------------------------------------------
315
- // Camadas
316
- // ---------------------------------------------------------------------------
311
+ class WatchdogsIndexTemplate implements ScaffoldTemplate {
312
+ readonly path = join(PROJECT_DIR, "watchdogs", "index.ts");
317
313
 
318
- /** Arquivos do usuário: config, watchdogs, exclusões e mapeamentos. */
319
- export function writeUserLayer(ctx: ScaffoldContext): void {
320
- writeUserFile(ctx, "houndsman.config.ts", CONFIG_TEMPLATE(ctx.projectName));
321
- writeUserFile(ctx, join(PROJECT_DIR, "watchdogs", "example.ts"), WATCHDOG_EXAMPLE);
322
- writeUserFile(ctx, join(PROJECT_DIR, "watchdogs", "index.ts"), WATCHDOGS_INDEX);
323
- writeUserFile(ctx, join(PROJECT_DIR, "exclusions.ts"), EXCLUSIONS_TEMPLATE);
324
- writeUserFile(ctx, join(PROJECT_DIR, "doc-mappings.ts"), DOC_MAPPINGS_TEMPLATE);
314
+ render(): string {
315
+ return WATCHDOGS_INDEX;
316
+ }
325
317
  }
326
318
 
327
- /**
328
- * Arquivos auto-gerados: tipos, docs do skill, `.gitignore` (ignora `core/` e
329
- * `.runs/`) e o `SKILL.md` de cada harness. Sempre sobrescritos (é o alvo do
330
- * `update`).
331
- */
332
- export function syncGeneratedFiles(ctx: ScaffoldContext): void {
333
- writeGeneratedFile(ctx, join(CORE_DIR, "types.ts"), TYPES_TEMPLATE);
334
- writeGeneratedFile(ctx, join(PROJECT_DIR, ".gitignore"), PROJECT_GITIGNORE);
335
-
336
- // Docs do skill vão para `.houndsman/core/skill/`. O `SKILL.md` é o entry point
337
- // dos harnesses (escrito em `writeHarnessPointers`), não um doc da camada.
338
- for (const name of readdirSync(SKILL_DIR)) {
339
- if (!name.endsWith(".md") || name === SKILL_FILE) continue;
340
- const raw = readFileSync(join(SKILL_DIR, name), "utf8");
341
- writeGeneratedFile(
342
- ctx,
343
- join(CORE_SKILL_DIR, name),
344
- raw.replaceAll("{{projectName}}", ctx.projectName),
319
+ class ExclusionsTemplate implements ScaffoldTemplate {
320
+ readonly path = join(PROJECT_DIR, "exclusions.ts");
321
+
322
+ render(): string {
323
+ return EXCLUSIONS_TEMPLATE;
324
+ }
325
+ }
326
+
327
+ class DocMappingsTemplate implements ScaffoldTemplate {
328
+ readonly path = join(PROJECT_DIR, "doc-mappings.ts");
329
+
330
+ render(): string {
331
+ return DOC_MAPPINGS_TEMPLATE;
332
+ }
333
+ }
334
+
335
+ class TypesTemplate implements ScaffoldTemplate {
336
+ readonly path = join(CORE_DIR, "types.ts");
337
+
338
+ render(): string {
339
+ return TYPES_TEMPLATE;
340
+ }
341
+ }
342
+
343
+ class GitignoreTemplate implements ScaffoldTemplate {
344
+ readonly path = join(PROJECT_DIR, ".gitignore");
345
+
346
+ render(): string {
347
+ return PROJECT_GITIGNORE;
348
+ }
349
+ }
350
+
351
+ class SkillDocTemplate implements ScaffoldTemplate {
352
+ private readonly fileName: string;
353
+
354
+ constructor(fileName: string) {
355
+ this.fileName = fileName;
356
+ }
357
+
358
+ get path(): string {
359
+ return join(CORE_SKILL_DIR, this.fileName);
360
+ }
361
+
362
+ render(context: TemplateContext): string {
363
+ return readFileSync(join(SKILL_DIR, this.fileName), "utf8").replaceAll(
364
+ "{{projectName}}",
365
+ context.projectName,
345
366
  );
346
367
  }
368
+ }
369
+
370
+ class HarnessPointerTemplate implements ScaffoldTemplate {
371
+ private readonly harness: Harness;
347
372
 
348
- for (const harness of ctx.harnesses) writeHarnessPointers(ctx, harness);
373
+ constructor(harness: Harness) {
374
+ this.harness = harness;
375
+ }
376
+
377
+ get path(): string {
378
+ return join(`.${this.harness}`, "skills", "houndsman", SKILL_FILE);
379
+ }
380
+
381
+ render(context: TemplateContext): string {
382
+ return readFileSync(join(SKILL_DIR, SKILL_FILE), "utf8").replaceAll(
383
+ "{{projectName}}",
384
+ context.projectName,
385
+ );
386
+ }
349
387
  }
350
388
 
351
- /**
352
- * Escreve o `SKILL.md` do harness a partir do asset base (`src/skill/SKILL.md`),
353
- * resolvendo `{{projectName}}`. O texto do skill vive no asset — não aqui.
354
- */
355
- function writeHarnessPointers(ctx: ScaffoldContext, harness: Harness): void {
356
- const raw = readFileSync(join(SKILL_DIR, SKILL_FILE), "utf8");
357
- writeGeneratedFile(
358
- ctx,
359
- join(`.${harness}`, "skills", "houndsman", SKILL_FILE),
360
- raw.replaceAll("{{projectName}}", ctx.projectName),
361
- );
389
+ // ---------------------------------------------------------------------------
390
+ // Scaffolder
391
+ // ---------------------------------------------------------------------------
392
+
393
+ /** Escreve as camadas do projeto (`setup`) e sincroniza os auto-gerados (`update`). */
394
+ export class ProjectScaffolder {
395
+ private readonly ctx: ScaffoldContext;
396
+
397
+ constructor(ctx: ScaffoldContext) {
398
+ this.ctx = ctx;
399
+ }
400
+
401
+ /** Arquivos do usuário: config, watchdogs, exclusões e mapeamentos. */
402
+ writeUserLayer(): void {
403
+ const context = this.context();
404
+ const templates: ScaffoldTemplate[] = [
405
+ new ConfigTemplate(),
406
+ new WatchdogExampleTemplate(),
407
+ new WatchdogsIndexTemplate(),
408
+ new ExclusionsTemplate(),
409
+ new DocMappingsTemplate(),
410
+ ];
411
+ for (const template of templates) this.writeUserFile(template, context);
412
+ }
413
+
414
+ /** Arquivos auto-gerados (sempre sobrescritos). */
415
+ syncGenerated(): void {
416
+ const context = this.context();
417
+ this.writeGeneratedFile(new TypesTemplate(), context);
418
+ this.writeGeneratedFile(new GitignoreTemplate(), context);
419
+
420
+ // Docs do skill vão para `.houndsman/core/skill/`. O `SKILL.md` é o entry
421
+ // point dos harnesses, não um doc da camada.
422
+ for (const name of readdirSync(SKILL_DIR)) {
423
+ if (!name.endsWith(".md") || name === SKILL_FILE) continue;
424
+ this.writeGeneratedFile(new SkillDocTemplate(name), context);
425
+ }
426
+
427
+ for (const harness of this.ctx.harnesses) {
428
+ this.writeGeneratedFile(new HarnessPointerTemplate(harness), context);
429
+ }
430
+ }
431
+
432
+ private context(): TemplateContext {
433
+ return { projectName: this.ctx.projectName };
434
+ }
435
+
436
+ /** Usuário: só escreve se ausente (ou `force`). */
437
+ private writeUserFile(template: ScaffoldTemplate, context: TemplateContext): void {
438
+ const full = join(this.ctx.cwd, template.path);
439
+ if (existsSync(full) && !this.ctx.force) {
440
+ this.ctx.skipped.push(template.path);
441
+ return;
442
+ }
443
+ mkdirSync(join(full, ".."), { recursive: true });
444
+ writeFileSync(full, template.render(context));
445
+ this.ctx.created.push(template.path);
446
+ }
447
+
448
+ /** Auto-gerado: sempre sobrescreve. */
449
+ private writeGeneratedFile(
450
+ template: ScaffoldTemplate,
451
+ context: TemplateContext,
452
+ ): void {
453
+ const full = join(this.ctx.cwd, template.path);
454
+ mkdirSync(join(full, ".."), { recursive: true });
455
+ writeFileSync(full, template.render(context));
456
+ this.ctx.generated.push(template.path);
457
+ }
458
+ }
459
+
460
+ // ---------------------------------------------------------------------------
461
+ // Fachada (compatibilidade)
462
+ // ---------------------------------------------------------------------------
463
+
464
+ /** Arquivos do usuário (setup). */
465
+ export function writeUserLayer(ctx: ScaffoldContext): void {
466
+ new ProjectScaffolder(ctx).writeUserLayer();
467
+ }
468
+
469
+ /** Arquivos auto-gerados (setup/update). */
470
+ export function syncGeneratedFiles(ctx: ScaffoldContext): void {
471
+ new ProjectScaffolder(ctx).syncGenerated();
362
472
  }
@@ -49,27 +49,32 @@ function ensurePackageScript(ctx: ScaffoldContext): void {
49
49
  ctx.created.push("package.json#scripts.houndsman");
50
50
  }
51
51
 
52
- /** Cria a configuração inicial do projeto. */
53
- export async function runSetupCommand(options: SetupOptions): Promise<void> {
52
+ /** Cria a configuração inicial do projeto. Devolve as mensagens do CLI. */
53
+ export async function runSetupCommand(options: SetupOptions): Promise<string[]> {
54
54
  const ctx = createScaffoldContext(
55
55
  options.cwd,
56
56
  options.args,
57
57
  options.args.includes("--force"),
58
58
  );
59
59
 
60
- console.log(`→ Configurando houndsman em ${ctx.cwd} (projeto: ${ctx.projectName})`);
60
+ const messages = [
61
+ `→ Configurando houndsman em ${ctx.cwd} (projeto: ${ctx.projectName})`,
62
+ ];
61
63
 
62
64
  writeUserLayer(ctx);
63
65
  syncGeneratedFiles(ctx);
64
66
  ensurePackageScript(ctx);
65
67
 
66
- for (const path of ctx.created) console.log(` + ${path}`);
67
- for (const path of ctx.skipped) console.log(` = ${path} (mantido)`);
68
- for (const path of ctx.generated) console.log(` ~ ${path} (auto)`);
68
+ for (const path of ctx.created) messages.push(` + ${path}`);
69
+ for (const path of ctx.skipped) messages.push(` = ${path} (mantido)`);
70
+ for (const path of ctx.generated) messages.push(` ~ ${path} (auto)`);
69
71
 
70
- console.log("\nPróximos passos:");
71
- console.log(" 1. Ajuste `houndsman.config.ts` e `.houndsman/watchdogs/`.");
72
- console.log(" 2. Valide: bun run houndsman doctor");
73
- console.log(" 3. Prepare a revisão: bun run houndsman");
74
- console.log(" · Sincronizar arquivos auto-gerados: bun run houndsman update");
72
+ messages.push(
73
+ "\nPróximos passos:",
74
+ " 1. Ajuste `houndsman.config.ts` e `.houndsman/watchdogs/`.",
75
+ " 2. Valide: bun run houndsman doctor",
76
+ " 3. Prepare a revisão: bun run houndsman",
77
+ " · Sincronizar arquivos auto-gerados: bun run houndsman update",
78
+ );
79
+ return messages;
75
80
  }