@payconductor/houndsman 0.3.0 → 0.5.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.
- package/README.md +25 -39
- package/package.json +1 -1
- package/src/cli/commands/context-command.ts +11 -9
- package/src/cli/commands/doctor-command.ts +5 -0
- package/src/cli/commands/findings-command.ts +13 -3
- package/src/cli/commands/review-command.ts +1 -2
- package/src/cli/commands/status-command.ts +46 -0
- package/src/cli/help.ts +8 -3
- package/src/cli/index.ts +5 -2
- package/src/core/commands/context.ts +29 -13
- package/src/core/commands/findings.ts +216 -125
- package/src/core/commands/review.ts +7 -8
- package/src/core/commands/scaffold.ts +15 -16
- package/src/core/commands/status.ts +63 -0
- package/src/core/config.ts +16 -7
- package/src/core/engine/houndsman-engine.ts +15 -1
- package/src/core/engine/review-scope.ts +26 -8
- package/src/core/engine/review-session.ts +37 -9
- package/src/core/findings-state.ts +53 -75
- package/src/core/github-review.ts +620 -57
- package/src/core/jev/jev-candidates.ts +49 -35
- package/src/core/orchestrator.ts +271 -134
- package/src/core/paths.ts +26 -17
- package/src/core/pr-context.ts +19 -5
- package/src/core/project-config.ts +4 -5
- package/src/core/renderers/manifest-writer.ts +29 -18
- package/src/core/renderers/resume-renderer.ts +151 -0
- package/src/core/report-builder.ts +7 -4
- package/src/core/run-layout.ts +4 -104
- package/src/core/run-status.ts +59 -50
- package/src/core/services/git-repository.ts +16 -0
- package/src/core/services/project-files.ts +13 -1
- package/src/core/storage/factory.ts +24 -0
- package/src/core/storage/file/file-storage.ts +271 -0
- package/src/core/storage/paths.ts +64 -0
- package/src/core/storage/repo-identity.ts +70 -0
- package/src/core/storage/sqlite/mappers.ts +162 -0
- package/src/core/storage/sqlite/schema.ts +116 -0
- package/src/core/storage/sqlite/sqlite-storage.ts +431 -0
- package/src/core/storage/types.ts +152 -0
- package/src/core/storage/work-area.ts +191 -0
- package/src/index.ts +27 -6
- package/src/server/artifacts.ts +8 -61
- package/src/server/client/App.tsx +26 -9
- package/src/server/client/ArtifactsPanel.tsx +144 -23
- package/src/server/client/AutoRefresh.tsx +1 -1
- package/src/server/client/Filters.tsx +7 -2
- package/src/server/client/FindingCard.tsx +50 -15
- package/src/server/client/FindingsList.tsx +2 -2
- package/src/server/client/MarkdownText.tsx +181 -0
- package/src/server/client/ResumePanel.tsx +368 -0
- package/src/server/client/RunList.tsx +3 -3
- package/src/server/client/SubmitModal.tsx +58 -10
- package/src/server/client/SummaryBar.tsx +1 -1
- package/src/server/client/icons.tsx +17 -0
- package/src/server/client/utils/code-highlight.ts +422 -0
- package/src/server/client/utils/constants.ts +226 -0
- package/src/server/client/utils/json-highlight.ts +55 -0
- package/src/server/client/utils/markdown.ts +319 -0
- package/src/server/client/{types.ts → utils/types.ts} +81 -0
- package/src/server/index.ts +154 -16
- package/src/server/run-repository.ts +153 -136
- package/src/skill/OUTPUT-FORMAT.md +6 -4
- package/src/skill/README.md +22 -14
- package/src/skill/SETUP.md +4 -4
- package/src/skill/example-review.md +22 -14
- package/src/skill/fix.md +5 -5
- package/src/skill/hunt.md +55 -32
- package/src/skill/train.md +1 -1
- package/src/skill/verify.md +7 -6
- package/src/tests/__snapshots__/baseline.test.ts.snap +74 -61
- package/src/tests/baseline.test.ts +79 -34
- package/src/tests/cli.test.ts +40 -19
- package/src/tests/code-highlight.test.ts +123 -0
- package/src/tests/domain.test.ts +1 -1
- package/src/tests/findings-commands.test.ts +153 -131
- package/src/tests/findings-state.test.ts +56 -5
- package/src/tests/fixtures/fake-project.ts +0 -1
- package/src/tests/github-review.test.ts +409 -60
- package/src/tests/infrastructure.test.ts +33 -38
- package/src/tests/markdown.test.ts +149 -0
- package/src/tests/review-resume.test.ts +101 -0
- package/src/tests/review-scope.test.ts +302 -0
- package/src/tests/review-scripts.test.ts +81 -5
- package/src/tests/run-status-view.test.ts +94 -0
- package/src/tests/run-status.test.ts +86 -23
- package/src/tests/server.test.ts +184 -96
- package/src/tests/storage.test.ts +449 -0
- package/src/server/client/constants.ts +0 -91
- /package/src/server/client/{api.ts → utils/api.ts} +0 -0
- /package/src/server/client/{format.ts → utils/format.ts} +0 -0
|
@@ -0,0 +1,449 @@
|
|
|
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
|
+
});
|
|
387
|
+
|
|
388
|
+
describe("migração do schema SQLite", () => {
|
|
389
|
+
test("banco v1 ganha a coluna heartbeat_at sem perder reviews", () => {
|
|
390
|
+
const dir = mkdtempSync(join(tmpdir(), "houndsman-migrate-"));
|
|
391
|
+
const path = join(dir, "houndsman.db");
|
|
392
|
+
const legacy = new Database(path);
|
|
393
|
+
try {
|
|
394
|
+
legacy.exec(`
|
|
395
|
+
CREATE TABLE reviews (
|
|
396
|
+
id TEXT PRIMARY KEY,
|
|
397
|
+
repo_key TEXT NOT NULL,
|
|
398
|
+
repo_root TEXT NOT NULL,
|
|
399
|
+
repo_remote TEXT,
|
|
400
|
+
branch TEXT NOT NULL,
|
|
401
|
+
slug TEXT NOT NULL,
|
|
402
|
+
base TEXT,
|
|
403
|
+
head TEXT,
|
|
404
|
+
head_sha TEXT,
|
|
405
|
+
reviewed_sha TEXT,
|
|
406
|
+
reviewed_at TEXT,
|
|
407
|
+
status TEXT,
|
|
408
|
+
started_at TEXT,
|
|
409
|
+
created_at TEXT NOT NULL,
|
|
410
|
+
updated_at TEXT NOT NULL,
|
|
411
|
+
UNIQUE(repo_key, branch)
|
|
412
|
+
);
|
|
413
|
+
PRAGMA user_version = 1;
|
|
414
|
+
`);
|
|
415
|
+
legacy.run(
|
|
416
|
+
`INSERT INTO reviews
|
|
417
|
+
(id, repo_key, repo_root, branch, slug, status, created_at, updated_at)
|
|
418
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
419
|
+
[
|
|
420
|
+
"11111111-1111-4111-8111-111111111111",
|
|
421
|
+
"path:/repo",
|
|
422
|
+
dir,
|
|
423
|
+
"feature/x",
|
|
424
|
+
"feature-x",
|
|
425
|
+
"reviewing",
|
|
426
|
+
"2026-01-01T00:00:00.000Z",
|
|
427
|
+
"2026-01-01T00:00:00.000Z",
|
|
428
|
+
],
|
|
429
|
+
);
|
|
430
|
+
} finally {
|
|
431
|
+
legacy.close();
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const storage = new SQLiteStorageProvider(path);
|
|
435
|
+
try {
|
|
436
|
+
const review = storage.listReviews()[0];
|
|
437
|
+
expect(review?.heartbeatAt).toBeUndefined();
|
|
438
|
+
storage.updateReview(review.id, {
|
|
439
|
+
heartbeatAt: "2026-01-01T00:05:00.000Z",
|
|
440
|
+
});
|
|
441
|
+
expect(storage.getReview(review.id)?.heartbeatAt).toBe(
|
|
442
|
+
"2026-01-01T00:05:00.000Z",
|
|
443
|
+
);
|
|
444
|
+
} finally {
|
|
445
|
+
storage.close();
|
|
446
|
+
rmSync(dir, { recursive: true, force: true });
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
});
|
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Constantes de apresentação do cliente (labels, ordem, emojis).
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import type { Decision, ExecState, RunStatus, RunSummary, Severity } from "./types";
|
|
6
|
-
|
|
7
|
-
/** Rótulo de exibição de cada severidade (o CSS dá a cor via `sev-<nivel>`). */
|
|
8
|
-
export const SEVERITY_LABELS: Record<Severity, string> = {
|
|
9
|
-
blocking: "Blocking",
|
|
10
|
-
attention: "Attention",
|
|
11
|
-
suggestion: "Suggestion",
|
|
12
|
-
nit: "Nit",
|
|
13
|
-
};
|
|
14
|
-
|
|
15
|
-
export const SEVERITY_ORDER: Severity[] = ["blocking", "attention", "suggestion", "nit"];
|
|
16
|
-
|
|
17
|
-
export const DECISIONS: Decision[] = ["pending", "execute", "ignore"];
|
|
18
|
-
|
|
19
|
-
export const DECISION_LABELS: Record<Decision, string> = {
|
|
20
|
-
pending: "Pendente",
|
|
21
|
-
execute: "Executar",
|
|
22
|
-
ignore: "Ignorar",
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
export const EXEC_STATES: ExecState[] = ["pending", "in_progress", "done", "stale"];
|
|
26
|
-
|
|
27
|
-
export const RUN_STATUS_LABELS: Record<RunStatus, string> = {
|
|
28
|
-
reviewing: "Revisando",
|
|
29
|
-
review_done: "Revisão pronta",
|
|
30
|
-
fixing: "Corrigindo",
|
|
31
|
-
fix_done: "Correção pronta",
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
/** Limiar de inatividade para exibir timeout (display-only; não é status). */
|
|
35
|
-
export const STATUS_TIMEOUT_MS = 10 * 60 * 1000;
|
|
36
|
-
|
|
37
|
-
/** Intervalo do polling da UI (o badge conta o tempo desde a última sync). */
|
|
38
|
-
export const AUTO_REFRESH_MS = 10_000;
|
|
39
|
-
|
|
40
|
-
export type RunStatusView = { kind: string; label: string; timeout: boolean };
|
|
41
|
-
|
|
42
|
-
/** View do status de uma run — label; vira timeout quando ativo e sem ping recente. */
|
|
43
|
-
export function describeRunStatus(
|
|
44
|
-
run: RunSummary,
|
|
45
|
-
now: number = Date.now(),
|
|
46
|
-
): RunStatusView | null {
|
|
47
|
-
const status = run.status;
|
|
48
|
-
if (!status) return null;
|
|
49
|
-
const active = status === "reviewing" || status === "fixing";
|
|
50
|
-
const updated = run.updatedAt ? Date.parse(run.updatedAt) : Number.NaN;
|
|
51
|
-
if (active && Number.isFinite(updated) && now - updated > STATUS_TIMEOUT_MS) {
|
|
52
|
-
return {
|
|
53
|
-
kind: "timeout",
|
|
54
|
-
label: status === "reviewing" ? "Timeout na revisão" : "Timeout na correção",
|
|
55
|
-
timeout: true,
|
|
56
|
-
};
|
|
57
|
-
}
|
|
58
|
-
return { kind: status, label: RUN_STATUS_LABELS[status], timeout: false };
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/** Artefatos que podem ser lidos na aba "Artefatos". */
|
|
62
|
-
export const ARTIFACTS = [
|
|
63
|
-
"manifest.json",
|
|
64
|
-
"classification.json",
|
|
65
|
-
"candidates.json",
|
|
66
|
-
"context-summary.json",
|
|
67
|
-
"review.md",
|
|
68
|
-
] as const;
|
|
69
|
-
|
|
70
|
-
export type ArtifactName = (typeof ARTIFACTS)[number];
|
|
71
|
-
|
|
72
|
-
/** Descrição de cada artefato (mostrada ao selecioná-lo na aba). */
|
|
73
|
-
export const ARTIFACT_INFO: Record<ArtifactName, string> = {
|
|
74
|
-
"manifest.json":
|
|
75
|
-
"Resumo da execução: watchdogs ativos, arquivos, orçamento, churn, Jev e contexto da PR.",
|
|
76
|
-
"classification.json":
|
|
77
|
-
"Classificação do diff: watchdogs ativados por arquivo, status (A/M/D/R) e churn por arquivo.",
|
|
78
|
-
"candidates.json":
|
|
79
|
-
"Indícios da pré-análise Jev (regra + arquivo + confiança) — não são achados.",
|
|
80
|
-
"context-summary.json":
|
|
81
|
-
"Resumo da PR (objetivo, motivação e alterações) gerado no Passo 0.",
|
|
82
|
-
"review.md": "Relatório consolidado da revisão — o entregável.",
|
|
83
|
-
};
|
|
84
|
-
|
|
85
|
-
/** Legenda única de cada aba (explica o que a aba é; mostrada quando selecionada). */
|
|
86
|
-
export const TAB_CAPTIONS = {
|
|
87
|
-
findings:
|
|
88
|
-
"Triagem dos achados da revisão: decida Executar, Ignorar ou deixe Pendente — o comentário orienta o auto-fix. Agente: houndsman findings list --decision pending --json · findings done <id>.",
|
|
89
|
-
artifacts:
|
|
90
|
-
"Artefatos brutos da run: manifesto, classificação, candidatos Jev, contexto da PR e o relatório consolidado (review.md).",
|
|
91
|
-
} as const;
|
|
File without changes
|
|
File without changes
|