@payconductor/houndsman 0.3.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 (69) hide show
  1. package/README.md +18 -6
  2. package/package.json +1 -1
  3. package/src/cli/commands/context-command.ts +11 -9
  4. package/src/cli/commands/doctor-command.ts +5 -0
  5. package/src/cli/commands/findings-command.ts +13 -3
  6. package/src/cli/commands/review-command.ts +1 -2
  7. package/src/cli/help.ts +7 -3
  8. package/src/cli/index.ts +2 -2
  9. package/src/core/commands/context.ts +29 -13
  10. package/src/core/commands/findings.ts +155 -120
  11. package/src/core/commands/review.ts +7 -8
  12. package/src/core/commands/scaffold.ts +15 -16
  13. package/src/core/config.ts +16 -7
  14. package/src/core/engine/houndsman-engine.ts +15 -1
  15. package/src/core/engine/review-session.ts +37 -9
  16. package/src/core/findings-state.ts +32 -71
  17. package/src/core/jev/jev-candidates.ts +49 -35
  18. package/src/core/orchestrator.ts +175 -128
  19. package/src/core/paths.ts +23 -17
  20. package/src/core/pr-context.ts +19 -5
  21. package/src/core/project-config.ts +4 -5
  22. package/src/core/renderers/manifest-writer.ts +29 -18
  23. package/src/core/run-layout.ts +4 -104
  24. package/src/core/run-status.ts +28 -53
  25. package/src/core/services/git-repository.ts +16 -0
  26. package/src/core/services/project-files.ts +13 -1
  27. package/src/core/storage/factory.ts +24 -0
  28. package/src/core/storage/file/file-storage.ts +269 -0
  29. package/src/core/storage/paths.ts +64 -0
  30. package/src/core/storage/repo-identity.ts +70 -0
  31. package/src/core/storage/sqlite/mappers.ts +160 -0
  32. package/src/core/storage/sqlite/schema.ts +108 -0
  33. package/src/core/storage/sqlite/sqlite-storage.ts +427 -0
  34. package/src/core/storage/types.ts +143 -0
  35. package/src/core/storage/work-area.ts +191 -0
  36. package/src/index.ts +27 -6
  37. package/src/server/artifacts.ts +8 -61
  38. package/src/server/client/ArtifactsPanel.tsx +137 -23
  39. package/src/server/client/FindingCard.tsx +37 -6
  40. package/src/server/client/MarkdownText.tsx +101 -0
  41. package/src/server/client/SubmitModal.tsx +9 -4
  42. package/src/server/client/code-highlight.ts +422 -0
  43. package/src/server/client/constants.ts +130 -24
  44. package/src/server/client/json-highlight.ts +55 -0
  45. package/src/server/client/markdown.ts +153 -0
  46. package/src/server/client/types.ts +23 -0
  47. package/src/server/index.ts +95 -15
  48. package/src/server/run-repository.ts +144 -136
  49. package/src/skill/OUTPUT-FORMAT.md +6 -4
  50. package/src/skill/README.md +22 -14
  51. package/src/skill/SETUP.md +4 -4
  52. package/src/skill/fix.md +5 -5
  53. package/src/skill/hunt.md +27 -18
  54. package/src/skill/train.md +1 -1
  55. package/src/skill/verify.md +5 -5
  56. package/src/tests/__snapshots__/baseline.test.ts.snap +44 -33
  57. package/src/tests/baseline.test.ts +76 -34
  58. package/src/tests/cli.test.ts +40 -19
  59. package/src/tests/code-highlight.test.ts +123 -0
  60. package/src/tests/domain.test.ts +1 -1
  61. package/src/tests/findings-commands.test.ts +153 -131
  62. package/src/tests/findings-state.test.ts +6 -3
  63. package/src/tests/fixtures/fake-project.ts +0 -1
  64. package/src/tests/infrastructure.test.ts +33 -38
  65. package/src/tests/markdown.test.ts +89 -0
  66. package/src/tests/review-scripts.test.ts +8 -5
  67. package/src/tests/run-status.test.ts +33 -23
  68. package/src/tests/server.test.ts +108 -75
  69. 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
+ });