@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
@@ -155,6 +155,52 @@ describe("temperature e providerOptions", () => {
155
155
  deepseek: { thinking: { type: "disabled" } },
156
156
  });
157
157
  });
158
+
159
+ test("objetos estruturados desligam thinking por default", async () => {
160
+ const provider = new FakeModelProvider(() => ({ ok: true }));
161
+ const client = new AiClient({ project, provider });
162
+ await client.generateObject("business", {
163
+ prompt: "oi",
164
+ outputSchema: z.object({ ok: z.boolean() }),
165
+ });
166
+ expect(provider.requests[0]?.providerOptions).toEqual({
167
+ deepseek: { thinking: { type: "disabled" } },
168
+ });
169
+ });
170
+
171
+ test("thinking configurado pelo projeto vence o default", async () => {
172
+ const configured = new ProjectConfig({
173
+ ...fakeHoundsmanConfig,
174
+ ai: { providerOptions: { deepseek: { thinking: { type: "enabled" } } } },
175
+ });
176
+ const provider = new FakeModelProvider(() => ({ ok: true }));
177
+ const client = new AiClient({ project: configured, provider });
178
+ await client.generateObject("business", {
179
+ prompt: "oi",
180
+ outputSchema: z.object({ ok: z.boolean() }),
181
+ });
182
+ expect(provider.requests[0]?.providerOptions).toEqual({
183
+ deepseek: { thinking: { type: "enabled" } },
184
+ });
185
+ });
186
+
187
+ test("resposta truncada falha e é registrada sem debitar o budget", async () => {
188
+ const provider = new FakeModelProvider(
189
+ () => ({ ok: true }),
190
+ "fake-model",
191
+ "length",
192
+ );
193
+ const client = new AiClient({ project, provider });
194
+ const result = await client.generateObject("brief", {
195
+ prompt: "oi",
196
+ outputSchema: z.object({ ok: z.boolean() }),
197
+ maxOutputTokens: 100,
198
+ });
199
+ expect(result.ok).toBe(false);
200
+ expect(client.recorder.total.calls).toBe(0);
201
+ expect(client.recorder.failures[0]?.finishReason).toBe("length");
202
+ expect(client.recorder.summary.attempts).toBe(1);
203
+ });
158
204
  });
159
205
 
160
206
  describe("avisos do AI SDK", () => {
@@ -5,12 +5,15 @@
5
5
  import { describe, expect, test } from "bun:test";
6
6
  import { AiClient } from "../core/ai";
7
7
  import { AiBriefEnricher } from "../core/ai/features/brief-enricher";
8
+ import { AiBusinessAnalyzer } from "../core/ai/features/business-analyzer";
9
+ import { AiBusinessSummarizer } from "../core/ai/features/business-summarizer";
8
10
  import { AiClassifier } from "../core/ai/features/classifier";
9
11
  import { AiContextSummarizer } from "../core/ai/features/context-summarizer";
10
12
  import { AiPreAnalyzer } from "../core/ai/features/pre-analyzer";
11
13
  import { AiTechnicalSummarizer } from "../core/ai/features/technical-summarizer";
12
14
  import { AiVerifier } from "../core/ai/features/verifier";
13
15
  import { briefInstructions, briefPrompt } from "../core/ai/prompts/brief";
16
+ import { businessInstructions } from "../core/ai/prompts/business";
14
17
  import { contextInstructions } from "../core/ai/prompts/context";
15
18
  import { technicalInstructions } from "../core/ai/prompts/technical";
16
19
  import { renderBrief } from "../core/brief";
@@ -116,8 +119,8 @@ describe("AiBriefEnricher", () => {
116
119
  focus: [],
117
120
  questions: [],
118
121
  risks: [],
119
- logic: ["nome vs comportamento"],
120
- domain: ["transição de estado"],
122
+ logic: [{ statement: "nome vs comportamento" }],
123
+ domain: [{ statement: "transição de estado" }],
121
124
  },
122
125
  });
123
126
  expect(markdown).toContain("Consistência lógica");
@@ -221,10 +224,98 @@ describe("AiPreAnalyzer", () => {
221
224
  });
222
225
 
223
226
  describe("idioma dos resumos", () => {
224
- test("prompts de contexto e pontos técnicos exigem pt-BR", () => {
225
- for (const instructions of [contextInstructions(), technicalInstructions()]) {
227
+ test("prompts de contexto, intenção e pontos técnicos exigem pt-BR", () => {
228
+ for (const instructions of [
229
+ contextInstructions(),
230
+ businessInstructions(),
231
+ technicalInstructions(),
232
+ ]) {
226
233
  expect(instructions).toContain("português do Brasil");
227
234
  expect(instructions).toContain("pt-BR");
228
235
  }
229
236
  });
230
237
  });
238
+
239
+ describe("AiBusinessSummarizer", () => {
240
+ const input = {
241
+ classification: {
242
+ files: { "src/alpha/a.ts": { status: "modified" as const } },
243
+ churn: { "src/alpha/a.ts": { additions: 3, deletions: 1 } },
244
+ watchdogs: { alpha: ["src/alpha/a.ts"] },
245
+ },
246
+ domains: [{ id: "alpha", name: "Alpha" }],
247
+ headSha: "abc123",
248
+ };
249
+
250
+ test("devolve o modelo com source ai", async () => {
251
+ const provider = new FakeModelProvider(() => ({
252
+ intent: "Entregar o split",
253
+ flows: [{ name: "Renovação", trigger: "job", files: ["src/alpha/a.ts"] }],
254
+ invariants: ["split sempre presente"],
255
+ contracts: [{ name: "AutomaticPix", sides: ["asaas", "processor"] }],
256
+ states: [],
257
+ }));
258
+ const summarizer = new AiBusinessSummarizer(new AiClient({ project, provider }));
259
+ const summary = await summarizer.summarize(input);
260
+ expect(summary?.source).toBe("ai");
261
+ expect(summary?.headSha).toBe("abc123");
262
+ expect(summary?.flows[0]?.name).toBe("Renovação");
263
+ expect(summary?.invariants).toEqual(["split sempre presente"]);
264
+ });
265
+
266
+ test("sem IA → null (fallback determinístico no chamador)", async () => {
267
+ const summarizer = new AiBusinessSummarizer(
268
+ new AiClient({ project, provider: null }),
269
+ );
270
+ expect(summarizer.enabled).toBe(false);
271
+ expect(await summarizer.summarize(input)).toBeNull();
272
+ });
273
+ });
274
+
275
+ describe("AiBusinessAnalyzer", () => {
276
+ test("gera candidatos do general e descarta arquivo fora do escopo", async () => {
277
+ const provider = new FakeModelProvider(() => ({
278
+ hypotheses: [
279
+ {
280
+ file: "src/alpha/a.ts",
281
+ kind: "domain",
282
+ statement: "renovação perde o split",
283
+ confidence: 0.9,
284
+ },
285
+ {
286
+ file: "src/outro.ts",
287
+ kind: "logic",
288
+ statement: "fora do escopo",
289
+ confidence: 0.9,
290
+ },
291
+ {
292
+ file: "src/alpha/a.ts",
293
+ kind: "logic",
294
+ statement: "confiança baixa",
295
+ confidence: 0.2,
296
+ },
297
+ ],
298
+ }));
299
+ const analyzer = new AiBusinessAnalyzer(new AiClient({ project, provider }));
300
+ const candidates = await analyzer.analyze({
301
+ business: {
302
+ source: "deterministic",
303
+ flows: [],
304
+ invariants: [],
305
+ contracts: [],
306
+ states: [],
307
+ },
308
+ payload: {
309
+ classification: {
310
+ files: { "src/alpha/a.ts": { status: "modified" } },
311
+ churn: {},
312
+ watchdogs: {},
313
+ },
314
+ },
315
+ });
316
+ expect(candidates).toHaveLength(1);
317
+ expect(candidates[0]?.watchdogId).toBe("general");
318
+ expect(candidates[0]?.rule).toBe("Domínio: renovação perde o split");
319
+ expect(candidates[0]?.severity).toBe("attention");
320
+ });
321
+ });
@@ -29,12 +29,6 @@ import { defineHoundsmanConfig } from "../core/config";
29
29
  import { defaultOptions, finalize, prepare, reviewContext } from "../core/orchestrator";
30
30
  import { displayPath } from "../core/paths";
31
31
  import { RESUME_MAX_CHARS } from "../core/renderers/resume-renderer";
32
- import {
33
- type MergedFinding,
34
- renderDomainReport,
35
- renderReport,
36
- type WatchdogReport,
37
- } from "../core/report-builder";
38
32
  import { resolveRepoIdentity } from "../core/storage/repo-identity";
39
33
  import { SQLiteStorageProvider } from "../core/storage/sqlite/sqlite-storage";
40
34
  import { defineWatchdog } from "../core/types";
@@ -157,112 +151,6 @@ describe("baseline — brief", () => {
157
151
  });
158
152
  });
159
153
 
160
- // ---------------------------------------------------------------------------
161
- // Relatório
162
- // ---------------------------------------------------------------------------
163
-
164
- const reports: WatchdogReport[] = [
165
- {
166
- watchdogId: "alpha",
167
- reviewedFiles: ["src/alpha/a.ts", "src/alpha/b.ts"],
168
- skipped: ["src/alpha/c.ts"],
169
- findings: [
170
- {
171
- rule: "Nunca usar `any`.",
172
- severity: "blocking",
173
- file: "src/alpha/a.ts",
174
- line: 1,
175
- message: "any detectado",
176
- action: "tipar",
177
- evidence: "export const a: any = 1;",
178
- id: "id-alpha",
179
- },
180
- {
181
- rule: "Promover: validar a entrada.",
182
- severity: "attention",
183
- file: "src/alpha/b.ts",
184
- line: 2,
185
- message: "sem validação",
186
- action: "adicionar schema",
187
- id: "id-beta",
188
- },
189
- ],
190
- },
191
- {
192
- watchdogId: "beta",
193
- reviewedFiles: ["src/beta/x.test.ts"],
194
- skipped: [],
195
- findings: [],
196
- },
197
- ];
198
-
199
- const merged: MergedFinding[] = [
200
- { ...reports[0].findings[0], watchdogs: ["alpha"] },
201
- { ...reports[0].findings[1], watchdogs: ["alpha", "beta"] },
202
- ];
203
-
204
- const reportContext = {
205
- available: true,
206
- source: {
207
- number: 42,
208
- title: "Entrega X",
209
- url: "https://example.test/pull/42",
210
- head: "feature/x",
211
- base: "main",
212
- },
213
- objective: "Entregar X",
214
- motivation: "Reduzir Y",
215
- changeSummary: ["Faz A", "Remove B"],
216
- };
217
-
218
- describe("baseline — relatório", () => {
219
- test("renderReport consolidado", () => {
220
- const md = renderReport(
221
- "feature/x",
222
- reports,
223
- merged,
224
- { classified: { alpha: ["src/alpha/a.ts", "src/alpha/b.ts"], beta: [] } },
225
- reportContext,
226
- [
227
- {
228
- watchdogId: "alpha",
229
- rule: "Nunca usar `any`.",
230
- severity: "blocking",
231
- file: "src/alpha/a.ts",
232
- confidence: 0.82,
233
- questionKey: "alpha::rule-0",
234
- },
235
- ],
236
- (id) => (id === "alpha" ? "Alpha" : "Beta"),
237
- [
238
- {
239
- id: "alpha",
240
- name: "Alpha",
241
- file: "review.alpha.md",
242
- findings: 2,
243
- reviewedFiles: 2,
244
- expectedFiles: 2,
245
- },
246
- ],
247
- );
248
-
249
- expect(normalizeArtifacts(md)).toMatchSnapshot();
250
- });
251
-
252
- test("renderDomainReport isolado", () => {
253
- const md = renderDomainReport(
254
- "feature/x",
255
- reports[0],
256
- merged,
257
- 2,
258
- reportContext,
259
- (id) => (id === "alpha" ? "Alpha" : "Beta"),
260
- );
261
-
262
- expect(normalizeArtifacts(md)).toMatchSnapshot();
263
- });
264
- });
265
-
266
154
  // ---------------------------------------------------------------------------
267
155
  // Camada gerada pelo setup/update
268
156
  // ---------------------------------------------------------------------------
@@ -321,7 +209,7 @@ describe("baseline — camada gerada", () => {
321
209
  // ---------------------------------------------------------------------------
322
210
 
323
211
  describe("baseline — artefatos de prepare/finalize", () => {
324
- test("gera classification, brief, manifest, review e state", async () => {
212
+ test("gera classification, brief, manifest, resumo e state", async () => {
325
213
  const cwd = mkdtempSync(join(tmpdir(), "houndsman-baseline-run-"));
326
214
  const originalCwd = process.cwd();
327
215
  const originalHome = process.env.HOUNDSMAN_HOME;
@@ -388,9 +276,17 @@ describe("baseline — artefatos de prepare/finalize", () => {
388
276
  .replaceAll(homeDisplay, "<cwd>"),
389
277
  );
390
278
 
391
- const prepared = await prepare(opts, "main", config, ctx);
279
+ const prepared = await prepare(ctx, config, opts);
392
280
  expect(normalize(prepared.messages.join("\n"))).toMatchSnapshot();
393
281
 
282
+ // Instrumentação do prepare: etapas medidas sem alterar o manifesto.
283
+ const prepareReport = storage.readArtifact(review.id, "prepare-report.json");
284
+ expect(prepareReport?.kind).toBe("prepare");
285
+ const report = JSON.parse(prepareReport?.content ?? "{}") as {
286
+ stages?: { stage: string; durationMs: number }[];
287
+ };
288
+ expect((report.stages ?? []).length).toBeGreaterThan(0);
289
+
394
290
  for (const name of ["classification.json", "manifest.json"]) {
395
291
  const artifact = storage.readArtifact(review.id, name);
396
292
  expect(normalize(artifact?.content ?? "")).toMatchSnapshot(name);
@@ -425,19 +321,32 @@ describe("baseline — artefatos de prepare/finalize", () => {
425
321
  2,
426
322
  )}\n`,
427
323
  );
324
+ writeFileSync(
325
+ ctx.work.findingFile("general"),
326
+ `${JSON.stringify(
327
+ {
328
+ watchdogId: "general",
329
+ reviewedFiles: ["src/alpha/a.ts", "src/alpha/b.ts"],
330
+ skipped: [],
331
+ findings: [],
332
+ },
333
+ null,
334
+ 2,
335
+ )}\n`,
336
+ );
428
337
 
429
- const finalized = finalize(opts, "main", config, ctx);
338
+ const finalized = finalize(ctx, config, opts);
430
339
  expect(normalize(finalized.messages.join("\n"))).toMatchSnapshot();
431
340
 
432
- for (const name of ["review.md", "review.alpha.md"]) {
433
- const artifact = storage.readArtifact(review.id, name);
434
- expect(normalize(artifact?.content ?? "")).toMatchSnapshot(name);
435
- }
341
+ // Único artefato humano: o resumo final (negócio, técnico, cobertura, conclusão).
436
342
  const resume = storage.readArtifact(review.id, "review-resume.md");
437
343
  expect(resume?.kind).toBe("resume");
438
344
  expect(resume?.content).toContain("## Pontos técnicos");
439
345
  expect(resume?.content).toContain("Remoções");
346
+ expect(resume?.content).toContain("## Cobertura por domínio");
347
+ expect(resume?.content).toContain("## Conclusão");
440
348
  expect((resume?.content ?? "").length).toBeLessThanOrEqual(RESUME_MAX_CHARS);
349
+ expect(normalize(resume?.content ?? "")).toMatchSnapshot("review-resume.md");
441
350
  expect(
442
351
  normalize(JSON.stringify(storage.readFindings(review.id), null, 2)),
443
352
  ).toMatchSnapshot("state.json");
@@ -500,7 +409,7 @@ const REQUIRED_EXPORTS = [
500
409
  "ProjectScaffolder",
501
410
  "FixBatchBuilder",
502
411
  "BriefRenderer",
503
- "ReportRenderer",
412
+ "renderReviewResume",
504
413
  "ManifestWriter",
505
414
  "applyFindingUpdate",
506
415
  "assetPath",
@@ -537,7 +446,6 @@ const REQUIRED_EXPORTS = [
537
446
  "reanchor",
538
447
  "relFromRoot",
539
448
  "renderBrief",
540
- "renderDomainReport",
541
449
  "reorder",
542
450
  "resolveConfigPath",
543
451
  "resolveJevConcurrency",
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Brief — recorte do modelo de intenção e injeção dos hunks.
3
+ *
4
+ * Depois do `business-brief.md`, cada watchdog recebia o modelo inteiro e
5
+ * perseguia fluxos de outros domínios; o brief também não trazia o código
6
+ * alterado, forçando a releitura dos arquivos. Estes testes congelam o recorte.
7
+ */
8
+
9
+ import { describe, expect, test } from "bun:test";
10
+ import { renderBrief } from "../core/brief";
11
+ import { generalWatchdog } from "../core/builtins";
12
+ import type { BusinessSummary } from "../core/business-summary";
13
+ import {
14
+ buildBriefSnippets,
15
+ generalFlowFiles,
16
+ shouldSliceGeneral,
17
+ truncatePatch,
18
+ } from "../core/engine/prepare-pipeline";
19
+ import { buildPrepareReport } from "../core/prepare-report";
20
+ import type { WatchdogRenderArgs } from "../core/types";
21
+ import { alphaWatchdog } from "./fixtures/fake-project";
22
+
23
+ /** Modelo com um fluxo do domínio alpha e outro de fora. */
24
+ const business: BusinessSummary = {
25
+ source: "ai",
26
+ intent: "Renovar assinaturas sem perder a credencial da conta.",
27
+ flows: [
28
+ {
29
+ name: "Alpha flow",
30
+ trigger: "webhook de renovação",
31
+ expected: "assinatura renovada",
32
+ files: ["src/alpha/a.ts"],
33
+ },
34
+ {
35
+ name: "Beta flow",
36
+ trigger: "job de cobrança",
37
+ expected: "cobrança criada",
38
+ files: ["src/beta/b.ts"],
39
+ },
40
+ ],
41
+ invariants: ["A conta do pagador é preservada."],
42
+ contracts: [],
43
+ states: [],
44
+ openQuestions: ["Pergunta global?"],
45
+ };
46
+
47
+ const baseArgs: WatchdogRenderArgs = {
48
+ watchdogId: "alpha",
49
+ files: ["src/alpha/a.ts"],
50
+ statuses: { "src/alpha/a.ts": { status: "modified" } },
51
+ deleted: [],
52
+ branch: "feature/x",
53
+ outputPath: "findings/alpha.json",
54
+ outputFormatPath: "OUTPUT-FORMAT.md",
55
+ docs: [],
56
+ business,
57
+ businessSummaryPath: "/home/.houndsman/work/review/business-summary.json",
58
+ snippets: [
59
+ {
60
+ file: "src/alpha/a.ts",
61
+ snippet: "@@ -1 +1 @@\n-const a = 1;\n+const a: any = 1;",
62
+ },
63
+ ],
64
+ };
65
+
66
+ describe("brief — recorte do modelo de intenção", () => {
67
+ test("watchdog de domínio vê só os fluxos dos seus arquivos", () => {
68
+ const markdown = renderBrief(alphaWatchdog, baseArgs);
69
+ expect(markdown).toContain("Alpha flow");
70
+ expect(markdown).not.toContain("Beta flow");
71
+ expect(markdown).toContain("1 fluxo(s) fora do seu domínio foram omitidos");
72
+ expect(markdown).toContain("não os investigue");
73
+ });
74
+
75
+ test("domínio não recebe o caminho canônico (evita leitura extra)", () => {
76
+ const markdown = renderBrief(alphaWatchdog, baseArgs);
77
+ expect(markdown).not.toContain("business-summary.json");
78
+ });
79
+
80
+ test("general recebe o modelo completo e o caminho canônico", () => {
81
+ const markdown = renderBrief(generalWatchdog, {
82
+ ...baseArgs,
83
+ watchdogId: generalWatchdog.id,
84
+ files: ["src/alpha/a.ts", "src/beta/b.ts"],
85
+ });
86
+ expect(markdown).toContain("Alpha flow");
87
+ expect(markdown).toContain("Beta flow");
88
+ expect(markdown).toContain("Pergunta global?");
89
+ expect(markdown).toContain("business-summary.json");
90
+ });
91
+ });
92
+
93
+ describe("brief — trechos alterados", () => {
94
+ test("injeta o diff e proíbe reler o arquivo inteiro", () => {
95
+ const markdown = renderBrief(alphaWatchdog, baseArgs);
96
+ expect(markdown).toContain("## Trechos alterados (diff)");
97
+ expect(markdown).toContain("const a: any = 1;");
98
+ expect(markdown).toContain("não abra os arquivos");
99
+ });
100
+
101
+ test("sem trechos, a seção não aparece", () => {
102
+ const markdown = renderBrief(alphaWatchdog, {
103
+ ...baseArgs,
104
+ snippets: undefined,
105
+ });
106
+ expect(markdown).not.toContain("## Trechos alterados (diff)");
107
+ });
108
+
109
+ test("renderiza propagação mecânica e lacunas de importadores", () => {
110
+ const markdown = renderBrief(alphaWatchdog, {
111
+ ...baseArgs,
112
+ propagation: {
113
+ lines: ["await pay(charge, true);"],
114
+ files: ["src/alpha/a.ts", "src/alpha/b.ts"],
115
+ outliers: [
116
+ { file: "src/alpha/c.ts", lines: ["await pay(charge, false);"] },
117
+ ],
118
+ },
119
+ propagationGaps: [{ file: "src/alpha/a.ts", importers: ["src/beta/b.ts"] }],
120
+ });
121
+ expect(markdown).toContain("## Propagação mecânica");
122
+ expect(markdown).toContain("await pay(charge, true)");
123
+ expect(markdown).toContain("Outliers (revisar)");
124
+ expect(markdown).toContain("## Possível propagação incompleta");
125
+ expect(markdown).toContain("src/beta/b.ts");
126
+ });
127
+ });
128
+
129
+ describe("prepare-report", () => {
130
+ test("preserva etapas, uso de IA e falhas", () => {
131
+ const report = buildPrepareReport({
132
+ startedAt: "2026-10-25T00:00:00.000Z",
133
+ durationMs: 1_500,
134
+ stages: [
135
+ { stage: "business", durationMs: 900 },
136
+ { stage: "technical", durationMs: 300 },
137
+ ],
138
+ ai: {
139
+ inputTokens: 10,
140
+ outputTokens: 5,
141
+ totalTokens: 15,
142
+ calls: 1,
143
+ attempts: 2,
144
+ failures: [
145
+ {
146
+ feature: "brief",
147
+ model: "deepseek-v4-pro",
148
+ durationMs: 12,
149
+ error: "Resposta truncada",
150
+ finishReason: "length",
151
+ },
152
+ ],
153
+ },
154
+ });
155
+ expect(report.version).toBe(1);
156
+ expect(report.durationMs).toBe(1_500);
157
+ expect(report.stages.map((item) => item.stage)).toEqual([
158
+ "business",
159
+ "technical",
160
+ ]);
161
+ expect(report.ai?.calls).toBe(1);
162
+ expect(report.ai?.attempts).toBe(2);
163
+ expect(report.ai?.failures[0]?.finishReason).toBe("length");
164
+ });
165
+ });
166
+
167
+ describe("prepare — orçamento de snippets", () => {
168
+ const patch = (chars: number) =>
169
+ Array.from(
170
+ { length: Math.ceil(chars / 40) },
171
+ () => "+linha de codigo alterada",
172
+ ).join("\n");
173
+
174
+ test("distribui o teto entre todos os arquivos (não só nos primeiros)", () => {
175
+ const files = Array.from({ length: 10 }, (_, i) => `src/f${i}.ts`);
176
+ const snippets = buildBriefSnippets(
177
+ files,
178
+ new Map(files.map((file) => [file, patch(2_000)])),
179
+ { total: 10_000, perFile: 4_000 },
180
+ );
181
+ expect(snippets).toHaveLength(10);
182
+ // Todos começam com uma parte igual; o excedente vai para os primeiros.
183
+ expect(snippets[0]?.snippet.length).toBeGreaterThan(
184
+ snippets[9]?.snippet.length ?? 0,
185
+ );
186
+ expect(snippets.every((item) => item.snippet.length > 0)).toBe(true);
187
+ });
188
+
189
+ test("corta em linha inteira e sinaliza o trecho truncado", () => {
190
+ const cut = truncatePatch(patch(1_000), 120);
191
+ expect(cut.length).toBeLessThanOrEqual(120);
192
+ expect(cut).toContain("[trecho truncado");
193
+ expect(cut.startsWith("+linha de codigo")).toBe(true);
194
+ expect(truncatePatch("curto", 100)).toBe("curto");
195
+ });
196
+ });
197
+
198
+ describe("general — recorte por fluxo", () => {
199
+ const flowBusiness: BusinessSummary = {
200
+ source: "ai",
201
+ flows: [
202
+ {
203
+ name: "Renovação",
204
+ trigger: "job",
205
+ expected: "assinatura renovada",
206
+ files: ["./src/alpha/a.ts"],
207
+ },
208
+ ],
209
+ invariants: [],
210
+ contracts: [],
211
+ states: [],
212
+ };
213
+
214
+ test("dispara por nº de arquivos ou por linhas alteradas", () => {
215
+ const manyFiles = Array.from({ length: 41 }, (_, i) => `src/f${i}.ts`);
216
+ expect(shouldSliceGeneral({ watchdogs: { general: manyFiles }, churn: {} })).toBe(
217
+ true,
218
+ );
219
+ expect(
220
+ shouldSliceGeneral({
221
+ watchdogs: { general: ["src/a.ts"] },
222
+ churn: { "src/a.ts": { additions: 1_200, deletions: 900 } },
223
+ }),
224
+ ).toBe(true);
225
+ expect(
226
+ shouldSliceGeneral({
227
+ watchdogs: { general: ["src/a.ts"] },
228
+ churn: { "src/a.ts": { additions: 10, deletions: 5 } },
229
+ }),
230
+ ).toBe(false);
231
+ });
232
+
233
+ test("prioriza os arquivos dos fluxos e limita o recorte", () => {
234
+ const files = [
235
+ "src/alpha/a.ts",
236
+ ...Array.from({ length: 19 }, (_, i) => `src/f${i}.ts`),
237
+ ];
238
+ const result = generalFlowFiles(
239
+ {
240
+ watchdogs: { general: files },
241
+ churn: { "src/f19.ts": { additions: 100, deletions: 0 } },
242
+ },
243
+ flowBusiness,
244
+ 3,
245
+ );
246
+ expect(result.selected).toHaveLength(3);
247
+ expect(result.selected[0]).toBe("src/alpha/a.ts");
248
+ expect(result.overflow).toHaveLength(17);
249
+ });
250
+ });