@payconductor/houndsman 0.4.0 → 0.6.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 (196) hide show
  1. package/README.md +35 -41
  2. package/assets/grammars/tree-sitter-javascript.wasm +0 -0
  3. package/assets/grammars/tree-sitter-tsx.wasm +0 -0
  4. package/assets/grammars/tree-sitter-typescript.wasm +0 -0
  5. package/assets/grammars/web-tree-sitter.wasm +0 -0
  6. package/package.json +11 -3
  7. package/src/cli/commands/command.ts +5 -0
  8. package/src/cli/commands/doctor-command.ts +116 -24
  9. package/src/cli/commands/hunt-command.ts +25 -0
  10. package/src/cli/commands/status-command.ts +46 -0
  11. package/src/cli/help.ts +18 -1
  12. package/src/cli/index.ts +13 -6
  13. package/src/core/ai/ai-client.ts +221 -0
  14. package/src/core/ai/ai-sdk-model-provider.ts +101 -0
  15. package/src/core/ai/errors.ts +36 -0
  16. package/src/core/ai/features/brief-enricher.ts +62 -0
  17. package/src/core/ai/features/classifier.ts +50 -0
  18. package/src/core/ai/features/context-summarizer.ts +31 -0
  19. package/src/core/ai/features/pre-analyzer.ts +51 -0
  20. package/src/core/ai/features/verifier.ts +45 -0
  21. package/src/core/ai/index.ts +56 -0
  22. package/src/core/ai/model-factory.ts +137 -0
  23. package/src/core/ai/prompts/base.ts +36 -0
  24. package/src/core/ai/prompts/brief.ts +87 -0
  25. package/src/core/ai/prompts/classification.ts +36 -0
  26. package/src/core/ai/prompts/context.ts +29 -0
  27. package/src/core/ai/prompts/pre-analysis.ts +49 -0
  28. package/src/core/ai/prompts/verify.ts +40 -0
  29. package/src/core/ai/schemas.ts +110 -0
  30. package/src/core/ai/types.ts +76 -0
  31. package/src/core/ai/usage-budget.ts +75 -0
  32. package/src/core/ai/usage-recorder.ts +79 -0
  33. package/src/core/assets.ts +16 -0
  34. package/src/core/ast/diff.ts +220 -0
  35. package/src/core/ast/impact.ts +127 -0
  36. package/src/core/ast/parser.ts +167 -0
  37. package/src/core/ast/service.ts +233 -0
  38. package/src/core/ast/symbols.ts +294 -0
  39. package/src/core/ast/types.ts +125 -0
  40. package/src/core/brief.ts +138 -0
  41. package/src/core/commands/findings.ts +120 -6
  42. package/src/core/commands/review.ts +2 -0
  43. package/src/core/commands/scaffold.ts +159 -10
  44. package/src/core/commands/status.ts +63 -0
  45. package/src/core/commands/update.ts +5 -3
  46. package/src/core/config.ts +85 -0
  47. package/src/core/defaults.ts +27 -0
  48. package/src/core/domain/watchdog.ts +43 -5
  49. package/src/core/engine/houndsman-engine.ts +16 -1
  50. package/src/core/engine/review-scope.ts +77 -13
  51. package/src/core/engine/review-session.ts +27 -2
  52. package/src/core/env.ts +101 -0
  53. package/src/core/file-classifier.ts +25 -4
  54. package/src/core/findings-state.ts +21 -4
  55. package/src/core/github-review.ts +620 -57
  56. package/src/core/jev/jev-client.ts +8 -7
  57. package/src/core/jev/jev-verify.ts +2 -1
  58. package/src/core/logging/index.ts +17 -0
  59. package/src/core/logging/logger.ts +187 -0
  60. package/src/core/orchestrator.ts +454 -16
  61. package/src/core/paths.ts +3 -0
  62. package/src/core/project-config.ts +123 -1
  63. package/src/core/registry-check.ts +148 -6
  64. package/src/core/renderers/manifest-writer.ts +33 -0
  65. package/src/core/renderers/resume-renderer.ts +151 -0
  66. package/src/core/report-builder.ts +7 -4
  67. package/src/core/rule-text.ts +97 -0
  68. package/src/core/run-status.ts +39 -5
  69. package/src/core/services/file-classifier.ts +10 -3
  70. package/src/core/services/verifier.ts +14 -3
  71. package/src/core/storage/file/file-storage.ts +2 -0
  72. package/src/core/storage/paths.ts +6 -5
  73. package/src/core/storage/sqlite/mappers.ts +2 -0
  74. package/src/core/storage/sqlite/schema.ts +9 -1
  75. package/src/core/storage/sqlite/sqlite-storage.ts +5 -1
  76. package/src/core/storage/types.ts +15 -0
  77. package/src/core/storage/work-area.ts +12 -0
  78. package/src/core/types.ts +37 -1
  79. package/src/core/verify.ts +17 -5
  80. package/src/harness/README.md +15 -0
  81. package/src/harness/agent/agent-context.ts +33 -0
  82. package/src/harness/agent/agent-errors.ts +30 -0
  83. package/src/harness/agent/agent-factory.ts +45 -0
  84. package/src/harness/agent/agent-reporter.ts +34 -0
  85. package/src/harness/agent/agent-result.ts +16 -0
  86. package/src/harness/agent/agent-runner.ts +32 -0
  87. package/src/harness/agent/agent-scheduler.ts +37 -0
  88. package/src/harness/agent/review-agent.ts +391 -0
  89. package/src/harness/config/harness-config.ts +84 -0
  90. package/src/harness/hunt/hunt-harness.ts +83 -0
  91. package/src/harness/hunt/hunt-options.ts +75 -0
  92. package/src/harness/hunt/hunt-result.ts +17 -0
  93. package/src/harness/hunt/hunt-session.ts +372 -0
  94. package/src/harness/hunt/review-planner.ts +98 -0
  95. package/src/harness/index.ts +36 -0
  96. package/src/harness/investigation/agent-state.ts +134 -0
  97. package/src/harness/investigation/evidence-verifier.ts +129 -0
  98. package/src/harness/investigation/evidence.ts +110 -0
  99. package/src/harness/investigation/finding-critic.ts +96 -0
  100. package/src/harness/investigation/investigation-planner.ts +118 -0
  101. package/src/harness/investigation/investigation-task.ts +10 -0
  102. package/src/harness/investigation/review-journal.ts +87 -0
  103. package/src/harness/investigation/rule-analyzer.ts +178 -0
  104. package/src/harness/investigation/rule-signals.ts +110 -0
  105. package/src/harness/prompts/agent-schemas.ts +57 -0
  106. package/src/harness/prompts/reviewer-prompt-builder.ts +101 -0
  107. package/src/harness/prompts/states/conclude-prompt.ts +16 -0
  108. package/src/harness/prompts/states/investigate-prompt.ts +37 -0
  109. package/src/harness/prompts/states/orient-prompt.ts +15 -0
  110. package/src/harness/prompts/states/verify-prompt.ts +22 -0
  111. package/src/harness/tools/agent-tool.ts +102 -0
  112. package/src/harness/tools/builtin/changed-symbols-tool.ts +48 -0
  113. package/src/harness/tools/builtin/doc-context-tool.ts +74 -0
  114. package/src/harness/tools/builtin/file-outline-tool.ts +66 -0
  115. package/src/harness/tools/builtin/find-references-tool.ts +66 -0
  116. package/src/harness/tools/builtin/find-symbol-tool.ts +91 -0
  117. package/src/harness/tools/builtin/helpers.ts +115 -0
  118. package/src/harness/tools/builtin/impact-tool.ts +48 -0
  119. package/src/harness/tools/builtin/imports-exports-tool.ts +53 -0
  120. package/src/harness/tools/builtin/index.ts +59 -0
  121. package/src/harness/tools/builtin/list-files-tool.ts +85 -0
  122. package/src/harness/tools/builtin/pr-context-tool.ts +41 -0
  123. package/src/harness/tools/builtin/read-file-range-tool.ts +73 -0
  124. package/src/harness/tools/builtin/rule-signals-tool.ts +36 -0
  125. package/src/harness/tools/builtin/search-code-tool.ts +65 -0
  126. package/src/harness/tools/builtin/symbol-context-tool.ts +106 -0
  127. package/src/harness/tools/builtin/tests-for-symbol-tool.ts +61 -0
  128. package/src/harness/tools/path-guard.ts +90 -0
  129. package/src/harness/tools/result-limiter.ts +49 -0
  130. package/src/harness/tools/secret-redactor.ts +54 -0
  131. package/src/harness/tools/tool-cache.ts +50 -0
  132. package/src/harness/tools/tool-call-budget.ts +60 -0
  133. package/src/harness/tools/tool-context.ts +5 -0
  134. package/src/harness/tools/tool-middleware.ts +105 -0
  135. package/src/harness/tools/tool-policy.ts +51 -0
  136. package/src/harness/tools/tool-registry.ts +112 -0
  137. package/src/harness/tools/tool-result.ts +15 -0
  138. package/src/harness/tools/toolbox.ts +36 -0
  139. package/src/harness/validation/finding-validator.ts +119 -0
  140. package/src/index.ts +46 -0
  141. package/src/server/client/App.tsx +26 -9
  142. package/src/server/client/ArtifactsPanel.tsx +12 -5
  143. package/src/server/client/AutoRefresh.tsx +1 -1
  144. package/src/server/client/Filters.tsx +7 -2
  145. package/src/server/client/FindingCard.tsx +14 -10
  146. package/src/server/client/FindingsList.tsx +2 -2
  147. package/src/server/client/MarkdownText.tsx +92 -12
  148. package/src/server/client/ResumePanel.tsx +368 -0
  149. package/src/server/client/RunList.tsx +3 -3
  150. package/src/server/client/SubmitModal.tsx +49 -6
  151. package/src/server/client/SummaryBar.tsx +1 -1
  152. package/src/server/client/icons.tsx +17 -0
  153. package/src/server/client/{constants.ts → utils/constants.ts} +61 -10
  154. package/src/server/client/utils/markdown.ts +319 -0
  155. package/src/server/client/{types.ts → utils/types.ts} +64 -0
  156. package/src/server/index.ts +66 -7
  157. package/src/server/run-repository.ts +9 -0
  158. package/src/skill/OUTPUT-FORMAT.md +12 -0
  159. package/src/skill/SETUP.md +6 -2
  160. package/src/skill/example-review.md +22 -14
  161. package/src/skill/hunt.md +45 -19
  162. package/src/skill/verify.md +3 -2
  163. package/src/tests/__snapshots__/baseline.test.ts.snap +126 -42
  164. package/src/tests/ai-core.test.ts +120 -0
  165. package/src/tests/ai-features.test.ts +183 -0
  166. package/src/tests/ast.test.ts +335 -0
  167. package/src/tests/baseline.test.ts +3 -0
  168. package/src/tests/code-highlight.test.ts +1 -1
  169. package/src/tests/env.test.ts +69 -0
  170. package/src/tests/fallback.test.ts +172 -0
  171. package/src/tests/findings-state.test.ts +50 -2
  172. package/src/tests/fixtures/fake-agent-tools.ts +69 -0
  173. package/src/tests/fixtures/fake-investigation.ts +64 -0
  174. package/src/tests/fixtures/fake-model-provider.ts +48 -0
  175. package/src/tests/github-review.test.ts +409 -60
  176. package/src/tests/harness-agent.test.ts +122 -0
  177. package/src/tests/harness-investigation.test.ts +206 -0
  178. package/src/tests/harness-tools.test.ts +148 -0
  179. package/src/tests/hunt-command.test.ts +212 -0
  180. package/src/tests/logger.test.ts +83 -0
  181. package/src/tests/markdown.test.ts +61 -1
  182. package/src/tests/review-resume.test.ts +101 -0
  183. package/src/tests/review-scope.test.ts +302 -0
  184. package/src/tests/review-scripts.test.ts +73 -0
  185. package/src/tests/rule-analyzer.test.ts +61 -0
  186. package/src/tests/run-status-view.test.ts +94 -0
  187. package/src/tests/run-status.test.ts +53 -0
  188. package/src/tests/server.test.ts +76 -21
  189. package/src/tests/storage.test.ts +63 -0
  190. package/src/tests/tool-contracts.test.ts +82 -0
  191. package/src/tests/verify.test.ts +1 -1
  192. package/src/server/client/markdown.ts +0 -153
  193. /package/src/server/client/{api.ts → utils/api.ts} +0 -0
  194. /package/src/server/client/{code-highlight.ts → utils/code-highlight.ts} +0 -0
  195. /package/src/server/client/{format.ts → utils/format.ts} +0 -0
  196. /package/src/server/client/{json-highlight.ts → utils/json-highlight.ts} +0 -0
@@ -3,7 +3,13 @@
3
3
  */
4
4
 
5
5
  import { describe, expect, test } from "bun:test";
6
- import { parseInline, parseMarkdown, safeUrl } from "../server/client/markdown";
6
+ import {
7
+ isFilePath,
8
+ parseInline,
9
+ parseMarkdown,
10
+ safeUrl,
11
+ sectionBlocks,
12
+ } from "../server/client/utils/markdown";
7
13
 
8
14
  describe("parseMarkdown", () => {
9
15
  test("separa parágrafos, listas, quote e bloco de código", () => {
@@ -42,6 +48,40 @@ describe("parseMarkdown", () => {
42
48
  test("texto vazio não gera blocos", () => {
43
49
  expect(parseMarkdown(" \n\n ")).toEqual([]);
44
50
  });
51
+
52
+ test("reconhece títulos, tabela e régua horizontal", () => {
53
+ const blocks = parseMarkdown(
54
+ "# Relatório\n\n| Arquivo | Severidade |\n|---|:---:|\n| a.ts | blocking |\n\n---",
55
+ );
56
+ expect(blocks).toEqual([
57
+ { type: "heading", level: 1, text: "Relatório" },
58
+ {
59
+ type: "table",
60
+ align: [undefined, "center"],
61
+ header: ["Arquivo", "Severidade"],
62
+ rows: [["a.ts", "blocking"]],
63
+ },
64
+ { type: "hr" },
65
+ ]);
66
+ });
67
+
68
+ test("colunas só de números e barra alinham à direita", () => {
69
+ const blocks = parseMarkdown(
70
+ "| Arquivo | Linhas | Razão |\n|---|---|---|\n| a.ts | 12 | 3/4 |\n| b.ts | 7 | 1/2 |",
71
+ );
72
+ expect(blocks[0]).toMatchObject({
73
+ type: "table",
74
+ align: [undefined, "right", "right"],
75
+ });
76
+ });
77
+
78
+ test("extrai os blocos de uma seção específica", () => {
79
+ const blocks = sectionBlocks(
80
+ "# Título\n\n## Resumo de negócio\n\nTexto A.\n\n## Pontos técnicos\n\nTexto B.",
81
+ "resumo de negócio",
82
+ );
83
+ expect(blocks).toEqual([{ type: "paragraph", text: "Texto A." }]);
84
+ });
45
85
  });
46
86
 
47
87
  describe("parseInline", () => {
@@ -74,6 +114,26 @@ describe("parseInline", () => {
74
114
  { type: "em", text: "rápido" },
75
115
  ]);
76
116
  });
117
+
118
+ test("destaca caminhos de arquivo no texto corrido", () => {
119
+ expect(parseInline("veja src/foo/bar.ts e review.md")).toEqual([
120
+ { type: "text", text: "veja " },
121
+ { type: "path", text: "src/foo/bar.ts" },
122
+ { type: "text", text: " e " },
123
+ { type: "path", text: "review.md" },
124
+ ]);
125
+ });
126
+ });
127
+
128
+ describe("isFilePath", () => {
129
+ test("reconhece arquivos e caminhos, sem confundir texto comum", () => {
130
+ expect(isFilePath("src/foo/bar.ts")).toBe(true);
131
+ expect(isFilePath("review.md")).toBe(true);
132
+ expect(isFilePath("./scripts/ci.sh")).toBe(true);
133
+ expect(isFilePath("M/D")).toBe(false);
134
+ expect(isFilePath("duas palavras.ts")).toBe(false);
135
+ expect(isFilePath("https://x.dev/file.ts")).toBe(false);
136
+ });
77
137
  });
78
138
 
79
139
  describe("safeUrl", () => {
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Resumo final (`review-resume.md`) — conteúdo de negócio + pontos técnicos,
3
+ * determinístico e limitado a `RESUME_MAX_CHARS`.
4
+ */
5
+
6
+ import { describe, expect, test } from "bun:test";
7
+ import {
8
+ RESUME_MAX_CHARS,
9
+ type ResumeClassification,
10
+ renderReviewResume,
11
+ } from "../core/renderers/resume-renderer";
12
+ import type { MergedFinding } from "../core/report-builder";
13
+
14
+ function finding(overrides: Partial<MergedFinding> = {}): MergedFinding {
15
+ return {
16
+ rule: "regra",
17
+ severity: "suggestion",
18
+ file: "src/a.ts",
19
+ line: 1,
20
+ message: "mensagem",
21
+ action: "ação",
22
+ watchdogs: ["alpha"],
23
+ ...overrides,
24
+ };
25
+ }
26
+
27
+ const classification: ResumeClassification = {
28
+ files: {
29
+ "src/a.ts": { status: "modified" },
30
+ "src/b.ts": { status: "added" },
31
+ },
32
+ watchdogs: { alpha: ["src/a.ts"], beta: ["src/b.ts"] },
33
+ };
34
+
35
+ describe("renderReviewResume", () => {
36
+ test("une contexto de negócio e pontos técnicos", () => {
37
+ const md = renderReviewResume({
38
+ branch: "feat/x",
39
+ date: "2026-10-17",
40
+ context: {
41
+ available: true,
42
+ objective: "Entregar a feature X.",
43
+ motivation: "Reduzir o esforço manual.",
44
+ changeSummary: ["Novo fluxo de cadastro.", "Ajuste no relatório."],
45
+ },
46
+ classification,
47
+ findings: [
48
+ finding({ severity: "blocking", message: "quebra o cadastro" }),
49
+ finding({ severity: "attention", file: "src/b.ts", line: undefined }),
50
+ ],
51
+ nameOf: (id) => id.toUpperCase(),
52
+ });
53
+
54
+ expect(md).toContain("# Resumo da revisão — feat/x");
55
+ expect(md).toContain("**Data:** 2026-10-17");
56
+ expect(md).toContain("❌ Mudanças necessárias");
57
+ expect(md).toContain("Entregar a feature X.");
58
+ expect(md).toContain("**Por quê:** Reduzir o esforço manual.");
59
+ expect(md).toContain("- Novo fluxo de cadastro.");
60
+ expect(md).not.toContain("## Números");
61
+ expect(md).toContain("`src/a.ts:1` — quebra o cadastro _(ALPHA)_");
62
+ expect(md).toContain("`src/b.ts` — mensagem _(ALPHA)_");
63
+ expect(md.length).toBeLessThanOrEqual(RESUME_MAX_CHARS);
64
+ });
65
+
66
+ test("sem contexto da PR, cai no alcance da branch", () => {
67
+ const md = renderReviewResume({
68
+ branch: "main",
69
+ classification,
70
+ findings: [],
71
+ });
72
+
73
+ expect(md).toContain("Sem resumo de contexto da PR disponível.");
74
+ expect(md).toContain("✅ Aprovado");
75
+ expect(md).toContain("- Nenhum bloqueio ou ponto de atenção.");
76
+ });
77
+
78
+ test("respeita o teto de caracteres", () => {
79
+ const findings = Array.from({ length: 40 }, (_, index) =>
80
+ finding({
81
+ severity: index % 2 === 0 ? "blocking" : "attention",
82
+ file: `src/very/long/path/file-${index}.ts`,
83
+ message: "x".repeat(300),
84
+ }),
85
+ );
86
+ const md = renderReviewResume({
87
+ branch: "main",
88
+ context: {
89
+ available: true,
90
+ objective: "o".repeat(1000),
91
+ motivation: "m".repeat(1000),
92
+ changeSummary: ["a".repeat(200), "b".repeat(200)],
93
+ },
94
+ classification,
95
+ findings,
96
+ });
97
+
98
+ expect(md.length).toBeLessThanOrEqual(RESUME_MAX_CHARS);
99
+ expect(md.endsWith("…")).toBe(true);
100
+ });
101
+ });
@@ -0,0 +1,302 @@
1
+ /**
2
+ * Escopo do review: completo × incremental × no-op.
3
+ *
4
+ * Congela a decisão que evita re-revisar a PR inteira: mesmo commit revisado
5
+ * vira no-op (sem `--full`/`--clean`), delta vira incremental com carry-over.
6
+ */
7
+
8
+ import { describe, expect, test } from "bun:test";
9
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { ReviewScopeResolver, type ScopeOptions } from "../core/engine/review-scope";
13
+ import type { FindingState, FindingsStateFile } from "../core/findings-state";
14
+ import { defaultOptions, finalize, prepare, reviewContext } from "../core/orchestrator";
15
+ import { resolveRepoIdentity } from "../core/storage/repo-identity";
16
+ import { SQLiteStorageProvider } from "../core/storage/sqlite/sqlite-storage";
17
+ import { fakeHoundsmanConfig } from "./fixtures/fake-project";
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Helpers
21
+ // ---------------------------------------------------------------------------
22
+
23
+ function findingState(overrides: Partial<FindingState> = {}): FindingState {
24
+ return {
25
+ id: "abcdef1234",
26
+ fingerprint: "0123456789abcdef",
27
+ watchdogIds: ["alpha"],
28
+ rule: "regra",
29
+ severity: "blocking",
30
+ file: "src/alpha/keep.ts",
31
+ line: 1,
32
+ message: "mensagem",
33
+ action: "ação",
34
+ anchor: null,
35
+ count: 1,
36
+ present: true,
37
+ decision: "pending",
38
+ execState: "pending",
39
+ createdAt: "2026-01-01T00:00:00.000Z",
40
+ updatedAt: "2026-01-01T00:00:00.000Z",
41
+ history: [],
42
+ ...overrides,
43
+ };
44
+ }
45
+
46
+ function stateWith(
47
+ reviewedSha: string,
48
+ findings: Record<string, FindingState> = {},
49
+ ): FindingsStateFile {
50
+ return {
51
+ version: 2,
52
+ branch: "main",
53
+ slug: "main",
54
+ updatedAt: "2026-01-01T00:00:00.000Z",
55
+ order: Object.keys(findings),
56
+ reviewed: { headSha: reviewedSha, at: "2026-01-01T00:00:00.000Z" },
57
+ findings,
58
+ };
59
+ }
60
+
61
+ function options(overrides: Partial<ScopeOptions> = {}): ScopeOptions {
62
+ return { base: "main", head: "HEAD", full: false, clean: false, ...overrides };
63
+ }
64
+
65
+ function git(cwd: string, args: string[]): string {
66
+ const result = Bun.spawnSync(["git", ...args], {
67
+ cwd,
68
+ stdout: "pipe",
69
+ stderr: "pipe",
70
+ });
71
+ if (result.exitCode !== 0) {
72
+ throw new Error(`git ${args.join(" ")}: ${result.stderr.toString()}`);
73
+ }
74
+ return result.stdout.toString();
75
+ }
76
+
77
+ type Repo = { cwd: string; base: string; head: string; cleanup: () => void };
78
+
79
+ /** Repositório temporário: `base` → `head` altera `src/alpha/a.ts`. */
80
+ function makeRepo(): Repo {
81
+ const cwd = mkdtempSync(join(tmpdir(), "houndsman-scope-"));
82
+ git(cwd, ["init", "-b", "main"]);
83
+ git(cwd, ["config", "user.email", "scope@example.test"]);
84
+ git(cwd, ["config", "user.name", "Scope"]);
85
+ git(cwd, ["config", "commit.gpgsign", "false"]);
86
+ git(cwd, ["config", "tag.gpgsign", "false"]);
87
+
88
+ mkdirSync(join(cwd, "src", "alpha"), { recursive: true });
89
+ writeFileSync(join(cwd, "src", "alpha", "a.ts"), "export const a = 1;\n");
90
+ writeFileSync(join(cwd, "src", "alpha", "keep.ts"), "export const keep = 1;\n");
91
+ writeFileSync(join(cwd, "README.md"), "# Fake\n");
92
+ git(cwd, ["add", "."]);
93
+ git(cwd, ["commit", "-m", "initial"]);
94
+ const base = git(cwd, ["rev-parse", "HEAD"]).trim();
95
+
96
+ writeFileSync(join(cwd, "src", "alpha", "a.ts"), "export const a = 2;\n");
97
+ git(cwd, ["add", "-A"]);
98
+ git(cwd, ["commit", "-m", "change"]);
99
+ const head = git(cwd, ["rev-parse", "HEAD"]).trim();
100
+
101
+ return {
102
+ cwd,
103
+ base,
104
+ head,
105
+ cleanup: () => rmSync(cwd, { recursive: true, force: true }),
106
+ };
107
+ }
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // Decisão de escopo
111
+ // ---------------------------------------------------------------------------
112
+
113
+ describe("ReviewScopeResolver", () => {
114
+ test("mesmo commit revisado vira no-op (sem tocar no git)", async () => {
115
+ const sha = "a".repeat(40);
116
+ const scope = await new ReviewScopeResolver(
117
+ options(),
118
+ fakeHoundsmanConfig,
119
+ ).resolve(stateWith(sha), sha);
120
+
121
+ expect(scope.kind).toBe("noop");
122
+ if (scope.kind !== "noop") throw new Error("esperava noop");
123
+ expect(scope.reviewedFrom).toBe(sha);
124
+ expect(scope.headSha).toBe(sha);
125
+ });
126
+
127
+ test("`--full` re-revisa o mesmo commit", async () => {
128
+ const repo = makeRepo();
129
+ const originalCwd = process.cwd();
130
+ try {
131
+ process.chdir(repo.cwd);
132
+ const scope = await new ReviewScopeResolver(
133
+ options({ base: repo.base, full: true }),
134
+ fakeHoundsmanConfig,
135
+ ).resolve(stateWith(repo.head), repo.head);
136
+
137
+ expect(scope.kind).toBe("review");
138
+ if (scope.kind !== "review") throw new Error("esperava review");
139
+ expect(scope.classification.mode).toBe("full");
140
+ expect(scope.classification.headSha).toBe(repo.head);
141
+ } finally {
142
+ process.chdir(originalCwd);
143
+ repo.cleanup();
144
+ }
145
+ });
146
+
147
+ test("delta revisa só os arquivos alterados e carrega o resto", async () => {
148
+ const repo = makeRepo();
149
+ const originalCwd = process.cwd();
150
+ try {
151
+ process.chdir(repo.cwd);
152
+ const previous = stateWith(repo.base, {
153
+ abcdef1234: findingState({ file: "src/alpha/keep.ts" }),
154
+ fedcba4321: findingState({ file: "src/alpha/a.ts" }),
155
+ });
156
+ const scope = await new ReviewScopeResolver(
157
+ options({ base: repo.base }),
158
+ fakeHoundsmanConfig,
159
+ ).resolve(previous, repo.head);
160
+
161
+ expect(scope.kind).toBe("review");
162
+ if (scope.kind !== "review") throw new Error("esperava review");
163
+ expect(scope.classification.mode).toBe("incremental");
164
+ expect(scope.classification.reviewedFrom).toBe(repo.base);
165
+ expect(Object.keys(scope.classification.files)).toEqual(["src/alpha/a.ts"]);
166
+ expect(scope.classification.carryover?.map((f) => f.file)).toEqual([
167
+ "src/alpha/keep.ts",
168
+ ]);
169
+ } finally {
170
+ process.chdir(originalCwd);
171
+ repo.cleanup();
172
+ }
173
+ });
174
+
175
+ test("histórico reescrito (HEAD sem ancestrais comuns) cai no completo", async () => {
176
+ const repo = makeRepo();
177
+ const originalCwd = process.cwd();
178
+ try {
179
+ process.chdir(repo.cwd);
180
+ // `repo.head` não é ancestral de `repo.base` → revisão completa.
181
+ const scope = await new ReviewScopeResolver(
182
+ options({ base: repo.base }),
183
+ fakeHoundsmanConfig,
184
+ ).resolve(stateWith(repo.head), repo.base);
185
+
186
+ expect(scope.kind).toBe("review");
187
+ if (scope.kind !== "review") throw new Error("esperava review");
188
+ expect(scope.classification.mode).toBe("full");
189
+ } finally {
190
+ process.chdir(originalCwd);
191
+ repo.cleanup();
192
+ }
193
+ });
194
+ });
195
+
196
+ // ---------------------------------------------------------------------------
197
+ // Integração: prepare/finalize no mesmo commit
198
+ // ---------------------------------------------------------------------------
199
+
200
+ describe("prepare/finalize — mesmo commit", () => {
201
+ test("no-op preserva relatório e não gera briefs", async () => {
202
+ const repo = makeRepo();
203
+ const originalCwd = process.cwd();
204
+ const originalHome = process.env.HOUNDSMAN_HOME;
205
+ let storage: SQLiteStorageProvider | null = null;
206
+
207
+ try {
208
+ process.env.HOUNDSMAN_HOME = join(repo.cwd, ".home");
209
+ process.chdir(repo.cwd);
210
+
211
+ storage = new SQLiteStorageProvider(join(repo.cwd, "houndsman.db"));
212
+ const record = storage.getOrCreateReview({
213
+ repo: resolveRepoIdentity(),
214
+ branch: "main",
215
+ slug: "main",
216
+ });
217
+ const opts = {
218
+ ...defaultOptions(),
219
+ base: repo.base,
220
+ head: "HEAD",
221
+ noJev: true,
222
+ };
223
+
224
+ let ctx = reviewContext(storage, record);
225
+ await prepare(opts, "main", fakeHoundsmanConfig, ctx);
226
+ expect(storage.getReview(record.id)?.status).toBe("reviewing");
227
+
228
+ writeFileSync(
229
+ ctx.work.findingFile("alpha"),
230
+ `${JSON.stringify(
231
+ {
232
+ watchdogId: "alpha",
233
+ reviewedFiles: ["src/alpha/a.ts"],
234
+ skipped: [],
235
+ findings: [
236
+ {
237
+ rule: "regra",
238
+ severity: "blocking",
239
+ file: "src/alpha/a.ts",
240
+ line: 1,
241
+ message: "achado",
242
+ action: "corrigir",
243
+ },
244
+ ],
245
+ },
246
+ null,
247
+ 2,
248
+ )}\n`,
249
+ );
250
+ finalize(opts, "main", fakeHoundsmanConfig, ctx);
251
+
252
+ const classificationBefore = storage.readArtifact(
253
+ record.id,
254
+ "classification.json",
255
+ )?.content;
256
+ const reportBefore = storage.readArtifact(record.id, "review.md")?.content;
257
+ const findingsBefore = JSON.stringify(
258
+ storage.readFindings(record.id)?.findings,
259
+ );
260
+ const reviewedBefore = storage.getReview(record.id)?.reviewedSha;
261
+ expect(classificationBefore).toBeTruthy();
262
+ expect(reportBefore).toContain("src/alpha/a.ts");
263
+
264
+ // Mesmo commit: prepare vira no-op e nada é reescrito.
265
+ ctx = reviewContext(storage, storage.getReview(record.id) ?? record);
266
+ const again = await prepare(opts, "main", fakeHoundsmanConfig, ctx);
267
+ expect(again.messages.join("\n")).toContain(
268
+ "Nenhuma alteração desde a última revisão",
269
+ );
270
+ expect(storage.readArtifact(record.id, "classification.json")?.content).toBe(
271
+ classificationBefore,
272
+ );
273
+ expect(storage.readArtifact(record.id, "review.md")?.content).toBe(
274
+ reportBefore,
275
+ );
276
+ expect(storage.getReview(record.id)?.status).toBe("review_done");
277
+ expect(existsSync(ctx.work.briefFile("alpha"))).toBe(false);
278
+
279
+ // Finalize sem ciclo preparado: no-op (não degrada o relatório).
280
+ const finalizedAgain = finalize(
281
+ opts,
282
+ "main",
283
+ fakeHoundsmanConfig,
284
+ reviewContext(storage, storage.getReview(record.id) ?? record),
285
+ );
286
+ expect(finalizedAgain.messages.join("\n")).toContain("Nada a finalizar");
287
+ expect(storage.readArtifact(record.id, "review.md")?.content).toBe(
288
+ reportBefore,
289
+ );
290
+ expect(JSON.stringify(storage.readFindings(record.id)?.findings)).toBe(
291
+ findingsBefore,
292
+ );
293
+ expect(storage.getReview(record.id)?.reviewedSha).toBe(reviewedBefore);
294
+ } finally {
295
+ storage?.close();
296
+ process.chdir(originalCwd);
297
+ if (originalHome === undefined) delete process.env.HOUNDSMAN_HOME;
298
+ else process.env.HOUNDSMAN_HOME = originalHome;
299
+ repo.cleanup();
300
+ }
301
+ }, 30_000);
302
+ });
@@ -673,6 +673,79 @@ describe("report-builder", () => {
673
673
  expect(md).toContain("review.alpha.md");
674
674
  });
675
675
 
676
+ test("renderReport coloca o sumário antes dos achados", () => {
677
+ const md = renderReport(
678
+ "main",
679
+ [{ watchdogId: "alpha", reviewedFiles: ["a.ts"], skipped: [], findings: [] }],
680
+ [
681
+ {
682
+ rule: "R1",
683
+ severity: "blocking" as const,
684
+ file: "a.ts",
685
+ line: 1,
686
+ message: "m",
687
+ action: "a",
688
+ watchdogs: ["alpha"],
689
+ },
690
+ ],
691
+ { classified: { alpha: ["a.ts"] } },
692
+ null,
693
+ undefined,
694
+ undefined,
695
+ [
696
+ {
697
+ id: "alpha",
698
+ name: "Alpha",
699
+ file: "review.alpha.md",
700
+ findings: 1,
701
+ reviewedFiles: 1,
702
+ expectedFiles: 1,
703
+ },
704
+ ],
705
+ );
706
+ const sections = [
707
+ "## Relatórios por domínio",
708
+ "## Cobertura",
709
+ "## Conclusão",
710
+ "## ❌ blocking",
711
+ ].map((section) => md.indexOf(section));
712
+ expect(sections.every((index) => index >= 0)).toBe(true);
713
+ expect([...sections].sort((a, b) => a - b)).toEqual(sections);
714
+ });
715
+
716
+ test("renderReport anexa candidatos Jev depois dos achados", () => {
717
+ const md = renderReport(
718
+ "main",
719
+ [{ watchdogId: "alpha", reviewedFiles: [], skipped: [], findings: [] }],
720
+ [
721
+ {
722
+ rule: "R1",
723
+ severity: "blocking" as const,
724
+ file: "a.ts",
725
+ line: 1,
726
+ message: "m",
727
+ action: "a",
728
+ watchdogs: ["alpha"],
729
+ },
730
+ ],
731
+ {},
732
+ null,
733
+ [
734
+ {
735
+ watchdogId: "alpha",
736
+ rule: "R2",
737
+ severity: "attention",
738
+ file: "b.ts",
739
+ confidence: 0.6,
740
+ questionKey: "q",
741
+ },
742
+ ],
743
+ );
744
+ expect(md.indexOf("## ❌ blocking")).toBeLessThan(
745
+ md.indexOf("## 🔎 Candidatos não verificados"),
746
+ );
747
+ });
748
+
676
749
  test("renderDomainReport mostra cobertura e não revisados", () => {
677
750
  const md = renderDomainReport(
678
751
  "main",
@@ -0,0 +1,61 @@
1
+ /**
2
+ * RuleAnalyzer — polaridade, categoria, sinais e hints.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { RuleAnalyzer } from "../harness/investigation/rule-analyzer";
7
+
8
+ const analyzer = new RuleAnalyzer();
9
+
10
+ describe("RuleAnalyzer", () => {
11
+ test("detecta polaridade explícita", () => {
12
+ const [avoid, promote] = analyzer.analyze([
13
+ { severity: "blocking", text: "Evitar: chamar banco diretamente em rotas." },
14
+ { severity: "attention", text: "Promover: usar `logger` estruturado." },
15
+ ]);
16
+ expect(avoid?.intent).toBe("avoid");
17
+ expect(promote?.intent).toBe("promote");
18
+ });
19
+
20
+ test("classifica categoria por keywords", () => {
21
+ const [data, tests] = analyzer.analyze([
22
+ {
23
+ severity: "attention",
24
+ text: "Evitar: query direta no banco sem migration.",
25
+ },
26
+ { severity: "nit", text: "Promover: cobertura de teste no handler." },
27
+ ]);
28
+ expect(data?.category).toBe("data");
29
+ expect(tests?.category).toBe("tests");
30
+ });
31
+
32
+ test("extrai sinais de backticks e hints (confiança alta)", () => {
33
+ const [analysis] = analyzer.analyze([
34
+ {
35
+ severity: "blocking",
36
+ text: "Evitar: usar `db.query` fora do service.",
37
+ hints: {
38
+ symbols: ["OrderService"],
39
+ imports: ["db"],
40
+ checks: ["symbol_context"],
41
+ },
42
+ },
43
+ ]);
44
+ expect(analysis?.confidence).toBe("high");
45
+ expect(analysis?.signals.some((signal) => signal.value === "OrderService")).toBe(
46
+ true,
47
+ );
48
+ expect(analysis?.signals.some((signal) => signal.value === "db")).toBe(true);
49
+ expect(analysis?.checks.some((check) => check.tool === "symbol_context")).toBe(
50
+ true,
51
+ );
52
+ });
53
+
54
+ test("keywords ignoram stopwords", () => {
55
+ const [analysis] = analyzer.analyze([
56
+ { severity: "nit", text: "Nunca usar any em produção" },
57
+ ]);
58
+ expect(analysis?.keywords).not.toContain("nunca");
59
+ expect(analysis?.keywords).toContain("produção");
60
+ });
61
+ });