@payconductor/houndsman 0.1.0 → 0.1.3

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 (61) hide show
  1. package/README.md +68 -89
  2. package/assets/logo-legacy.png +0 -0
  3. package/assets/logo.png +0 -0
  4. package/package.json +3 -2
  5. package/src/cli/index.ts +101 -10
  6. package/src/core/assets.ts +11 -0
  7. package/src/core/brief.ts +27 -5
  8. package/src/core/commands/findings.ts +618 -0
  9. package/src/core/commands/review.ts +2 -0
  10. package/src/core/commands/scaffold.ts +33 -44
  11. package/src/core/config.ts +42 -0
  12. package/src/core/diff-source.ts +140 -0
  13. package/src/core/file-classifier.ts +50 -2
  14. package/src/core/finding-anchor.ts +147 -0
  15. package/src/core/findings-state.ts +556 -0
  16. package/src/core/fix-batch.ts +236 -0
  17. package/src/core/git-revision.ts +105 -0
  18. package/src/core/jev/index.ts +1 -0
  19. package/src/core/jev/jev-client.ts +72 -11
  20. package/src/core/jev/jev-detector.ts +21 -19
  21. package/src/core/jev/jev-verify.ts +178 -0
  22. package/src/core/orchestrator.ts +362 -23
  23. package/src/core/paths.ts +9 -0
  24. package/src/core/registry-check.ts +15 -0
  25. package/src/core/report-builder.ts +118 -9
  26. package/src/core/run-status.ts +123 -0
  27. package/src/core/types.ts +10 -0
  28. package/src/core/verify.ts +175 -0
  29. package/src/core/watchdog-registry.ts +1 -2
  30. package/src/index.ts +78 -2
  31. package/src/server/artifacts.ts +67 -0
  32. package/src/server/client/App.tsx +158 -0
  33. package/src/server/client/ArtifactsPanel.tsx +48 -0
  34. package/src/server/client/Filters.tsx +83 -0
  35. package/src/server/client/FindingCard.tsx +122 -0
  36. package/src/server/client/FindingsList.tsx +54 -0
  37. package/src/server/client/RunList.tsx +52 -0
  38. package/src/server/client/SummaryBar.tsx +21 -0
  39. package/src/server/client/api.ts +37 -0
  40. package/src/server/client/constants.ts +94 -0
  41. package/src/server/client/index.tsx +12 -0
  42. package/src/server/client/types.ts +52 -0
  43. package/src/server/client-build.ts +47 -0
  44. package/src/server/index.ts +376 -0
  45. package/src/skill/AUDIT-PROTOCOL.md +2 -2
  46. package/src/skill/README.md +43 -11
  47. package/src/skill/SETUP.md +16 -4
  48. package/src/skill/SKILL.md +48 -0
  49. package/src/skill/fix.md +206 -0
  50. package/src/skill/{houndsman.md → hunt.md} +83 -33
  51. package/src/skill/{houndsman-update.md → train.md} +12 -12
  52. package/src/skill/verify.md +70 -0
  53. package/src/tests/findings-anchor.test.ts +120 -0
  54. package/src/tests/findings-commands.test.ts +476 -0
  55. package/src/tests/findings-state.test.ts +325 -0
  56. package/src/tests/fix-batch.test.ts +257 -0
  57. package/src/tests/review-scripts.test.ts +299 -0
  58. package/src/tests/run-status.test.ts +147 -0
  59. package/src/tests/scaffold.test.ts +59 -0
  60. package/src/tests/server.test.ts +242 -0
  61. package/src/tests/verify.test.ts +170 -0
@@ -0,0 +1,325 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import {
6
+ applyFindingUpdate,
7
+ buildStateFromFindings,
8
+ emptyState,
9
+ FindingsStateStore,
10
+ fileReader,
11
+ filterFindings,
12
+ summarize,
13
+ syncState,
14
+ } from "../core/findings-state";
15
+ import type { MergedFinding } from "../core/report-builder";
16
+ import type { Severity } from "../core/types";
17
+
18
+ /** Projeto em disco temporário (limpo no fim). */
19
+ function makeProject(files: Record<string, string>): {
20
+ cwd: string;
21
+ cleanup: () => void;
22
+ } {
23
+ const cwd = mkdtempSync(join(tmpdir(), "houndsman-state-"));
24
+ for (const [rel, content] of Object.entries(files)) {
25
+ const full = join(cwd, rel);
26
+ mkdirSync(dirname(full), { recursive: true });
27
+ writeFileSync(full, content);
28
+ }
29
+ return { cwd, cleanup: () => rmSync(cwd, { recursive: true, force: true }) };
30
+ }
31
+
32
+ function merged(
33
+ partial: Partial<MergedFinding> & { file: string; rule: string },
34
+ ): MergedFinding {
35
+ return {
36
+ rule: partial.rule,
37
+ severity: (partial.severity ?? "attention") as Severity,
38
+ file: partial.file,
39
+ line: partial.line,
40
+ message: partial.message ?? "msg",
41
+ action: partial.action ?? "ação",
42
+ evidence: partial.evidence,
43
+ watchdogs: partial.watchdogs ?? ["alpha"],
44
+ };
45
+ }
46
+
47
+ const DUPLICATED = [
48
+ "function a() {",
49
+ '\tconsole.log("x");',
50
+ "}",
51
+ "function b() {",
52
+ '\tconsole.log("x");',
53
+ "}",
54
+ ].join("\n");
55
+
56
+ const AFTER_FIX = [
57
+ "function a() {",
58
+ "}",
59
+ "function b() {",
60
+ '\tconsole.log("x");',
61
+ "}",
62
+ ].join("\n");
63
+
64
+ describe("buildStateFromFindings", () => {
65
+ test("cria entradas pendentes com id e ancora a linha", () => {
66
+ const project = makeProject({ "src/f.ts": DUPLICATED });
67
+ try {
68
+ const findings = [
69
+ merged({ file: "src/f.ts", rule: "r", line: 2 }),
70
+ merged({ file: "src/f.ts", rule: "r", line: 5 }),
71
+ ];
72
+ const state = buildStateFromFindings(findings, {
73
+ branch: "feature/x",
74
+ readFile: fileReader(project.cwd),
75
+ });
76
+
77
+ expect(state.slug).toBe("feature-x");
78
+ expect(Object.keys(state.findings)).toHaveLength(2);
79
+ // Os ids voltam para os findings (usados no relatório).
80
+ expect(findings[0].id).toBeTruthy();
81
+ expect(findings[0].id).not.toBe(findings[1].id);
82
+ for (const finding of Object.values(state.findings)) {
83
+ expect(finding.decision).toBe("pending");
84
+ expect(finding.execState).toBe("pending");
85
+ expect(finding.present).toBe(true);
86
+ expect(finding.line).toBeGreaterThan(0);
87
+ }
88
+ } finally {
89
+ project.cleanup();
90
+ }
91
+ });
92
+
93
+ test("preserva id e decisão entre execuções (casa por fingerprint)", () => {
94
+ const project = makeProject({ "src/f.ts": DUPLICATED });
95
+ try {
96
+ const reader = fileReader(project.cwd);
97
+ const first = buildStateFromFindings(
98
+ [merged({ file: "src/f.ts", rule: "r", line: 2 })],
99
+ { branch: "main", readFile: reader },
100
+ );
101
+ const id = Object.keys(first.findings)[0];
102
+ applyFindingUpdate(first.findings[id], { decision: "ignore" });
103
+
104
+ const second = buildStateFromFindings(
105
+ [merged({ file: "src/f.ts", rule: "r", line: 2 })],
106
+ { branch: "main", readFile: reader, previous: first },
107
+ );
108
+ expect(Object.keys(second.findings)).toEqual([id]);
109
+ expect(second.findings[id].decision).toBe("ignore");
110
+ } finally {
111
+ project.cleanup();
112
+ }
113
+ });
114
+
115
+ test("finding que sumiu fica ausente e vira done se era 'execute'", () => {
116
+ const project = makeProject({ "src/f.ts": DUPLICATED });
117
+ try {
118
+ const reader = fileReader(project.cwd);
119
+ const first = buildStateFromFindings(
120
+ [merged({ file: "src/f.ts", rule: "r", line: 2 })],
121
+ { branch: "main", readFile: reader },
122
+ );
123
+ const id = Object.keys(first.findings)[0];
124
+ applyFindingUpdate(first.findings[id], { decision: "execute" });
125
+
126
+ const second = buildStateFromFindings([], {
127
+ branch: "main",
128
+ readFile: reader,
129
+ previous: first,
130
+ });
131
+ expect(second.findings[id].present).toBe(false);
132
+ expect(second.findings[id].execState).toBe("done");
133
+ } finally {
134
+ project.cleanup();
135
+ }
136
+ });
137
+
138
+ test("ocorrências idênticas (mesmo contexto) viram um grupo com count", () => {
139
+ const content = [
140
+ "const a = 1;",
141
+ 'console.log("x");',
142
+ "const a = 1;",
143
+ 'console.log("x");',
144
+ "const a = 1;",
145
+ ].join("\n");
146
+ const project = makeProject({ "src/g.ts": content });
147
+ try {
148
+ const findings = [
149
+ merged({ file: "src/g.ts", rule: "r", line: 2 }),
150
+ merged({ file: "src/g.ts", rule: "r", line: 4 }),
151
+ ];
152
+ const state = buildStateFromFindings(findings, {
153
+ branch: "main",
154
+ readFile: fileReader(project.cwd),
155
+ });
156
+ expect(Object.keys(state.findings)).toHaveLength(1);
157
+ const only = Object.values(state.findings)[0];
158
+ expect(only.count).toBe(2);
159
+ expect(findings[0].id).toBe(findings[1].id);
160
+ } finally {
161
+ project.cleanup();
162
+ }
163
+ });
164
+ });
165
+
166
+ describe("syncState", () => {
167
+ test("move a sobrevivente de linha e marca a removida como stale/done", () => {
168
+ const before = makeProject({ "src/f.ts": DUPLICATED });
169
+ const after = makeProject({ "src/f.ts": AFTER_FIX });
170
+ try {
171
+ const state = buildStateFromFindings(
172
+ [
173
+ merged({ file: "src/f.ts", rule: "r", line: 2 }),
174
+ merged({ file: "src/f.ts", rule: "r", line: 5 }),
175
+ ],
176
+ { branch: "main", readFile: fileReader(before.cwd) },
177
+ );
178
+ const ids = Object.keys(state.findings);
179
+ // A primeira ocorrência foi marcada para executar.
180
+ const firstId = ids[0];
181
+ applyFindingUpdate(state.findings[firstId], { decision: "execute" });
182
+
183
+ const result = syncState(state, fileReader(after.cwd));
184
+ expect(result.moved).toHaveLength(1);
185
+ expect(result.stale).toHaveLength(1);
186
+ expect(result.stale).toContain(firstId);
187
+ expect(state.findings[firstId].execState).toBe("done");
188
+
189
+ const survivor = state.findings[result.moved[0]];
190
+ expect(survivor.line).toBe(4);
191
+ } finally {
192
+ before.cleanup();
193
+ after.cleanup();
194
+ }
195
+ });
196
+ });
197
+
198
+ describe("filterFindings / summarize", () => {
199
+ test("filtra por decisão e severity, e resume as contagens", () => {
200
+ const project = makeProject({ "src/f.ts": DUPLICATED });
201
+ try {
202
+ const state = buildStateFromFindings(
203
+ [
204
+ merged({
205
+ file: "src/f.ts",
206
+ rule: "r",
207
+ line: 2,
208
+ severity: "blocking",
209
+ }),
210
+ merged({ file: "src/f.ts", rule: "r2", line: 5, severity: "nit" }),
211
+ ],
212
+ { branch: "main", readFile: fileReader(project.cwd) },
213
+ );
214
+ const ids = Object.keys(state.findings);
215
+ applyFindingUpdate(state.findings[ids[0]], { decision: "execute" });
216
+
217
+ expect(
218
+ filterFindings(state, { decision: "execute" }).map((f) => f.id),
219
+ ).toEqual([ids[0]]);
220
+ expect(filterFindings(state, { severity: "nit" }).map((f) => f.id)).toEqual([
221
+ ids[1],
222
+ ]);
223
+
224
+ const summary = summarize(state);
225
+ expect(summary.total).toBe(2);
226
+ expect(summary.byDecision.execute).toBe(1);
227
+ expect(summary.byDecision.pending).toBe(1);
228
+ expect(summary.bySeverity.blocking).toBe(1);
229
+ } finally {
230
+ project.cleanup();
231
+ }
232
+ });
233
+ });
234
+
235
+ describe("FindingsStateStore", () => {
236
+ test("grava e lê o state.json de forma atômica", () => {
237
+ const project = makeProject({ "src/f.ts": DUPLICATED });
238
+ const outDir = join(project.cwd, "artifacts");
239
+ try {
240
+ const store = FindingsStateStore.at(outDir, "feature/x");
241
+ const state = buildStateFromFindings(
242
+ [merged({ file: "src/f.ts", rule: "r", line: 2 })],
243
+ { branch: "feature/x", readFile: fileReader(project.cwd) },
244
+ );
245
+ store.write(state);
246
+
247
+ expect(store.filePath).toBe(join(outDir, "feature-x", "state.json"));
248
+ expect(existsSync(store.filePath)).toBe(true);
249
+ const read = store.read();
250
+ expect(read?.slug).toBe("feature-x");
251
+ expect(Object.keys(read?.findings ?? {})).toHaveLength(1);
252
+ } finally {
253
+ project.cleanup();
254
+ }
255
+ });
256
+
257
+ test("read devolve null quando o arquivo não existe", () => {
258
+ const project = makeProject({});
259
+ try {
260
+ const store = FindingsStateStore.at(join(project.cwd, "artifacts"), "main");
261
+ expect(store.read()).toBeNull();
262
+ expect(store.update(() => {})).toBeTruthy();
263
+ } finally {
264
+ project.cleanup();
265
+ }
266
+ });
267
+
268
+ test("emptyState cria um estado vazio válido", () => {
269
+ const state = emptyState("feature/x");
270
+ expect(state.version).toBe(2);
271
+ expect(state.slug).toBe("feature-x");
272
+ expect(state.findings).toEqual({});
273
+ });
274
+ });
275
+
276
+ describe("rastreio de revisão (incremental)", () => {
277
+ test("carimba reviewed e foundAtSha nos findings do ciclo", () => {
278
+ const project = makeProject({ "src/f.ts": DUPLICATED });
279
+ try {
280
+ const state = buildStateFromFindings(
281
+ [merged({ file: "src/f.ts", rule: "r", line: 2 })],
282
+ {
283
+ branch: "main",
284
+ readFile: fileReader(project.cwd),
285
+ headSha: "abc1234",
286
+ reviewed: { headSha: "abc1234", at: "2026-01-01T00:00:00.000Z" },
287
+ },
288
+ );
289
+ expect(state.reviewed?.headSha).toBe("abc1234");
290
+ const only = Object.values(state.findings)[0];
291
+ expect(only.foundAtSha).toBe("abc1234");
292
+ } finally {
293
+ project.cleanup();
294
+ }
295
+ });
296
+
297
+ test("carry-over preserva o foundAtSha original", () => {
298
+ const project = makeProject({ "src/f.ts": DUPLICATED });
299
+ try {
300
+ const state = buildStateFromFindings(
301
+ [
302
+ // Novo/re-reportado: recebe o head atual.
303
+ merged({ file: "src/f.ts", rule: "r", line: 2 }),
304
+ // Carry-over: carrega o `foundAtSha` de quando foi observado.
305
+ {
306
+ ...merged({ file: "src/f.ts", rule: "r2", line: 5 }),
307
+ foundAtSha: "old0001",
308
+ },
309
+ ],
310
+ {
311
+ branch: "main",
312
+ readFile: fileReader(project.cwd),
313
+ headSha: "new0002",
314
+ reviewed: { headSha: "new0002", at: "2026-01-02T00:00:00.000Z" },
315
+ },
316
+ );
317
+ const byRule = (rule: string): string | undefined =>
318
+ Object.values(state.findings).find((f) => f.rule === rule)?.foundAtSha;
319
+ expect(byRule("r")).toBe("new0002");
320
+ expect(byRule("r2")).toBe("old0001");
321
+ } finally {
322
+ project.cleanup();
323
+ }
324
+ });
325
+ });
@@ -0,0 +1,257 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { FindingState, FindingsStateFile } from "../core/findings-state";
3
+ import {
4
+ buildFixBatch,
5
+ chooseGrouping,
6
+ fixQueue,
7
+ groupFindings,
8
+ } from "../core/fix-batch";
9
+ import type { Severity } from "../core/types";
10
+
11
+ let seq = 0;
12
+
13
+ /** Finding de teste com defaults sensatos. */
14
+ function f(
15
+ partial: Partial<FindingState> & { file: string; rule: string; severity: Severity },
16
+ ): FindingState {
17
+ seq++;
18
+ return {
19
+ id: partial.id ?? `id${seq}`,
20
+ fingerprint: partial.fingerprint ?? `fp${seq}`,
21
+ watchdogIds: partial.watchdogIds ?? ["alpha"],
22
+ rule: partial.rule,
23
+ severity: partial.severity,
24
+ file: partial.file,
25
+ line: partial.line ?? 1,
26
+ message: partial.message ?? "m",
27
+ action: partial.action ?? "a",
28
+ evidence: partial.evidence,
29
+ anchor: null,
30
+ count: partial.count ?? 1,
31
+ present: partial.present ?? true,
32
+ decision: partial.decision ?? "execute",
33
+ comment: partial.comment,
34
+ execState: partial.execState ?? "pending",
35
+ createdAt: "",
36
+ updatedAt: "",
37
+ history: [],
38
+ };
39
+ }
40
+
41
+ function stateOf(findings: FindingState[]): FindingsStateFile {
42
+ return {
43
+ version: 2,
44
+ branch: "feature/x",
45
+ slug: "feature-x",
46
+ updatedAt: "",
47
+ order: findings.map((item) => item.id),
48
+ findings: Object.fromEntries(findings.map((item) => [item.id, item])),
49
+ };
50
+ }
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // chooseGrouping (heurística determinística)
54
+ // ---------------------------------------------------------------------------
55
+
56
+ describe("chooseGrouping", () => {
57
+ test("prefere arquivo quando há poucos arquivos com coesão", () => {
58
+ const findings = ["f1", "f2", "f3"].flatMap((file, i) => [
59
+ f({
60
+ file,
61
+ rule: `r${i * 2 + 1}`,
62
+ severity: "attention",
63
+ watchdogIds: [`w${i}`],
64
+ }),
65
+ f({ file, rule: `r${i * 2 + 2}`, severity: "nit", watchdogIds: [`w${i}`] }),
66
+ ]);
67
+ expect(chooseGrouping(findings)).toBe("file");
68
+ });
69
+
70
+ test("prefere regra quando uma regra domina vários arquivos", () => {
71
+ const findings = ["f1", "f2", "f3", "f4", "f5", "f6"].map((file, i) =>
72
+ f({
73
+ file,
74
+ rule: i < 3 ? "r1" : "r2",
75
+ severity: "attention",
76
+ watchdogIds: [`w${i}`],
77
+ }),
78
+ );
79
+ expect(chooseGrouping(findings)).toBe("rule");
80
+ });
81
+
82
+ test("cai em watchdog quando nem arquivo nem regra têm coesão", () => {
83
+ const findings = ["f1", "f2", "f3", "f4", "f5", "f6"].map((file, i) =>
84
+ f({
85
+ file,
86
+ rule: `r${Math.min(i, 4)}`,
87
+ severity: "attention",
88
+ watchdogIds: [i < 3 ? "w1" : "w2"],
89
+ }),
90
+ );
91
+ expect(chooseGrouping(findings)).toBe("watchdog");
92
+ });
93
+
94
+ test("sem elegíveis (tudo singleton), cai em file", () => {
95
+ const findings = ["f1", "f2"].map((file, i) =>
96
+ f({ file, rule: `r${i}`, severity: "nit", watchdogIds: [`w${i}`] }),
97
+ );
98
+ expect(chooseGrouping(findings)).toBe("file");
99
+ });
100
+
101
+ test("fila vazia devolve file", () => {
102
+ expect(chooseGrouping([])).toBe("file");
103
+ });
104
+ });
105
+
106
+ describe("groupFindings", () => {
107
+ test("agrupa por arquivo com chaves prefixadas", () => {
108
+ const findings = [
109
+ f({ file: "src/a.ts", rule: "r1", severity: "attention" }),
110
+ f({ file: "src/a.ts", rule: "r2", severity: "nit" }),
111
+ f({ file: "src/b.ts", rule: "r3", severity: "nit" }),
112
+ ];
113
+ const groups = groupFindings(findings, "file");
114
+ expect(groups.size).toBe(2);
115
+ expect(groups.get("file:src/a.ts")).toHaveLength(2);
116
+ expect(groups.get("file:src/b.ts")).toHaveLength(1);
117
+ });
118
+ });
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // fixQueue
122
+ // ---------------------------------------------------------------------------
123
+
124
+ describe("fixQueue", () => {
125
+ test("só execute, presentes e não done; includeDone inclui", () => {
126
+ const done = f({
127
+ file: "a.ts",
128
+ rule: "r1",
129
+ severity: "attention",
130
+ execState: "done",
131
+ });
132
+ const pending = f({ file: "a.ts", rule: "r2", severity: "attention" });
133
+ const ignored = f({
134
+ file: "a.ts",
135
+ rule: "r3",
136
+ severity: "attention",
137
+ decision: "ignore",
138
+ });
139
+ const absent = f({
140
+ file: "a.ts",
141
+ rule: "r4",
142
+ severity: "attention",
143
+ present: false,
144
+ });
145
+ const state = stateOf([done, pending, ignored, absent]);
146
+
147
+ expect(fixQueue(state).map((item) => item.id)).toEqual([pending.id]);
148
+ expect(fixQueue(state, { includeDone: true }).map((item) => item.id)).toEqual([
149
+ done.id,
150
+ pending.id,
151
+ ]);
152
+ });
153
+
154
+ test("filtra por severidade", () => {
155
+ const blocking = f({ file: "a.ts", rule: "r1", severity: "blocking" });
156
+ const nit = f({ file: "a.ts", rule: "r2", severity: "nit" });
157
+ const state = stateOf([blocking, nit]);
158
+ expect(fixQueue(state, { severity: "blocking" }).map((item) => item.id)).toEqual([
159
+ blocking.id,
160
+ ]);
161
+ });
162
+ });
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // buildFixBatch
166
+ // ---------------------------------------------------------------------------
167
+
168
+ /** Fila coesa em 2 arquivos / 2 watchdogs (arquivo e watchdog empatam → file). */
169
+ function coesaState(): FindingsStateFile {
170
+ return stateOf([
171
+ f({ file: "src/a.ts", rule: "r1", severity: "blocking", watchdogIds: ["alpha"] }),
172
+ f({
173
+ file: "src/a.ts",
174
+ rule: "r2",
175
+ severity: "attention",
176
+ watchdogIds: ["alpha"],
177
+ }),
178
+ f({
179
+ file: "src/a.ts",
180
+ rule: "r3",
181
+ severity: "attention",
182
+ watchdogIds: ["alpha"],
183
+ }),
184
+ f({ file: "src/a.ts", rule: "r4", severity: "nit", watchdogIds: ["alpha"] }),
185
+ f({ file: "src/b.ts", rule: "r5", severity: "attention", watchdogIds: ["beta"] }),
186
+ f({
187
+ file: "src/b.ts",
188
+ rule: "r6",
189
+ severity: "suggestion",
190
+ watchdogIds: ["beta"],
191
+ }),
192
+ ]);
193
+ }
194
+
195
+ describe("buildFixBatch", () => {
196
+ test("escolhe o agrupamento e devolve grupo atual + outros grupos", () => {
197
+ const result = buildFixBatch(coesaState());
198
+ expect(result.dimension).toBe("file");
199
+ expect(result.totals).toEqual({ queue: 6, groups: 2, returned: 4 });
200
+ expect(result.current.key).toBe("file:src/a.ts");
201
+ expect(result.current.total).toBe(4);
202
+ expect(result.current.findings).toHaveLength(4);
203
+ expect(result.current.remaining).toBe(0);
204
+ expect(result.others).toHaveLength(1);
205
+ expect(result.others[0].key).toBe("file:src/b.ts");
206
+ expect(result.others[0].count).toBe(2);
207
+ });
208
+
209
+ test("pagina o grupo atual com pageSize", () => {
210
+ const result = buildFixBatch(coesaState(), { pageSize: 2 });
211
+ expect(result.current.returned).toBe(2);
212
+ expect(result.current.remaining).toBe(2);
213
+ expect(result.totals.returned).toBe(2);
214
+ });
215
+
216
+ test("--group foca um grupo específico", () => {
217
+ const result = buildFixBatch(coesaState(), { group: "file:src/b.ts" });
218
+ expect(result.current.key).toBe("file:src/b.ts");
219
+ expect(result.current.total).toBe(2);
220
+ expect(result.others).toHaveLength(1);
221
+ expect(result.others[0].key).toBe("file:src/a.ts");
222
+ });
223
+
224
+ test("--group inexistente lança com as chaves disponíveis", () => {
225
+ expect(() => buildFixBatch(coesaState(), { group: "file:nao-existe" })).toThrow(
226
+ /não encontrado/,
227
+ );
228
+ });
229
+
230
+ test("--severity filtra a fila", () => {
231
+ const result = buildFixBatch(coesaState(), { severity: "blocking" });
232
+ expect(result.totals.queue).toBe(1);
233
+ expect(result.current.key).toBe("file:src/a.ts");
234
+ expect(result.current.total).toBe(1);
235
+ expect(result.others).toHaveLength(0);
236
+ });
237
+
238
+ test("fila vazia devolve lote vazio", () => {
239
+ const state = stateOf([
240
+ f({ file: "a.ts", rule: "r1", severity: "attention", decision: "pending" }),
241
+ ]);
242
+ const result = buildFixBatch(state);
243
+ expect(result.totals).toEqual({ queue: 0, groups: 0, returned: 0 });
244
+ expect(result.current.findings).toHaveLength(0);
245
+ expect(result.others).toHaveLength(0);
246
+ });
247
+
248
+ test("outros grupos trazem o mix de severidade", () => {
249
+ const result = buildFixBatch(coesaState());
250
+ expect(result.others[0].bySeverity).toEqual({
251
+ blocking: 0,
252
+ attention: 1,
253
+ suggestion: 1,
254
+ nit: 0,
255
+ });
256
+ });
257
+ });