@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
@@ -19,9 +19,12 @@ import {
19
19
  type GhRunner,
20
20
  type PrTarget,
21
21
  renderGeneralBody,
22
+ renderReviewBody,
23
+ resolveFindingThreads,
22
24
  resolvePrTarget,
23
25
  selectSubmitCandidates,
24
26
  submitFindings,
27
+ syncResolvedThreads,
25
28
  } from "../core/github-review";
26
29
  import type { Severity } from "../core/types";
27
30
 
@@ -77,15 +80,6 @@ function stateWith(findings: FindingState[]): FindingsStateFile {
77
80
 
78
81
  const TARGET: PrTarget = { number: 7, repo: "acme/app", headSha: "abc" };
79
82
 
80
- function okComment(id: number): GhResult {
81
- return {
82
- ok: true,
83
- code: 0,
84
- stderr: "",
85
- stdout: JSON.stringify({ id, html_url: `https://x/${id}` }),
86
- };
87
- }
88
-
89
83
  /** Runner fake: o handler decide a resposta a partir dos args (os chamados ficam registrados). */
90
84
  function fakeRunner(handler: (args: string[]) => GhResult): {
91
85
  runner: GhRunner;
@@ -99,15 +93,177 @@ function fakeRunner(handler: (args: string[]) => GhResult): {
99
93
  return { runner, calls };
100
94
  }
101
95
 
102
- /** Respostas por tipo de destino: geral (issues), arquivo (subject_type=file) e linha. */
103
- function endpointRunner(ids: { line?: number; file?: number; general?: number } = {}): {
96
+ /** Resposta OK do `gh` com um JSON. */
97
+ function okJson(payload: unknown): GhResult {
98
+ return { ok: true, code: 0, stderr: "", stdout: JSON.stringify(payload) };
99
+ }
100
+
101
+ type ReviewRunnerOptions = {
102
+ /** Revisão PENDING já existente. */
103
+ pending?: { id: string; databaseId: number };
104
+ /** Erro GraphQL ao consultar/criar a revisão. */
105
+ lookupError?: string;
106
+ /** Erro ao adicionar a thread da linha. */
107
+ threadError?: string;
108
+ /** Erro ao finalizar a revisão. */
109
+ submitError?: string;
110
+ /** Erro no comentário de arquivo (REST). */
111
+ fileError?: boolean;
112
+ lineId?: number;
113
+ fileId?: number;
114
+ generalId?: number;
115
+ /** Threads do PR (comentário → conversa). */
116
+ threads?: { id: string; commentId: number; isResolved?: boolean }[];
117
+ /** Erro GraphQL ao listar/resolver threads. */
118
+ threadsError?: string;
119
+ };
120
+
121
+ /**
122
+ * Runner fake do `gh`: responde às queries/mutations GraphQL da revisão e aos
123
+ * endpoints REST de fallback. Os chamados ficam registrados.
124
+ */
125
+ function reviewRunner(options: ReviewRunnerOptions = {}): {
104
126
  runner: GhRunner;
105
127
  calls: string[][];
106
128
  } {
107
129
  return fakeRunner((args) => {
108
- if (args[1].includes("/issues/")) return okComment(ids.general ?? 900);
109
- if (args.includes("subject_type=file")) return okComment(ids.file ?? 800);
110
- return okComment(ids.line ?? 700);
130
+ if (args[0] === "api" && args[1] === "graphql") {
131
+ const query =
132
+ args.find((arg) => arg.startsWith("query="))?.slice("query=".length) ??
133
+ "";
134
+ if (query.includes("states: [PENDING]")) {
135
+ if (options.lookupError) {
136
+ return okJson({ errors: [{ message: options.lookupError }] });
137
+ }
138
+ return okJson({
139
+ data: {
140
+ repository: {
141
+ pullRequest: {
142
+ id: "PR_1",
143
+ reviews: {
144
+ nodes: options.pending
145
+ ? [
146
+ {
147
+ id: options.pending.id,
148
+ databaseId:
149
+ options.pending.databaseId,
150
+ resourcePath: `/acme/app/pull/7#pullrequestreview-${options.pending.databaseId}`,
151
+ },
152
+ ]
153
+ : [],
154
+ },
155
+ },
156
+ },
157
+ },
158
+ });
159
+ }
160
+ if (query.includes("addPullRequestReview(input")) {
161
+ return okJson({
162
+ data: {
163
+ addPullRequestReview: {
164
+ pullRequestReview: {
165
+ id: "REV_NEW",
166
+ databaseId: 11,
167
+ resourcePath: "/acme/app/pull/7#pullrequestreview-11",
168
+ },
169
+ },
170
+ },
171
+ });
172
+ }
173
+ if (query.includes("addPullRequestReviewThread")) {
174
+ if (options.threadError) {
175
+ return okJson({ errors: [{ message: options.threadError }] });
176
+ }
177
+ const lineId = options.lineId ?? 700;
178
+ return okJson({
179
+ data: {
180
+ addPullRequestReviewThread: {
181
+ thread: {
182
+ id: `THREAD_${lineId}`,
183
+ comments: {
184
+ nodes: [
185
+ {
186
+ databaseId: lineId,
187
+ resourcePath: `/acme/app/pull/7#discussion_r${lineId}`,
188
+ },
189
+ ],
190
+ },
191
+ },
192
+ },
193
+ },
194
+ });
195
+ }
196
+ if (query.includes("reviewThreads")) {
197
+ if (options.threadsError) {
198
+ return okJson({ errors: [{ message: options.threadsError }] });
199
+ }
200
+ return okJson({
201
+ data: {
202
+ repository: {
203
+ pullRequest: {
204
+ reviewThreads: {
205
+ nodes: (options.threads ?? []).map((thread) => ({
206
+ id: thread.id,
207
+ isResolved: thread.isResolved ?? false,
208
+ comments: {
209
+ nodes: [{ databaseId: thread.commentId }],
210
+ },
211
+ })),
212
+ },
213
+ },
214
+ },
215
+ },
216
+ });
217
+ }
218
+ if (query.includes("resolveReviewThread")) {
219
+ if (options.threadsError) {
220
+ return okJson({ errors: [{ message: options.threadsError }] });
221
+ }
222
+ return okJson({
223
+ data: {
224
+ resolveReviewThread: {
225
+ thread: { id: "THREAD", isResolved: true },
226
+ },
227
+ },
228
+ });
229
+ }
230
+ if (query.includes("submitPullRequestReview")) {
231
+ if (options.submitError) {
232
+ return okJson({ errors: [{ message: options.submitError }] });
233
+ }
234
+ return okJson({
235
+ data: {
236
+ submitPullRequestReview: {
237
+ pullRequestReview: {
238
+ id: "REV_NEW",
239
+ databaseId: 11,
240
+ state: "COMMENTED",
241
+ },
242
+ },
243
+ },
244
+ });
245
+ }
246
+ return okJson({ errors: [{ message: "mutation desconhecida" }] });
247
+ }
248
+ if (args[1]?.includes("/issues/")) {
249
+ const generalId = options.generalId ?? 900;
250
+ return okJson({ id: generalId, html_url: `https://x/${generalId}` });
251
+ }
252
+ if (args[0] === "pr" && args[1] === "view") {
253
+ return okJson({
254
+ number: 7,
255
+ url: "https://github.com/acme/app/pull/7",
256
+ headRefOid: "abc",
257
+ });
258
+ }
259
+ if (args.includes("subject_type=file")) {
260
+ if (options.fileError) {
261
+ return { ok: false, code: 422, stderr: "fora do diff", stdout: "" };
262
+ }
263
+ const fileId = options.fileId ?? 800;
264
+ return okJson({ id: fileId, html_url: `https://x/${fileId}` });
265
+ }
266
+ return { ok: false, code: 404, stdout: "", stderr: "chamada não esperada" };
111
267
  });
112
268
  }
113
269
 
@@ -189,6 +345,19 @@ describe("corpo dos comentários", () => {
189
345
  expect(body).toContain("Regra X");
190
346
  expect(body).toContain("src/a.ts");
191
347
  });
348
+
349
+ test("renderReviewBody resume as severidades enviadas", () => {
350
+ const candidates = selectSubmitCandidates(
351
+ stateWith([
352
+ finding({ id: "a", severity: "blocking" }),
353
+ finding({ id: "b", severity: "nit" }),
354
+ ]),
355
+ );
356
+ const body = renderReviewBody(candidates);
357
+ expect(body).toContain("2 achado");
358
+ expect(body).toContain("blocking");
359
+ expect(body).toContain("nit");
360
+ });
192
361
  });
193
362
 
194
363
  describe("resolvePrTarget", () => {
@@ -229,66 +398,121 @@ describe("resolvePrTarget", () => {
229
398
  });
230
399
 
231
400
  describe("submitFindings", () => {
232
- test("posta na linha e no arquivo; grava o vínculo", () => {
233
- const state = stateWith([finding({ id: "a" }), finding({ id: "c", line: null })]);
234
- const { runner } = endpointRunner({ line: 100, file: 150, general: 200 });
401
+ test("cria a revisão e finaliza com COMMENT", () => {
402
+ const state = stateWith([finding({ id: "a", severity: "attention" })]);
403
+ const { runner, calls } = reviewRunner();
235
404
  const outcome = submitFindings({ state, target: TARGET, runner });
236
405
 
237
- expect(outcome.posted).toHaveLength(2);
238
- expect(outcome.failed).toEqual([]);
239
- expect(outcome.general).toBeUndefined();
406
+ expect(outcome.posted).toEqual([
407
+ {
408
+ id: "a",
409
+ kind: "line",
410
+ commentId: 700,
411
+ url: "https://github.com/acme/app/pull/7#discussion_r700",
412
+ },
413
+ ]);
414
+ expect(outcome.review).toMatchObject({
415
+ id: 11,
416
+ created: true,
417
+ finalized: true,
418
+ event: "COMMENT",
419
+ });
240
420
  expect(state.findings.a.github).toMatchObject({
241
- commentId: 100,
421
+ commentId: 700,
242
422
  kind: "line",
243
423
  pr: 7,
424
+ threadId: "THREAD_700",
244
425
  });
245
- expect(state.findings.c.github).toMatchObject({
246
- commentId: 150,
247
- kind: "file",
248
- pr: 7,
426
+ expect(calls.filter((args) => args[1] === "graphql")).toHaveLength(4);
427
+ });
428
+
429
+ test("cria a revisão e finaliza com REQUEST_CHANGES quando há blocking", () => {
430
+ const state = stateWith([finding({ id: "a", severity: "blocking" })]);
431
+ const { runner } = reviewRunner();
432
+ const outcome = submitFindings({ state, target: TARGET, runner });
433
+
434
+ expect(outcome.review?.event).toBe("REQUEST_CHANGES");
435
+ expect(outcome.review?.finalized).toBe(true);
436
+ });
437
+
438
+ test("adiciona à revisão pendente existente sem finalizar", () => {
439
+ const state = stateWith([finding({ id: "a" })]);
440
+ const { runner, calls } = reviewRunner({
441
+ pending: { id: "REV_OLD", databaseId: 42 },
442
+ });
443
+ const outcome = submitFindings({ state, target: TARGET, runner });
444
+
445
+ expect(outcome.review).toMatchObject({
446
+ id: 42,
447
+ created: false,
448
+ finalized: false,
249
449
  });
450
+ expect(outcome.posted[0].kind).toBe("line");
451
+ expect(state.findings.a.github?.commentId).toBe(700);
452
+
453
+ const submitCall = calls.find((args) =>
454
+ args.some((arg) => arg.includes("submitPullRequestReview")),
455
+ );
456
+ expect(submitCall).toBeUndefined();
457
+ const threadCall = calls.find((args) =>
458
+ args.some((arg) => arg.includes("addPullRequestReviewThread")),
459
+ );
460
+ expect(threadCall).toContain("review=REV_OLD");
250
461
  });
251
462
 
252
463
  test("linha fora do diff cai no comentário de arquivo", () => {
253
464
  const state = stateWith([finding({ id: "a" })]);
254
- const { runner } = fakeRunner((args) =>
255
- args.includes("subject_type=file")
256
- ? okComment(150)
257
- : {
258
- ok: false,
259
- code: 422,
260
- stderr: "line must be part of the diff",
261
- stdout: "",
262
- },
263
- );
465
+ const { runner } = reviewRunner({ threadError: "line must be part of the diff" });
264
466
  const outcome = submitFindings({ state, target: TARGET, runner });
265
467
 
266
468
  expect(outcome.failed).toEqual([]);
267
469
  expect(outcome.general).toBeUndefined();
268
- expect(state.findings.a.github).toMatchObject({
269
- kind: "file",
270
- commentId: 150,
271
- });
470
+ expect(outcome.posted[0]).toMatchObject({ kind: "file", commentId: 800 });
471
+ expect(state.findings.a.github).toMatchObject({ kind: "file", commentId: 800 });
272
472
  });
273
473
 
274
474
  test("o que não cola em linha nem arquivo vai para o comentário geral", () => {
275
475
  const state = stateWith([finding({ id: "a" })]);
276
- const { runner } = fakeRunner((args) =>
277
- args[1].includes("/issues/")
278
- ? okComment(200)
279
- : { ok: false, code: 422, stderr: "fora do diff", stdout: "" },
280
- );
476
+ const { runner } = reviewRunner({
477
+ threadError: "fora do diff",
478
+ fileError: true,
479
+ });
281
480
  const outcome = submitFindings({ state, target: TARGET, runner });
282
481
 
283
482
  expect(outcome.failed).toHaveLength(1);
284
483
  expect(outcome.general?.ids).toEqual(["a"]);
285
484
  expect(state.findings.a.github?.kind).toBe("comment");
286
- expect(state.findings.a.github?.commentId).toBe(200);
485
+ expect(state.findings.a.github?.commentId).toBe(900);
486
+ });
487
+
488
+ test("finding sem linha continua comentário de arquivo, sem revisão", () => {
489
+ const state = stateWith([finding({ id: "a", line: null })]);
490
+ const { runner, calls } = reviewRunner();
491
+ const outcome = submitFindings({ state, target: TARGET, runner });
492
+
493
+ expect(outcome.review).toBeUndefined();
494
+ expect(outcome.posted[0]).toMatchObject({ kind: "file", commentId: 800 });
495
+ expect(state.findings.a.github?.kind).toBe("file");
496
+ expect(calls.some((args) => args.includes("subject_type=file"))).toBe(true);
497
+ expect(calls.some((args) => args[1] === "graphql")).toBe(false);
498
+ });
499
+
500
+ test("mistura linha na revisão e finding sem linha no arquivo", () => {
501
+ const state = stateWith([finding({ id: "a" }), finding({ id: "c", line: null })]);
502
+ const { runner } = reviewRunner();
503
+ const outcome = submitFindings({ state, target: TARGET, runner });
504
+
505
+ expect(outcome.review?.created).toBe(true);
506
+ expect(outcome.posted.map((entry) => entry.kind).sort()).toEqual([
507
+ "file",
508
+ "line",
509
+ ]);
510
+ expect(state.findings.c.github?.kind).toBe("file");
287
511
  });
288
512
 
289
513
  test("pula já enviados (sem chamar o gh)", () => {
290
514
  const state = stateWith([finding({ id: "a", commentId: 55 })]);
291
- const { runner, calls } = endpointRunner();
515
+ const { runner, calls } = reviewRunner();
292
516
  const outcome = submitFindings({ state, target: TARGET, runner });
293
517
 
294
518
  expect(calls).toHaveLength(0);
@@ -296,25 +520,150 @@ describe("submitFindings", () => {
296
520
  expect(state.findings.a.github?.commentId).toBe(55);
297
521
  });
298
522
 
299
- test("manda commit_id e side=RIGHT na linha", () => {
523
+ test("manda path, line e body na thread da revisão", () => {
300
524
  const state = stateWith([finding({ id: "a", line: 9 })]);
301
- const { runner, calls } = endpointRunner({ line: 100 });
525
+ const { runner, calls } = reviewRunner({
526
+ pending: { id: "REV_OLD", databaseId: 42 },
527
+ });
302
528
  submitFindings({ state, target: TARGET, runner });
303
529
 
304
- const line = calls[0];
305
- expect(line).toContain("line=9");
306
- expect(line).toContain("side=RIGHT");
307
- expect(line).toContain("commit_id=abc");
308
- expect(line).toContain("path=src/a.ts");
530
+ const thread = calls.find((args) =>
531
+ args.some((arg) => arg.includes("addPullRequestReviewThread")),
532
+ );
533
+ expect(thread).toContain("path=src/a.ts");
534
+ expect(thread).toContain("line=9");
535
+ expect(thread).toContain("review=REV_OLD");
536
+ expect(thread?.some((arg) => arg.startsWith("body="))).toBe(true);
309
537
  });
310
538
 
311
- test("finding sem linha usa subject_type=file", () => {
312
- const state = stateWith([finding({ id: "a", line: null })]);
313
- const { runner, calls } = endpointRunner({ file: 150 });
314
- submitFindings({ state, target: TARGET, runner });
539
+ test("falha ao finalizar reporta o erro e mantém o vínculo", () => {
540
+ const state = stateWith([finding({ id: "a" })]);
541
+ const { runner } = reviewRunner({
542
+ submitError: "review não pode ser submetida",
543
+ });
544
+ const outcome = submitFindings({ state, target: TARGET, runner });
315
545
 
316
- expect(calls[0]).toContain("subject_type=file");
317
- expect(calls[0]).not.toContain("side=RIGHT");
318
- expect(state.findings.a.github?.kind).toBe("file");
546
+ expect(outcome.review).toMatchObject({
547
+ created: true,
548
+ finalized: false,
549
+ error: "review não pode ser submetida",
550
+ });
551
+ expect(state.findings.a.github?.commentId).toBe(700);
552
+ });
553
+
554
+ test("falha no lookup da revisão não volta a comentários soltos", () => {
555
+ const state = stateWith([finding({ id: "a" })]);
556
+ const { runner, calls } = reviewRunner({ lookupError: "sem permissão" });
557
+ const outcome = submitFindings({ state, target: TARGET, runner });
558
+
559
+ expect(outcome.posted).toEqual([]);
560
+ expect(outcome.general).toBeUndefined();
561
+ expect(outcome.failed).toEqual([{ id: "a", reason: "sem permissão" }]);
562
+ expect(outcome.review).toMatchObject({
563
+ created: false,
564
+ finalized: false,
565
+ error: "sem permissão",
566
+ });
567
+ expect(calls.some((args) => args.includes("subject_type=file"))).toBe(false);
568
+ });
569
+ });
570
+
571
+ describe("resolveFindingThreads", () => {
572
+ const NOW = "2026-02-02T00:00:00.000Z";
573
+
574
+ test("resolve a thread aberta e grava threadId/resolvedAt", () => {
575
+ const state = stateWith([finding({ id: "a", commentId: 700 })]);
576
+ const { runner, calls } = reviewRunner({
577
+ threads: [{ id: "THREAD_700", commentId: 700 }],
578
+ });
579
+ const outcome = resolveFindingThreads({
580
+ target: TARGET,
581
+ findings: Object.values(state.findings),
582
+ runner,
583
+ now: NOW,
584
+ });
585
+
586
+ expect(outcome.checked).toBe(1);
587
+ expect(outcome.resolved).toEqual([{ id: "a", threadId: "THREAD_700" }]);
588
+ expect(state.findings.a.github).toMatchObject({
589
+ threadId: "THREAD_700",
590
+ resolvedAt: NOW,
591
+ });
592
+ expect(outcome.updated).toEqual(["a"]);
593
+ expect(
594
+ calls.some((args) => args.some((arg) => arg.includes("resolveReviewThread"))),
595
+ ).toBe(true);
596
+ });
597
+
598
+ test("pula thread já resolvida (sem mutation)", () => {
599
+ const state = stateWith([finding({ id: "a", commentId: 700 })]);
600
+ const { runner, calls } = reviewRunner({
601
+ threads: [{ id: "THREAD_700", commentId: 700, isResolved: true }],
602
+ });
603
+ const outcome = resolveFindingThreads({
604
+ target: TARGET,
605
+ findings: Object.values(state.findings),
606
+ runner,
607
+ now: NOW,
608
+ });
609
+
610
+ expect(outcome.resolved).toEqual([]);
611
+ expect(outcome.skipped).toEqual([{ id: "a", reason: "já resolvida no GitHub." }]);
612
+ expect(state.findings.a.github?.resolvedAt).toBe(NOW);
613
+ expect(
614
+ calls.some((args) => args.some((arg) => arg.includes("resolveReviewThread"))),
615
+ ).toBe(false);
616
+ });
617
+
618
+ test("comentário geral não é thread (não consulta o gh)", () => {
619
+ const state = stateWith([finding({ id: "a", commentId: 900 })]);
620
+ const link = state.findings.a.github;
621
+ if (link) link.kind = "comment";
622
+ const { runner, calls } = reviewRunner();
623
+
624
+ const outcome = resolveFindingThreads({
625
+ target: TARGET,
626
+ findings: Object.values(state.findings),
627
+ runner,
628
+ });
629
+
630
+ expect(outcome.checked).toBe(0);
631
+ expect(calls).toHaveLength(0);
632
+ });
633
+
634
+ test("falha do gh vira failed (fail-open)", () => {
635
+ const state = stateWith([finding({ id: "a", commentId: 700 })]);
636
+ const { runner } = reviewRunner({ threadsError: "sem permissão" });
637
+ const outcome = resolveFindingThreads({
638
+ target: TARGET,
639
+ findings: Object.values(state.findings),
640
+ runner,
641
+ });
642
+
643
+ expect(outcome.failed).toEqual([{ id: "a", reason: "sem permissão" }]);
644
+ expect(state.findings.a.github?.resolvedAt).toBeUndefined();
645
+ });
646
+
647
+ test("syncResolvedThreads resolve o alvo via gh pr view", () => {
648
+ const state = stateWith([finding({ id: "a", commentId: 700 })]);
649
+ const { runner, calls } = reviewRunner({
650
+ threads: [{ id: "THREAD_700", commentId: 700 }],
651
+ });
652
+
653
+ const outcome = syncResolvedThreads({ state, ids: ["a"], runner, now: NOW });
654
+
655
+ expect(outcome.available).toBe(true);
656
+ expect(outcome.resolved).toHaveLength(1);
657
+ expect(calls.some((args) => args[0] === "pr" && args[1] === "view")).toBe(true);
658
+ });
659
+
660
+ test("syncResolvedThreads sem vínculo remoto não chama o gh", () => {
661
+ const state = stateWith([finding({ id: "a" })]);
662
+ const { runner, calls } = reviewRunner();
663
+
664
+ const outcome = syncResolvedThreads({ state, ids: ["a"], runner });
665
+
666
+ expect(outcome.checked).toBe(0);
667
+ expect(calls).toHaveLength(0);
319
668
  });
320
669
  });
@@ -0,0 +1,122 @@
1
+ /**
2
+ * ReviewAgent — máquina de estados com provider fake e tools reais.
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 { SilentReporter } from "../harness/agent/agent-reporter";
10
+ import { ReviewAgent } from "../harness/agent/review-agent";
11
+ import { fakeAgentContext, fakeAgentDeps } from "./fixtures/fake-investigation";
12
+ import { FakeModelProvider } from "./fixtures/fake-model-provider";
13
+
14
+ const RULE = "Nunca usar `any`.";
15
+
16
+ const dir = mkdtempSync(join(tmpdir(), "houndsman-agent-"));
17
+ mkdirSync(join(dir, "src/alpha"), { recursive: true });
18
+ writeFileSync(join(dir, "src/alpha/a.ts"), "const x: any = 1;\nconsole.log(x);\n");
19
+ afterAll(() => rmSync(dir, { recursive: true, force: true }));
20
+
21
+ /** Provider que investiga com `read_file_range` e devolve um draft. */
22
+ function investigatingProvider(options: { withEvidence?: boolean } = {}) {
23
+ return new FakeModelProvider(async (request, index) => {
24
+ if (index === 0) return { hypotheses: ["uso de any"] };
25
+ if (index === 1) return { taskIds: ["rule-0"] };
26
+ const tool = request.tools?.read_file_range;
27
+ if (!tool) throw new Error("tool read_file_range ausente");
28
+ const raw = await tool.execute({
29
+ path: "src/alpha/a.ts",
30
+ startLine: 1,
31
+ endLine: 2,
32
+ });
33
+ const parsed = JSON.parse(raw) as {
34
+ evidence?: { id: string }[];
35
+ };
36
+ const evidenceId = parsed.evidence?.[0]?.id;
37
+ return {
38
+ findingDrafts: [
39
+ {
40
+ rule: RULE,
41
+ severity: "blocking",
42
+ file: "src/alpha/a.ts",
43
+ line: 1,
44
+ message: "Uso de any.",
45
+ action: "Tipar o valor.",
46
+ evidenceIds:
47
+ options.withEvidence === false || !evidenceId ? [] : [evidenceId],
48
+ confidence: 0.9,
49
+ },
50
+ ],
51
+ coverage: [
52
+ { rule: RULE, status: "checked" },
53
+ { rule: "Validar entrada do usuário.", status: "not_applicable" },
54
+ { rule: "Evitar console.log.", status: "checked" },
55
+ ],
56
+ };
57
+ });
58
+ }
59
+
60
+ function buildAgent(provider: FakeModelProvider): {
61
+ agent: ReviewAgent;
62
+ reporter: SilentReporter;
63
+ } {
64
+ const reporter = new SilentReporter();
65
+ const deps = fakeAgentDeps(provider, {
66
+ context: fakeAgentContext({ repoRoot: dir }),
67
+ reporter,
68
+ });
69
+ return { agent: new ReviewAgent(deps), reporter };
70
+ }
71
+
72
+ describe("ReviewAgent", () => {
73
+ test("ORIENT→PLAN→INVESTIGATE→VERIFY→CONCLUDE com evidência", async () => {
74
+ const { agent, reporter } = buildAgent(investigatingProvider());
75
+ const result = await agent.run();
76
+ expect(result.status).toBe("done");
77
+ expect(result.report?.findings).toHaveLength(1);
78
+ expect(result.report?.findings[0]?.evidence).toContain("src/alpha/a.ts");
79
+ expect(result.report?.reviewedFiles).toContain("src/alpha/a.ts");
80
+ const transitions = reporter.journal.transitions.map((item) => item.to);
81
+ expect(transitions).toEqual(["PLAN", "INVESTIGATE", "VERIFY", "CONCLUDE"]);
82
+ expect(result.state.coverage.size).toBe(3);
83
+ });
84
+
85
+ test("VERIFY é obrigatório: draft sem evidência é descartado", async () => {
86
+ const { agent } = buildAgent(investigatingProvider({ withEvidence: false }));
87
+ const result = await agent.run();
88
+ expect(result.status).toBe("done");
89
+ expect(result.report?.findings).toHaveLength(0);
90
+ const coverage = result.state.coverageOf(RULE);
91
+ expect(coverage?.status).toBe("checked");
92
+ expect(coverage?.reason).toContain("refutado");
93
+ });
94
+
95
+ test("toda regra termina com cobertura (mesmo sem o modelo cobrir)", async () => {
96
+ const provider = new FakeModelProvider((_request, index) => {
97
+ if (index === 0) return { hypotheses: [] };
98
+ if (index === 1) return { taskIds: [] };
99
+ return { findingDrafts: [], coverage: [] };
100
+ });
101
+ const { agent } = buildAgent(provider);
102
+ const result = await agent.run();
103
+ expect(result.state.coverage.size).toBe(3);
104
+ expect(
105
+ [...result.state.coverage.values()].every(
106
+ (item) => item.status === "skipped",
107
+ ),
108
+ ).toBe(true);
109
+ });
110
+
111
+ test("falha do provider vira status failed", async () => {
112
+ const provider = new FakeModelProvider((_request, index) => {
113
+ if (index === 0) throw new Error("boom");
114
+ if (index === 1) throw new Error("boom");
115
+ throw new Error("429 rate limit");
116
+ });
117
+ const { agent } = buildAgent(provider);
118
+ const result = await agent.run();
119
+ expect(result.status).toBe("failed");
120
+ expect(result.error).toContain("429");
121
+ });
122
+ });