@payconductor/houndsman 0.2.0 → 0.4.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 (107) hide show
  1. package/README.md +18 -6
  2. package/package.json +1 -1
  3. package/src/cli/arg-parser.ts +8 -0
  4. package/src/cli/commands/command.ts +23 -0
  5. package/src/cli/commands/context-command.ts +40 -0
  6. package/src/cli/commands/doctor-command.ts +60 -0
  7. package/src/cli/commands/findings-command.ts +38 -0
  8. package/src/cli/commands/help-command.ts +13 -0
  9. package/src/cli/commands/registry.ts +34 -0
  10. package/src/cli/commands/review-command.ts +21 -0
  11. package/src/cli/commands/server-command.ts +37 -0
  12. package/src/cli/commands/setup-command.ts +34 -0
  13. package/src/cli/commands/watchdog-command.ts +34 -0
  14. package/src/cli/help.ts +47 -0
  15. package/src/cli/index.ts +60 -268
  16. package/src/core/arg-parser.ts +48 -0
  17. package/src/core/commands/context.ts +29 -13
  18. package/src/core/commands/findings.ts +185 -260
  19. package/src/core/commands/review.ts +21 -39
  20. package/src/core/commands/scaffold.ts +189 -80
  21. package/src/core/commands/setup.ts +16 -11
  22. package/src/core/commands/update.ts +7 -7
  23. package/src/core/commands/watchdog.ts +8 -80
  24. package/src/core/config.ts +24 -25
  25. package/src/core/defaults.ts +20 -0
  26. package/src/core/diff-source.ts +1 -13
  27. package/src/core/doc-discoverer.ts +25 -15
  28. package/src/core/domain/budget.ts +42 -0
  29. package/src/core/domain/classification.ts +94 -0
  30. package/src/core/domain/diff.ts +53 -0
  31. package/src/core/domain/watchdog.ts +96 -0
  32. package/src/core/engine/carryover.ts +67 -0
  33. package/src/core/engine/houndsman-engine.ts +65 -0
  34. package/src/core/engine/review-scope.ts +95 -0
  35. package/src/core/engine/review-session.ts +79 -0
  36. package/src/core/file-classifier.ts +11 -67
  37. package/src/core/findings-state.ts +193 -100
  38. package/src/core/git-revision.ts +14 -40
  39. package/src/core/git-runner.ts +46 -0
  40. package/src/core/github-review.ts +4 -33
  41. package/src/core/jev/jev-candidates.ts +49 -35
  42. package/src/core/orchestrator.ts +247 -421
  43. package/src/core/paths.ts +26 -11
  44. package/src/core/pr-context.ts +39 -51
  45. package/src/core/project-config.ts +56 -0
  46. package/src/core/renderers/batch-presenter.ts +40 -0
  47. package/src/core/renderers/brief-renderer.ts +23 -0
  48. package/src/core/renderers/findings-presenter.ts +74 -0
  49. package/src/core/renderers/manifest-writer.ts +123 -0
  50. package/src/core/renderers/report-renderer.ts +66 -0
  51. package/src/core/renderers/verify-presenter.ts +50 -0
  52. package/src/core/renderers/watchdog-presenter.ts +100 -0
  53. package/src/core/report-builder.ts +12 -7
  54. package/src/core/run-layout.ts +8 -0
  55. package/src/core/run-status.ts +29 -59
  56. package/src/core/services/clock.ts +12 -0
  57. package/src/core/services/file-classifier.ts +34 -0
  58. package/src/core/services/fix-batch-builder.ts +13 -0
  59. package/src/core/services/gh-client.ts +54 -0
  60. package/src/core/services/git-repository.ts +139 -0
  61. package/src/core/services/project-files.ts +91 -0
  62. package/src/core/services/verifier.ts +58 -0
  63. package/src/core/storage/factory.ts +24 -0
  64. package/src/core/storage/file/file-storage.ts +269 -0
  65. package/src/core/storage/paths.ts +64 -0
  66. package/src/core/storage/repo-identity.ts +70 -0
  67. package/src/core/storage/sqlite/mappers.ts +160 -0
  68. package/src/core/storage/sqlite/schema.ts +108 -0
  69. package/src/core/storage/sqlite/sqlite-storage.ts +427 -0
  70. package/src/core/storage/types.ts +143 -0
  71. package/src/core/storage/work-area.ts +191 -0
  72. package/src/core/verify.ts +2 -1
  73. package/src/core/watchdog-registry.ts +58 -16
  74. package/src/index.ts +57 -6
  75. package/src/server/artifacts.ts +8 -61
  76. package/src/server/client/ArtifactsPanel.tsx +137 -23
  77. package/src/server/client/FindingCard.tsx +37 -6
  78. package/src/server/client/MarkdownText.tsx +101 -0
  79. package/src/server/client/SubmitModal.tsx +9 -4
  80. package/src/server/client/code-highlight.ts +422 -0
  81. package/src/server/client/constants.ts +130 -24
  82. package/src/server/client/json-highlight.ts +55 -0
  83. package/src/server/client/markdown.ts +153 -0
  84. package/src/server/client/types.ts +23 -0
  85. package/src/server/index.ts +155 -245
  86. package/src/server/run-repository.ts +299 -0
  87. package/src/skill/OUTPUT-FORMAT.md +6 -4
  88. package/src/skill/README.md +22 -14
  89. package/src/skill/SETUP.md +4 -4
  90. package/src/skill/fix.md +5 -5
  91. package/src/skill/hunt.md +27 -18
  92. package/src/skill/train.md +1 -1
  93. package/src/skill/verify.md +5 -5
  94. package/src/tests/__snapshots__/baseline.test.ts.snap +825 -0
  95. package/src/tests/baseline.test.ts +555 -493
  96. package/src/tests/cli.test.ts +40 -19
  97. package/src/tests/code-highlight.test.ts +123 -0
  98. package/src/tests/domain.test.ts +328 -0
  99. package/src/tests/findings-commands.test.ts +153 -131
  100. package/src/tests/findings-state.test.ts +6 -3
  101. package/src/tests/fixtures/fake-project.ts +0 -1
  102. package/src/tests/infrastructure.test.ts +205 -0
  103. package/src/tests/markdown.test.ts +89 -0
  104. package/src/tests/review-scripts.test.ts +8 -5
  105. package/src/tests/run-status.test.ts +33 -23
  106. package/src/tests/server.test.ts +108 -75
  107. package/src/tests/storage.test.ts +386 -0
@@ -7,9 +7,13 @@
7
7
  */
8
8
 
9
9
  import { describe, expect, test } from "bun:test";
10
- import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
10
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
11
11
  import { tmpdir } from "node:os";
12
12
  import { join } from "node:path";
13
+ import { buildStateFromFindings, fileReader } from "../core/findings-state";
14
+ import { dedupeFindings, parseWatchdogReport } from "../core/report-builder";
15
+ import { buildRepoIdentity } from "../core/storage/repo-identity";
16
+ import { SQLiteStorageProvider } from "../core/storage/sqlite/sqlite-storage";
13
17
 
14
18
  /** Caminho do executável do CLI (TS, rodado pelo Bun). */
15
19
  const CLI = join(import.meta.dir, "..", "cli", "index.ts");
@@ -17,6 +21,7 @@ const CLI = join(import.meta.dir, "..", "cli", "index.ts");
17
21
  /** Config mínima, como objeto literal (o `loadConfig` deriva `watchdogIds`). */
18
22
  const CONFIG = `export default {
19
23
  projectName: "Smoke",
24
+ storage: { provider: "sqlite", path: "./houndsman.db" },
20
25
  watchdogs: {
21
26
  alpha: {
22
27
  id: "alpha",
@@ -33,22 +38,18 @@ const CONFIG = `export default {
33
38
  };
34
39
  `;
35
40
 
36
- type Project = { cwd: string; artifacts: string; cleanup: () => void };
41
+ type Project = { cwd: string; cleanup: () => void };
37
42
 
38
- /** Projeto temporário: config + uma run com um finding. */
43
+ /** Projeto temporário: config + banco com uma run e um finding. */
39
44
  function makeProject(): Project {
40
45
  const cwd = mkdtempSync(join(tmpdir(), "houndsman-cli-"));
41
- const artifacts = join(cwd, ".houndsman", ".runs");
42
- const runDir = join(artifacts, "main");
43
- mkdirSync(join(runDir, "findings"), { recursive: true });
44
46
  writeFileSync(join(cwd, "houndsman.config.ts"), CONFIG);
45
- writeFileSync(
46
- join(runDir, "classification.json"),
47
- JSON.stringify({ watchdogs: { alpha: ["src/a.ts"] } }),
48
- );
49
- writeFileSync(
50
- join(runDir, "findings", "alpha.json"),
51
- JSON.stringify({
47
+
48
+ const storage = new SQLiteStorageProvider(join(cwd, "houndsman.db"));
49
+ const repo = buildRepoIdentity(null, cwd);
50
+ const review = storage.getOrCreateReview({ repo, branch: "main", slug: "main" });
51
+ const report = parseWatchdogReport(
52
+ {
52
53
  watchdogId: "alpha",
53
54
  reviewedFiles: ["src/a.ts"],
54
55
  skipped: [],
@@ -62,11 +63,22 @@ function makeProject(): Project {
62
63
  action: "tipar",
63
64
  },
64
65
  ],
65
- }),
66
+ },
67
+ "alpha.json",
66
68
  );
69
+ if (report) {
70
+ storage.writeFindings(
71
+ review.id,
72
+ buildStateFromFindings(dedupeFindings([report]), {
73
+ branch: "main",
74
+ readFile: fileReader(cwd),
75
+ }),
76
+ );
77
+ }
78
+ storage.close();
79
+
67
80
  return {
68
81
  cwd,
69
- artifacts,
70
82
  cleanup: () => rmSync(cwd, { recursive: true, force: true }),
71
83
  };
72
84
  }
@@ -112,8 +124,6 @@ describe("cli smoke — imports dinâmicos", () => {
112
124
  "findings",
113
125
  "summary",
114
126
  "--json",
115
- "--dir",
116
- project.artifacts,
117
127
  "--branch",
118
128
  "main",
119
129
  ]);
@@ -132,8 +142,6 @@ describe("cli smoke — imports dinâmicos", () => {
132
142
  "submit",
133
143
  "--dry-run",
134
144
  "--json",
135
- "--dir",
136
- project.artifacts,
137
145
  "--branch",
138
146
  "main",
139
147
  ]);
@@ -160,6 +168,19 @@ describe("cli smoke — imports dinâmicos", () => {
160
168
  try {
161
169
  const result = runCli(project.cwd, ["context", "--branch", "main"]);
162
170
  expect(result.code).toBe(0);
171
+
172
+ // Auditoria: o brief do contexto fica persistido mesmo sem PR.
173
+ const storage = new SQLiteStorageProvider(join(project.cwd, "houndsman.db"));
174
+ try {
175
+ const repo = buildRepoIdentity(null, project.cwd);
176
+ const review = storage.findReview(repo.key, "main");
177
+ expect(review).not.toBeNull();
178
+ expect(
179
+ storage.readArtifact(review?.id ?? "", "context-brief.md")?.kind,
180
+ ).toBe("context-brief");
181
+ } finally {
182
+ storage.close();
183
+ }
163
184
  } finally {
164
185
  project.cleanup();
165
186
  }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Destaque de código do evidence — tokenizer próprio, sem dependência.
3
+ *
4
+ * Garante que a inferência por extensão funciona, que o texto original é
5
+ * preservado e que os tokens principais são classificados.
6
+ */
7
+
8
+ import { describe, expect, test } from "bun:test";
9
+ import {
10
+ highlightCode,
11
+ languageFromFile,
12
+ languageFromName,
13
+ } from "../server/client/code-highlight";
14
+
15
+ /** O texto concatenado dos tokens deve ser idêntico ao input. */
16
+ function joined(tokens: { text: string }[]): string {
17
+ return tokens.map((token) => token.text).join("");
18
+ }
19
+
20
+ describe("languageFromFile", () => {
21
+ test("infere pela extensão e ignora extensões desconhecidas", () => {
22
+ expect(languageFromFile("src/a.ts")).toBe("typescript");
23
+ expect(languageFromFile("src/a.tsx")).toBe("typescript");
24
+ expect(languageFromFile("scripts/a.py")).toBe("python");
25
+ expect(languageFromFile("db/a.sql")).toBe("sql");
26
+ expect(languageFromFile("charts/a.yaml")).toBe("yaml");
27
+ expect(languageFromFile("README.md")).toBeNull();
28
+ expect(languageFromFile("Makefile")).toBeNull();
29
+ expect(languageFromFile("a.unknown")).toBeNull();
30
+ });
31
+
32
+ test("aliases de fence markdown", () => {
33
+ expect(languageFromName("ts")).toBe("typescript");
34
+ expect(languageFromName("js")).toBe("javascript");
35
+ expect(languageFromName("py")).toBe("python");
36
+ expect(languageFromName("json")).toBe("json");
37
+ expect(languageFromName("yml")).toBe("yaml");
38
+ expect(languageFromName("cobol")).toBeNull();
39
+ expect(languageFromName(null)).toBeNull();
40
+ });
41
+ });
42
+
43
+ describe("highlightCode", () => {
44
+ test("TypeScript: keywords, tipos, números, strings e comentários", () => {
45
+ const input = 'const answer: number = 42; // resposta\nconst url = "https://x";';
46
+ const result = highlightCode(input, "typescript");
47
+ expect(result?.label).toBe("TypeScript");
48
+ expect(joined(result?.tokens ?? [])).toBe(input);
49
+ expect(result?.tokens.find((token) => token.text === "const")?.type).toBe(
50
+ "keyword",
51
+ );
52
+ expect(result?.tokens.find((token) => token.text === "number")?.type).toBe(
53
+ "type",
54
+ );
55
+ expect(result?.tokens.find((token) => token.text === "42")?.type).toBe("number");
56
+ expect(result?.tokens.find((token) => token.text === "// resposta")?.type).toBe(
57
+ "comment",
58
+ );
59
+ expect(result?.tokens.find((token) => token.text === '"https://x"')?.type).toBe(
60
+ "string",
61
+ );
62
+ });
63
+
64
+ test("Python: comentários com # e strings de três aspas", () => {
65
+ const input = 'def greet(name):\n """doc"""\n return "hi" # ok';
66
+ const result = highlightCode(input, "python");
67
+ expect(joined(result?.tokens ?? [])).toBe(input);
68
+ expect(result?.tokens.find((token) => token.text === "def")?.type).toBe(
69
+ "keyword",
70
+ );
71
+ expect(result?.tokens.find((token) => token.text === "return")?.type).toBe(
72
+ "keyword",
73
+ );
74
+ expect(result?.tokens.find((token) => token.text === '"""doc"""')?.type).toBe(
75
+ "string",
76
+ );
77
+ expect(result?.tokens.find((token) => token.text === "# ok")?.type).toBe(
78
+ "comment",
79
+ );
80
+ });
81
+
82
+ test("JSON: chave vira propriedade e literal vira keyword", () => {
83
+ const input = '{"a": 1, "b": true}';
84
+ const result = highlightCode(input, "json");
85
+ expect(result?.label).toBe("JSON");
86
+ expect(joined(result?.tokens ?? [])).toBe(input);
87
+ expect(result?.tokens.find((token) => token.text === '"a"')?.type).toBe(
88
+ "property",
89
+ );
90
+ expect(result?.tokens.find((token) => token.text === "true")?.type).toBe(
91
+ "keyword",
92
+ );
93
+ });
94
+
95
+ test("HTML: tag e atributo", () => {
96
+ const input = '<div class="x">hi</div>';
97
+ const result = highlightCode(input, "html");
98
+ expect(joined(result?.tokens ?? [])).toBe(input);
99
+ expect(result?.tokens.find((token) => token.text === "div")?.type).toBe(
100
+ "keyword",
101
+ );
102
+ expect(result?.tokens.find((token) => token.text === "class")?.type).toBe(
103
+ "property",
104
+ );
105
+ });
106
+
107
+ test("YAML: chave no início da linha e comentário", () => {
108
+ const input = "name: value # note";
109
+ const result = highlightCode(input, "yaml");
110
+ expect(joined(result?.tokens ?? [])).toBe(input);
111
+ expect(result?.tokens.find((token) => token.text === "name")?.type).toBe(
112
+ "property",
113
+ );
114
+ expect(result?.tokens.find((token) => token.text === "# note")?.type).toBe(
115
+ "comment",
116
+ );
117
+ });
118
+
119
+ test("sem linguagem/desconhecida devolve null", () => {
120
+ expect(highlightCode("const x = 1", null)).toBeNull();
121
+ expect(highlightCode("const x = 1", "cobol")).toBeNull();
122
+ });
123
+ });
@@ -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.storage).toEqual({});
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
+ });