@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,513 @@
1
+ /**
2
+ * Baseline de comportamento (Fase 0 do refactor OO).
3
+ *
4
+ * Congela a saída observável do motor — briefs, relatórios, camada gerada e
5
+ * artefatos de `prepare`/`finalize` — para provar que o refactor não muda
6
+ * comportamento. Qualquer divergência nesses snapshots é uma regressão.
7
+ */
8
+
9
+ import { describe, expect, test } from "bun:test";
10
+ import { createHash } from "node:crypto";
11
+ import {
12
+ mkdirSync,
13
+ mkdtempSync,
14
+ readdirSync,
15
+ readFileSync,
16
+ rmSync,
17
+ statSync,
18
+ writeFileSync,
19
+ } from "node:fs";
20
+ import { tmpdir } from "node:os";
21
+ import { join, relative } from "node:path";
22
+ import { renderBrief } from "../core/brief";
23
+ import {
24
+ createScaffoldContext,
25
+ syncGeneratedFiles,
26
+ writeUserLayer,
27
+ } from "../core/commands/scaffold";
28
+ import { defineHoundsmanConfig } from "../core/config";
29
+ import { defaultOptions, finalize, prepare } from "../core/orchestrator";
30
+ import {
31
+ type MergedFinding,
32
+ renderDomainReport,
33
+ renderReport,
34
+ type WatchdogReport,
35
+ } from "../core/report-builder";
36
+ import { defineWatchdog } from "../core/types";
37
+ import * as facade from "../index";
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Config baseline (projeto fake, 1 watchdog para artefatos determinísticos)
41
+ // ---------------------------------------------------------------------------
42
+
43
+ const baselineWatchdog = defineWatchdog({
44
+ id: "alpha",
45
+ name: "Alpha",
46
+ description: "Watchdog baseline.",
47
+ scope: "src/alpha/**",
48
+ classify: [{ value: "^src/alpha/" }],
49
+ rules: [
50
+ { severity: "blocking", text: "Nunca usar `any`." },
51
+ { severity: "attention", text: "Promover: validar a entrada." },
52
+ ],
53
+ contextFiles: ["README.md"],
54
+ breakingChange: true,
55
+ });
56
+
57
+ const baselineConfig = defineHoundsmanConfig({
58
+ projectName: "Baseline",
59
+ artifactDir: ".houndsman/.runs",
60
+ watchdogs: { alpha: baselineWatchdog },
61
+ exclusions: [/^dist\//],
62
+ docMappings: [[/^src\//, ["README.md"]]],
63
+ alwaysIncludeDocs: ["README.md"],
64
+ });
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Helpers
68
+ // ---------------------------------------------------------------------------
69
+
70
+ /** Substitui valores voláteis (ids, timestamps, SHAs) por marcadores estáveis. */
71
+ function normalizeArtifacts(text: string): string {
72
+ return text
73
+ .replace(
74
+ /"(createdAt|updatedAt|generatedAt|startedAt|at)": "[^"]*"/g,
75
+ '"$1": "<timestamp>"',
76
+ )
77
+ .replace(/"\d{4}-\d{2}-\d{2}T[^"]*"/g, '"<timestamp>"')
78
+ .replace(/\b[0-9a-f]{40}\b/g, "<sha>")
79
+ .replace(/\b[0-9a-f]{16}\b/g, "<fingerprint>")
80
+ .replace(/\b[0-9a-f]{10}\b/g, "<id>")
81
+ .replace(/\d{4}-\d{2}-\d{2}/g, "<date>");
82
+ }
83
+
84
+ /** Lista recursiva de arquivos (caminhos relativos, ordenados). */
85
+ function listFiles(root: string): string[] {
86
+ const files: string[] = [];
87
+ const walk = (dir: string): void => {
88
+ for (const name of readdirSync(dir)) {
89
+ const full = join(dir, name);
90
+ if (statSync(full).isDirectory()) walk(full);
91
+ else files.push(relative(root, full).replace(/\\/g, "/"));
92
+ }
93
+ };
94
+ walk(root);
95
+ return files.sort();
96
+ }
97
+
98
+ /** Executa git no diretório e devolve stdout (lança em falha). */
99
+ function git(cwd: string, args: string[]): string {
100
+ const result = Bun.spawnSync(["git", ...args], {
101
+ cwd,
102
+ stdout: "pipe",
103
+ stderr: "pipe",
104
+ });
105
+ if (result.exitCode !== 0) {
106
+ throw new Error(`git ${args.join(" ")}: ${result.stderr.toString()}`);
107
+ }
108
+ return result.stdout.toString();
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // Brief
113
+ // ---------------------------------------------------------------------------
114
+
115
+ describe("baseline — brief", () => {
116
+ test("defaultRenderBrief (contexto, churn, overflow, candidatos e deletados)", () => {
117
+ const brief = renderBrief(baselineWatchdog, {
118
+ watchdogId: "alpha",
119
+ files: ["src/alpha/a.ts", "src/alpha/b.ts"],
120
+ statuses: {
121
+ "src/alpha/a.ts": { status: "modified" },
122
+ "src/alpha/b.ts": { status: "added" },
123
+ },
124
+ churn: {
125
+ "src/alpha/a.ts": { additions: 3, deletions: 1 },
126
+ "src/alpha/b.ts": { additions: 2, deletions: 0 },
127
+ },
128
+ overflow: ["src/alpha/c.ts"],
129
+ deleted: ["src/alpha/old.ts"],
130
+ branch: "feature/x",
131
+ outputPath: ".houndsman/.runs/feature-x/findings/alpha.json",
132
+ outputFormatPath: ".houndsman/core/skill/OUTPUT-FORMAT.md",
133
+ docs: ["AGENTS.md", "README.md"],
134
+ context: {
135
+ objective: "Entregar X",
136
+ motivation: "Reduzir Y",
137
+ changeSummary: ["Faz A", "Remove B"],
138
+ },
139
+ candidates: [
140
+ {
141
+ file: "src/alpha/a.ts",
142
+ severity: "blocking",
143
+ confidence: 0.82,
144
+ rule: "Nunca usar `any`.",
145
+ },
146
+ ],
147
+ });
148
+
149
+ expect(brief).toMatchSnapshot();
150
+ });
151
+ });
152
+
153
+ // ---------------------------------------------------------------------------
154
+ // Relatório
155
+ // ---------------------------------------------------------------------------
156
+
157
+ const reports: WatchdogReport[] = [
158
+ {
159
+ watchdogId: "alpha",
160
+ reviewedFiles: ["src/alpha/a.ts", "src/alpha/b.ts"],
161
+ skipped: ["src/alpha/c.ts"],
162
+ findings: [
163
+ {
164
+ rule: "Nunca usar `any`.",
165
+ severity: "blocking",
166
+ file: "src/alpha/a.ts",
167
+ line: 1,
168
+ message: "any detectado",
169
+ action: "tipar",
170
+ evidence: "export const a: any = 1;",
171
+ id: "id-alpha",
172
+ },
173
+ {
174
+ rule: "Promover: validar a entrada.",
175
+ severity: "attention",
176
+ file: "src/alpha/b.ts",
177
+ line: 2,
178
+ message: "sem validação",
179
+ action: "adicionar schema",
180
+ id: "id-beta",
181
+ },
182
+ ],
183
+ },
184
+ {
185
+ watchdogId: "beta",
186
+ reviewedFiles: ["src/beta/x.test.ts"],
187
+ skipped: [],
188
+ findings: [],
189
+ },
190
+ ];
191
+
192
+ const merged: MergedFinding[] = [
193
+ { ...reports[0].findings[0], watchdogs: ["alpha"] },
194
+ { ...reports[0].findings[1], watchdogs: ["alpha", "beta"] },
195
+ ];
196
+
197
+ const reportContext = {
198
+ available: true,
199
+ source: {
200
+ number: 42,
201
+ title: "Entrega X",
202
+ url: "https://example.test/pull/42",
203
+ head: "feature/x",
204
+ base: "main",
205
+ },
206
+ objective: "Entregar X",
207
+ motivation: "Reduzir Y",
208
+ changeSummary: ["Faz A", "Remove B"],
209
+ };
210
+
211
+ describe("baseline — relatório", () => {
212
+ test("renderReport consolidado", () => {
213
+ const md = renderReport(
214
+ "feature/x",
215
+ reports,
216
+ merged,
217
+ { classified: { alpha: ["src/alpha/a.ts", "src/alpha/b.ts"], beta: [] } },
218
+ reportContext,
219
+ [
220
+ {
221
+ watchdogId: "alpha",
222
+ rule: "Nunca usar `any`.",
223
+ severity: "blocking",
224
+ file: "src/alpha/a.ts",
225
+ confidence: 0.82,
226
+ questionKey: "alpha::rule-0",
227
+ },
228
+ ],
229
+ (id) => (id === "alpha" ? "Alpha" : "Beta"),
230
+ [
231
+ {
232
+ id: "alpha",
233
+ name: "Alpha",
234
+ file: "review.alpha.md",
235
+ findings: 2,
236
+ reviewedFiles: 2,
237
+ expectedFiles: 2,
238
+ },
239
+ ],
240
+ );
241
+
242
+ expect(normalizeArtifacts(md)).toMatchSnapshot();
243
+ });
244
+
245
+ test("renderDomainReport isolado", () => {
246
+ const md = renderDomainReport(
247
+ "feature/x",
248
+ reports[0],
249
+ merged,
250
+ 2,
251
+ reportContext,
252
+ (id) => (id === "alpha" ? "Alpha" : "Beta"),
253
+ );
254
+
255
+ expect(normalizeArtifacts(md)).toMatchSnapshot();
256
+ });
257
+ });
258
+
259
+ // ---------------------------------------------------------------------------
260
+ // Camada gerada pelo setup/update
261
+ // ---------------------------------------------------------------------------
262
+
263
+ /** Arquivos-template cujo conteúdo precisa permanecer idêntico. */
264
+ const TEMPLATE_FILES = [
265
+ "houndsman.config.ts",
266
+ ".houndsman/core/types.ts",
267
+ ".houndsman/watchdogs/example.ts",
268
+ ".houndsman/watchdogs/index.ts",
269
+ ".houndsman/exclusions.ts",
270
+ ".houndsman/doc-mappings.ts",
271
+ ".houndsman/.gitignore",
272
+ ".agents/skills/houndsman/SKILL.md",
273
+ ".claude/skills/houndsman/SKILL.md",
274
+ ];
275
+
276
+ describe("baseline — camada gerada", () => {
277
+ test("setup gera os mesmos arquivos e conteúdos", () => {
278
+ const cwd = mkdtempSync(join(tmpdir(), "houndsman-baseline-scaffold-"));
279
+ try {
280
+ const ctx = createScaffoldContext(cwd, ["--name", "acme"], false);
281
+ writeUserLayer(ctx);
282
+ syncGeneratedFiles(ctx);
283
+
284
+ const files = listFiles(cwd);
285
+ expect(files).toMatchSnapshot();
286
+
287
+ const contents = Object.fromEntries(
288
+ TEMPLATE_FILES.map((file) => [
289
+ file,
290
+ readFileSync(join(cwd, file), "utf8"),
291
+ ]),
292
+ );
293
+ expect(contents).toMatchSnapshot();
294
+
295
+ // Assets copiados (skill docs): hash estável, sem colar o markdown inteiro.
296
+ const skillDocs = files
297
+ .filter((file) => file.startsWith(".houndsman/core/skill/"))
298
+ .map((file) => ({
299
+ file,
300
+ sha256: createHash("sha256")
301
+ .update(readFileSync(join(cwd, file), "utf8"))
302
+ .digest("hex")
303
+ .slice(0, 16),
304
+ }));
305
+ expect(skillDocs).toMatchSnapshot();
306
+ } finally {
307
+ rmSync(cwd, { recursive: true, force: true });
308
+ }
309
+ });
310
+ });
311
+
312
+ // ---------------------------------------------------------------------------
313
+ // prepare → findings → finalize (end-to-end em repo git temporário)
314
+ // ---------------------------------------------------------------------------
315
+
316
+ describe("baseline — artefatos de prepare/finalize", () => {
317
+ test("gera classification, brief, manifest, review e state", async () => {
318
+ const cwd = mkdtempSync(join(tmpdir(), "houndsman-baseline-run-"));
319
+ const originalCwd = process.cwd();
320
+
321
+ try {
322
+ git(cwd, ["init", "-b", "main"]);
323
+ git(cwd, ["config", "user.email", "baseline@example.test"]);
324
+ git(cwd, ["config", "user.name", "Baseline"]);
325
+ // Evita assinatura de commit (ex.: 1Password) no repo temporário.
326
+ git(cwd, ["config", "commit.gpgsign", "false"]);
327
+ git(cwd, ["config", "tag.gpgsign", "false"]);
328
+ mkdirSync(join(cwd, "src", "alpha"), { recursive: true });
329
+ writeFileSync(join(cwd, "src", "alpha", "a.ts"), "export const a = 1;\n");
330
+ writeFileSync(join(cwd, "src", "alpha", "old.ts"), "export const old = 1;\n");
331
+ writeFileSync(join(cwd, "README.md"), "# Baseline\n");
332
+ git(cwd, ["add", "."]);
333
+ git(cwd, ["commit", "-m", "initial"]);
334
+ const base = git(cwd, ["rev-parse", "HEAD"]).trim();
335
+
336
+ writeFileSync(
337
+ join(cwd, "src", "alpha", "a.ts"),
338
+ "export const a: any = 1;\nexport const b = 2;\n",
339
+ );
340
+ writeFileSync(join(cwd, "src", "alpha", "b.ts"), "export const b = 3;\n");
341
+ rmSync(join(cwd, "src", "alpha", "old.ts"));
342
+ git(cwd, ["add", "-A"]);
343
+ git(cwd, ["commit", "-m", "change"]);
344
+
345
+ const opts = {
346
+ ...defaultOptions(".houndsman/.runs"),
347
+ base,
348
+ head: "HEAD",
349
+ noJev: true,
350
+ };
351
+
352
+ process.chdir(cwd);
353
+
354
+ const prepared = await prepare(opts, "main", baselineConfig);
355
+ expect(normalizeArtifacts(prepared.messages.join("\n"))).toMatchSnapshot();
356
+
357
+ const runDir = join(cwd, ".houndsman", ".runs", "main");
358
+ for (const file of [
359
+ "classification.json",
360
+ "manifest.json",
361
+ "briefs/alpha.md",
362
+ ]) {
363
+ expect(
364
+ normalizeArtifacts(readFileSync(join(runDir, file), "utf8")),
365
+ ).toMatchSnapshot(file);
366
+ }
367
+
368
+ writeFileSync(
369
+ join(runDir, "findings", "alpha.json"),
370
+ `${JSON.stringify(
371
+ {
372
+ watchdogId: "alpha",
373
+ reviewedFiles: ["src/alpha/a.ts", "src/alpha/b.ts"],
374
+ skipped: [],
375
+ findings: [
376
+ {
377
+ rule: "Nunca usar `any`.",
378
+ severity: "blocking",
379
+ file: "src/alpha/a.ts",
380
+ line: 1,
381
+ message: "any detectado",
382
+ action: "tipar",
383
+ evidence: "export const a: any = 1;",
384
+ },
385
+ ],
386
+ },
387
+ null,
388
+ 2,
389
+ )}\n`,
390
+ );
391
+
392
+ const finalized = finalize(opts, "main", baselineConfig);
393
+ expect(normalizeArtifacts(finalized.messages.join("\n"))).toMatchSnapshot();
394
+
395
+ for (const file of [
396
+ "review.md",
397
+ "review.alpha.md",
398
+ "state.json",
399
+ "run.json",
400
+ ]) {
401
+ expect(
402
+ normalizeArtifacts(readFileSync(join(runDir, file), "utf8")),
403
+ ).toMatchSnapshot(file);
404
+ }
405
+ } finally {
406
+ process.chdir(originalCwd);
407
+ rmSync(cwd, { recursive: true, force: true });
408
+ }
409
+ }, 30_000);
410
+ });
411
+
412
+ // ---------------------------------------------------------------------------
413
+ // Contrato da API pública (facade src/index.ts)
414
+ // ---------------------------------------------------------------------------
415
+
416
+ /** Símbolos que consumidores importam de `@payconductor/houndsman`. */
417
+ const REQUIRED_EXPORTS = [
418
+ "PROCEDURE",
419
+ "SEVERITY_EMOJI",
420
+ "SEVERITY_ORDER",
421
+ "FINDING_DECISIONS",
422
+ "FINDING_EXEC_STATES",
423
+ "FINDINGS_STATE_VERSION",
424
+ "FindingsStateStore",
425
+ "FindingsRepository",
426
+ "FindingsState",
427
+ "Finding",
428
+ "Classification",
429
+ "Diff",
430
+ "DocDiscoverer",
431
+ "FileClassifier",
432
+ "GhClient",
433
+ "GitRepository",
434
+ "ProjectConfig",
435
+ "ProjectFiles",
436
+ "RunLayout",
437
+ "Watchdog",
438
+ "WatchdogRegistry",
439
+ "systemClock",
440
+ "HoundsmanServer",
441
+ "HoundsmanEngine",
442
+ "ReviewSession",
443
+ "ReviewScopeResolver",
444
+ "buildCarryover",
445
+ "mergeCarryover",
446
+ "RunRepository",
447
+ "ArgParser",
448
+ "ProjectScaffolder",
449
+ "FixBatchBuilder",
450
+ "Verifier",
451
+ "BriefRenderer",
452
+ "ReportRenderer",
453
+ "ManifestWriter",
454
+ "applyFindingUpdate",
455
+ "assetPath",
456
+ "buildAnchor",
457
+ "buildStateFromFindings",
458
+ "buildStateFromRunDir",
459
+ "checkRegistry",
460
+ "churnOf",
461
+ "classifyEntries",
462
+ "classifyRange",
463
+ "collectChurn",
464
+ "collectDiff",
465
+ "compareFindings",
466
+ "createRequestHandler",
467
+ "defaultRenderBrief",
468
+ "defineHoundsmanConfig",
469
+ "defineWatchdog",
470
+ "emptyState",
471
+ "fileReader",
472
+ "filterFindings",
473
+ "findWatchdog",
474
+ "fingerprintOf",
475
+ "getWatchdog",
476
+ "getWatchdogContext",
477
+ "hashKey",
478
+ "isExcludedPath",
479
+ "loadConfig",
480
+ "locateAnchor",
481
+ "matchWatchdogs",
482
+ "newFindingId",
483
+ "normalizeLine",
484
+ "parseDiffIndex",
485
+ "parseEntries",
486
+ "parseNumstat",
487
+ "reanchor",
488
+ "relFromRoot",
489
+ "renderBrief",
490
+ "renderDomainReport",
491
+ "reorder",
492
+ "resolveConfigPath",
493
+ "resolveJevConcurrency",
494
+ "resolveMaxFilesPerBrief",
495
+ "runDir",
496
+ "runFindingsCommand",
497
+ "runVerifyCommand",
498
+ "selectByBudget",
499
+ "severityRank",
500
+ "sortByRisk",
501
+ "startServer",
502
+ "stateFile",
503
+ "summarize",
504
+ "syncState",
505
+ ] as const;
506
+
507
+ describe("baseline — API pública", () => {
508
+ test("a facade mantém todos os símbolos exportados", () => {
509
+ for (const name of REQUIRED_EXPORTS) {
510
+ expect(facade).toHaveProperty(name);
511
+ }
512
+ });
513
+ });
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Smoke test do CLI (`src/cli/index.ts`).
3
+ *
4
+ * O `main()` carrega cada comando por **import dinâmico** — este teste invoca o
5
+ * CLI como processo (num projeto mínimo em disco) para garantir que os caminhos
6
+ * lazy resolvem em runtime. É a única cobertura end-to-end do binário.
7
+ */
8
+
9
+ import { describe, expect, test } from "bun:test";
10
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+
14
+ /** Caminho do executável do CLI (TS, rodado pelo Bun). */
15
+ const CLI = join(import.meta.dir, "..", "cli", "index.ts");
16
+
17
+ /** Config mínima, como objeto literal (o `loadConfig` deriva `watchdogIds`). */
18
+ const CONFIG = `export default {
19
+ projectName: "Smoke",
20
+ watchdogs: {
21
+ alpha: {
22
+ id: "alpha",
23
+ name: "Alpha",
24
+ description: "Watchdog de smoke.",
25
+ scope: "src/**",
26
+ classify: [{ value: "^src/" }],
27
+ rules: [{ severity: "attention", text: "Evitar any." }],
28
+ contextFiles: [],
29
+ },
30
+ },
31
+ exclusions: [],
32
+ docMappings: [],
33
+ };
34
+ `;
35
+
36
+ type Project = { cwd: string; artifacts: string; cleanup: () => void };
37
+
38
+ /** Projeto temporário: config + uma run com um finding. */
39
+ function makeProject(): Project {
40
+ const cwd = mkdtempSync(join(tmpdir(), "houndsman-cli-"));
41
+ const artifacts = join(cwd, ".houndsman", ".runs");
42
+ const runDir = join(artifacts, "main");
43
+ mkdirSync(join(runDir, "findings"), { recursive: true });
44
+ writeFileSync(join(cwd, "houndsman.config.ts"), CONFIG);
45
+ writeFileSync(
46
+ join(runDir, "classification.json"),
47
+ JSON.stringify({ watchdogs: { alpha: ["src/a.ts"] } }),
48
+ );
49
+ writeFileSync(
50
+ join(runDir, "findings", "alpha.json"),
51
+ JSON.stringify({
52
+ watchdogId: "alpha",
53
+ reviewedFiles: ["src/a.ts"],
54
+ skipped: [],
55
+ findings: [
56
+ {
57
+ rule: "Evitar any",
58
+ severity: "attention",
59
+ file: "src/a.ts",
60
+ line: 1,
61
+ message: "any detectado",
62
+ action: "tipar",
63
+ },
64
+ ],
65
+ }),
66
+ );
67
+ return {
68
+ cwd,
69
+ artifacts,
70
+ cleanup: () => rmSync(cwd, { recursive: true, force: true }),
71
+ };
72
+ }
73
+
74
+ type CliResult = { code: number; stdout: string; stderr: string };
75
+
76
+ /** Invoca o CLI como processo, no cwd informado. */
77
+ function runCli(cwd: string, args: string[]): CliResult {
78
+ const proc = Bun.spawnSync(["bun", CLI, ...args], {
79
+ cwd,
80
+ stdout: "pipe",
81
+ stderr: "pipe",
82
+ });
83
+ return {
84
+ code: proc.exitCode ?? -1,
85
+ stdout: proc.stdout.toString(),
86
+ stderr: proc.stderr.toString(),
87
+ };
88
+ }
89
+
90
+ describe("cli smoke — imports dinâmicos", () => {
91
+ test("help imprime o uso (sem carregar config)", () => {
92
+ const result = runCli(process.cwd(), ["help"]);
93
+ expect(result.code).toBe(0);
94
+ expect(result.stdout).toContain("Uso: houndsman");
95
+ });
96
+
97
+ test("watchdog <id> devolve o contexto (import lazy de commands/watchdog)", () => {
98
+ const project = makeProject();
99
+ try {
100
+ const result = runCli(project.cwd, ["watchdog", "alpha", "--json"]);
101
+ expect(result.code).toBe(0);
102
+ expect(JSON.parse(result.stdout).id).toBe("alpha");
103
+ } finally {
104
+ project.cleanup();
105
+ }
106
+ });
107
+
108
+ test("findings summary devolve a triagem (import lazy de commands/findings)", () => {
109
+ const project = makeProject();
110
+ try {
111
+ const result = runCli(project.cwd, [
112
+ "findings",
113
+ "summary",
114
+ "--json",
115
+ "--dir",
116
+ project.artifacts,
117
+ "--branch",
118
+ "main",
119
+ ]);
120
+ expect(result.code).toBe(0);
121
+ expect(JSON.parse(result.stdout).total).toBe(1);
122
+ } finally {
123
+ project.cleanup();
124
+ }
125
+ });
126
+
127
+ test("findings submit --dry-run devolve a prévia (fail-open sem gh)", () => {
128
+ const project = makeProject();
129
+ try {
130
+ const result = runCli(project.cwd, [
131
+ "findings",
132
+ "submit",
133
+ "--dry-run",
134
+ "--json",
135
+ "--dir",
136
+ project.artifacts,
137
+ "--branch",
138
+ "main",
139
+ ]);
140
+ expect(result.code).toBe(0);
141
+ expect(JSON.parse(result.stdout).totals.candidates).toBe(1);
142
+ } finally {
143
+ project.cleanup();
144
+ }
145
+ });
146
+
147
+ test("doctor valida a config (import lazy de registry-check)", () => {
148
+ const project = makeProject();
149
+ try {
150
+ const result = runCli(project.cwd, ["doctor"]);
151
+ expect(result.code).toBe(0);
152
+ expect(result.stdout).toContain("Validando");
153
+ } finally {
154
+ project.cleanup();
155
+ }
156
+ });
157
+
158
+ test("context é fail-open sem PR (import lazy de commands/context)", () => {
159
+ const project = makeProject();
160
+ try {
161
+ const result = runCli(project.cwd, ["context", "--branch", "main"]);
162
+ expect(result.code).toBe(0);
163
+ } finally {
164
+ project.cleanup();
165
+ }
166
+ });
167
+ });