@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,493 +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
- readFileSync,
15
- readdirSync,
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
- /** Captura o stdout de `fn` (o baseline inclui a saída textual do CLI). */
112
- async function captureStdout(fn: () => Promise<void>): Promise<string> {
113
- const original = console.log;
114
- const lines: string[] = [];
115
- console.log = (...args: unknown[]) => {
116
- lines.push(args.map(String).join(" "));
117
- };
118
- try {
119
- await fn();
120
- } finally {
121
- console.log = original;
122
- }
123
- return lines.join("\n");
124
- }
125
-
126
- // ---------------------------------------------------------------------------
127
- // Brief
128
- // ---------------------------------------------------------------------------
129
-
130
- describe("baseline — brief", () => {
131
- test("defaultRenderBrief (contexto, churn, overflow, candidatos e deletados)", () => {
132
- const brief = renderBrief(baselineWatchdog, {
133
- watchdogId: "alpha",
134
- files: ["src/alpha/a.ts", "src/alpha/b.ts"],
135
- statuses: {
136
- "src/alpha/a.ts": { status: "modified" },
137
- "src/alpha/b.ts": { status: "added" },
138
- },
139
- churn: {
140
- "src/alpha/a.ts": { additions: 3, deletions: 1 },
141
- "src/alpha/b.ts": { additions: 2, deletions: 0 },
142
- },
143
- overflow: ["src/alpha/c.ts"],
144
- deleted: ["src/alpha/old.ts"],
145
- branch: "feature/x",
146
- outputPath: ".houndsman/.runs/feature-x/findings/alpha.json",
147
- outputFormatPath: ".houndsman/core/skill/OUTPUT-FORMAT.md",
148
- docs: ["AGENTS.md", "README.md"],
149
- context: {
150
- objective: "Entregar X",
151
- motivation: "Reduzir Y",
152
- changeSummary: ["Faz A", "Remove B"],
153
- },
154
- candidates: [
155
- {
156
- file: "src/alpha/a.ts",
157
- severity: "blocking",
158
- confidence: 0.82,
159
- rule: "Nunca usar `any`.",
160
- },
161
- ],
162
- });
163
-
164
- expect(brief).toMatchSnapshot();
165
- });
166
- });
167
-
168
- // ---------------------------------------------------------------------------
169
- // Relatório
170
- // ---------------------------------------------------------------------------
171
-
172
- const reports: WatchdogReport[] = [
173
- {
174
- watchdogId: "alpha",
175
- reviewedFiles: ["src/alpha/a.ts", "src/alpha/b.ts"],
176
- skipped: ["src/alpha/c.ts"],
177
- findings: [
178
- {
179
- rule: "Nunca usar `any`.",
180
- severity: "blocking",
181
- file: "src/alpha/a.ts",
182
- line: 1,
183
- message: "any detectado",
184
- action: "tipar",
185
- evidence: "export const a: any = 1;",
186
- id: "id-alpha",
187
- },
188
- {
189
- rule: "Promover: validar a entrada.",
190
- severity: "attention",
191
- file: "src/alpha/b.ts",
192
- line: 2,
193
- message: "sem validação",
194
- action: "adicionar schema",
195
- id: "id-beta",
196
- },
197
- ],
198
- },
199
- {
200
- watchdogId: "beta",
201
- reviewedFiles: ["src/beta/x.test.ts"],
202
- skipped: [],
203
- findings: [],
204
- },
205
- ];
206
-
207
- const merged: MergedFinding[] = [
208
- { ...reports[0].findings[0], watchdogs: ["alpha"] },
209
- { ...reports[0].findings[1], watchdogs: ["alpha", "beta"] },
210
- ];
211
-
212
- const reportContext = {
213
- available: true,
214
- source: {
215
- number: 42,
216
- title: "Entrega X",
217
- url: "https://example.test/pull/42",
218
- head: "feature/x",
219
- base: "main",
220
- },
221
- objective: "Entregar X",
222
- motivation: "Reduzir Y",
223
- changeSummary: ["Faz A", "Remove B"],
224
- };
225
-
226
- describe("baseline — relatório", () => {
227
- test("renderReport consolidado", () => {
228
- const md = renderReport(
229
- "feature/x",
230
- reports,
231
- merged,
232
- { classified: { alpha: ["src/alpha/a.ts", "src/alpha/b.ts"], beta: [] } },
233
- reportContext,
234
- [
235
- {
236
- watchdogId: "alpha",
237
- rule: "Nunca usar `any`.",
238
- severity: "blocking",
239
- file: "src/alpha/a.ts",
240
- confidence: 0.82,
241
- questionKey: "alpha::rule-0",
242
- },
243
- ],
244
- (id) => (id === "alpha" ? "Alpha" : "Beta"),
245
- [
246
- {
247
- id: "alpha",
248
- name: "Alpha",
249
- file: "review.alpha.md",
250
- findings: 2,
251
- reviewedFiles: 2,
252
- expectedFiles: 2,
253
- },
254
- ],
255
- );
256
-
257
- expect(md).toMatchSnapshot();
258
- });
259
-
260
- test("renderDomainReport isolado", () => {
261
- const md = renderDomainReport(
262
- "feature/x",
263
- reports[0],
264
- merged,
265
- 2,
266
- reportContext,
267
- (id) => (id === "alpha" ? "Alpha" : "Beta"),
268
- );
269
-
270
- expect(md).toMatchSnapshot();
271
- });
272
- });
273
-
274
- // ---------------------------------------------------------------------------
275
- // Camada gerada pelo setup/update
276
- // ---------------------------------------------------------------------------
277
-
278
- /** Arquivos-template cujo conteúdo precisa permanecer idêntico. */
279
- const TEMPLATE_FILES = [
280
- "houndsman.config.ts",
281
- ".houndsman/core/types.ts",
282
- ".houndsman/watchdogs/example.ts",
283
- ".houndsman/watchdogs/index.ts",
284
- ".houndsman/exclusions.ts",
285
- ".houndsman/doc-mappings.ts",
286
- ".houndsman/.gitignore",
287
- ".agents/skills/houndsman/SKILL.md",
288
- ".claude/skills/houndsman/SKILL.md",
289
- ];
290
-
291
- describe("baseline — camada gerada", () => {
292
- test("setup gera os mesmos arquivos e conteúdos", () => {
293
- const cwd = mkdtempSync(join(tmpdir(), "houndsman-baseline-scaffold-"));
294
- try {
295
- const ctx = createScaffoldContext(cwd, ["--name", "acme"], false);
296
- writeUserLayer(ctx);
297
- syncGeneratedFiles(ctx);
298
-
299
- const files = listFiles(cwd);
300
- expect(files).toMatchSnapshot();
301
-
302
- const contents = Object.fromEntries(
303
- TEMPLATE_FILES.map((file) => [file, readFileSync(join(cwd, file), "utf8")]),
304
- );
305
- expect(contents).toMatchSnapshot();
306
-
307
- // Assets copiados (skill docs): hash estável, sem colar o markdown inteiro.
308
- const skillDocs = files
309
- .filter((file) => file.startsWith(".houndsman/core/skill/"))
310
- .map((file) => ({
311
- file,
312
- sha256: createHash("sha256")
313
- .update(readFileSync(join(cwd, file), "utf8"))
314
- .digest("hex")
315
- .slice(0, 16),
316
- }));
317
- expect(skillDocs).toMatchSnapshot();
318
- } finally {
319
- rmSync(cwd, { recursive: true, force: true });
320
- }
321
- });
322
- });
323
-
324
- // ---------------------------------------------------------------------------
325
- // prepare → findings → finalize (end-to-end em repo git temporário)
326
- // ---------------------------------------------------------------------------
327
-
328
- describe("baseline — artefatos de prepare/finalize", () => {
329
- test("gera classification, brief, manifest, review e state", async () => {
330
- const cwd = mkdtempSync(join(tmpdir(), "houndsman-baseline-run-"));
331
- const originalCwd = process.cwd();
332
-
333
- try {
334
- git(cwd, ["init", "-b", "main"]);
335
- git(cwd, ["config", "user.email", "baseline@example.test"]);
336
- git(cwd, ["config", "user.name", "Baseline"]);
337
- mkdirSync(join(cwd, "src", "alpha"), { recursive: true });
338
- writeFileSync(join(cwd, "src", "alpha", "a.ts"), "export const a = 1;\n");
339
- writeFileSync(join(cwd, "src", "alpha", "old.ts"), "export const old = 1;\n");
340
- writeFileSync(join(cwd, "README.md"), "# Baseline\n");
341
- git(cwd, ["add", "."]);
342
- git(cwd, ["commit", "-m", "initial"]);
343
- const base = git(cwd, ["rev-parse", "HEAD"]).trim();
344
-
345
- writeFileSync(
346
- join(cwd, "src", "alpha", "a.ts"),
347
- "export const a: any = 1;\nexport const b = 2;\n",
348
- );
349
- writeFileSync(join(cwd, "src", "alpha", "b.ts"), "export const b = 3;\n");
350
- rmSync(join(cwd, "src", "alpha", "old.ts"));
351
- git(cwd, ["add", "-A"]);
352
- git(cwd, ["commit", "-m", "change"]);
353
-
354
- const opts = {
355
- ...defaultOptions(".houndsman/.runs"),
356
- base,
357
- head: "HEAD",
358
- noJev: true,
359
- };
360
-
361
- process.chdir(cwd);
362
-
363
- const prepareOutput = await captureStdout(async () => {
364
- await prepare(opts, "main", baselineConfig);
365
- });
366
- expect(normalizeArtifacts(prepareOutput)).toMatchSnapshot();
367
-
368
- const runDir = join(cwd, ".houndsman", ".runs", "main");
369
- for (const file of ["classification.json", "manifest.json", "briefs/alpha.md"]) {
370
- expect(
371
- normalizeArtifacts(readFileSync(join(runDir, file), "utf8")),
372
- ).toMatchSnapshot(file);
373
- }
374
-
375
- writeFileSync(
376
- join(runDir, "findings", "alpha.json"),
377
- `${JSON.stringify(
378
- {
379
- watchdogId: "alpha",
380
- reviewedFiles: ["src/alpha/a.ts", "src/alpha/b.ts"],
381
- skipped: [],
382
- findings: [
383
- {
384
- rule: "Nunca usar `any`.",
385
- severity: "blocking",
386
- file: "src/alpha/a.ts",
387
- line: 1,
388
- message: "any detectado",
389
- action: "tipar",
390
- evidence: "export const a: any = 1;",
391
- },
392
- ],
393
- },
394
- null,
395
- 2,
396
- )}\n`,
397
- );
398
-
399
- const finalizeOutput = await captureStdout(async () => {
400
- finalize(opts, "main", baselineConfig);
401
- });
402
- expect(normalizeArtifacts(finalizeOutput)).toMatchSnapshot();
403
-
404
- for (const file of [
405
- "review.md",
406
- "review.alpha.md",
407
- "state.json",
408
- "run.json",
409
- ]) {
410
- expect(
411
- normalizeArtifacts(readFileSync(join(runDir, file), "utf8")),
412
- ).toMatchSnapshot(file);
413
- }
414
- } finally {
415
- process.chdir(originalCwd);
416
- rmSync(cwd, { recursive: true, force: true });
417
- }
418
- });
419
- });
420
-
421
- // ---------------------------------------------------------------------------
422
- // Contrato da API pública (facade src/index.ts)
423
- // ---------------------------------------------------------------------------
424
-
425
- /** Símbolos que consumidores importam de `@payconductor/houndsman`. */
426
- const REQUIRED_EXPORTS = [
427
- "PROCEDURE",
428
- "SEVERITY_EMOJI",
429
- "SEVERITY_ORDER",
430
- "FINDING_DECISIONS",
431
- "FINDING_EXEC_STATES",
432
- "FINDINGS_STATE_VERSION",
433
- "FindingsStateStore",
434
- "applyFindingUpdate",
435
- "assetPath",
436
- "buildAnchor",
437
- "buildStateFromFindings",
438
- "buildStateFromRunDir",
439
- "checkRegistry",
440
- "churnOf",
441
- "classifyEntries",
442
- "classifyRange",
443
- "collectChurn",
444
- "collectDiff",
445
- "compareFindings",
446
- "createRequestHandler",
447
- "defaultRenderBrief",
448
- "defineHoundsmanConfig",
449
- "defineWatchdog",
450
- "emptyState",
451
- "fileReader",
452
- "filterFindings",
453
- "findWatchdog",
454
- "fingerprintOf",
455
- "getWatchdog",
456
- "getWatchdogContext",
457
- "hashKey",
458
- "isExcludedPath",
459
- "loadConfig",
460
- "locateAnchor",
461
- "matchWatchdogs",
462
- "newFindingId",
463
- "normalizeLine",
464
- "parseDiffIndex",
465
- "parseEntries",
466
- "parseNumstat",
467
- "reanchor",
468
- "relFromRoot",
469
- "renderBrief",
470
- "renderDomainReport",
471
- "reorder",
472
- "resolveConfigPath",
473
- "resolveJevConcurrency",
474
- "resolveMaxFilesPerBrief",
475
- "runDir",
476
- "runFindingsCommand",
477
- "runVerifyCommand",
478
- "selectByBudget",
479
- "severityRank",
480
- "sortByRisk",
481
- "startServer",
482
- "stateFile",
483
- "summarize",
484
- "syncState",
485
- ] as const;
486
-
487
- describe("baseline — API pública", () => {
488
- test("a facade mantém todos os símbolos exportados", () => {
489
- for (const name of REQUIRED_EXPORTS) {
490
- expect(facade).toHaveProperty(name);
491
- }
492
- });
493
- });
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
+ });