@payconductor/houndsman 0.0.0-stage → 0.1.1

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 (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +156 -3
  3. package/assets/houndsman.png +0 -0
  4. package/assets/logo-legacy.png +0 -0
  5. package/assets/logo.png +0 -0
  6. package/package.json +64 -5
  7. package/src/cli/index.ts +290 -0
  8. package/src/core/assets.ts +28 -0
  9. package/src/core/brief.ts +185 -0
  10. package/src/core/commands/context.ts +39 -0
  11. package/src/core/commands/findings.ts +390 -0
  12. package/src/core/commands/review.ts +50 -0
  13. package/src/core/commands/scaffold.ts +393 -0
  14. package/src/core/commands/setup.ts +75 -0
  15. package/src/core/commands/test.ts +20 -0
  16. package/src/core/commands/update.ts +30 -0
  17. package/src/core/commands/watchdog.ts +105 -0
  18. package/src/core/config-loader.ts +87 -0
  19. package/src/core/config.ts +101 -0
  20. package/src/core/diff-source.ts +140 -0
  21. package/src/core/doc-discoverer.ts +33 -0
  22. package/src/core/file-classifier.ts +295 -0
  23. package/src/core/finding-anchor.ts +147 -0
  24. package/src/core/findings-state.ts +538 -0
  25. package/src/core/jev/index.ts +12 -0
  26. package/src/core/jev/jev-candidates.ts +105 -0
  27. package/src/core/jev/jev-client.ts +160 -0
  28. package/src/core/jev/jev-detector.ts +205 -0
  29. package/src/core/jev/jev-questions.ts +92 -0
  30. package/src/core/jev/jev-types.ts +91 -0
  31. package/src/core/orchestrator.ts +596 -0
  32. package/src/core/paths.ts +33 -0
  33. package/src/core/pr-context.ts +271 -0
  34. package/src/core/registry-check.ts +160 -0
  35. package/src/core/report-builder.ts +461 -0
  36. package/src/core/types.ts +147 -0
  37. package/src/core/watchdog-registry.ts +43 -0
  38. package/src/index.ts +132 -0
  39. package/src/server/artifacts.ts +66 -0
  40. package/src/server/client/App.tsx +158 -0
  41. package/src/server/client/ArtifactsPanel.tsx +48 -0
  42. package/src/server/client/Filters.tsx +83 -0
  43. package/src/server/client/FindingCard.tsx +122 -0
  44. package/src/server/client/FindingsList.tsx +54 -0
  45. package/src/server/client/RunList.tsx +43 -0
  46. package/src/server/client/SummaryBar.tsx +21 -0
  47. package/src/server/client/api.ts +37 -0
  48. package/src/server/client/constants.ts +63 -0
  49. package/src/server/client/index.tsx +12 -0
  50. package/src/server/client/types.ts +48 -0
  51. package/src/server/client-build.ts +47 -0
  52. package/src/server/index.ts +365 -0
  53. package/src/skill/AUDIT-PROTOCOL.md +96 -0
  54. package/src/skill/OUTPUT-FORMAT.md +70 -0
  55. package/src/skill/README.md +124 -0
  56. package/src/skill/SETUP.md +101 -0
  57. package/src/skill/example-review.md +58 -0
  58. package/src/skill/houndsman-update.md +251 -0
  59. package/src/skill/houndsman.md +316 -0
  60. package/src/tests/findings-anchor.test.ts +120 -0
  61. package/src/tests/findings-commands.test.ts +226 -0
  62. package/src/tests/findings-state.test.ts +274 -0
  63. package/src/tests/fixtures/fake-project.ts +64 -0
  64. package/src/tests/review-scripts.test.ts +1150 -0
  65. package/src/tests/server.test.ts +212 -0
@@ -0,0 +1,120 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ buildAnchor,
4
+ fingerprintOf,
5
+ locateAnchor,
6
+ normalizeLine,
7
+ reanchor,
8
+ } from "../core/finding-anchor";
9
+
10
+ const DUPLICATED = [
11
+ "function a() {",
12
+ '\tconsole.log("x");',
13
+ "}",
14
+ "function b() {",
15
+ '\tconsole.log("x");',
16
+ "}",
17
+ ].join("\n");
18
+
19
+ // Após um "auto-fix" que remove o PRIMEIRO console.log.
20
+ const AFTER_FIX = [
21
+ "function a() {",
22
+ "}",
23
+ "function b() {",
24
+ '\tconsole.log("x");',
25
+ "}",
26
+ ].join("\n");
27
+
28
+ describe("finding-anchor", () => {
29
+ test("normalizeLine colapsa espaços e apara as pontas", () => {
30
+ expect(normalizeLine(' \t console.log( "x" ); ')).toBe('console.log( "x" );');
31
+ expect(normalizeLine(" ")).toBe("");
32
+ });
33
+
34
+ test("buildAnchor usa a linha quando válida", () => {
35
+ const anchor = buildAnchor(DUPLICATED, 2);
36
+ expect(anchor).not.toBeNull();
37
+ expect(anchor?.snippet).toBe('console.log("x");');
38
+ expect(anchor?.contextBefore).toBe("function a() {");
39
+ expect(anchor?.contextAfter).toBe("}");
40
+ expect(anchor?.line).toBe(2);
41
+ });
42
+
43
+ test("buildAnchor cai na evidence quando a linha é inválida", () => {
44
+ const anchor = buildAnchor(DUPLICATED, 999, 'console.log("x");');
45
+ expect(anchor?.line).toBe(2);
46
+ });
47
+
48
+ test("buildAnchor devolve null sem linha válida nem evidence localizável", () => {
49
+ expect(buildAnchor(DUPLICATED, 999, "inexistente")).toBeNull();
50
+ });
51
+
52
+ test("locateAnchor encontra a linha e conta as ocorrências", () => {
53
+ const anchor = buildAnchor(DUPLICATED, 2);
54
+ const located = locateAnchor(DUPLICATED, anchor);
55
+ expect(located.line).toBe(2);
56
+ expect(located.occurrences).toBe(1);
57
+ });
58
+
59
+ test("fingerprint é estável e distingue contexto", () => {
60
+ const a1 = buildAnchor(DUPLICATED, 2);
61
+ const a2 = buildAnchor(DUPLICATED, 5);
62
+
63
+ // Mesmo arquivo + regra + âncora → mesmo fingerprint.
64
+ expect(fingerprintOf("f.ts", "rule", a1)).toBe(
65
+ fingerprintOf("f.ts", "rule", buildAnchor(DUPLICATED, 2)),
66
+ );
67
+ // Snippet idêntico mas contexto diferente → fingerprints diferentes.
68
+ expect(fingerprintOf("f.ts", "rule", a1)).not.toBe(
69
+ fingerprintOf("f.ts", "rule", a2),
70
+ );
71
+ // Regra diferente muda o fingerprint.
72
+ expect(fingerprintOf("f.ts", "outra", a1)).not.toBe(
73
+ fingerprintOf("f.ts", "rule", a1),
74
+ );
75
+ });
76
+
77
+ test("fingerprint sem âncora usa o fallback", () => {
78
+ expect(fingerprintOf("f.ts", "rule", null, "mensagem")).toBe(
79
+ fingerprintOf("f.ts", "rule", null, "mensagem"),
80
+ );
81
+ expect(fingerprintOf("f.ts", "rule", null, "a")).not.toBe(
82
+ fingerprintOf("f.ts", "rule", null, "b"),
83
+ );
84
+ });
85
+
86
+ test("reanchor: ok, moved e stale", () => {
87
+ const anchor = buildAnchor(DUPLICATED, 5);
88
+ expect(reanchor(DUPLICATED, anchor, 5).status).toBe("ok");
89
+
90
+ const moved = reanchor(AFTER_FIX, anchor, 5);
91
+ expect(moved.status).toBe("moved");
92
+ expect(moved.line).toBe(4);
93
+
94
+ const removed = buildAnchor(DUPLICATED, 2);
95
+ expect(reanchor(AFTER_FIX, removed, 2).status).toBe("stale");
96
+ });
97
+
98
+ test("duas ocorrências iguais não se perdem quando o fix remove a primeira", () => {
99
+ const first = buildAnchor(DUPLICATED, 2);
100
+ const second = buildAnchor(DUPLICATED, 5);
101
+
102
+ // A âncora removida vira stale; a sobrevivente apenas desloca de linha.
103
+ expect(reanchor(AFTER_FIX, first, 2).status).toBe("stale");
104
+ const survivor = reanchor(AFTER_FIX, second, 5);
105
+ expect(survivor.status).toBe("moved");
106
+ expect(survivor.line).toBe(4);
107
+ });
108
+
109
+ test("ocorrências byte-idênticas compartilham o fingerprint (grupo)", () => {
110
+ // Mesmo snippet E mesmo contexto (sem vizinhos) → mesmo fingerprint.
111
+ const content = "x\ny\nx\ny";
112
+ const a1 = buildAnchor(content, 1);
113
+ const a2 = buildAnchor(content, 3);
114
+ // contextBefore ausente em ambos (linha 1 vs 3 tem 'y' antes → difere), então
115
+ // forçamos âncoras sem contexto para simular colisão real.
116
+ const bare = a1 && { ...a1, contextBefore: undefined, contextAfter: undefined };
117
+ const bare2 = a2 && { ...a2, contextBefore: undefined, contextAfter: undefined };
118
+ expect(fingerprintOf("f.ts", "r", bare)).toBe(fingerprintOf("f.ts", "r", bare2));
119
+ });
120
+ });
@@ -0,0 +1,226 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { runFindingsCommand } from "../core/commands/findings";
6
+ import { fakeHoundsmanConfig } from "./fixtures/fake-project";
7
+
8
+ /** Run fake em disco: classification.json + findings/alpha.json. */
9
+ function makeRun(findings: unknown[]): { dir: string; cleanup: () => void } {
10
+ const dir = mkdtempSync(join(tmpdir(), "houndsman-findings-"));
11
+ const outDir = join(dir, "main");
12
+ mkdirSync(join(outDir, "findings"), { recursive: true });
13
+ writeFileSync(
14
+ join(outDir, "classification.json"),
15
+ JSON.stringify({ watchdogs: { alpha: ["src/alpha/f.ts"] } }),
16
+ );
17
+ writeFileSync(
18
+ join(outDir, "findings", "alpha.json"),
19
+ JSON.stringify({
20
+ watchdogId: "alpha",
21
+ reviewedFiles: ["src/alpha/f.ts"],
22
+ skipped: [],
23
+ findings,
24
+ }),
25
+ );
26
+ return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
27
+ }
28
+
29
+ const ONE_FINDING = [
30
+ {
31
+ rule: "Nunca usar any",
32
+ severity: "blocking",
33
+ file: "src/alpha/f.ts",
34
+ line: 2,
35
+ message: "any detectado",
36
+ action: "tipar",
37
+ },
38
+ ];
39
+
40
+ function run(args: string[]) {
41
+ return runFindingsCommand(fakeHoundsmanConfig, args);
42
+ }
43
+
44
+ describe("findings command", () => {
45
+ test("list reconstrói o estado a partir dos artefatos", () => {
46
+ const run0 = makeRun(ONE_FINDING);
47
+ try {
48
+ const result = run(["list", "--dir", run0.dir, "--branch", "main", "--json"]);
49
+ expect(result.exitCode).toBe(0);
50
+ const parsed = JSON.parse(result.output);
51
+ expect(parsed).toHaveLength(1);
52
+ expect(parsed[0].decision).toBe("pending");
53
+ expect(parsed[0].id).toBeTruthy();
54
+ expect(parsed[0].severity).toBe("blocking");
55
+ } finally {
56
+ run0.cleanup();
57
+ }
58
+ });
59
+
60
+ test("set atualiza decisão + comentário e persiste", () => {
61
+ const run0 = makeRun(ONE_FINDING);
62
+ try {
63
+ const list = JSON.parse(
64
+ run(["list", "--dir", run0.dir, "--branch", "main", "--json"]).output,
65
+ );
66
+ const id = list[0].id;
67
+
68
+ const set = run([
69
+ "set",
70
+ id,
71
+ "--decision",
72
+ "execute",
73
+ "--comment",
74
+ "trocar por unknown",
75
+ "--dir",
76
+ run0.dir,
77
+ "--branch",
78
+ "main",
79
+ ]);
80
+ expect(set.exitCode).toBe(0);
81
+
82
+ const after = JSON.parse(
83
+ run(["list", "--dir", run0.dir, "--branch", "main", "--json"]).output,
84
+ );
85
+ expect(after[0].decision).toBe("execute");
86
+ expect(after[0].comment).toBe("trocar por unknown");
87
+ expect(after[0].history.length).toBeGreaterThan(0);
88
+ } finally {
89
+ run0.cleanup();
90
+ }
91
+ });
92
+
93
+ test("done marca execState como done", () => {
94
+ const run0 = makeRun(ONE_FINDING);
95
+ try {
96
+ const id = JSON.parse(
97
+ run(["list", "--dir", run0.dir, "--branch", "main", "--json"]).output,
98
+ )[0].id;
99
+ expect(
100
+ run(["done", id, "--dir", run0.dir, "--branch", "main"]).exitCode,
101
+ ).toBe(0);
102
+ const after = JSON.parse(
103
+ run(["list", "--dir", run0.dir, "--branch", "main", "--json"]).output,
104
+ );
105
+ expect(after[0].execState).toBe("done");
106
+ } finally {
107
+ run0.cleanup();
108
+ }
109
+ });
110
+
111
+ test("filtra por decisão e resume contagens", () => {
112
+ const run0 = makeRun(ONE_FINDING);
113
+ try {
114
+ const id = JSON.parse(
115
+ run(["list", "--dir", run0.dir, "--branch", "main", "--json"]).output,
116
+ )[0].id;
117
+ run([
118
+ "set",
119
+ id,
120
+ "--decision",
121
+ "ignore",
122
+ "--dir",
123
+ run0.dir,
124
+ "--branch",
125
+ "main",
126
+ ]);
127
+
128
+ const pending = JSON.parse(
129
+ run([
130
+ "list",
131
+ "--decision",
132
+ "pending",
133
+ "--dir",
134
+ run0.dir,
135
+ "--branch",
136
+ "main",
137
+ "--json",
138
+ ]).output,
139
+ );
140
+ expect(pending).toHaveLength(0);
141
+
142
+ const summary = JSON.parse(
143
+ run(["summary", "--dir", run0.dir, "--branch", "main", "--json"]).output,
144
+ );
145
+ expect(summary.total).toBe(1);
146
+ expect(summary.byDecision.ignore).toBe(1);
147
+ } finally {
148
+ run0.cleanup();
149
+ }
150
+ });
151
+
152
+ test("sync marca como stale quando o arquivo não está disponível", () => {
153
+ const run0 = makeRun(ONE_FINDING);
154
+ try {
155
+ const result = JSON.parse(
156
+ run(["sync", "--dir", run0.dir, "--branch", "main", "--json"]).output,
157
+ );
158
+ expect(result.stale).toHaveLength(1);
159
+ } finally {
160
+ run0.cleanup();
161
+ }
162
+ });
163
+
164
+ test("erros claros: decisão inválida, id inexistente, run ausente", () => {
165
+ const run0 = makeRun(ONE_FINDING);
166
+ try {
167
+ const bad = run([
168
+ "set",
169
+ "inexistente",
170
+ "--decision",
171
+ "xpto",
172
+ "--dir",
173
+ run0.dir,
174
+ "--branch",
175
+ "main",
176
+ ]);
177
+ expect(bad.exitCode).toBe(1);
178
+ expect(bad.output).toContain("--decision inválido");
179
+
180
+ const missing = run([
181
+ "show",
182
+ "nao-existe",
183
+ "--dir",
184
+ run0.dir,
185
+ "--branch",
186
+ "main",
187
+ ]);
188
+ expect(missing.exitCode).toBe(1);
189
+ expect(missing.output).toContain("não encontrado");
190
+
191
+ const empty = mkdtempSync(join(tmpdir(), "houndsman-empty-"));
192
+ try {
193
+ const absent = run(["list", "--dir", empty, "--branch", "main"]);
194
+ expect(absent.exitCode).toBe(1);
195
+ expect(absent.output).toContain("--finalize");
196
+ } finally {
197
+ rmSync(empty, { recursive: true, force: true });
198
+ }
199
+ } finally {
200
+ run0.cleanup();
201
+ }
202
+ });
203
+
204
+ test("path devolve o caminho do state.json", () => {
205
+ const run0 = makeRun(ONE_FINDING);
206
+ try {
207
+ run(["list", "--dir", run0.dir, "--branch", "main"]);
208
+ const result = run(["path", "--dir", run0.dir, "--branch", "main"]);
209
+ expect(result.exitCode).toBe(0);
210
+ expect(result.output.replace(/\\/g, "/")).toContain("main/state.json");
211
+ } finally {
212
+ run0.cleanup();
213
+ }
214
+ });
215
+
216
+ test("sub desconhecido devolve erro", () => {
217
+ const run0 = makeRun(ONE_FINDING);
218
+ try {
219
+ const result = run(["xpto", "--dir", run0.dir, "--branch", "main"]);
220
+ expect(result.exitCode).toBe(1);
221
+ expect(result.output).toContain("desconhecido");
222
+ } finally {
223
+ run0.cleanup();
224
+ }
225
+ });
226
+ });
@@ -0,0 +1,274 @@
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(1);
271
+ expect(state.slug).toBe("feature-x");
272
+ expect(state.findings).toEqual({});
273
+ });
274
+ });
@@ -0,0 +1,64 @@
1
+ /**
2
+ * "Project fake" usado pela suíte de testes da lib.
3
+ *
4
+ * Os testes validam o MOTOR (`core/`), não as regras de um projeto real —
5
+ * por isso usam esta configuração fictícia em vez da config do projeto real.
6
+ */
7
+
8
+ import { defineHoundsmanConfig, type HoundsmanConfig } from "../../core/config";
9
+ import { defineWatchdog } from "../../core/types";
10
+
11
+ /** Watchdog A — regra bloqueante com spec Jev + outras severidades. */
12
+ export const alphaWatchdog = defineWatchdog({
13
+ id: "alpha",
14
+ name: "Alpha",
15
+ description: "Watchdog A do projeto fake.",
16
+ scope: "src/alpha/**",
17
+ classify: [{ value: "^src/alpha/" }],
18
+ rules: [
19
+ { severity: "blocking", text: "Nunca usar `any`.", jev: { kind: "noul" } },
20
+ { severity: "attention", text: "Validar entrada do usuário." },
21
+ { severity: "suggestion", text: "Evitar console.log." },
22
+ ],
23
+ contextFiles: ["README.md"],
24
+ });
25
+
26
+ /** Watchdog B — cobre testes e um padrão de nome de arquivo. */
27
+ export const betaWatchdog = defineWatchdog({
28
+ id: "beta",
29
+ name: "Beta",
30
+ description: "Watchdog B do projeto fake.",
31
+ scope: "src/beta/**, arquivos de teste e o arquivo especial",
32
+ classify: [
33
+ { value: "^src/beta/" },
34
+ { value: "\\.test\\.ts$" },
35
+ { value: "special\\.ts$" },
36
+ ],
37
+ rules: [
38
+ {
39
+ severity: "blocking",
40
+ text: "Nunca commitar segredos.",
41
+ jev: { kind: "noul", instructions: "O estado contém segredo hardcoded?" },
42
+ },
43
+ { severity: "attention", text: "Evitar código morto." },
44
+ ],
45
+ contextFiles: ["README.md"],
46
+ });
47
+
48
+ export const FAKE_WATCHDOG_DEFINITIONS = {
49
+ alpha: alphaWatchdog,
50
+ beta: betaWatchdog,
51
+ };
52
+
53
+ export type FakeWatchdogId = keyof typeof FAKE_WATCHDOG_DEFINITIONS;
54
+
55
+ /** Configuração completa do projeto fake. */
56
+ export const fakeHoundsmanConfig: HoundsmanConfig<FakeWatchdogId> =
57
+ defineHoundsmanConfig<FakeWatchdogId>({
58
+ projectName: "Fake Project",
59
+ artifactDir: "tmp/fake-review",
60
+ watchdogs: FAKE_WATCHDOG_DEFINITIONS,
61
+ exclusions: [/^dist\//, /\.lock$/],
62
+ docMappings: [[/^src\//, ["README.md"]]],
63
+ alwaysIncludeDocs: ["README.md"],
64
+ });