@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
@@ -0,0 +1,386 @@
1
+ /**
2
+ * Contrato do storage — SQLite (default) e arquivos (fallback).
3
+ *
4
+ * Garante que trocar `storage.provider` não muda o comportamento observável:
5
+ * reviews, findings (com histórico/âncora/github) e artefatos.
6
+ */
7
+
8
+ import { Database } from "bun:sqlite";
9
+ import { describe, expect, test } from "bun:test";
10
+ import { mkdtempSync, rmSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ import {
14
+ type FindingState,
15
+ type FindingsStateFile,
16
+ reorder,
17
+ resolveFindingId,
18
+ } from "../core/findings-state";
19
+ import { FileStorageProvider } from "../core/storage/file/file-storage";
20
+ import { buildRepoIdentity, normalizeRemote } from "../core/storage/repo-identity";
21
+ import { SQLiteStorageProvider } from "../core/storage/sqlite/sqlite-storage";
22
+ import type { StorageProvider } from "../core/storage/types";
23
+
24
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
25
+
26
+ function finding(id: string, partial: Partial<FindingState> = {}): FindingState {
27
+ return {
28
+ id,
29
+ fingerprint: `fp-${id}`,
30
+ watchdogIds: ["alpha"],
31
+ rule: "regra",
32
+ severity: "attention",
33
+ file: "src/a.ts",
34
+ line: 3,
35
+ message: "msg",
36
+ action: "ação",
37
+ evidence: "const a = 1;",
38
+ anchor: { snippet: "const a = 1;", line: 3 },
39
+ foundAtSha: "abc123",
40
+ count: 2,
41
+ present: true,
42
+ decision: "execute",
43
+ comment: "fazer",
44
+ execState: "pending",
45
+ createdAt: "2026-01-01T00:00:00.000Z",
46
+ updatedAt: "2026-01-01T00:00:00.000Z",
47
+ history: [
48
+ {
49
+ at: "2026-01-01T00:00:00.000Z",
50
+ decision: "pending",
51
+ execState: "pending",
52
+ },
53
+ {
54
+ at: "2026-01-01T00:05:00.000Z",
55
+ decision: "execute",
56
+ execState: "pending",
57
+ comment: "fazer",
58
+ by: "humano",
59
+ },
60
+ ],
61
+ github: {
62
+ commentId: 501,
63
+ url: "https://x/501",
64
+ kind: "line",
65
+ pr: 12,
66
+ at: "2026-01-01T00:10:00.000Z",
67
+ },
68
+ ...partial,
69
+ };
70
+ }
71
+
72
+ function sampleState(branch = "feature/x"): FindingsStateFile {
73
+ const state: FindingsStateFile = {
74
+ version: 2,
75
+ branch,
76
+ slug: "feature-x",
77
+ updatedAt: "2026-01-02T00:00:00.000Z",
78
+ order: [],
79
+ reviewed: { headSha: "deadbeef", at: "2026-01-02T00:00:00.000Z" },
80
+ findings: {
81
+ ab12cd34ef: finding("ab12cd34ef"),
82
+ ff00aa11bb: finding("ff00aa11bb", {
83
+ fingerprint: "fp-2",
84
+ file: "src/b.ts",
85
+ severity: "nit",
86
+ history: [],
87
+ }),
88
+ },
89
+ };
90
+ reorder(state);
91
+ return state;
92
+ }
93
+
94
+ /** Executa o mesmo contrato para qualquer provider. */
95
+ function contract(
96
+ name: string,
97
+ make: () => { storage: StorageProvider; cleanup: () => void },
98
+ ): void {
99
+ describe(`storage — ${name}`, () => {
100
+ test("reviews: get-or-create idempotente, update e listagem por repo", () => {
101
+ const { storage, cleanup } = make();
102
+ try {
103
+ const repo = buildRepoIdentity("git@github.com:acme/app.git", "/tmp/app");
104
+ const first = storage.getOrCreateReview({
105
+ repo,
106
+ branch: "feature/x",
107
+ slug: "feature-x",
108
+ });
109
+ expect(first.id).toMatch(UUID);
110
+ const again = storage.getOrCreateReview({
111
+ repo,
112
+ branch: "feature/x",
113
+ slug: "feature-x",
114
+ });
115
+ expect(again.id).toBe(first.id);
116
+ expect(storage.findReview(repo.key, "feature/x")?.id).toBe(first.id);
117
+
118
+ storage.updateReview(first.id, {
119
+ status: "reviewing",
120
+ resetStartedAt: true,
121
+ updatedAt: "2026-01-01T00:00:00.000Z",
122
+ });
123
+ storage.updateReview(first.id, {
124
+ status: "review_done",
125
+ updatedAt: "2026-01-01T00:10:00.000Z",
126
+ });
127
+ const updated = storage.getReview(first.id);
128
+ expect(updated?.status).toBe("review_done");
129
+ expect(updated?.startedAt).toBe("2026-01-01T00:00:00.000Z");
130
+ expect(updated?.updatedAt).toBe("2026-01-01T00:10:00.000Z");
131
+
132
+ // Outro repo não aparece no escopo.
133
+ storage.getOrCreateReview({
134
+ repo: buildRepoIdentity(null, "/tmp/other"),
135
+ branch: "main",
136
+ slug: "main",
137
+ });
138
+ expect(storage.listReviews(repo.key)).toHaveLength(1);
139
+ expect(storage.listReviews()).toHaveLength(2);
140
+ } finally {
141
+ cleanup();
142
+ }
143
+ });
144
+
145
+ test("findings: round-trip completo e preservação do short id", () => {
146
+ const { storage, cleanup } = make();
147
+ try {
148
+ const repo = buildRepoIdentity(null, "/tmp/app");
149
+ const review = storage.getOrCreateReview({
150
+ repo,
151
+ branch: "feature/x",
152
+ slug: "feature-x",
153
+ });
154
+ const state = sampleState();
155
+ storage.writeFindings(review.id, state);
156
+
157
+ const read = storage.readFindings(review.id);
158
+ expect(read?.branch).toBe("feature/x");
159
+ expect(read?.reviewed).toEqual(state.reviewed);
160
+ expect(Object.keys(read?.findings ?? {}).sort()).toEqual([
161
+ "ab12cd34ef",
162
+ "ff00aa11bb",
163
+ ]);
164
+ const one = read?.findings.ab12cd34ef;
165
+ expect(one?.fingerprint).toBe("fp-ab12cd34ef");
166
+ expect(one?.anchor).toEqual(state.findings.ab12cd34ef.anchor);
167
+ expect(one?.foundAtSha).toBe("abc123");
168
+ expect(one?.github?.commentId).toBe(501);
169
+ expect(one?.history).toHaveLength(2);
170
+ expect(storage.getReview(review.id)?.reviewedSha).toBe("deadbeef");
171
+
172
+ // Atualização: preserva id e troca o histórico.
173
+ if (read) {
174
+ read.findings.ab12cd34ef.execState = "done";
175
+ read.findings.ab12cd34ef.history = [];
176
+ read.updatedAt = "2026-01-03T00:00:00.000Z";
177
+ storage.writeFindings(review.id, read);
178
+ }
179
+ const after = storage.readFindings(review.id);
180
+ expect(after?.findings.ab12cd34ef.id).toBe("ab12cd34ef");
181
+ expect(after?.findings.ab12cd34ef.execState).toBe("done");
182
+ expect(after?.findings.ab12cd34ef.history).toHaveLength(0);
183
+ } finally {
184
+ cleanup();
185
+ }
186
+ });
187
+
188
+ test("artefatos: upsert, listagem e remoção", () => {
189
+ const { storage, cleanup } = make();
190
+ try {
191
+ const repo = buildRepoIdentity(null, "/tmp/app");
192
+ const review = storage.getOrCreateReview({
193
+ repo,
194
+ branch: "main",
195
+ slug: "main",
196
+ });
197
+ const first = storage.writeArtifact(review.id, {
198
+ name: "review.md",
199
+ kind: "review",
200
+ content: "# Review\n",
201
+ });
202
+ expect(first.id).toMatch(UUID);
203
+ const second = storage.writeArtifact(review.id, {
204
+ name: "review.md",
205
+ kind: "review",
206
+ content: "# Review 2\n",
207
+ });
208
+ expect(second.id).toBe(first.id);
209
+ expect(storage.readArtifact(review.id, "review.md")?.content).toBe(
210
+ "# Review 2\n",
211
+ );
212
+ storage.writeArtifact(review.id, {
213
+ name: "manifest.json",
214
+ kind: "manifest",
215
+ content: "{}",
216
+ });
217
+ expect(storage.listArtifacts(review.id).map((item) => item.name)).toEqual(
218
+ ["manifest.json", "review.md"],
219
+ );
220
+ storage.deleteArtifact(review.id, "manifest.json");
221
+ expect(storage.listArtifacts(review.id)).toHaveLength(1);
222
+ } finally {
223
+ cleanup();
224
+ }
225
+ });
226
+
227
+ test("delete da review remove findings e artefatos (cascade)", () => {
228
+ const { storage, cleanup } = make();
229
+ try {
230
+ const repo = buildRepoIdentity(null, "/tmp/app");
231
+ const review = storage.getOrCreateReview({
232
+ repo,
233
+ branch: "main",
234
+ slug: "main",
235
+ });
236
+ storage.writeFindings(review.id, sampleState("main"));
237
+ storage.writeArtifact(review.id, {
238
+ name: "review.md",
239
+ kind: "review",
240
+ content: "# Review\n",
241
+ });
242
+ storage.deleteReview(review.id);
243
+ expect(storage.getReview(review.id)).toBeNull();
244
+ expect(storage.readFindings(review.id)).toBeNull();
245
+ expect(storage.readArtifact(review.id, "review.md")).toBeNull();
246
+ } finally {
247
+ cleanup();
248
+ }
249
+ });
250
+
251
+ test("maintain e location não quebram", () => {
252
+ const { storage, cleanup } = make();
253
+ try {
254
+ const repo = buildRepoIdentity(null, "/tmp/app");
255
+ const review = storage.getOrCreateReview({
256
+ repo,
257
+ branch: "main",
258
+ slug: "main",
259
+ });
260
+ expect(() => storage.maintain()).not.toThrow();
261
+ expect(storage.location(review.id)).toContain(review.id);
262
+ } finally {
263
+ cleanup();
264
+ }
265
+ });
266
+ });
267
+ }
268
+
269
+ function tempDir(): string {
270
+ return mkdtempSync(join(tmpdir(), "houndsman-storage-"));
271
+ }
272
+
273
+ contract("sqlite", () => {
274
+ const dir = tempDir();
275
+ const storage = new SQLiteStorageProvider(join(dir, "houndsman.db"));
276
+ return {
277
+ storage,
278
+ cleanup: () => {
279
+ storage.close();
280
+ rmSync(dir, { recursive: true, force: true });
281
+ },
282
+ };
283
+ });
284
+
285
+ contract("file", () => {
286
+ const dir = tempDir();
287
+ const storage = new FileStorageProvider(join(dir, "runs"));
288
+ return {
289
+ storage,
290
+ cleanup: () => {
291
+ storage.close();
292
+ rmSync(dir, { recursive: true, force: true });
293
+ },
294
+ };
295
+ });
296
+
297
+ describe("storage — detalhes do SQLite", () => {
298
+ test("findings usam UUID como PK e short id como identidade de exibição", () => {
299
+ const dir = tempDir();
300
+ const path = join(dir, "houndsman.db");
301
+ const storage = new SQLiteStorageProvider(path);
302
+ try {
303
+ const repo = buildRepoIdentity(null, "/tmp/app");
304
+ const review = storage.getOrCreateReview({
305
+ repo,
306
+ branch: "main",
307
+ slug: "main",
308
+ });
309
+ storage.writeFindings(review.id, sampleState("main"));
310
+
311
+ const db = new Database(path, { readonly: true });
312
+ try {
313
+ const rows = db.query("SELECT id, short_id FROM findings").all() as {
314
+ id: string;
315
+ short_id: string;
316
+ }[];
317
+ expect(rows).toHaveLength(2);
318
+ for (const row of rows) {
319
+ expect(row.id).toMatch(UUID);
320
+ expect(row.short_id).toMatch(/^[0-9a-f]{10}$/);
321
+ expect(row.id).not.toBe(row.short_id);
322
+ }
323
+ } finally {
324
+ db.close();
325
+ }
326
+ } finally {
327
+ storage.close();
328
+ rmSync(dir, { recursive: true, force: true });
329
+ }
330
+ });
331
+ });
332
+
333
+ describe("repo identity", () => {
334
+ test("normaliza remotes HTTPS, SSH e SCP", () => {
335
+ expect(normalizeRemote("https://github.com/Acme/App.git")).toBe(
336
+ "github.com/Acme/App",
337
+ );
338
+ expect(normalizeRemote("ssh://git@gitlab.com/group/repo.git")).toBe(
339
+ "gitlab.com/group/repo",
340
+ );
341
+ expect(normalizeRemote("git@github.com:acme/app.git")).toBe(
342
+ "github.com/acme/app",
343
+ );
344
+ expect(normalizeRemote("/tmp/local")).toBeNull();
345
+ });
346
+
347
+ test("usa remote como key e cai na raiz quando ausente", () => {
348
+ const withRemote = buildRepoIdentity("git@github.com:acme/app.git", "/tmp/app");
349
+ expect(withRemote.key).toBe("remote:github.com/acme/app");
350
+ const local = buildRepoIdentity(null, "/tmp/app");
351
+ expect(local.key.startsWith("path:")).toBe(true);
352
+ expect(local.key.endsWith("/tmp/app")).toBe(true);
353
+ });
354
+ });
355
+
356
+ describe("resolveFindingId", () => {
357
+ const state = sampleState();
358
+ test("exato vence, prefixo único resolve e ambíguo lista candidatos", () => {
359
+ expect(resolveFindingId(state, "ab12cd34ef")).toEqual({
360
+ status: "found",
361
+ id: "ab12cd34ef",
362
+ });
363
+ expect(resolveFindingId(state, "ab12")).toEqual({
364
+ status: "found",
365
+ id: "ab12cd34ef",
366
+ });
367
+ expect(resolveFindingId(state, "ff00")).toEqual({
368
+ status: "found",
369
+ id: "ff00aa11bb",
370
+ });
371
+ expect(resolveFindingId(state, "zzz")).toEqual({ status: "missing" });
372
+ });
373
+
374
+ test("prefixo compartilhado é ambíguo", () => {
375
+ const clash = sampleState();
376
+ clash.findings = {
377
+ abcd000001: { ...clash.findings.ab12cd34ef, id: "abcd000001" },
378
+ abcd000002: { ...clash.findings.ff00aa11bb, id: "abcd000002" },
379
+ };
380
+ reorder(clash);
381
+ expect(resolveFindingId(clash, "abcd")).toEqual({
382
+ status: "ambiguous",
383
+ matches: ["abcd000001", "abcd000002"],
384
+ });
385
+ });
386
+ });