@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,94 @@
1
+ /**
2
+ * View do status na sidebar — timeout só vale quando há heartbeat de execução.
3
+ * `updatedAt` (triagem/sync) não deve simular uma revisão em andamento.
4
+ */
5
+
6
+ import { describe, expect, test } from "bun:test";
7
+ import {
8
+ describeRunStatus,
9
+ REVIEW_TIMEOUT_MS,
10
+ STATUS_TIMEOUT_MS,
11
+ } from "../server/client/utils/constants";
12
+ import type { RunSummary, Summary } from "../server/client/utils/types";
13
+
14
+ const summary: Summary = {
15
+ total: 0,
16
+ present: 0,
17
+ byDecision: { pending: 0, execute: 0, ignore: 0 },
18
+ byExecState: { pending: 0, in_progress: 0, done: 0, stale: 0 },
19
+ bySeverity: { blocking: 0, attention: 0, suggestion: 0, nit: 0 },
20
+ };
21
+
22
+ const NOW = Date.parse("2026-10-17T12:00:00.000Z");
23
+ const iso = (offsetMs: number): string => new Date(NOW + offsetMs).toISOString();
24
+
25
+ function run(overrides: Partial<RunSummary>): RunSummary {
26
+ return {
27
+ slug: "feature-x",
28
+ branch: "feature/x",
29
+ summary,
30
+ ...overrides,
31
+ };
32
+ }
33
+
34
+ describe("describeRunStatus", () => {
35
+ test("heartbeat recente mantém o status de execução", () => {
36
+ expect(
37
+ describeRunStatus(
38
+ run({ status: "reviewing", heartbeatAt: iso(-60_000) }),
39
+ NOW,
40
+ ),
41
+ ).toEqual({ kind: "reviewing", label: "Revisando", timeout: false });
42
+ });
43
+
44
+ test("heartbeat antigo vira timeout", () => {
45
+ expect(
46
+ describeRunStatus(
47
+ run({
48
+ status: "reviewing",
49
+ heartbeatAt: iso(-REVIEW_TIMEOUT_MS - 1_000),
50
+ }),
51
+ NOW,
52
+ ),
53
+ ).toEqual({ kind: "timeout", label: "Timeout na revisão", timeout: true });
54
+ });
55
+
56
+ test("updatedAt recente não mascara heartbeat antigo", () => {
57
+ expect(
58
+ describeRunStatus(
59
+ run({
60
+ status: "reviewing",
61
+ updatedAt: iso(-1_000),
62
+ heartbeatAt: iso(-REVIEW_TIMEOUT_MS - 1_000),
63
+ }),
64
+ NOW,
65
+ )?.timeout,
66
+ ).toBe(true);
67
+ });
68
+
69
+ test("sem heartbeat não inventa revisão em andamento", () => {
70
+ expect(
71
+ describeRunStatus(run({ status: "reviewing", updatedAt: iso(-1_000) }), NOW),
72
+ ).toBeNull();
73
+ });
74
+
75
+ test("status terminal aparece normalmente sem heartbeat", () => {
76
+ expect(describeRunStatus(run({ status: "review_done" }), NOW)).toEqual({
77
+ kind: "review_done",
78
+ label: "Revisão pronta",
79
+ timeout: false,
80
+ });
81
+ });
82
+
83
+ test("correção também usa heartbeat para timeout", () => {
84
+ expect(
85
+ describeRunStatus(
86
+ run({
87
+ status: "fixing",
88
+ heartbeatAt: iso(-STATUS_TIMEOUT_MS - 1_000),
89
+ }),
90
+ NOW,
91
+ ),
92
+ ).toEqual({ kind: "timeout", label: "Timeout na correção", timeout: true });
93
+ });
94
+ });
@@ -7,6 +7,7 @@ import {
7
7
  pingFindingsStatus,
8
8
  readRunStatus,
9
9
  statusAfterFindingsChange,
10
+ touchRunStatus,
10
11
  writeRunStatus,
11
12
  } from "../core/run-status";
12
13
  import { buildRepoIdentity } from "../core/storage/repo-identity";
@@ -78,6 +79,7 @@ describe("run-status — storage", () => {
78
79
  expect(first?.status).toBe("reviewing");
79
80
  expect(first?.slug).toBe("feature-x");
80
81
  expect(first?.startedAt).toBe("2026-01-01T00:00:00.000Z");
82
+ expect(first?.heartbeatAt).toBe("2026-01-01T00:00:00.000Z");
81
83
 
82
84
  writeRunStatus(storage, reviewId, {
83
85
  status: "review_done",
@@ -86,6 +88,7 @@ describe("run-status — storage", () => {
86
88
  const second = readRunStatus(storage, reviewId);
87
89
  expect(second?.status).toBe("review_done");
88
90
  expect(second?.startedAt).toBe("2026-01-01T00:00:00.000Z");
91
+ expect(second?.heartbeatAt).toBeUndefined();
89
92
  expect(second?.updatedAt).toBe("2026-01-01T00:10:00.000Z");
90
93
 
91
94
  writeRunStatus(storage, reviewId, {
@@ -96,6 +99,56 @@ describe("run-status — storage", () => {
96
99
  expect(readRunStatus(storage, reviewId)?.startedAt).toBe(
97
100
  "2026-01-01T00:20:00.000Z",
98
101
  );
102
+ expect(readRunStatus(storage, reviewId)?.heartbeatAt).toBe(
103
+ "2026-01-01T00:20:00.000Z",
104
+ );
105
+ });
106
+ });
107
+
108
+ test("touch renova o heartbeat apenas em execução ativa", () => {
109
+ withStorage((storage, reviewId) => {
110
+ writeRunStatus(storage, reviewId, {
111
+ status: "reviewing",
112
+ resetStartedAt: true,
113
+ now: "2026-01-01T00:00:00.000Z",
114
+ });
115
+ touchRunStatus(storage, reviewId, "2026-01-01T00:05:00.000Z");
116
+ expect(readRunStatus(storage, reviewId)?.heartbeatAt).toBe(
117
+ "2026-01-01T00:05:00.000Z",
118
+ );
119
+
120
+ writeRunStatus(storage, reviewId, {
121
+ status: "review_done",
122
+ now: "2026-01-01T00:10:00.000Z",
123
+ });
124
+ touchRunStatus(storage, reviewId, "2026-01-01T00:15:00.000Z");
125
+ expect(readRunStatus(storage, reviewId)?.heartbeatAt).toBeUndefined();
126
+ });
127
+ });
128
+
129
+ test("triagem em reviewing não renova o heartbeat; fix em andamento sim", () => {
130
+ withStorage((storage, reviewId) => {
131
+ writeRunStatus(storage, reviewId, {
132
+ status: "reviewing",
133
+ resetStartedAt: true,
134
+ now: "2026-01-01T00:00:00.000Z",
135
+ });
136
+
137
+ // Triagem (decision/comment) mantém reviewing — não é execução.
138
+ pingFindingsStatus(storage, reviewId, stateOf([]));
139
+ expect(readRunStatus(storage, reviewId)?.heartbeatAt).toBe(
140
+ "2026-01-01T00:00:00.000Z",
141
+ );
142
+
143
+ // Correção em andamento renova o heartbeat.
144
+ pingFindingsStatus(
145
+ storage,
146
+ reviewId,
147
+ stateOf([findingState({ execState: "in_progress" })]),
148
+ );
149
+ const fixed = readRunStatus(storage, reviewId);
150
+ expect(fixed?.status).toBe("fixing");
151
+ expect(fixed?.heartbeatAt).not.toBe("2026-01-01T00:00:00.000Z");
99
152
  });
100
153
  });
101
154
 
@@ -87,6 +87,11 @@ function makeEnv(): {
87
87
  const get = (handler: (r: Request) => Promise<Response>, path: string) =>
88
88
  handler(new Request(`http://localhost${path}`));
89
89
 
90
+ /** Resposta OK do `gh` com um JSON. */
91
+ function ghJson(payload: unknown) {
92
+ return { ok: true, code: 0, stderr: "", stdout: JSON.stringify(payload) };
93
+ }
94
+
90
95
  describe("server API", () => {
91
96
  test("GET /api/runs lista as runs com resumo", async () => {
92
97
  const env = makeEnv();
@@ -231,27 +236,77 @@ describe("server API", () => {
231
236
  const handler = createRequestHandler(env.config, {
232
237
  storage: env.storage,
233
238
  repo: env.repo,
234
- ghRunner: (args) =>
235
- args[0] === "pr"
236
- ? {
237
- ok: true,
238
- code: 0,
239
- stderr: "",
240
- stdout: JSON.stringify({
241
- number: 12,
242
- url: "https://github.com/acme/app/pull/12",
243
- headRefOid: "deadbeef",
244
- }),
245
- }
246
- : {
247
- ok: true,
248
- code: 0,
249
- stderr: "",
250
- stdout: JSON.stringify({
251
- id: 501,
252
- html_url: "https://x/501",
253
- }),
254
- },
239
+ ghRunner: (args) => {
240
+ if (args[0] === "pr") {
241
+ return ghJson({
242
+ number: 12,
243
+ url: "https://github.com/acme/app/pull/12",
244
+ headRefOid: "deadbeef",
245
+ });
246
+ }
247
+ if (args[1] === "graphql") {
248
+ const query = args.find((arg) => arg.startsWith("query=")) ?? "";
249
+ if (query.includes("states: [PENDING]")) {
250
+ return ghJson({
251
+ data: {
252
+ repository: {
253
+ pullRequest: {
254
+ id: "PR_1",
255
+ reviews: { nodes: [] },
256
+ },
257
+ },
258
+ },
259
+ });
260
+ }
261
+ if (query.includes("addPullRequestReview(input")) {
262
+ return ghJson({
263
+ data: {
264
+ addPullRequestReview: {
265
+ pullRequestReview: {
266
+ id: "REV_NEW",
267
+ databaseId: 11,
268
+ resourcePath:
269
+ "/acme/app/pull/12#pullrequestreview-11",
270
+ },
271
+ },
272
+ },
273
+ });
274
+ }
275
+ if (query.includes("addPullRequestReviewThread")) {
276
+ return ghJson({
277
+ data: {
278
+ addPullRequestReviewThread: {
279
+ thread: {
280
+ comments: {
281
+ nodes: [
282
+ {
283
+ databaseId: 501,
284
+ resourcePath:
285
+ "/acme/app/pull/12#discussion_r501",
286
+ },
287
+ ],
288
+ },
289
+ },
290
+ },
291
+ },
292
+ });
293
+ }
294
+ if (query.includes("submitPullRequestReview")) {
295
+ return ghJson({
296
+ data: {
297
+ submitPullRequestReview: {
298
+ pullRequestReview: {
299
+ id: "REV_NEW",
300
+ databaseId: 11,
301
+ state: "CHANGES_REQUESTED",
302
+ },
303
+ },
304
+ },
305
+ });
306
+ }
307
+ }
308
+ return { ok: false, code: 404, stderr: "", stdout: "" };
309
+ },
255
310
  });
256
311
  const res = await handler(
257
312
  new Request("http://localhost/api/runs/main/submit", {
@@ -384,3 +384,66 @@ describe("resolveFindingId", () => {
384
384
  });
385
385
  });
386
386
  });
387
+
388
+ describe("migração do schema SQLite", () => {
389
+ test("banco v1 ganha a coluna heartbeat_at sem perder reviews", () => {
390
+ const dir = mkdtempSync(join(tmpdir(), "houndsman-migrate-"));
391
+ const path = join(dir, "houndsman.db");
392
+ const legacy = new Database(path);
393
+ try {
394
+ legacy.exec(`
395
+ CREATE TABLE reviews (
396
+ id TEXT PRIMARY KEY,
397
+ repo_key TEXT NOT NULL,
398
+ repo_root TEXT NOT NULL,
399
+ repo_remote TEXT,
400
+ branch TEXT NOT NULL,
401
+ slug TEXT NOT NULL,
402
+ base TEXT,
403
+ head TEXT,
404
+ head_sha TEXT,
405
+ reviewed_sha TEXT,
406
+ reviewed_at TEXT,
407
+ status TEXT,
408
+ started_at TEXT,
409
+ created_at TEXT NOT NULL,
410
+ updated_at TEXT NOT NULL,
411
+ UNIQUE(repo_key, branch)
412
+ );
413
+ PRAGMA user_version = 1;
414
+ `);
415
+ legacy.run(
416
+ `INSERT INTO reviews
417
+ (id, repo_key, repo_root, branch, slug, status, created_at, updated_at)
418
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
419
+ [
420
+ "11111111-1111-4111-8111-111111111111",
421
+ "path:/repo",
422
+ dir,
423
+ "feature/x",
424
+ "feature-x",
425
+ "reviewing",
426
+ "2026-01-01T00:00:00.000Z",
427
+ "2026-01-01T00:00:00.000Z",
428
+ ],
429
+ );
430
+ } finally {
431
+ legacy.close();
432
+ }
433
+
434
+ const storage = new SQLiteStorageProvider(path);
435
+ try {
436
+ const review = storage.listReviews()[0];
437
+ expect(review?.heartbeatAt).toBeUndefined();
438
+ storage.updateReview(review.id, {
439
+ heartbeatAt: "2026-01-01T00:05:00.000Z",
440
+ });
441
+ expect(storage.getReview(review.id)?.heartbeatAt).toBe(
442
+ "2026-01-01T00:05:00.000Z",
443
+ );
444
+ } finally {
445
+ storage.close();
446
+ rmSync(dir, { recursive: true, force: true });
447
+ }
448
+ });
449
+ });
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Contract test das tools built-in: metadata, schema e erros estruturados.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { AstService } from "../core/ast/service";
7
+ import { Watchdog } from "../core/domain/watchdog";
8
+ import { SilentReporter } from "../harness/agent/agent-reporter";
9
+ import { AgentState } from "../harness/investigation/agent-state";
10
+ import type { ToolContext } from "../harness/tools/agent-tool";
11
+ import { BuiltinToolsModule } from "../harness/tools/builtin";
12
+ import { ToolCache } from "../harness/tools/tool-cache";
13
+ import { ToolCallBudget } from "../harness/tools/tool-call-budget";
14
+ import { ToolMiddleware } from "../harness/tools/tool-middleware";
15
+ import { ToolRegistry } from "../harness/tools/tool-registry";
16
+ import { fakeAgentContext } from "./fixtures/fake-investigation";
17
+ import { fakeHoundsmanConfig } from "./fixtures/fake-project";
18
+
19
+ function registry(): ToolRegistry {
20
+ const value = new ToolRegistry();
21
+ value.registerModule(new BuiltinToolsModule());
22
+ return value;
23
+ }
24
+
25
+ function context(): ToolContext {
26
+ return {
27
+ repoRoot: process.cwd(),
28
+ watchdog: new Watchdog(fakeHoundsmanConfig.watchdogs.alpha),
29
+ diff: { byFile: new Map(), churn: new Map() },
30
+ ast: new AstService(fakeHoundsmanConfig),
31
+ state: new AgentState(),
32
+ budget: new ToolCallBudget(20, 10),
33
+ cache: new ToolCache(),
34
+ logger: new SilentReporter(),
35
+ agent: fakeAgentContext({ repoRoot: process.cwd() }),
36
+ rules: new Map(),
37
+ };
38
+ }
39
+
40
+ describe("contratos das tools", () => {
41
+ test("todas declaram metadata read-only e capabilities", () => {
42
+ for (const tool of registry().list()) {
43
+ expect(tool.metadata.name.length).toBeGreaterThan(0);
44
+ expect(tool.metadata.description.length).toBeGreaterThan(0);
45
+ expect(tool.metadata.readOnly).toBe(true);
46
+ expect(tool.metadata.capabilities.length).toBeGreaterThan(0);
47
+ expect(tool.metadata.cost).toBeGreaterThanOrEqual(1);
48
+ }
49
+ });
50
+
51
+ test("nenhuma tool devolve arquivo inteiro por padrão", () => {
52
+ // `read_file_range` exige faixa explícita.
53
+ const range = registry().get("read_file_range");
54
+ expect(range?.inputSchema.safeParse({ path: "a.ts" }).success).toBe(false);
55
+ // `file_outline` só devolve metadados (não tem parâmetro de corpo).
56
+ const outline = registry().get("file_outline");
57
+ expect(outline?.inputSchema.safeParse({ path: "a.ts" }).success).toBe(true);
58
+ });
59
+
60
+ test("tools de arquivo devolvem erro estruturado (não lançam)", async () => {
61
+ const middleware = new ToolMiddleware();
62
+ for (const [name, input] of [
63
+ ["read_file_range", { path: "../x", startLine: 1, endLine: 2 }],
64
+ ["file_outline", { path: "../x" }],
65
+ ["imports_exports", { file: "../x" }],
66
+ ] as const) {
67
+ const result = await registry().execute(name, input, context(), middleware);
68
+ expect(result.ok).toBe(false);
69
+ expect(typeof result.error).toBe("string");
70
+ }
71
+ });
72
+
73
+ test("rule_signals devolve erro quando a regra não existe", async () => {
74
+ const result = await registry().execute(
75
+ "rule_signals",
76
+ { rule: "regra inexistente" },
77
+ context(),
78
+ new ToolMiddleware(),
79
+ );
80
+ expect(result.ok).toBe(false);
81
+ });
82
+ });
@@ -158,7 +158,7 @@ describe("JevVerifier", () => {
158
158
  ]);
159
159
  expect(outcome.checked).toBe(0);
160
160
  expect(outcome.skipped).toBe(1);
161
- expect(outcome.reason).toContain("TYPESAFE_API_KEY");
161
+ expect(outcome.reason).toContain("HOUNDSMAN_TYPE_SAFE_API_KEY");
162
162
  });
163
163
 
164
164
  test("sem casos ⇒ vazio", async () => {
@@ -1,153 +0,0 @@
1
- /**
2
- * Markdown mínimo do campo `action` (cliente).
3
- *
4
- * Suporta o suficiente para instruções curtas: parágrafos, quebras de linha,
5
- * listas, blockquote, blocos de código e formatação inline (`code`, **bold**,
6
- * *itálico*, links). Sem lib externa e sem HTML cru.
7
- */
8
-
9
- export type MarkdownBlock =
10
- | { type: "paragraph"; text: string }
11
- | { type: "list"; ordered: boolean; items: string[] }
12
- | { type: "code"; language: string | null; code: string }
13
- | { type: "quote"; text: string };
14
-
15
- export type InlineToken =
16
- | { type: "text"; text: string }
17
- | { type: "code"; text: string }
18
- | { type: "strong"; text: string }
19
- | { type: "em"; text: string }
20
- | { type: "link"; text: string; url: string };
21
-
22
- const FENCE = /^\s*```(\S*)\s*$/;
23
- const BULLET = /^\s*[-*+]\s+(.*)$/;
24
- const ORDERED = /^\s*\d+\.\s+(.*)$/;
25
- const QUOTE = /^\s*>\s?(.*)$/;
26
-
27
- function isBlockStart(line: string): boolean {
28
- return (
29
- FENCE.test(line) || BULLET.test(line) || ORDERED.test(line) || QUOTE.test(line)
30
- );
31
- }
32
-
33
- /** Divide o texto em blocos simples (sem aninhamento profundo). */
34
- export function parseMarkdown(text: string): MarkdownBlock[] {
35
- const lines = text.replace(/\r\n?/g, "\n").split("\n");
36
- const blocks: MarkdownBlock[] = [];
37
- let index = 0;
38
-
39
- while (index < lines.length) {
40
- const line = lines[index];
41
- if (line.trim().length === 0) {
42
- index++;
43
- continue;
44
- }
45
-
46
- const fence = FENCE.exec(line);
47
- if (fence) {
48
- const language = fence[1] || null;
49
- const code: string[] = [];
50
- index++;
51
- while (index < lines.length && !FENCE.test(lines[index])) {
52
- code.push(lines[index]);
53
- index++;
54
- }
55
- if (index < lines.length) index++;
56
- blocks.push({ type: "code", language, code: code.join("\n") });
57
- continue;
58
- }
59
-
60
- if (BULLET.test(line)) {
61
- const items: string[] = [];
62
- while (index < lines.length) {
63
- const item = BULLET.exec(lines[index]);
64
- if (!item) break;
65
- items.push(item[1]);
66
- index++;
67
- }
68
- blocks.push({ type: "list", ordered: false, items });
69
- continue;
70
- }
71
-
72
- if (ORDERED.test(line)) {
73
- const items: string[] = [];
74
- while (index < lines.length) {
75
- const item = ORDERED.exec(lines[index]);
76
- if (!item) break;
77
- items.push(item[1]);
78
- index++;
79
- }
80
- blocks.push({ type: "list", ordered: true, items });
81
- continue;
82
- }
83
-
84
- if (QUOTE.test(line)) {
85
- const quote: string[] = [];
86
- while (index < lines.length) {
87
- const item = QUOTE.exec(lines[index]);
88
- if (!item) break;
89
- quote.push(item[1]);
90
- index++;
91
- }
92
- blocks.push({ type: "quote", text: quote.join("\n") });
93
- continue;
94
- }
95
-
96
- const paragraph: string[] = [line];
97
- index++;
98
- while (
99
- index < lines.length &&
100
- lines[index].trim().length > 0 &&
101
- !isBlockStart(lines[index])
102
- ) {
103
- paragraph.push(lines[index]);
104
- index++;
105
- }
106
- blocks.push({ type: "paragraph", text: paragraph.join("\n") });
107
- }
108
-
109
- return blocks;
110
- }
111
-
112
- /** Tokeniza formatação inline preservando o texto e a ordem dos marcadores. */
113
- export function parseInline(text: string): InlineToken[] {
114
- const tokens: InlineToken[] = [];
115
- // Ordem importa: code/bold antes de itálico; `_` exige fronteira de palavra.
116
- const pattern =
117
- /`[^`\n]+`|\*\*[^*\n]+\*\*|__[^_\n]+__|\*[^*\n]+\*|(?<![\w])_[^_\n]+_(?![\w])|\[[^\]\n]+\]\([^)\s]+\)/g;
118
- let last = 0;
119
- for (const match of text.matchAll(pattern)) {
120
- const start = match.index ?? 0;
121
- if (start > last) {
122
- tokens.push({ type: "text", text: text.slice(last, start) });
123
- }
124
- const raw = match[0];
125
- if (raw.startsWith("`")) {
126
- tokens.push({ type: "code", text: raw.slice(1, -1) });
127
- } else if (raw.startsWith("**") || raw.startsWith("__")) {
128
- tokens.push({ type: "strong", text: raw.slice(2, -2) });
129
- } else if (raw.startsWith("*") || raw.startsWith("_")) {
130
- tokens.push({ type: "em", text: raw.slice(1, -1) });
131
- } else {
132
- const link = /^\[([^\]]+)\]\(([^)\s]+)\)$/.exec(raw);
133
- if (link) {
134
- tokens.push({ type: "link", text: link[1], url: link[2] });
135
- } else {
136
- tokens.push({ type: "text", text: raw });
137
- }
138
- }
139
- last = start + raw.length;
140
- }
141
- if (last < text.length) {
142
- tokens.push({ type: "text", text: text.slice(last) });
143
- }
144
- return tokens;
145
- }
146
-
147
- /** URL segura para links de markdown (evita `javascript:` e afins). */
148
- export function safeUrl(url: string): string | null {
149
- const value = url.trim();
150
- if (/^(https?:|mailto:)/i.test(value)) return value;
151
- if (value.startsWith("/") && !value.startsWith("//")) return value;
152
- return null;
153
- }
File without changes