@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
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Investigação: planner, evidência, crítico e cobertura.
3
+ */
4
+
5
+ import { afterAll, describe, expect, test } from "bun:test";
6
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { AgentState } from "../harness/investigation/agent-state";
10
+ import { EvidenceVerifier } from "../harness/investigation/evidence-verifier";
11
+ import { FindingCritic } from "../harness/investigation/finding-critic";
12
+ import { InvestigationPlanner } from "../harness/investigation/investigation-planner";
13
+ import { RuleAnalyzer } from "../harness/investigation/rule-analyzer";
14
+
15
+ const RULE = "Nunca usar `any`.";
16
+ const analyses = new RuleAnalyzer().analyze([
17
+ { severity: "suggestion", text: "Evitar console.log." },
18
+ { severity: "blocking", text: RULE },
19
+ { severity: "attention", text: "Validar entrada do usuário." },
20
+ ]);
21
+
22
+ const dir = mkdtempSync(join(tmpdir(), "houndsman-evidence-"));
23
+ const file = join(dir, "a.ts");
24
+ writeFileSync(file, "const x: any = 1;\nconsole.log(x);\n");
25
+ afterAll(() => rmSync(dir, { recursive: true, force: true }));
26
+
27
+ function verifier(): EvidenceVerifier {
28
+ return new EvidenceVerifier({
29
+ allowedFiles: ["a.ts"],
30
+ readFile: (path) =>
31
+ path === "a.ts" ? "const x: any = 1;\nconsole.log(x);\n" : null,
32
+ });
33
+ }
34
+
35
+ describe("InvestigationPlanner", () => {
36
+ test("prioriza por severidade e calcula estimativa", () => {
37
+ const plan = new InvestigationPlanner().plan(
38
+ { watchdogId: "alpha", files: ["a.ts"], symbols: [] },
39
+ analyses,
40
+ );
41
+ expect(plan.tasks[0]?.rule).toBe(RULE);
42
+ expect(plan.tasks[0]?.severity).toBe("blocking");
43
+ expect(plan.totalFiles).toBe(1);
44
+ expect(plan.estimatedToolCalls).toBeGreaterThan(0);
45
+ });
46
+
47
+ test("filtra arquivos por hint de path", () => {
48
+ const [analysis] = new RuleAnalyzer().analyze([
49
+ {
50
+ severity: "attention",
51
+ text: "Evitar: query no banco em rota.",
52
+ hints: { paths: ["^src/alpha/"] },
53
+ },
54
+ ]);
55
+ const plan = new InvestigationPlanner().plan(
56
+ {
57
+ watchdogId: "alpha",
58
+ files: ["src/alpha/a.ts", "src/beta/b.ts"],
59
+ symbols: [],
60
+ },
61
+ analysis ? [analysis] : [],
62
+ );
63
+ expect(plan.tasks[0]?.files).toEqual(["src/alpha/a.ts"]);
64
+ });
65
+ });
66
+
67
+ describe("EvidenceVerifier", () => {
68
+ test("aceita snippet na linha e reancora quando deslocado", () => {
69
+ const state = new AgentState();
70
+ const exact = state.addEvidence({
71
+ file: "a.ts",
72
+ startLine: 1,
73
+ endLine: 1,
74
+ snippet: "const x: any = 1;",
75
+ kind: "definition",
76
+ sourceTool: "t",
77
+ confidence: 0.9,
78
+ });
79
+ const moved = state.addEvidence({
80
+ file: "a.ts",
81
+ startLine: 9,
82
+ endLine: 9,
83
+ snippet: "console.log(x);",
84
+ kind: "reference",
85
+ sourceTool: "t",
86
+ confidence: 0.7,
87
+ });
88
+ expect(verifier().verify(exact).valid).toBe(true);
89
+ const verdict = verifier().verify(moved);
90
+ expect(verdict.valid).toBe(true);
91
+ expect(verdict.startLine).toBe(2);
92
+ });
93
+
94
+ test("rejeita arquivo fora do domínio e snippet inexistente", () => {
95
+ const state = new AgentState();
96
+ const outside = state.addEvidence({
97
+ file: "b.ts",
98
+ startLine: 1,
99
+ endLine: 1,
100
+ snippet: "x",
101
+ kind: "definition",
102
+ sourceTool: "t",
103
+ confidence: 1,
104
+ });
105
+ const missing = state.addEvidence({
106
+ file: "a.ts",
107
+ startLine: 1,
108
+ endLine: 1,
109
+ snippet: "não existe",
110
+ kind: "definition",
111
+ sourceTool: "t",
112
+ confidence: 1,
113
+ });
114
+ expect(verifier().verify(outside).valid).toBe(false);
115
+ expect(verifier().verify(missing).valid).toBe(false);
116
+ });
117
+
118
+ test("draft sem evidência válida é inválido", () => {
119
+ const state = new AgentState();
120
+ const evidence = state.addEvidence({
121
+ file: "a.ts",
122
+ startLine: 1,
123
+ endLine: 1,
124
+ snippet: "const x: any = 1;",
125
+ kind: "definition",
126
+ sourceTool: "t",
127
+ confidence: 0.9,
128
+ });
129
+ const draft = {
130
+ rule: RULE,
131
+ severity: "blocking" as const,
132
+ file: "a.ts",
133
+ line: 1,
134
+ message: "m",
135
+ action: "a",
136
+ evidenceIds: [evidence.id],
137
+ confidence: 0.9,
138
+ };
139
+ expect(verifier().verifyDraft(draft, state).valid).toBe(true);
140
+ expect(
141
+ verifier().verifyDraft({ ...draft, evidenceIds: ["nope"] }, state).valid,
142
+ ).toBe(false);
143
+ });
144
+ });
145
+
146
+ describe("FindingCritic", () => {
147
+ const state = new AgentState();
148
+ const evidence = state.addEvidence({
149
+ file: "a.ts",
150
+ startLine: 1,
151
+ endLine: 1,
152
+ snippet: "const x: any = 1;",
153
+ kind: "definition",
154
+ sourceTool: "t",
155
+ confidence: 0.9,
156
+ });
157
+ const critic = new FindingCritic({
158
+ knownRules: new Set([RULE]),
159
+ verifier: verifier(),
160
+ });
161
+ const draft = {
162
+ rule: RULE,
163
+ severity: "blocking" as const,
164
+ file: "a.ts",
165
+ line: 1,
166
+ message: "m",
167
+ action: "a",
168
+ evidenceIds: [evidence.id],
169
+ confidence: 0.9,
170
+ };
171
+
172
+ test("keep com evidência e confiança", () => {
173
+ expect(critic.reviewOne(draft, state).verdict).toBe("keep");
174
+ });
175
+
176
+ test("drop sem evidência, sem regra ou confiança baixa", () => {
177
+ expect(critic.reviewOne({ ...draft, evidenceIds: [] }, state).verdict).toBe(
178
+ "drop",
179
+ );
180
+ expect(critic.reviewOne({ ...draft, rule: "inexistente" }, state).verdict).toBe(
181
+ "drop",
182
+ );
183
+ expect(critic.reviewOne({ ...draft, confidence: 0.2 }, state).verdict).toBe(
184
+ "drop",
185
+ );
186
+ });
187
+
188
+ test("downgrade quando a confiança não sustenta a severidade", () => {
189
+ expect(critic.reviewOne({ ...draft, confidence: 0.5 }, state).verdict).toBe(
190
+ "downgrade",
191
+ );
192
+ });
193
+ });
194
+
195
+ describe("AgentState", () => {
196
+ test("cobertura e hipóteses sem duplicar", () => {
197
+ const state = new AgentState();
198
+ state.addHypothesis("a");
199
+ state.addHypothesis("a");
200
+ expect(state.hypotheses).toEqual(["a"]);
201
+ state.setCoverage(RULE, "skipped", "motivo");
202
+ expect(state.isCovered(RULE)).toBe(true);
203
+ state.setCoverage(RULE, "checked");
204
+ expect(state.coverageOf(RULE)?.status).toBe("checked");
205
+ });
206
+ });
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Tools do harness: path guard, limites, cache, budget e redaction.
3
+ */
4
+
5
+ import { afterAll, describe, expect, test } from "bun:test";
6
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { AstService } from "../core/ast/service";
10
+ import { Watchdog } from "../core/domain/watchdog";
11
+ import { SilentReporter } from "../harness/agent/agent-reporter";
12
+ import { AgentState } from "../harness/investigation/agent-state";
13
+ import type { ToolContext } from "../harness/tools/agent-tool";
14
+ import { BuiltinToolsModule } from "../harness/tools/builtin";
15
+ import { PathGuard } from "../harness/tools/path-guard";
16
+ import { redactSecrets } from "../harness/tools/secret-redactor";
17
+ import { ToolCache } from "../harness/tools/tool-cache";
18
+ import { ToolCallBudget } from "../harness/tools/tool-call-budget";
19
+ import { ToolMiddleware } from "../harness/tools/tool-middleware";
20
+ import { ToolRegistry } from "../harness/tools/tool-registry";
21
+ import { EchoTool } from "./fixtures/fake-agent-tools";
22
+ import { fakeAgentContext } from "./fixtures/fake-investigation";
23
+ import { fakeHoundsmanConfig } from "./fixtures/fake-project";
24
+
25
+ const dir = mkdtempSync(join(tmpdir(), "houndsman-tools-"));
26
+ mkdirSync(join(dir, "src/alpha"), { recursive: true });
27
+ writeFileSync(
28
+ join(dir, "src/alpha/a.ts"),
29
+ "const x: any = 1;\nconsole.log(x);\nconst y = 2;\n",
30
+ );
31
+ afterAll(() => rmSync(dir, { recursive: true, force: true }));
32
+
33
+ function context(): ToolContext {
34
+ const state = new AgentState();
35
+ return {
36
+ repoRoot: dir,
37
+ watchdog: new Watchdog(fakeHoundsmanConfig.watchdogs.alpha),
38
+ diff: { byFile: new Map(), churn: new Map() },
39
+ ast: new AstService(fakeHoundsmanConfig),
40
+ state,
41
+ budget: new ToolCallBudget(20, 10),
42
+ cache: new ToolCache(),
43
+ logger: new SilentReporter(),
44
+ agent: fakeAgentContext({ repoRoot: dir }),
45
+ rules: new Map(),
46
+ };
47
+ }
48
+
49
+ describe("PathGuard", () => {
50
+ test("bloqueia traversal, .git e .env", () => {
51
+ const guard = new PathGuard(dir);
52
+ expect(() => guard.resolvePath("../segredo")).toThrow();
53
+ expect(() => guard.resolvePath(".git/config")).toThrow();
54
+ expect(() => guard.resolvePath(".env")).toThrow();
55
+ });
56
+
57
+ test("aceita caminho interno", () => {
58
+ const guard = new PathGuard(dir);
59
+ expect(guard.resolvePath("src/alpha/a.ts")).toContain("a.ts");
60
+ });
61
+ });
62
+
63
+ describe("read_file_range", () => {
64
+ test("devolve faixa numerada + evidência com id", async () => {
65
+ const registry = new ToolRegistry();
66
+ registry.registerModule(new BuiltinToolsModule());
67
+ const middleware = new ToolMiddleware();
68
+ const ctx = context();
69
+ const result = await registry.execute(
70
+ "read_file_range",
71
+ { path: "src/alpha/a.ts", startLine: 1, endLine: 2 },
72
+ ctx,
73
+ middleware,
74
+ );
75
+ expect(result.ok).toBe(true);
76
+ const data = result.data as { content: string };
77
+ expect(data.content).toContain("1\tconst x: any = 1;");
78
+ expect(data.content).toContain("2\tconsole.log(x);");
79
+ expect(result.evidence?.[0]?.id).toBeTruthy();
80
+ expect(ctx.state.evidence.size).toBe(1);
81
+ });
82
+
83
+ test("caminho fora do repo vira erro estruturado", async () => {
84
+ const registry = new ToolRegistry();
85
+ registry.registerModule(new BuiltinToolsModule());
86
+ const result = await registry.execute(
87
+ "read_file_range",
88
+ { path: "../x", startLine: 1, endLine: 2 },
89
+ context(),
90
+ new ToolMiddleware(),
91
+ );
92
+ expect(result.ok).toBe(false);
93
+ expect(result.error).toContain("fora do repositório");
94
+ });
95
+ });
96
+
97
+ describe("ToolRegistry / middleware", () => {
98
+ test("nome duplicado falha no registro", () => {
99
+ const registry = new ToolRegistry();
100
+ registry.registerTool(new EchoTool());
101
+ expect(() => registry.registerTool(new EchoTool())).toThrow();
102
+ });
103
+
104
+ test("resultado em cache não consome budget", async () => {
105
+ const registry = new ToolRegistry();
106
+ registry.registerTool(new EchoTool());
107
+ const middleware = new ToolMiddleware();
108
+ const ctx = context();
109
+ await registry.execute("echo", { value: "a" }, ctx, middleware);
110
+ expect(ctx.budget.total).toBe(1);
111
+ await registry.execute("echo", { value: "a" }, ctx, middleware);
112
+ expect(ctx.budget.total).toBe(1);
113
+ await registry.execute("echo", { value: "b" }, ctx, middleware);
114
+ expect(ctx.budget.total).toBe(2);
115
+ });
116
+
117
+ test("input inválido vira erro (Zod)", async () => {
118
+ const registry = new ToolRegistry();
119
+ registry.registerTool(new EchoTool());
120
+ const result = await registry.execute(
121
+ "echo",
122
+ { value: 1 },
123
+ context(),
124
+ new ToolMiddleware(),
125
+ );
126
+ expect(result.ok).toBe(false);
127
+ expect(result.error).toContain("input inválido");
128
+ });
129
+
130
+ test("budget esgotado interrompe", async () => {
131
+ const registry = new ToolRegistry();
132
+ registry.registerTool(new EchoTool());
133
+ const ctx = context();
134
+ ctx.budget = new ToolCallBudget(1, 1);
135
+ const middleware = new ToolMiddleware();
136
+ await registry.execute("echo", { value: "a" }, ctx, middleware);
137
+ const second = await registry.execute("echo", { value: "b" }, ctx, middleware);
138
+ expect(second.ok).toBe(false);
139
+ expect(second.error).toContain("Orçamento");
140
+ });
141
+ });
142
+
143
+ describe("redaction", () => {
144
+ test("remove tokens e chaves", () => {
145
+ expect(redactSecrets("token sk-abcdefgh123")).toContain("[REDACTED]");
146
+ expect(redactSecrets("api_key = segredo")).toContain("[REDACTED]");
147
+ });
148
+ });
@@ -0,0 +1,212 @@
1
+ /**
2
+ * `hunt` — parser de flags, dry-run e fluxo completo com provider fake.
3
+ */
4
+
5
+ import { afterAll, describe, expect, test } from "bun:test";
6
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { defineHoundsmanConfig } from "../core/config";
10
+ import { HoundsmanEngine } from "../core/engine/houndsman-engine";
11
+ import { SilentLogger } from "../core/logging";
12
+ import { defineWatchdog } from "../core/types";
13
+ import { HuntHarness } from "../harness/hunt/hunt-harness";
14
+ import { parseHuntArgs } from "../harness/hunt/hunt-options";
15
+ import { FakeModelProvider } from "./fixtures/fake-model-provider";
16
+
17
+ const RULE = "Nunca usar `any`.";
18
+
19
+ const configBase = defineHoundsmanConfig({
20
+ projectName: "Hunt",
21
+ watchdogs: {
22
+ alpha: defineWatchdog({
23
+ id: "alpha",
24
+ name: "Alpha",
25
+ description: "Watchdog do hunt.",
26
+ scope: "src/alpha/**",
27
+ classify: [{ value: "^src/alpha/" }],
28
+ rules: [{ severity: "blocking", text: RULE }],
29
+ contextFiles: [],
30
+ }),
31
+ },
32
+ exclusions: [],
33
+ docMappings: [],
34
+ });
35
+
36
+ describe("parseHuntArgs", () => {
37
+ test("flags e defaults", () => {
38
+ const options = parseHuntArgs([
39
+ "--base",
40
+ "origin/main",
41
+ "--head",
42
+ "abc",
43
+ "--watchdog",
44
+ "alpha",
45
+ "--full",
46
+ "--no-ast",
47
+ "--resume",
48
+ "--allow-partial",
49
+ "--dry-run",
50
+ "--fail-on-blocking",
51
+ "--max-calls",
52
+ "7",
53
+ "--concurrency",
54
+ "3",
55
+ "--model",
56
+ "m",
57
+ ]);
58
+ expect(options.base).toBe("origin/main");
59
+ expect(options.head).toBe("abc");
60
+ expect(options.watchdog).toBe("alpha");
61
+ expect(options.full).toBe(true);
62
+ expect(options.noAst).toBe(true);
63
+ expect(options.noJev).toBe(true);
64
+ expect(options.resume).toBe(true);
65
+ expect(options.allowPartial).toBe(true);
66
+ expect(options.dryRun).toBe(true);
67
+ expect(options.failOnBlocking).toBe(true);
68
+ expect(options.maxCalls).toBe(7);
69
+ expect(options.concurrency).toBe(3);
70
+ expect(options.model).toBe("m");
71
+ });
72
+ });
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // Repo git temporário
76
+ // ---------------------------------------------------------------------------
77
+
78
+ const cwd = mkdtempSync(join(tmpdir(), "houndsman-hunt-"));
79
+ const originalCwd = process.cwd();
80
+ const originalHome = process.env.HOUNDSMAN_HOME;
81
+ const originalKey = process.env.HOUNDSMAN_DEEP_SEEK_API_KEY;
82
+ let base = "";
83
+
84
+ function git(args: string[]): string {
85
+ const result = Bun.spawnSync(["git", ...args], {
86
+ cwd,
87
+ stdout: "pipe",
88
+ stderr: "pipe",
89
+ });
90
+ if (result.exitCode !== 0) {
91
+ throw new Error(`git ${args.join(" ")}: ${result.stderr.toString()}`);
92
+ }
93
+ return result.stdout.toString();
94
+ }
95
+
96
+ function setupRepo(): void {
97
+ git(["init", "-b", "main"]);
98
+ git(["config", "user.email", "hunt@example.test"]);
99
+ git(["config", "user.name", "Hunt"]);
100
+ git(["config", "commit.gpgsign", "false"]);
101
+ mkdirSync(join(cwd, "src", "alpha"), { recursive: true });
102
+ writeFileSync(join(cwd, "src/alpha/a.ts"), "export const a = 1;\n");
103
+ git(["add", "."]);
104
+ git(["commit", "-m", "initial"]);
105
+ base = git(["rev-parse", "HEAD"]).trim();
106
+ writeFileSync(join(cwd, "src/alpha/a.ts"), "export const a: any = 1;\n");
107
+ git(["add", "-A"]);
108
+ git(["commit", "-m", "change"]);
109
+ process.env.HOUNDSMAN_HOME = join(cwd, ".home");
110
+ process.chdir(cwd);
111
+ }
112
+
113
+ function engine(): HoundsmanEngine {
114
+ return new HoundsmanEngine({
115
+ ...configBase,
116
+ storage: { provider: "sqlite", path: join(cwd, "houndsman.db") },
117
+ });
118
+ }
119
+
120
+ setupRepo();
121
+ afterAll(() => {
122
+ process.chdir(originalCwd);
123
+ if (originalHome === undefined) delete process.env.HOUNDSMAN_HOME;
124
+ else process.env.HOUNDSMAN_HOME = originalHome;
125
+ if (originalKey === undefined) delete process.env.HOUNDSMAN_DEEP_SEEK_API_KEY;
126
+ else process.env.HOUNDSMAN_DEEP_SEEK_API_KEY = originalKey;
127
+ rmSync(cwd, { recursive: true, force: true });
128
+ });
129
+
130
+ describe("hunt", () => {
131
+ test("dry-run lista watchdogs sem chamar IA", async () => {
132
+ const instance = engine();
133
+ try {
134
+ const harness = await HuntHarness.from(
135
+ instance,
136
+ parseHuntArgs(["--base", base, "--head", "HEAD", "--dry-run"]),
137
+ new SilentLogger(),
138
+ );
139
+ const result = await harness.run();
140
+ expect(result.status).toBe("dry-run");
141
+ expect(result.messages.join("\n")).toContain("alpha");
142
+ } finally {
143
+ instance.close();
144
+ }
145
+ });
146
+
147
+ test("sem chave e sem --dry-run falha com mensagem clara", async () => {
148
+ delete process.env.HOUNDSMAN_DEEP_SEEK_API_KEY;
149
+ const instance = engine();
150
+ try {
151
+ await expect(
152
+ HuntHarness.from(
153
+ instance,
154
+ parseHuntArgs(["--base", base, "--head", "HEAD"]),
155
+ new SilentLogger(),
156
+ ),
157
+ ).rejects.toThrow(/hunt exige IA configurada/);
158
+ } finally {
159
+ instance.close();
160
+ }
161
+ });
162
+
163
+ test("provider fake investiga, grava findings e finaliza", async () => {
164
+ const provider = new FakeModelProvider(async (request, index) => {
165
+ if (index === 0) return { hypotheses: ["any"] };
166
+ if (index === 1) return { taskIds: ["rule-0"] };
167
+ const tool = request.tools?.read_file_range;
168
+ if (!tool) throw new Error("read_file_range ausente");
169
+ const parsed = JSON.parse(
170
+ await tool.execute({ path: "src/alpha/a.ts", startLine: 1, endLine: 1 }),
171
+ ) as { evidence?: { id: string }[] };
172
+ return {
173
+ findingDrafts: [
174
+ {
175
+ rule: RULE,
176
+ severity: "blocking",
177
+ file: "src/alpha/a.ts",
178
+ line: 1,
179
+ message: "any detectado",
180
+ action: "tipar",
181
+ evidenceIds: [parsed.evidence?.[0]?.id].filter(Boolean),
182
+ confidence: 0.9,
183
+ },
184
+ ],
185
+ coverage: [{ rule: RULE, status: "checked" }],
186
+ };
187
+ });
188
+ const instance = engine();
189
+ try {
190
+ const harness = await HuntHarness.from(
191
+ instance,
192
+ parseHuntArgs(["--base", base, "--head", "HEAD"]),
193
+ new SilentLogger(),
194
+ provider,
195
+ );
196
+ const result = await harness.run();
197
+ expect(result.status).toBe("completed");
198
+ expect(result.blockers).toBe(1);
199
+ const review = instance.storage.readArtifact(result.reviewId, "review.md");
200
+ expect(review?.content).toContain("any detectado");
201
+ const transcript = instance.storage.readArtifact(
202
+ result.reviewId,
203
+ "harness-transcript.json",
204
+ );
205
+ expect(transcript?.content).toContain("VERIFY");
206
+ const usage = instance.storage.readArtifact(result.reviewId, "ai-usage.json");
207
+ expect(usage?.content).toContain('"calls"');
208
+ } finally {
209
+ instance.close();
210
+ }
211
+ });
212
+ });
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Logger único: cor opcional, spinner em não-TTY e tabela.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { Logger, SilentLogger } from "../core/logging";
7
+
8
+ function memoryStream(): { stream: NodeJS.WritableStream; text: () => string } {
9
+ let content = "";
10
+ const stream = {
11
+ write(chunk: string) {
12
+ content += chunk;
13
+ return true;
14
+ },
15
+ isTTY: false,
16
+ } as unknown as NodeJS.WritableStream;
17
+ return { stream, text: () => content };
18
+ }
19
+
20
+ describe("Logger", () => {
21
+ test("escreve texto puro quando color=false e sem TTY", () => {
22
+ const out = memoryStream();
23
+ const err = memoryStream();
24
+ const logger = new Logger({
25
+ stdout: out.stream,
26
+ stderr: err.stream,
27
+ color: false,
28
+ isTTY: false,
29
+ });
30
+ logger.info("info");
31
+ logger.success("ok");
32
+ logger.warn("aviso");
33
+ logger.error("falhou");
34
+ logger.step("passo");
35
+ const text = `${out.text()}${err.text()}`;
36
+ expect(text).toContain("info");
37
+ expect(text).toContain("ok");
38
+ expect(text).toContain("aviso");
39
+ expect(text).toContain("falhou");
40
+ expect(text).toContain("passo");
41
+ expect(text).not.toContain("\u001b[");
42
+ });
43
+
44
+ test("spinner é nulo em não-TTY", () => {
45
+ const out = memoryStream();
46
+ const logger = new Logger({ stdout: out.stream, isTTY: false });
47
+ expect(logger.spinner("x")).toBeNull();
48
+ });
49
+
50
+ test("quiet suprime info/sucesso mas não erro", () => {
51
+ const out = memoryStream();
52
+ const err = memoryStream();
53
+ const logger = new Logger({
54
+ stdout: out.stream,
55
+ stderr: err.stream,
56
+ quiet: true,
57
+ color: false,
58
+ });
59
+ logger.info("info");
60
+ logger.success("ok");
61
+ logger.error("erro");
62
+ expect(out.text()).toBe("");
63
+ expect(err.text()).toContain("erro");
64
+ });
65
+
66
+ test("tabela alinha colunas", () => {
67
+ const out = memoryStream();
68
+ const logger = new Logger({ stdout: out.stream, color: false });
69
+ logger.table([
70
+ ["id", "findings"],
71
+ ["alpha", "2"],
72
+ ]);
73
+ expect(out.text()).toContain("id");
74
+ expect(out.text()).toContain("alpha");
75
+ });
76
+
77
+ test("SilentLogger não escreve e executa withSpinner", async () => {
78
+ const logger = new SilentLogger();
79
+ logger.info("x");
80
+ const result = await logger.withSpinner("x", async () => 42);
81
+ expect(result).toBe(42);
82
+ });
83
+ });