@payconductor/houndsman 0.13.0 → 0.16.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 (116) hide show
  1. package/README.md +6 -1
  2. package/package.json +1 -1
  3. package/src/cli/commands/compare-command.ts +112 -0
  4. package/src/cli/commands/context-command.ts +1 -0
  5. package/src/cli/commands/findings-command.ts +11 -1
  6. package/src/cli/commands/golden-command.ts +92 -0
  7. package/src/cli/commands/hunt-command.ts +105 -4
  8. package/src/cli/commands/review-command.ts +38 -0
  9. package/src/cli/commands/status-command.ts +3 -2
  10. package/src/cli/help.ts +6 -1
  11. package/src/cli/index.ts +5 -1
  12. package/src/cli/progress.ts +7 -2
  13. package/src/core/ai/ai-client.ts +38 -2
  14. package/src/core/ai/features/brief-enricher.ts +1 -1
  15. package/src/core/ai/features/business-analyzer.ts +61 -0
  16. package/src/core/ai/features/business-summarizer.ts +44 -0
  17. package/src/core/ai/index.ts +8 -0
  18. package/src/core/ai/prompts/brief.ts +33 -7
  19. package/src/core/ai/prompts/business.ts +76 -0
  20. package/src/core/ai/provider-capabilities.ts +12 -0
  21. package/src/core/ai/schemas.ts +70 -2
  22. package/src/core/ai/usage-recorder.ts +53 -1
  23. package/src/core/brief.ts +278 -16
  24. package/src/core/builtins.ts +108 -0
  25. package/src/core/business-summary.ts +583 -0
  26. package/src/core/commands/compare.ts +266 -0
  27. package/src/core/commands/context.ts +6 -2
  28. package/src/core/commands/findings.ts +38 -30
  29. package/src/core/commands/golden.ts +176 -0
  30. package/src/core/commands/review.ts +6 -0
  31. package/src/core/commands/scaffold.ts +29 -0
  32. package/src/core/commands/status.ts +9 -4
  33. package/src/core/compare.ts +310 -0
  34. package/src/core/concurrency.ts +30 -0
  35. package/src/core/config.ts +41 -3
  36. package/src/core/defaults.ts +50 -4
  37. package/src/core/domain/budget.ts +62 -4
  38. package/src/core/domain/classification.ts +12 -2
  39. package/src/core/engine/carryover.ts +4 -0
  40. package/src/core/engine/finalize-pipeline.ts +371 -0
  41. package/src/core/engine/prepare-pipeline.ts +1294 -0
  42. package/src/core/engine/review-context.ts +51 -0
  43. package/src/core/engine/review-options.ts +47 -0
  44. package/src/core/engine/review-scope.ts +14 -1
  45. package/src/core/engine/review-session.ts +11 -13
  46. package/src/core/engine/run-artifacts.ts +196 -0
  47. package/src/core/engine/run-format.ts +4 -0
  48. package/src/core/findings-state.ts +22 -2
  49. package/src/core/github-review.ts +5 -2
  50. package/src/core/golden.ts +346 -0
  51. package/src/core/hunt/hunt-runner.ts +621 -34
  52. package/src/core/hunt/index.ts +40 -1
  53. package/src/core/hunt/maki-intent-prompt.ts +50 -0
  54. package/src/core/hunt/maki-prompt.ts +298 -5
  55. package/src/core/hunt/maki-runner.ts +93 -16
  56. package/src/core/hunt/pool.ts +4 -26
  57. package/src/core/hunt/review-checks.ts +460 -0
  58. package/src/core/logging/index.ts +6 -1
  59. package/src/core/logging/progress.ts +85 -1
  60. package/src/core/orchestrator.ts +33 -1289
  61. package/src/core/paths.ts +6 -3
  62. package/src/core/pr-context.ts +2 -4
  63. package/src/core/prepare-report.ts +61 -0
  64. package/src/core/project-config.ts +18 -1
  65. package/src/core/propagation.ts +127 -0
  66. package/src/core/registry-check.ts +90 -27
  67. package/src/core/renderers/manifest-writer.ts +13 -0
  68. package/src/core/renderers/resume-renderer.ts +151 -11
  69. package/src/core/report-builder.ts +116 -278
  70. package/src/core/severity.ts +245 -0
  71. package/src/core/storage/file/file-storage.ts +7 -3
  72. package/src/core/storage/sqlite/mappers.ts +10 -0
  73. package/src/core/storage/sqlite/schema.ts +104 -23
  74. package/src/core/storage/sqlite/sqlite-storage.ts +22 -9
  75. package/src/core/storage/types.ts +17 -4
  76. package/src/core/storage/work-area.ts +59 -1
  77. package/src/core/types.ts +31 -0
  78. package/src/core/watchdog-registry.ts +21 -3
  79. package/src/index.ts +51 -4
  80. package/src/server/client/ArtifactsPanel.tsx +4 -2
  81. package/src/server/client/FindingCard.tsx +21 -1
  82. package/src/server/client/RunsSection.tsx +5 -0
  83. package/src/server/client/utils/constants.ts +21 -11
  84. package/src/server/client/utils/markdown.ts +1 -1
  85. package/src/server/client/utils/types.ts +14 -2
  86. package/src/server/run-repository.ts +35 -14
  87. package/src/server/styles.css +2 -0
  88. package/src/skill/OUTPUT-FORMAT.md +55 -10
  89. package/src/skill/README.md +19 -14
  90. package/src/skill/SETUP.md +1 -0
  91. package/src/skill/SKILL.md +13 -4
  92. package/src/skill/example-resume.md +43 -0
  93. package/src/skill/hunt.md +205 -58
  94. package/src/skill/train.md +3 -0
  95. package/src/tests/__snapshots__/baseline.test.ts.snap +208 -184
  96. package/src/tests/ai-core.test.ts +46 -0
  97. package/src/tests/ai-features.test.ts +95 -4
  98. package/src/tests/baseline.test.ts +29 -121
  99. package/src/tests/brief-scope.test.ts +250 -0
  100. package/src/tests/business-summary.test.ts +258 -0
  101. package/src/tests/compare.test.ts +256 -0
  102. package/src/tests/findings-state.test.ts +82 -0
  103. package/src/tests/fixtures/fake-model-provider.ts +2 -1
  104. package/src/tests/golden.test.ts +195 -0
  105. package/src/tests/hunt-maki.test.ts +710 -2
  106. package/src/tests/markdown.test.ts +3 -3
  107. package/src/tests/progress.test.ts +29 -1
  108. package/src/tests/propagation.test.ts +119 -0
  109. package/src/tests/review-resume.test.ts +60 -1
  110. package/src/tests/review-scope.test.ts +38 -29
  111. package/src/tests/review-scripts.test.ts +125 -218
  112. package/src/tests/server.test.ts +7 -4
  113. package/src/tests/severity.test.ts +235 -0
  114. package/src/tests/storage.test.ts +194 -9
  115. package/src/core/renderers/report-renderer.ts +0 -66
  116. package/src/skill/example-review.md +0 -66
@@ -116,11 +116,11 @@ describe("parseInline", () => {
116
116
  });
117
117
 
118
118
  test("destaca caminhos de arquivo no texto corrido", () => {
119
- expect(parseInline("veja src/foo/bar.ts e review.md")).toEqual([
119
+ expect(parseInline("veja src/foo/bar.ts e manifest.json")).toEqual([
120
120
  { type: "text", text: "veja " },
121
121
  { type: "path", text: "src/foo/bar.ts" },
122
122
  { type: "text", text: " e " },
123
- { type: "path", text: "review.md" },
123
+ { type: "path", text: "manifest.json" },
124
124
  ]);
125
125
  });
126
126
  });
@@ -128,7 +128,7 @@ describe("parseInline", () => {
128
128
  describe("isFilePath", () => {
129
129
  test("reconhece arquivos e caminhos, sem confundir texto comum", () => {
130
130
  expect(isFilePath("src/foo/bar.ts")).toBe(true);
131
- expect(isFilePath("review.md")).toBe(true);
131
+ expect(isFilePath("manifest.json")).toBe(true);
132
132
  expect(isFilePath("./scripts/ci.sh")).toBe(true);
133
133
  expect(isFilePath("M/D")).toBe(false);
134
134
  expect(isFilePath("duas palavras.ts")).toBe(false);
@@ -6,7 +6,13 @@
6
6
  */
7
7
 
8
8
  import { describe, expect, test } from "bun:test";
9
- import { formatDuration, Logger, PhaseReporter, ProgressBoard } from "../core/logging";
9
+ import {
10
+ formatDuration,
11
+ Logger,
12
+ ParallelPhaseReporter,
13
+ PhaseReporter,
14
+ ProgressBoard,
15
+ } from "../core/logging";
10
16
 
11
17
  function memoryStream(): { stream: NodeJS.WritableStream; text: () => string } {
12
18
  let content = "";
@@ -64,6 +70,28 @@ describe("PhaseReporter", () => {
64
70
  });
65
71
  });
66
72
 
73
+ describe("ParallelPhaseReporter", () => {
74
+ test("em pipe aceita fases concorrentes e loga cada uma", () => {
75
+ const { logger, out } = loggerFor(false);
76
+ const reporter = new ParallelPhaseReporter(logger);
77
+ reporter.begin("context", "Resumindo o contexto");
78
+ reporter.begin("business", "Reconstruindo a intenção");
79
+ reporter.end("context", "Contexto resumido", 50);
80
+ reporter.end("business", "Intenção reconstruída", 80);
81
+ reporter.stop();
82
+ const text = out.text();
83
+ expect(text).toContain("→ Resumindo o contexto");
84
+ expect(text).toContain("→ Reconstruindo a intenção");
85
+ expect(text).toContain("✔ Contexto resumido (50ms)");
86
+ expect(text).toContain("✔ Intenção reconstruída (80ms)");
87
+ });
88
+
89
+ test("stop sem fases ativas não lança", () => {
90
+ const { logger } = loggerFor(false);
91
+ expect(() => new ParallelPhaseReporter(logger).stop()).not.toThrow();
92
+ });
93
+ });
94
+
67
95
  describe("ProgressBoard", () => {
68
96
  test("em pipe loga início/fim e não usa ANSI", () => {
69
97
  const { logger, out, err } = loggerFor(false);
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Propagação mecânica — cluster de micro-alterações e lacunas de importadores.
3
+ *
4
+ * Congela a heurística usada no brief para não ler N diffs de uma linha e para
5
+ * apontar contratos cujos importadores ficaram fora do diff.
6
+ */
7
+
8
+ import { describe, expect, test } from "bun:test";
9
+ import type { AstAnalysis, AstImpact } from "../core/ast/types";
10
+ import { detectPropagation, detectPropagationGaps } from "../core/propagation";
11
+ import type { ChurnMap } from "../core/types";
12
+
13
+ const files = Array.from({ length: 7 }, (_, index) => `src/f${index}.ts`);
14
+
15
+ function patchFor(line: string): Map<string, string> {
16
+ return new Map(
17
+ files.map((file) => [file, `@@ -1 +1 @@\n- await pay(charge);\n+${line}`]),
18
+ );
19
+ }
20
+
21
+ function churnFor(extra: ChurnMap = {}): ChurnMap {
22
+ const base: ChurnMap = Object.fromEntries(
23
+ files.map((file) => [file, { additions: 1, deletions: 1 }]),
24
+ );
25
+ return { ...base, ...extra };
26
+ }
27
+
28
+ describe("detectPropagation", () => {
29
+ test("agrupa o padrão dominante e isola os outliers", () => {
30
+ const patch = patchFor(" await pay(charge, true);");
31
+ patch.set(
32
+ "src/f6.ts",
33
+ "@@ -1 +1 @@\n- await pay(charge);\n+ await pay(charge, false);",
34
+ );
35
+ const cluster = detectPropagation(files, patch, churnFor());
36
+ expect(cluster?.files).toEqual(files.slice(0, 6));
37
+ expect(cluster?.lines).toEqual(["await pay(charge, true);"]);
38
+ expect(cluster?.outliers).toEqual([
39
+ { file: "src/f6.ts", lines: ["await pay(charge, false);"] },
40
+ ]);
41
+ });
42
+
43
+ test("ignora arquivos com churn acima do limite de micro-alteração", () => {
44
+ const patch = patchFor(" await pay(charge, true);");
45
+ patch.set("src/big.ts", "+ await pay(charge, true);");
46
+ const cluster = detectPropagation([...files, "src/big.ts"], patch, {
47
+ ...churnFor(),
48
+ "src/big.ts": { additions: 40, deletions: 0 },
49
+ });
50
+ expect(cluster?.files).not.toContain("src/big.ts");
51
+ });
52
+
53
+ test("sem grupo dominante devolve null", () => {
54
+ const patch = new Map(
55
+ files.slice(0, 3).map((file) => [file, "+ await pay(charge, true);"]),
56
+ );
57
+ expect(detectPropagation(files.slice(0, 3), patch, churnFor())).toBeNull();
58
+ });
59
+ });
60
+
61
+ describe("detectPropagationGaps", () => {
62
+ const analysis = {
63
+ facts: {},
64
+ delta: {
65
+ addedSymbols: [],
66
+ removedSymbols: [],
67
+ modifiedSymbols: [],
68
+ changedSignatures: [
69
+ {
70
+ name: "pay",
71
+ before: "pay(charge)",
72
+ after: "pay(charge, x)",
73
+ exported: true,
74
+ },
75
+ ],
76
+ changedExports: [],
77
+ addedImports: [],
78
+ removedImports: [],
79
+ },
80
+ } as unknown as AstAnalysis;
81
+ const impact = {
82
+ affectedFiles: [],
83
+ importers: {
84
+ "src/types.ts": ["src/a.ts", "src/b.ts"],
85
+ "src/other.ts": ["src/c.ts"],
86
+ },
87
+ exportedChanges: [],
88
+ } as unknown as AstImpact;
89
+
90
+ test("lista só importadores que não estão no diff", () => {
91
+ const gaps = detectPropagationGaps(
92
+ ["src/types.ts", "src/other.ts"],
93
+ new Map([["src/types.ts", analysis]]),
94
+ impact,
95
+ new Set(["src/a.ts"]),
96
+ );
97
+ expect(gaps).toEqual([{ file: "src/types.ts", importers: ["src/b.ts"] }]);
98
+ });
99
+
100
+ test("ignora mudança que não toca contrato exportado", () => {
101
+ const internal = {
102
+ ...analysis,
103
+ delta: {
104
+ ...analysis.delta,
105
+ changedSignatures: [
106
+ { name: "pay", before: "pay(a)", after: "pay(b)", exported: false },
107
+ ],
108
+ },
109
+ } as unknown as AstAnalysis;
110
+ expect(
111
+ detectPropagationGaps(
112
+ ["src/types.ts"],
113
+ new Map([["src/types.ts", internal]]),
114
+ impact,
115
+ new Set(),
116
+ ),
117
+ ).toEqual([]);
118
+ });
119
+ });
@@ -95,6 +95,63 @@ describe("renderReviewResume", () => {
95
95
  expect(md).toContain("Sem mudanças estruturais detectadas nesta revisão.");
96
96
  });
97
97
 
98
+ test("inclui cobertura por domínio, candidatos e conclusão", () => {
99
+ const md = renderReviewResume({
100
+ branch: "feat/x",
101
+ date: "2026-10-17",
102
+ classification,
103
+ findings: [
104
+ finding({ severity: "blocking" }),
105
+ finding({ severity: "attention", file: "src/b.ts" }),
106
+ ],
107
+ domains: [
108
+ {
109
+ id: "alpha",
110
+ name: "Alpha",
111
+ findings: 1,
112
+ reviewedFiles: 1,
113
+ expectedFiles: 2,
114
+ },
115
+ {
116
+ id: "beta",
117
+ name: "Beta",
118
+ findings: 1,
119
+ reviewedFiles: 1,
120
+ expectedFiles: 1,
121
+ },
122
+ ],
123
+ candidates: [
124
+ {
125
+ watchdogId: "alpha",
126
+ rule: "Suspeita X",
127
+ severity: "attention",
128
+ file: "src/c.ts",
129
+ confidence: 0.7,
130
+ questionKey: "q1",
131
+ },
132
+ {
133
+ watchdogId: "alpha",
134
+ rule: "regra",
135
+ severity: "attention",
136
+ file: "src/a.ts",
137
+ confidence: 0.7,
138
+ questionKey: "q2",
139
+ },
140
+ ],
141
+ });
142
+
143
+ expect(md).toContain("## Cobertura por domínio");
144
+ expect(md).toContain("| Alpha | 1 | 1/2 | — | 1 |");
145
+ expect(md).toContain("| Beta | 1 | 1/1 | — | — |");
146
+ expect(md).toContain("## Candidatos não verificados (1)");
147
+ expect(md).toContain("Suspeita X");
148
+ expect(md).not.toContain("`src/a.ts` ·");
149
+ expect(md).toContain("## Conclusão");
150
+ expect(md).toContain(
151
+ "**Resumo:** 1 bloqueantes · 1 atenção · 0 sugestões · 0 nits.",
152
+ );
153
+ });
154
+
98
155
  test("respeita o teto de caracteres", () => {
99
156
  const findings = Array.from({ length: 40 }, (_, index) =>
100
157
  finding({
@@ -124,6 +181,8 @@ describe("renderReviewResume", () => {
124
181
  });
125
182
 
126
183
  expect(md.length).toBeLessThanOrEqual(RESUME_MAX_CHARS);
127
- expect(md.endsWith("…")).toBe(true);
184
+ // O corte acontece no texto narrativo; tabelas e conclusão seguem inteiras.
185
+ expect(md).toContain("\n…");
186
+ expect(md).toContain("## Conclusão");
128
187
  });
129
188
  });
@@ -256,7 +256,7 @@ describe("prepare/finalize — mesmo commit", () => {
256
256
  };
257
257
 
258
258
  let ctx = reviewContext(storage, record);
259
- await prepare(opts, "main", fakeHoundsmanConfig, ctx);
259
+ await prepare(ctx, fakeHoundsmanConfig, opts);
260
260
  expect(storage.getReview(record.id)?.status).toBe("reviewing");
261
261
 
262
262
  writeFileSync(
@@ -281,30 +281,46 @@ describe("prepare/finalize — mesmo commit", () => {
281
281
  2,
282
282
  )}\n`,
283
283
  );
284
- finalize(opts, "main", fakeHoundsmanConfig, ctx);
284
+ writeFileSync(
285
+ ctx.work.findingFile("general"),
286
+ `${JSON.stringify(
287
+ {
288
+ watchdogId: "general",
289
+ reviewedFiles: ["src/alpha/a.ts"],
290
+ skipped: [],
291
+ findings: [],
292
+ },
293
+ null,
294
+ 2,
295
+ )}\n`,
296
+ );
297
+ finalize(ctx, fakeHoundsmanConfig, opts);
285
298
 
286
299
  const classificationBefore = storage.readArtifact(
287
300
  record.id,
288
301
  "classification.json",
289
302
  )?.content;
290
- const reportBefore = storage.readArtifact(record.id, "review.md")?.content;
303
+ const reportBefore = storage.readArtifact(
304
+ record.id,
305
+ "review-resume.md",
306
+ )?.content;
291
307
  const findingsBefore = JSON.stringify(
292
308
  storage.readFindings(record.id)?.findings,
293
309
  );
294
310
  const reviewedBefore = storage.getReview(record.id)?.reviewedSha;
295
311
  expect(classificationBefore).toBeTruthy();
296
- expect(reportBefore).toContain("src/alpha/a.ts");
312
+ expect(reportBefore).toContain("## Cobertura por domínio");
297
313
 
298
314
  // Mesmo commit: prepare vira no-op e nada é reescrito.
299
315
  ctx = reviewContext(storage, storage.getReview(record.id) ?? record);
300
- const again = await prepare(opts, "main", fakeHoundsmanConfig, ctx);
316
+ const again = await prepare(ctx, fakeHoundsmanConfig, opts);
301
317
  expect(again.messages.join("\n")).toContain(
302
318
  "Nenhuma alteração desde a última revisão",
303
319
  );
304
320
  expect(storage.readArtifact(record.id, "classification.json")?.content).toBe(
305
321
  classificationBefore,
306
322
  );
307
- expect(storage.readArtifact(record.id, "review.md")?.content).toBe(
323
+ expect(storage.readArtifact(record.id, "review-resume.md")?.content).toBe(
308
324
  reportBefore,
309
325
  );
310
326
  expect(storage.getReview(record.id)?.status).toBe("review_done");
@@ -312,13 +328,12 @@ describe("prepare/finalize — mesmo commit", () => {
312
328
 
313
329
  // Finalize sem ciclo preparado: no-op (não degrada o relatório).
314
330
  const finalizedAgain = finalize(
315
- opts,
316
- "main",
317
- fakeHoundsmanConfig,
318
331
  reviewContext(storage, storage.getReview(record.id) ?? record),
332
+ fakeHoundsmanConfig,
333
+ opts,
319
334
  );
320
335
  expect(finalizedAgain.messages.join("\n")).toContain("Nada a finalizar");
321
- expect(storage.readArtifact(record.id, "review.md")?.content).toBe(
336
+ expect(storage.readArtifact(record.id, "review-resume.md")?.content).toBe(
322
337
  reportBefore,
323
338
  );
324
339
  expect(JSON.stringify(storage.readFindings(record.id)?.findings)).toBe(
@@ -364,10 +379,9 @@ describe("prepare sem alterações", () => {
364
379
  };
365
380
 
366
381
  const result = await prepare(
367
- opts,
368
- "main",
369
- fakeHoundsmanConfig,
370
382
  reviewContext(storage, record),
383
+ fakeHoundsmanConfig,
384
+ opts,
371
385
  );
372
386
  expect(result.messages.join("\n")).toContain("nenhuma run criada");
373
387
  expect(storage.getReview(record.id)).toBeNull();
@@ -384,12 +398,7 @@ describe("prepare sem alterações", () => {
384
398
  kind: "context",
385
399
  content: "{}\n",
386
400
  });
387
- await prepare(
388
- opts,
389
- "main",
390
- fakeHoundsmanConfig,
391
- reviewContext(storage, kept),
392
- );
401
+ await prepare(reviewContext(storage, kept), fakeHoundsmanConfig, opts);
393
402
  expect(storage.getReview(kept.id)?.status).toBe("review_done");
394
403
  expect(storage.readArtifact(kept.id, "context.json")).not.toBeNull();
395
404
  } finally {
@@ -432,20 +441,20 @@ describe("prepare — progresso", () => {
432
441
  };
433
442
 
434
443
  const events: PrepareProgressEvent[] = [];
435
- await prepare(
436
- opts,
437
- "main",
438
- fakeHoundsmanConfig,
439
- reviewContext(storage, record),
440
- undefined,
441
- undefined,
442
- (event) => events.push(event),
443
- );
444
+ await prepare(reviewContext(storage, record), fakeHoundsmanConfig, opts, {
445
+ onProgress: (event) => events.push(event),
446
+ });
444
447
 
445
448
  const starts = events
446
449
  .filter((event) => event.type === "start")
447
450
  .map((event) => event.stage);
448
- expect(starts).toEqual(["diff", "classify", "briefs", "manifest"]);
451
+ expect(starts).toEqual([
452
+ "diff",
453
+ "classify",
454
+ "business",
455
+ "briefs",
456
+ "manifest",
457
+ ]);
449
458
  const done = events.filter((event) => event.type === "done");
450
459
  expect(done.map((event) => event.stage)).toEqual(starts);
451
460
  for (const event of done) {