@payconductor/houndsman 0.1.4 → 0.3.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 (84) hide show
  1. package/package.json +1 -1
  2. package/src/cli/arg-parser.ts +8 -0
  3. package/src/cli/commands/command.ts +23 -0
  4. package/src/cli/commands/context-command.ts +38 -0
  5. package/src/cli/commands/doctor-command.ts +55 -0
  6. package/src/cli/commands/findings-command.ts +28 -0
  7. package/src/cli/commands/help-command.ts +13 -0
  8. package/src/cli/commands/registry.ts +34 -0
  9. package/src/cli/commands/review-command.ts +22 -0
  10. package/src/cli/commands/server-command.ts +37 -0
  11. package/src/cli/commands/setup-command.ts +34 -0
  12. package/src/cli/commands/watchdog-command.ts +34 -0
  13. package/src/cli/help.ts +43 -0
  14. package/src/cli/index.ts +62 -260
  15. package/src/core/arg-parser.ts +48 -0
  16. package/src/core/commands/findings.ts +163 -147
  17. package/src/core/commands/review.ts +18 -35
  18. package/src/core/commands/scaffold.ts +174 -64
  19. package/src/core/commands/setup.ts +16 -11
  20. package/src/core/commands/update.ts +7 -7
  21. package/src/core/commands/watchdog.ts +8 -80
  22. package/src/core/config.ts +9 -19
  23. package/src/core/defaults.ts +20 -0
  24. package/src/core/diff-source.ts +1 -13
  25. package/src/core/doc-discoverer.ts +25 -15
  26. package/src/core/domain/budget.ts +42 -0
  27. package/src/core/domain/classification.ts +94 -0
  28. package/src/core/domain/diff.ts +53 -0
  29. package/src/core/domain/watchdog.ts +96 -0
  30. package/src/core/engine/carryover.ts +67 -0
  31. package/src/core/engine/houndsman-engine.ts +51 -0
  32. package/src/core/engine/review-scope.ts +95 -0
  33. package/src/core/engine/review-session.ts +51 -0
  34. package/src/core/file-classifier.ts +11 -67
  35. package/src/core/findings-state.ts +189 -38
  36. package/src/core/git-revision.ts +14 -40
  37. package/src/core/git-runner.ts +46 -0
  38. package/src/core/github-review.ts +423 -0
  39. package/src/core/orchestrator.ts +116 -337
  40. package/src/core/paths.ts +9 -0
  41. package/src/core/pr-context.ts +28 -54
  42. package/src/core/project-config.ts +57 -0
  43. package/src/core/renderers/batch-presenter.ts +40 -0
  44. package/src/core/renderers/brief-renderer.ts +23 -0
  45. package/src/core/renderers/findings-presenter.ts +74 -0
  46. package/src/core/renderers/manifest-writer.ts +112 -0
  47. package/src/core/renderers/report-renderer.ts +66 -0
  48. package/src/core/renderers/verify-presenter.ts +50 -0
  49. package/src/core/renderers/watchdog-presenter.ts +100 -0
  50. package/src/core/report-builder.ts +12 -7
  51. package/src/core/run-layout.ts +108 -0
  52. package/src/core/run-status.ts +6 -11
  53. package/src/core/services/clock.ts +12 -0
  54. package/src/core/services/file-classifier.ts +34 -0
  55. package/src/core/services/fix-batch-builder.ts +13 -0
  56. package/src/core/services/gh-client.ts +54 -0
  57. package/src/core/services/git-repository.ts +123 -0
  58. package/src/core/services/project-files.ts +79 -0
  59. package/src/core/services/verifier.ts +58 -0
  60. package/src/core/verify.ts +2 -1
  61. package/src/core/watchdog-registry.ts +58 -16
  62. package/src/index.ts +31 -1
  63. package/src/server/client/App.tsx +174 -49
  64. package/src/server/client/ArtifactsPanel.tsx +2 -2
  65. package/src/server/client/AutoRefresh.tsx +38 -0
  66. package/src/server/client/Filters.tsx +72 -53
  67. package/src/server/client/FindingCard.tsx +212 -71
  68. package/src/server/client/FindingsList.tsx +8 -6
  69. package/src/server/client/RunList.tsx +30 -7
  70. package/src/server/client/SubmitModal.tsx +418 -0
  71. package/src/server/client/SummaryBar.tsx +65 -12
  72. package/src/server/client/constants.ts +9 -12
  73. package/src/server/client/format.ts +40 -0
  74. package/src/server/client/icons.tsx +143 -0
  75. package/src/server/client/types.ts +69 -0
  76. package/src/server/index.ts +316 -222
  77. package/src/server/run-repository.ts +291 -0
  78. package/src/tests/__snapshots__/baseline.test.ts.snap +814 -0
  79. package/src/tests/baseline.test.ts +513 -0
  80. package/src/tests/cli.test.ts +167 -0
  81. package/src/tests/domain.test.ts +328 -0
  82. package/src/tests/github-review.test.ts +320 -0
  83. package/src/tests/infrastructure.test.ts +210 -0
  84. package/src/tests/server.test.ts +117 -0
@@ -0,0 +1,328 @@
1
+ /**
2
+ * Domínio (Fase 2 do refactor OO): watchdog, registry, config do projeto,
3
+ * diff, classificação e descoberta de docs.
4
+ */
5
+
6
+ import { describe, expect, test } from "bun:test";
7
+ import { defineHoundsmanConfig } from "../core/config";
8
+ import { DocDiscoverer, discoverDocs } from "../core/doc-discoverer";
9
+ import { Classification } from "../core/domain/classification";
10
+ import { Diff } from "../core/domain/diff";
11
+ import { matchesClassifyRule, Watchdog } from "../core/domain/watchdog";
12
+ import {
13
+ emptyState,
14
+ Finding,
15
+ type FindingState,
16
+ FindingsState,
17
+ } from "../core/findings-state";
18
+ import { ProjectConfig } from "../core/project-config";
19
+ import { defineWatchdog } from "../core/types";
20
+ import { WatchdogRegistry } from "../core/watchdog-registry";
21
+ import { fakeHoundsmanConfig } from "./fixtures/fake-project";
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Watchdog / WatchdogRegistry
25
+ // ---------------------------------------------------------------------------
26
+
27
+ describe("Watchdog", () => {
28
+ const alpha = new Watchdog(fakeHoundsmanConfig.watchdogs.alpha);
29
+
30
+ test("casa arquivos do próprio domínio", () => {
31
+ expect(alpha.matches("src/alpha/a.ts")).toBe(true);
32
+ expect(alpha.matches("src/beta/a.ts")).toBe(false);
33
+ });
34
+
35
+ test("ordena regras por severidade e expõe contexto sem regras", () => {
36
+ expect(alpha.rules.map((rule) => rule.severity)).toEqual([
37
+ "blocking",
38
+ "attention",
39
+ "suggestion",
40
+ ]);
41
+ const context = alpha.context();
42
+ expect(context.id).toBe("alpha");
43
+ expect(context.contextFiles).toEqual(["README.md"]);
44
+ expect("rules" in context).toBe(false);
45
+ });
46
+
47
+ test("casa por conteúdo quando a regra é type=content", () => {
48
+ const probe = new Watchdog(
49
+ defineWatchdog({
50
+ id: "probe",
51
+ name: "Probe",
52
+ description: "Por conteúdo.",
53
+ scope: "qualquer",
54
+ classify: [{ type: "content", value: "NEEDLE" }],
55
+ rules: [],
56
+ contextFiles: [],
57
+ }),
58
+ );
59
+ expect(probe.matches("a.ts", "// NEEDLE")).toBe(true);
60
+ expect(probe.matches("a.ts", "sem alvo")).toBe(false);
61
+ expect(probe.matches("a.ts")).toBe(false);
62
+ });
63
+
64
+ test("matchesClassifyRule respeita filename/content", () => {
65
+ expect(
66
+ matchesClassifyRule("src/alpha/a.ts", { value: "^src/alpha/" }, undefined),
67
+ ).toBe(true);
68
+ expect(
69
+ matchesClassifyRule("a.ts", { type: "content", value: "X" }, "contém X"),
70
+ ).toBe(true);
71
+ expect(
72
+ matchesClassifyRule("a.ts", { type: "content", value: "X" }, undefined),
73
+ ).toBe(false);
74
+ });
75
+ });
76
+
77
+ describe("WatchdogRegistry", () => {
78
+ const registry = new WatchdogRegistry(fakeHoundsmanConfig);
79
+
80
+ test("resolve ids, nomes e buscas null-safe", () => {
81
+ expect(registry.ids).toEqual(["alpha", "beta"]);
82
+ expect(registry.get("alpha").name).toBe("Alpha");
83
+ expect(registry.find("alpha")?.name).toBe("Alpha");
84
+ expect(registry.find("nao-existe")).toBeNull();
85
+ expect(registry.nameOf("nao-existe")).toBe("nao-existe");
86
+ expect(registry.all()).toHaveLength(2);
87
+ });
88
+
89
+ test("contexto e matching por arquivo", () => {
90
+ expect(registry.context("beta").contextFiles).toContain("README.md");
91
+ expect(registry.matching("src/alpha/a.ts")).toEqual(["alpha"]);
92
+ expect(registry.matching("lib/special.ts")).toEqual(["beta"]);
93
+ expect(registry.matching("scripts/foo.ts")).toEqual([]);
94
+ });
95
+ });
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // ProjectConfig
99
+ // ---------------------------------------------------------------------------
100
+
101
+ describe("ProjectConfig", () => {
102
+ test("resolve defaults e valores do projeto", () => {
103
+ const project = new ProjectConfig(fakeHoundsmanConfig);
104
+ expect(project.projectName).toBe("Fake Project");
105
+ expect(project.artifactDir).toBe("tmp/fake-review");
106
+ expect(project.maxFilesPerBrief).toBe(40);
107
+ expect(project.jevConcurrency).toBe(5);
108
+ expect(project.alwaysIncludeDocs).toEqual(["README.md"]);
109
+ expect(project.watchdogs.ids).toEqual(["alpha", "beta"]);
110
+ });
111
+
112
+ test("sobrepõe orçamento/concorrência e ignora valores inválidos", () => {
113
+ const custom = defineHoundsmanConfig({
114
+ ...fakeHoundsmanConfig,
115
+ budgets: { maxFilesPerBrief: 60 },
116
+ concurrency: { jev: 8 },
117
+ });
118
+ const project = new ProjectConfig(custom);
119
+ expect(project.maxFilesPerBrief).toBe(60);
120
+ expect(project.jevConcurrency).toBe(8);
121
+
122
+ const invalid = defineHoundsmanConfig({
123
+ ...fakeHoundsmanConfig,
124
+ budgets: { maxFilesPerBrief: 0 },
125
+ concurrency: { jev: -1 },
126
+ });
127
+ const fallback = new ProjectConfig(invalid);
128
+ expect(fallback.maxFilesPerBrief).toBe(40);
129
+ expect(fallback.jevConcurrency).toBe(5);
130
+ });
131
+ });
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // Diff / Classification
135
+ // ---------------------------------------------------------------------------
136
+
137
+ describe("Diff", () => {
138
+ const diff = new Diff({
139
+ byFile: new Map([["src/a.ts", "patch-a"]]),
140
+ churn: new Map([
141
+ ["src/a.ts", { additions: 2, deletions: 1 }],
142
+ ["src/b.ts", { additions: 3, deletions: 0 }],
143
+ ]),
144
+ });
145
+
146
+ test("consulta patch, churn e totais", () => {
147
+ expect(diff.paths.sort()).toEqual(["src/a.ts", "src/b.ts"]);
148
+ expect(diff.patchFor("src/a.ts")).toBe("patch-a");
149
+ expect(diff.patchFor("src/b.ts")).toBeUndefined();
150
+ expect(diff.churnOf("src/a.ts")).toEqual({ additions: 2, deletions: 1 });
151
+ expect(diff.churnOf("src/missing.ts")).toEqual({
152
+ additions: 0,
153
+ deletions: 0,
154
+ });
155
+ expect(diff.totals).toEqual({ additions: 5, deletions: 1 });
156
+ });
157
+
158
+ test("empty não tem paths nem churn", () => {
159
+ expect(Diff.empty().paths).toEqual([]);
160
+ expect(Diff.empty().totals).toEqual({ additions: 0, deletions: 0 });
161
+ });
162
+ });
163
+
164
+ describe("Classification", () => {
165
+ const data = {
166
+ watchdogs: { alpha: ["src/a.ts", "src/b.ts"], beta: ["src/c.ts"] },
167
+ files: {
168
+ "src/a.ts": { status: "modified" as const },
169
+ "src/b.ts": { status: "added" as const },
170
+ "src/c.ts": { status: "modified" as const },
171
+ },
172
+ churn: {
173
+ "src/a.ts": { additions: 10, deletions: 0 },
174
+ "src/b.ts": { additions: 1, deletions: 0 },
175
+ "src/c.ts": { additions: 4, deletions: 2 },
176
+ },
177
+ unclassified: ["scripts/x.ts"],
178
+ excluded: ["dist/x.js"],
179
+ deleted: [],
180
+ stats: { alpha: 2, beta: 1 },
181
+ excludedCount: 1,
182
+ base: "main",
183
+ head: "HEAD",
184
+ };
185
+ const classification = Classification.from(data);
186
+
187
+ test("expõe coleções e ids ativos", () => {
188
+ expect(classification.activeIds).toEqual(["alpha", "beta"]);
189
+ expect(classification.watchdogsFor("alpha")).toEqual(["src/a.ts", "src/b.ts"]);
190
+ expect(classification.watchdogsFor("missing")).toEqual([]);
191
+ expect(classification.unclassified).toEqual(["scripts/x.ts"]);
192
+ expect(classification.excludedCount).toBe(1);
193
+ expect(classification.base).toBe("main");
194
+ });
195
+
196
+ test("aplica o orçamento por risco", () => {
197
+ expect(classification.budgetFor("alpha", 1)).toEqual({
198
+ selected: ["src/a.ts"],
199
+ overflow: ["src/b.ts"],
200
+ });
201
+ expect(classification.budgetFor("alpha", 40)).toEqual({
202
+ selected: ["src/a.ts", "src/b.ts"],
203
+ overflow: [],
204
+ });
205
+ });
206
+
207
+ test("soma churn e serializa no shape do classification.json", () => {
208
+ expect(classification.churnTotals).toEqual({ additions: 15, deletions: 2 });
209
+ expect(classification.toJSON()).toBe(data);
210
+ });
211
+ });
212
+
213
+ // ---------------------------------------------------------------------------
214
+ // DocDiscoverer
215
+ // ---------------------------------------------------------------------------
216
+
217
+ describe("DocDiscoverer", () => {
218
+ test("sempre inclui docs e aplica os mapeamentos do projeto", () => {
219
+ const config = defineHoundsmanConfig({
220
+ ...fakeHoundsmanConfig,
221
+ alwaysIncludeDocs: ["AGENTS.md"],
222
+ docMappings: [
223
+ [/^src\//, ["docs/DOMAIN.md"]],
224
+ [/\.test\.ts$/, ["docs/TESTS.md"]],
225
+ ],
226
+ });
227
+ const docs = new DocDiscoverer(config).discover([
228
+ "src/alpha/a.ts",
229
+ "src/alpha/a.test.ts",
230
+ "scripts/foo.ts",
231
+ ]);
232
+ expect(docs).toEqual(["AGENTS.md", "docs/DOMAIN.md", "docs/TESTS.md"]);
233
+ });
234
+
235
+ test("o adaptador discoverDocs delega para a classe", () => {
236
+ expect(discoverDocs(["src/alpha/a.ts"], fakeHoundsmanConfig)).toEqual([
237
+ "README.md",
238
+ ]);
239
+ });
240
+ });
241
+
242
+ // ---------------------------------------------------------------------------
243
+ // Finding / FindingsState
244
+ // ---------------------------------------------------------------------------
245
+
246
+ /** Finding mínimo para os testes de domínio. */
247
+ function makeFinding(id: string, overrides: Partial<FindingState> = {}): FindingState {
248
+ return {
249
+ id,
250
+ fingerprint: `fp-${id}`,
251
+ watchdogIds: ["alpha"],
252
+ rule: "R",
253
+ severity: "blocking",
254
+ file: "src/a.ts",
255
+ line: 1,
256
+ message: "m",
257
+ action: "a",
258
+ anchor: null,
259
+ count: 1,
260
+ present: true,
261
+ decision: "pending",
262
+ execState: "pending",
263
+ createdAt: "2026-01-01T00:00:00.000Z",
264
+ updatedAt: "2026-01-01T00:00:00.000Z",
265
+ history: [],
266
+ ...overrides,
267
+ };
268
+ }
269
+
270
+ describe("Finding / FindingsState", () => {
271
+ const file = emptyState("main");
272
+ file.findings.a = makeFinding("a");
273
+ file.findings.b = makeFinding("b", {
274
+ severity: "nit",
275
+ file: "src/z.ts",
276
+ present: false,
277
+ decision: "ignore",
278
+ });
279
+ file.order = ["a", "b"];
280
+
281
+ test("Finding expõe o estado e aplica updates", () => {
282
+ const state = makeFinding("x");
283
+ const finding = Finding.from(state);
284
+ expect(finding.id).toBe("x");
285
+ expect(finding.isDone).toBe(false);
286
+ expect(finding.toJSON()).toBe(state);
287
+
288
+ finding.apply({ decision: "execute", execState: "in_progress" }, "agora");
289
+ expect(finding.decision).toBe("execute");
290
+ expect(finding.execState).toBe("in_progress");
291
+ expect(state.updatedAt).toBe("agora");
292
+ expect(state.history).toHaveLength(1);
293
+ });
294
+
295
+ test("FindingsState lista, filtra e resume", () => {
296
+ const state = FindingsState.from(file);
297
+ expect(state.branch).toBe("main");
298
+ expect(state.list().map((f) => f.id)).toEqual(["a", "b"]);
299
+ expect(state.get("a")?.file).toBe("src/a.ts");
300
+ expect(state.get("missing")).toBeNull();
301
+ expect(state.present().map((f) => f.id)).toEqual(["a"]);
302
+ expect(state.filter({ severity: "nit", includeAbsent: true })).toHaveLength(1);
303
+ expect(state.summarize().total).toBe(2);
304
+ expect(state.summarize().present).toBe(1);
305
+ expect(state.toJSON()).toBe(file);
306
+ });
307
+
308
+ test("FindingsState aplica updates e delega sync/reorder", () => {
309
+ const state = FindingsState.from(emptyState("main"));
310
+ state.file.findings.a = makeFinding("a");
311
+ state.file.order = ["a"];
312
+
313
+ expect(state.apply("missing", { decision: "ignore" })).toBeNull();
314
+ const updated = state.apply("a", { decision: "execute" }, "agora");
315
+ expect(updated?.decision).toBe("execute");
316
+ expect(state.file.findings.a?.updatedAt).toBe("agora");
317
+
318
+ const result = state.sync(() => null);
319
+ expect(result.stale).toEqual(["a"]);
320
+ });
321
+
322
+ test("FindingsState.empty cria uma coleção vazia válida", () => {
323
+ const empty = FindingsState.empty("feature/x");
324
+ expect(empty.branch).toBe("feature/x");
325
+ expect(empty.list()).toEqual([]);
326
+ expect(empty.summarize().total).toBe(0);
327
+ });
328
+ });
@@ -0,0 +1,320 @@
1
+ /**
2
+ * Testes do envio da revisão ao GitHub (`core/github-review.ts`).
3
+ *
4
+ * Usam um runner fake do `gh` — nenhum teste toca a rede/repositório real.
5
+ */
6
+
7
+ import { describe, expect, test } from "bun:test";
8
+ import {
9
+ emptyState,
10
+ type FindingDecision,
11
+ type FindingExecState,
12
+ type FindingState,
13
+ type FindingsStateFile,
14
+ } from "../core/findings-state";
15
+ import {
16
+ buildCommentBody,
17
+ buildSubmitPreview,
18
+ type GhResult,
19
+ type GhRunner,
20
+ type PrTarget,
21
+ renderGeneralBody,
22
+ resolvePrTarget,
23
+ selectSubmitCandidates,
24
+ submitFindings,
25
+ } from "../core/github-review";
26
+ import type { Severity } from "../core/types";
27
+
28
+ type FindingSpec = {
29
+ id: string;
30
+ severity?: Severity;
31
+ decision?: FindingDecision;
32
+ execState?: FindingExecState;
33
+ line?: number | null;
34
+ present?: boolean;
35
+ rule?: string;
36
+ file?: string;
37
+ commentId?: number;
38
+ };
39
+
40
+ function finding(spec: FindingSpec): FindingState {
41
+ return {
42
+ id: spec.id,
43
+ fingerprint: spec.id,
44
+ watchdogIds: ["alpha"],
45
+ rule: spec.rule ?? "Regra de teste",
46
+ severity: spec.severity ?? "blocking",
47
+ file: spec.file ?? "src/a.ts",
48
+ line: spec.line === undefined ? 3 : spec.line,
49
+ message: "mensagem",
50
+ action: "corrigir",
51
+ evidence: "const x = 1;",
52
+ anchor: null,
53
+ count: 1,
54
+ present: spec.present ?? true,
55
+ decision: spec.decision ?? "pending",
56
+ execState: spec.execState ?? "pending",
57
+ createdAt: "2026-01-01T00:00:00.000Z",
58
+ updatedAt: "2026-01-01T00:00:00.000Z",
59
+ history: [],
60
+ github: spec.commentId
61
+ ? {
62
+ commentId: spec.commentId,
63
+ kind: "line",
64
+ pr: 1,
65
+ at: "2026-01-01T00:00:00.000Z",
66
+ }
67
+ : undefined,
68
+ };
69
+ }
70
+
71
+ function stateWith(findings: FindingState[]): FindingsStateFile {
72
+ const state = emptyState("feat/x");
73
+ for (const item of findings) state.findings[item.id] = item;
74
+ state.order = findings.map((item) => item.id);
75
+ return state;
76
+ }
77
+
78
+ const TARGET: PrTarget = { number: 7, repo: "acme/app", headSha: "abc" };
79
+
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
+ /** Runner fake: o handler decide a resposta a partir dos args (os chamados ficam registrados). */
90
+ function fakeRunner(handler: (args: string[]) => GhResult): {
91
+ runner: GhRunner;
92
+ calls: string[][];
93
+ } {
94
+ const calls: string[][] = [];
95
+ const runner: GhRunner = (args) => {
96
+ calls.push(args);
97
+ return handler(args);
98
+ };
99
+ return { runner, calls };
100
+ }
101
+
102
+ /** Respostas por tipo de destino: geral (issues), arquivo (subject_type=file) e linha. */
103
+ function endpointRunner(ids: { line?: number; file?: number; general?: number } = {}): {
104
+ runner: GhRunner;
105
+ calls: string[][];
106
+ } {
107
+ 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);
111
+ });
112
+ }
113
+
114
+ describe("selectSubmitCandidates", () => {
115
+ const state = stateWith([
116
+ finding({ id: "a", severity: "blocking", decision: "execute" }),
117
+ finding({ id: "b", severity: "attention", decision: "ignore" }),
118
+ finding({ id: "c", severity: "nit", line: null }),
119
+ finding({ id: "d", decision: "execute", present: false }),
120
+ ]);
121
+
122
+ test("exclui ignore e ausentes", () => {
123
+ expect(selectSubmitCandidates(state).map((c) => c.id)).toEqual(["a", "c"]);
124
+ });
125
+
126
+ test("filtra por severidade e define a âncora", () => {
127
+ const blocking = selectSubmitCandidates(state, { severities: ["blocking"] });
128
+ expect(blocking.map((c) => c.id)).toEqual(["a"]);
129
+ expect(blocking[0].kind).toBe("line");
130
+ expect(selectSubmitCandidates(state, { severities: ["nit"] })[0].kind).toBe(
131
+ "file",
132
+ );
133
+ });
134
+
135
+ test("filtra por estado de execução", () => {
136
+ const exec = selectSubmitCandidates(state, { execStates: ["pending"] });
137
+ expect(exec.map((c) => c.id)).toEqual(["a", "c"]);
138
+ expect(selectSubmitCandidates(state, { execStates: ["done"] })).toEqual([]);
139
+ });
140
+
141
+ test("marca sent/skip e respeita includeSent", () => {
142
+ const sent = stateWith([finding({ id: "a", commentId: 99 })]);
143
+ const [only] = selectSubmitCandidates(sent);
144
+ expect(only.sent).toBe(true);
145
+ expect(only.skip).toBe(true);
146
+ expect(only.sentUrl).toBeUndefined();
147
+ expect(selectSubmitCandidates(sent, { includeSent: true })[0].skip).toBe(false);
148
+ });
149
+ });
150
+
151
+ describe("buildSubmitPreview", () => {
152
+ test("disponível: conta linha/arquivo", () => {
153
+ const state = stateWith([finding({ id: "a" }), finding({ id: "c", line: null })]);
154
+ const preview = buildSubmitPreview(state, { ok: true, target: TARGET });
155
+ expect(preview.available).toBe(true);
156
+ expect(preview.target).toEqual(TARGET);
157
+ expect(preview.totals).toMatchObject({
158
+ candidates: 2,
159
+ toSend: 2,
160
+ line: 1,
161
+ file: 1,
162
+ sent: 0,
163
+ });
164
+ });
165
+
166
+ test("indisponível propaga o motivo", () => {
167
+ const preview = buildSubmitPreview(stateWith([]), {
168
+ ok: false,
169
+ reason: "sem gh",
170
+ });
171
+ expect(preview.available).toBe(false);
172
+ expect(preview.reason).toBe("sem gh");
173
+ });
174
+ });
175
+
176
+ describe("corpo dos comentários", () => {
177
+ test("buildCommentBody inclui regra, ação e evidência", () => {
178
+ const body = buildCommentBody(finding({ id: "a", rule: "Nunca usar any" }));
179
+ expect(body).toContain("Nunca usar any");
180
+ expect(body).toContain("**Ação:** corrigir");
181
+ expect(body).toContain("const x = 1;");
182
+ expect(body).toContain("houndsman");
183
+ });
184
+
185
+ test("renderGeneralBody lista os findings sem linha", () => {
186
+ const body = renderGeneralBody([
187
+ finding({ id: "a", rule: "Regra X", line: null }),
188
+ ]);
189
+ expect(body).toContain("Regra X");
190
+ expect(body).toContain("src/a.ts");
191
+ });
192
+ });
193
+
194
+ describe("resolvePrTarget", () => {
195
+ test("resolve número, repo e head", () => {
196
+ const runner: GhRunner = () => ({
197
+ ok: true,
198
+ code: 0,
199
+ stderr: "",
200
+ stdout: JSON.stringify({
201
+ number: 7,
202
+ url: "https://github.com/acme/app/pull/7",
203
+ headRefOid: "abc123",
204
+ }),
205
+ });
206
+ const result = resolvePrTarget(runner, "feat/x");
207
+ expect(result.ok).toBe(true);
208
+ if (result.ok) {
209
+ expect(result.target).toEqual({
210
+ number: 7,
211
+ repo: "acme/app",
212
+ headSha: "abc123",
213
+ url: "https://github.com/acme/app/pull/7",
214
+ });
215
+ }
216
+ });
217
+
218
+ test("fail-open sem gh", () => {
219
+ const runner: GhRunner = () => ({
220
+ ok: false,
221
+ code: 127,
222
+ stdout: "",
223
+ stderr: "command not found",
224
+ });
225
+ const result = resolvePrTarget(runner, "feat/x");
226
+ expect(result.ok).toBe(false);
227
+ if (!result.ok) expect(result.reason).toContain("gh não instalado");
228
+ });
229
+ });
230
+
231
+ 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 });
235
+ const outcome = submitFindings({ state, target: TARGET, runner });
236
+
237
+ expect(outcome.posted).toHaveLength(2);
238
+ expect(outcome.failed).toEqual([]);
239
+ expect(outcome.general).toBeUndefined();
240
+ expect(state.findings.a.github).toMatchObject({
241
+ commentId: 100,
242
+ kind: "line",
243
+ pr: 7,
244
+ });
245
+ expect(state.findings.c.github).toMatchObject({
246
+ commentId: 150,
247
+ kind: "file",
248
+ pr: 7,
249
+ });
250
+ });
251
+
252
+ test("linha fora do diff cai no comentário de arquivo", () => {
253
+ 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
+ );
264
+ const outcome = submitFindings({ state, target: TARGET, runner });
265
+
266
+ expect(outcome.failed).toEqual([]);
267
+ expect(outcome.general).toBeUndefined();
268
+ expect(state.findings.a.github).toMatchObject({
269
+ kind: "file",
270
+ commentId: 150,
271
+ });
272
+ });
273
+
274
+ test("o que não cola em linha nem arquivo vai para o comentário geral", () => {
275
+ 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
+ );
281
+ const outcome = submitFindings({ state, target: TARGET, runner });
282
+
283
+ expect(outcome.failed).toHaveLength(1);
284
+ expect(outcome.general?.ids).toEqual(["a"]);
285
+ expect(state.findings.a.github?.kind).toBe("comment");
286
+ expect(state.findings.a.github?.commentId).toBe(200);
287
+ });
288
+
289
+ test("pula já enviados (sem chamar o gh)", () => {
290
+ const state = stateWith([finding({ id: "a", commentId: 55 })]);
291
+ const { runner, calls } = endpointRunner();
292
+ const outcome = submitFindings({ state, target: TARGET, runner });
293
+
294
+ expect(calls).toHaveLength(0);
295
+ expect(outcome.skipped).toHaveLength(1);
296
+ expect(state.findings.a.github?.commentId).toBe(55);
297
+ });
298
+
299
+ test("manda commit_id e side=RIGHT na linha", () => {
300
+ const state = stateWith([finding({ id: "a", line: 9 })]);
301
+ const { runner, calls } = endpointRunner({ line: 100 });
302
+ submitFindings({ state, target: TARGET, runner });
303
+
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");
309
+ });
310
+
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 });
315
+
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");
319
+ });
320
+ });