@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.
- package/README.md +18 -6
- 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/help.ts +7 -3
- package/src/cli/index.ts +2 -2
- package/src/core/commands/context.ts +29 -13
- package/src/core/commands/findings.ts +155 -120
- package/src/core/commands/review.ts +7 -8
- package/src/core/commands/scaffold.ts +15 -16
- package/src/core/config.ts +16 -7
- package/src/core/engine/houndsman-engine.ts +15 -1
- package/src/core/engine/review-session.ts +37 -9
- package/src/core/findings-state.ts +32 -71
- package/src/core/jev/jev-candidates.ts +49 -35
- package/src/core/orchestrator.ts +175 -128
- package/src/core/paths.ts +23 -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/run-layout.ts +4 -104
- package/src/core/run-status.ts +28 -53
- 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 +269 -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 +160 -0
- package/src/core/storage/sqlite/schema.ts +108 -0
- package/src/core/storage/sqlite/sqlite-storage.ts +427 -0
- package/src/core/storage/types.ts +143 -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/ArtifactsPanel.tsx +137 -23
- package/src/server/client/FindingCard.tsx +37 -6
- package/src/server/client/MarkdownText.tsx +101 -0
- package/src/server/client/SubmitModal.tsx +9 -4
- package/src/server/client/code-highlight.ts +422 -0
- package/src/server/client/constants.ts +130 -24
- package/src/server/client/json-highlight.ts +55 -0
- package/src/server/client/markdown.ts +153 -0
- package/src/server/client/types.ts +23 -0
- package/src/server/index.ts +95 -15
- package/src/server/run-repository.ts +144 -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/fix.md +5 -5
- package/src/skill/hunt.md +27 -18
- package/src/skill/train.md +1 -1
- package/src/skill/verify.md +5 -5
- package/src/tests/__snapshots__/baseline.test.ts.snap +44 -33
- package/src/tests/baseline.test.ts +76 -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 +6 -3
- package/src/tests/fixtures/fake-project.ts +0 -1
- package/src/tests/infrastructure.test.ts +33 -38
- package/src/tests/markdown.test.ts +89 -0
- package/src/tests/review-scripts.test.ts +8 -5
- package/src/tests/run-status.test.ts +33 -23
- package/src/tests/server.test.ts +108 -75
- package/src/tests/storage.test.ts +386 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider de arquivos do houndsman (biblioteca) — fallback do SQLite.
|
|
3
|
+
*
|
|
4
|
+
* Mantém o formato antigo (JSON por review), mas sob o diretório global do
|
|
5
|
+
* houndsman. Serve para debug/rollback: trocar `storage.provider` para `"file"`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
9
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { FINDINGS_STATE_VERSION, type FindingsStateFile } from "../../findings-state";
|
|
12
|
+
import { systemClock } from "../../services/clock";
|
|
13
|
+
import { ProjectFiles } from "../../services/project-files";
|
|
14
|
+
import type {
|
|
15
|
+
ArtifactRecord,
|
|
16
|
+
CreateReviewInput,
|
|
17
|
+
ReviewPatch,
|
|
18
|
+
ReviewRecord,
|
|
19
|
+
StorageProvider,
|
|
20
|
+
WriteArtifactInput,
|
|
21
|
+
} from "../types";
|
|
22
|
+
|
|
23
|
+
const REVIEW_FILE = "review.json";
|
|
24
|
+
const FINDINGS_FILE = "findings.json";
|
|
25
|
+
const ARTIFACTS_FILE = "artifacts.json";
|
|
26
|
+
|
|
27
|
+
/** Provider em arquivos sob uma raiz global. */
|
|
28
|
+
export class FileStorageProvider implements StorageProvider {
|
|
29
|
+
readonly kind = "file" as const;
|
|
30
|
+
private readonly root: string;
|
|
31
|
+
private readonly files = new ProjectFiles();
|
|
32
|
+
|
|
33
|
+
constructor(root: string) {
|
|
34
|
+
this.root = root;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// -----------------------------------------------------------------------
|
|
38
|
+
// Reviews
|
|
39
|
+
// -----------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
getOrCreateReview(input: CreateReviewInput): ReviewRecord {
|
|
42
|
+
const existing = this.findReview(input.repo.key, input.branch);
|
|
43
|
+
if (existing) {
|
|
44
|
+
if (
|
|
45
|
+
existing.repoRoot !== input.repo.root ||
|
|
46
|
+
existing.repoRemote !== input.repo.remote
|
|
47
|
+
) {
|
|
48
|
+
return this.writeReview({
|
|
49
|
+
...existing,
|
|
50
|
+
repoRoot: input.repo.root,
|
|
51
|
+
repoRemote: input.repo.remote,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return existing;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const now = input.now ?? systemClock();
|
|
58
|
+
const record: ReviewRecord = {
|
|
59
|
+
id: randomUUID(),
|
|
60
|
+
repoKey: input.repo.key,
|
|
61
|
+
repoRoot: input.repo.root,
|
|
62
|
+
repoRemote: input.repo.remote,
|
|
63
|
+
branch: input.branch,
|
|
64
|
+
slug: input.slug,
|
|
65
|
+
base: input.base,
|
|
66
|
+
head: input.head,
|
|
67
|
+
status: null,
|
|
68
|
+
createdAt: now,
|
|
69
|
+
updatedAt: now,
|
|
70
|
+
};
|
|
71
|
+
return this.writeReview(record);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
findReview(repoKey: string, branch: string): ReviewRecord | null {
|
|
75
|
+
return (
|
|
76
|
+
this.readReviews(repoKey).find((review) => review.branch === branch) ?? null
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
getReview(id: string): ReviewRecord | null {
|
|
81
|
+
return this.readReviews().find((review) => review.id === id) ?? null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
listReviews(repoKey?: string): ReviewRecord[] {
|
|
85
|
+
return this.readReviews(repoKey).sort((a, b) =>
|
|
86
|
+
b.updatedAt.localeCompare(a.updatedAt),
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
updateReview(id: string, patch: ReviewPatch): ReviewRecord | null {
|
|
91
|
+
const review = this.getReview(id);
|
|
92
|
+
if (!review) return null;
|
|
93
|
+
const now = patch.updatedAt ?? systemClock();
|
|
94
|
+
const updated: ReviewRecord = { ...review };
|
|
95
|
+
if (patch.branch !== undefined) updated.branch = patch.branch;
|
|
96
|
+
if (patch.slug !== undefined) updated.slug = patch.slug;
|
|
97
|
+
if (patch.base !== undefined) updated.base = patch.base;
|
|
98
|
+
if (patch.head !== undefined) updated.head = patch.head;
|
|
99
|
+
if (patch.headSha !== undefined) updated.headSha = patch.headSha;
|
|
100
|
+
if (patch.reviewedSha !== undefined) updated.reviewedSha = patch.reviewedSha;
|
|
101
|
+
if (patch.reviewedAt !== undefined) updated.reviewedAt = patch.reviewedAt;
|
|
102
|
+
if (patch.status !== undefined) updated.status = patch.status;
|
|
103
|
+
if (patch.resetStartedAt) updated.startedAt = now;
|
|
104
|
+
else if (patch.startedAt !== undefined) updated.startedAt = patch.startedAt;
|
|
105
|
+
updated.updatedAt = patch.updatedAt ?? now;
|
|
106
|
+
return this.writeReview(updated);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
deleteReview(id: string): void {
|
|
110
|
+
const review = this.getReview(id);
|
|
111
|
+
if (!review) return;
|
|
112
|
+
this.files.removeDir(this.reviewDir(review));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// -----------------------------------------------------------------------
|
|
116
|
+
// Findings
|
|
117
|
+
// -----------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
readFindings(reviewId: string): FindingsStateFile | null {
|
|
120
|
+
const review = this.getReview(reviewId);
|
|
121
|
+
if (!review) return null;
|
|
122
|
+
const raw = this.files.readJson<FindingsStateFile>(
|
|
123
|
+
join(this.reviewDir(review), FINDINGS_FILE),
|
|
124
|
+
);
|
|
125
|
+
if (!raw || raw.version !== FINDINGS_STATE_VERSION || !raw.findings) return null;
|
|
126
|
+
return raw;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
writeFindings(reviewId: string, state: FindingsStateFile): void {
|
|
130
|
+
const review = this.getReview(reviewId);
|
|
131
|
+
if (!review) throw new Error(`Review '${reviewId}' não encontrada.`);
|
|
132
|
+
this.writeJson(join(this.reviewDir(review), FINDINGS_FILE), state);
|
|
133
|
+
this.writeReview({
|
|
134
|
+
...review,
|
|
135
|
+
reviewedSha: state.reviewed?.headSha,
|
|
136
|
+
reviewedAt: state.reviewed?.at,
|
|
137
|
+
updatedAt: state.updatedAt || systemClock(),
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// -----------------------------------------------------------------------
|
|
142
|
+
// Artefatos
|
|
143
|
+
// -----------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
readArtifact(reviewId: string, name: string): ArtifactRecord | null {
|
|
146
|
+
const review = this.getReview(reviewId);
|
|
147
|
+
if (!review) return null;
|
|
148
|
+
return this.readArtifacts(review)[name] ?? null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
writeArtifact(reviewId: string, input: WriteArtifactInput): ArtifactRecord {
|
|
152
|
+
const review = this.getReview(reviewId);
|
|
153
|
+
if (!review) throw new Error(`Review '${reviewId}' não encontrada.`);
|
|
154
|
+
const index = this.readArtifacts(review);
|
|
155
|
+
const now = systemClock();
|
|
156
|
+
const existing = index[input.name];
|
|
157
|
+
const contentType =
|
|
158
|
+
input.contentType ??
|
|
159
|
+
(input.name.toLowerCase().endsWith(".json")
|
|
160
|
+
? "application/json; charset=utf-8"
|
|
161
|
+
: "text/markdown; charset=utf-8");
|
|
162
|
+
const record: ArtifactRecord = {
|
|
163
|
+
id: existing?.id ?? randomUUID(),
|
|
164
|
+
reviewId,
|
|
165
|
+
name: input.name,
|
|
166
|
+
kind: input.kind,
|
|
167
|
+
content: input.content,
|
|
168
|
+
contentType,
|
|
169
|
+
createdAt: existing?.createdAt ?? now,
|
|
170
|
+
updatedAt: now,
|
|
171
|
+
};
|
|
172
|
+
index[input.name] = record;
|
|
173
|
+
this.writeJson(join(this.reviewDir(review), ARTIFACTS_FILE), index);
|
|
174
|
+
return record;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
listArtifacts(reviewId: string): ArtifactRecord[] {
|
|
178
|
+
const review = this.getReview(reviewId);
|
|
179
|
+
if (!review) return [];
|
|
180
|
+
return Object.values(this.readArtifacts(review)).sort((a, b) =>
|
|
181
|
+
a.name.localeCompare(b.name),
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
deleteArtifact(reviewId: string, name: string): void {
|
|
186
|
+
const review = this.getReview(reviewId);
|
|
187
|
+
if (!review) return;
|
|
188
|
+
const index = this.readArtifacts(review);
|
|
189
|
+
if (!index[name]) return;
|
|
190
|
+
delete index[name];
|
|
191
|
+
this.writeJson(join(this.reviewDir(review), ARTIFACTS_FILE), index);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// -----------------------------------------------------------------------
|
|
195
|
+
// Manutenção / ciclo de vida
|
|
196
|
+
// -----------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
maintain(): void {
|
|
199
|
+
// Nada a compactar: arquivos JSON são reescritos por inteiro.
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
location(reviewId: string): string {
|
|
203
|
+
const review = this.getReview(reviewId);
|
|
204
|
+
return review
|
|
205
|
+
? `file:${join(this.reviewDir(review), FINDINGS_FILE)}#${reviewId}`
|
|
206
|
+
: `file:${this.root}#${reviewId}`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
close(): void {
|
|
210
|
+
// Sem recurso aberto.
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// -----------------------------------------------------------------------
|
|
214
|
+
// Internos
|
|
215
|
+
// -----------------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
private repoDir(repoKey: string): string {
|
|
218
|
+
const hash = createHash("sha256").update(repoKey).digest("hex").slice(0, 16);
|
|
219
|
+
return join(this.root, hash);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private reviewDir(review: ReviewRecord): string {
|
|
223
|
+
return join(this.repoDir(review.repoKey), review.slug);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private writeReview(record: ReviewRecord): ReviewRecord {
|
|
227
|
+
this.writeJson(join(this.reviewDir(record), REVIEW_FILE), record);
|
|
228
|
+
return record;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
private readReviews(repoKey?: string): ReviewRecord[] {
|
|
232
|
+
const roots = repoKey ? [this.repoDir(repoKey)] : this.subdirectories(this.root);
|
|
233
|
+
const reviews: ReviewRecord[] = [];
|
|
234
|
+
for (const repoDir of roots) {
|
|
235
|
+
for (const slugDir of this.subdirectories(repoDir)) {
|
|
236
|
+
const review = this.files.readJson<ReviewRecord>(
|
|
237
|
+
join(slugDir, REVIEW_FILE),
|
|
238
|
+
);
|
|
239
|
+
if (review && (!repoKey || review.repoKey === repoKey)) {
|
|
240
|
+
reviews.push(review);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return reviews;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
private readArtifacts(review: ReviewRecord): Record<string, ArtifactRecord> {
|
|
248
|
+
return (
|
|
249
|
+
this.files.readJson<Record<string, ArtifactRecord>>(
|
|
250
|
+
join(this.reviewDir(review), ARTIFACTS_FILE),
|
|
251
|
+
) ?? {}
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
private writeJson(path: string, value: unknown): void {
|
|
256
|
+
this.files.writeAtomic(path, `${JSON.stringify(value, null, 2)}\n`);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
private subdirectories(dir: string): string[] {
|
|
260
|
+
if (!existsSync(dir)) return [];
|
|
261
|
+
try {
|
|
262
|
+
return readdirSync(dir)
|
|
263
|
+
.map((name) => join(dir, name))
|
|
264
|
+
.filter((path) => statSync(path).isDirectory());
|
|
265
|
+
} catch {
|
|
266
|
+
return [];
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Localização global do houndsman (biblioteca).
|
|
3
|
+
*
|
|
4
|
+
* O banco e o staging vivem no diretório de dados do usuário — nunca dentro do
|
|
5
|
+
* projeto nem da instalação npm. `HOUNDSMAN_HOME` sobrescreve tudo (útil em
|
|
6
|
+
* testes e para manter múltiplos ambientes).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { join, resolve } from "node:path";
|
|
11
|
+
import type { HoundsmanConfig } from "../config";
|
|
12
|
+
|
|
13
|
+
export const HOUNDSMAN_HOME_ENV = "HOUNDSMAN_HOME";
|
|
14
|
+
|
|
15
|
+
/** Diretório-base global do houndsman. */
|
|
16
|
+
export function houndsmanHome(): string {
|
|
17
|
+
const override = process.env[HOUNDSMAN_HOME_ENV];
|
|
18
|
+
if (override && override.trim().length > 0) return resolve(override);
|
|
19
|
+
|
|
20
|
+
const home = homedir();
|
|
21
|
+
if (process.platform === "win32") {
|
|
22
|
+
const local = process.env.LOCALAPPDATA;
|
|
23
|
+
return join(
|
|
24
|
+
local && local.length > 0 ? local : join(home, "AppData", "Local"),
|
|
25
|
+
"houndsman",
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
if (process.platform === "darwin") {
|
|
29
|
+
return join(home, "Library", "Application Support", "houndsman");
|
|
30
|
+
}
|
|
31
|
+
const xdg = process.env.XDG_DATA_HOME;
|
|
32
|
+
return join(xdg && xdg.length > 0 ? xdg : join(home, ".local", "share"), "houndsman");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Caminho do banco SQLite (config `storage.path` ou `<home>/houndsman.db`). */
|
|
36
|
+
export function databasePath(config: HoundsmanConfig): string {
|
|
37
|
+
const configured = config.storage?.path;
|
|
38
|
+
if (configured && configured.trim().length > 0) return resolve(configured);
|
|
39
|
+
return join(houndsmanHome(), "houndsman.db");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Raiz das runs do provider de arquivos (config `storage.path` ou `<home>/runs`). */
|
|
43
|
+
export function fileRoot(config: HoundsmanConfig): string {
|
|
44
|
+
const configured = config.storage?.path;
|
|
45
|
+
if (configured && configured.trim().length > 0) return resolve(configured);
|
|
46
|
+
return join(houndsmanHome(), "runs");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Raiz do staging por review (briefs/findings/contexto dos subagentes). */
|
|
50
|
+
export function workRoot(): string {
|
|
51
|
+
return join(houndsmanHome(), "work");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Referência legível do storage configurado (diagnóstico). */
|
|
55
|
+
export function storageLocation(config: HoundsmanConfig): string {
|
|
56
|
+
return config.storage?.provider === "file"
|
|
57
|
+
? `file:${fileRoot(config)}`
|
|
58
|
+
: `sqlite:${databasePath(config)}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Diretório de staging de uma review. */
|
|
62
|
+
export function workDir(reviewId: string): string {
|
|
63
|
+
return join(workRoot(), reviewId);
|
|
64
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Identidade do repositório (biblioteca).
|
|
3
|
+
*
|
|
4
|
+
* A UI e o CLI escopam runs por repositório. Preferimos o remote `origin`
|
|
5
|
+
* normalizado (`github.com/org/repo`) porque a pasta local pode ser movida;
|
|
6
|
+
* sem remote, caímos no caminho absoluto da raiz git.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { resolve } from "node:path";
|
|
10
|
+
import { GitRepository } from "../services/git-repository";
|
|
11
|
+
import type { RepoIdentity } from "./types";
|
|
12
|
+
|
|
13
|
+
/** Normaliza um remote para `host/owner/repo` (sem protocolo, credencial ou `.git`). */
|
|
14
|
+
export function normalizeRemote(raw: string): string | null {
|
|
15
|
+
const value = raw.trim().replace(/\/+$/, "");
|
|
16
|
+
if (value.length === 0) return null;
|
|
17
|
+
|
|
18
|
+
// SCP-like: `git@github.com:owner/repo.git`.
|
|
19
|
+
if (!value.includes("://")) {
|
|
20
|
+
const scp = value.match(/^(?:[^@/]+@)?([^:/]+):(.+)$/);
|
|
21
|
+
if (scp) return `${scp[1].toLowerCase()}/${stripGitSuffix(scp[2])}`;
|
|
22
|
+
// Caminho local — não é um remote utilizável para identidade.
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const url = new URL(value);
|
|
28
|
+
const host = url.hostname.toLowerCase();
|
|
29
|
+
const path = stripGitSuffix(url.pathname.replace(/^\/+/, "").replace(/\/+$/, ""));
|
|
30
|
+
return path.length > 0 ? `${host}/${path}` : host;
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function stripGitSuffix(value: string): string {
|
|
37
|
+
return value.replace(/\.git$/i, "").replace(/^\/+/, "");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Normaliza a raiz local para comparação (Windows é case-insensitive). */
|
|
41
|
+
function normalizeRoot(root: string): string {
|
|
42
|
+
const normalized = resolve(root).replace(/\\/g, "/").replace(/\/+$/, "");
|
|
43
|
+
return process.platform === "win32" ? normalized.toLowerCase() : normalized;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Monta a identidade a partir do remote cru e da raiz do repositório. */
|
|
47
|
+
export function buildRepoIdentity(
|
|
48
|
+
remote: string | null | undefined,
|
|
49
|
+
root: string,
|
|
50
|
+
): RepoIdentity {
|
|
51
|
+
const normalized = remote ? normalizeRemote(remote) : null;
|
|
52
|
+
const absoluteRoot = resolve(root);
|
|
53
|
+
if (normalized) {
|
|
54
|
+
return {
|
|
55
|
+
key: `remote:${normalized}`,
|
|
56
|
+
root: absoluteRoot,
|
|
57
|
+
remote: remote ?? undefined,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
return { key: `path:${normalizeRoot(absoluteRoot)}`, root: absoluteRoot };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Resolve a identidade do repositório a partir do cwd (nunca lança). */
|
|
64
|
+
export function resolveRepoIdentity(
|
|
65
|
+
git: GitRepository = new GitRepository(),
|
|
66
|
+
cwd: string = process.cwd(),
|
|
67
|
+
): RepoIdentity {
|
|
68
|
+
const root = git.rootPath() ?? resolve(cwd);
|
|
69
|
+
return buildRepoIdentity(git.remoteUrl(), root);
|
|
70
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mapeamento row ↔ domínio do storage SQLite.
|
|
3
|
+
*
|
|
4
|
+
* O banco guarda `short_id` (id curto exibido no CLI) e um `id` UUID como PK.
|
|
5
|
+
* O domínio continua usando `FindingState.id` = short id.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { FindingAnchor } from "../../finding-anchor";
|
|
9
|
+
import type {
|
|
10
|
+
FindingDecision,
|
|
11
|
+
FindingExecState,
|
|
12
|
+
FindingHistoryEntry,
|
|
13
|
+
FindingState,
|
|
14
|
+
GitHubLink,
|
|
15
|
+
} from "../../findings-state";
|
|
16
|
+
import type { Severity } from "../../types";
|
|
17
|
+
import type { ArtifactKind, ArtifactRecord, ReviewRecord } from "../types";
|
|
18
|
+
|
|
19
|
+
export type ReviewRow = {
|
|
20
|
+
id: string;
|
|
21
|
+
repo_key: string;
|
|
22
|
+
repo_root: string;
|
|
23
|
+
repo_remote: string | null;
|
|
24
|
+
branch: string;
|
|
25
|
+
slug: string;
|
|
26
|
+
base: string | null;
|
|
27
|
+
head: string | null;
|
|
28
|
+
head_sha: string | null;
|
|
29
|
+
reviewed_sha: string | null;
|
|
30
|
+
reviewed_at: string | null;
|
|
31
|
+
status: string | null;
|
|
32
|
+
started_at: string | null;
|
|
33
|
+
created_at: string;
|
|
34
|
+
updated_at: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type FindingRow = {
|
|
38
|
+
id: string;
|
|
39
|
+
review_id: string;
|
|
40
|
+
short_id: string;
|
|
41
|
+
fingerprint: string;
|
|
42
|
+
watchdog_ids: string;
|
|
43
|
+
rule: string;
|
|
44
|
+
severity: string;
|
|
45
|
+
file: string;
|
|
46
|
+
line: number | null;
|
|
47
|
+
message: string;
|
|
48
|
+
action: string;
|
|
49
|
+
evidence: string | null;
|
|
50
|
+
anchor: string | null;
|
|
51
|
+
found_at_sha: string | null;
|
|
52
|
+
count: number;
|
|
53
|
+
present: number;
|
|
54
|
+
decision: string;
|
|
55
|
+
comment: string | null;
|
|
56
|
+
exec_state: string;
|
|
57
|
+
github: string | null;
|
|
58
|
+
created_at: string;
|
|
59
|
+
updated_at: string;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export type HistoryRow = {
|
|
63
|
+
id: string;
|
|
64
|
+
finding_id: string;
|
|
65
|
+
at: string;
|
|
66
|
+
decision: string;
|
|
67
|
+
exec_state: string;
|
|
68
|
+
comment: string | null;
|
|
69
|
+
by: string | null;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export type ArtifactRow = {
|
|
73
|
+
id: string;
|
|
74
|
+
review_id: string;
|
|
75
|
+
name: string;
|
|
76
|
+
kind: string;
|
|
77
|
+
content: string;
|
|
78
|
+
content_type: string;
|
|
79
|
+
created_at: string;
|
|
80
|
+
updated_at: string;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** Converte a row de review no DTO do contrato. */
|
|
84
|
+
export function reviewFromRow(row: ReviewRow): ReviewRecord {
|
|
85
|
+
return {
|
|
86
|
+
id: row.id,
|
|
87
|
+
repoKey: row.repo_key,
|
|
88
|
+
repoRoot: row.repo_root,
|
|
89
|
+
repoRemote: row.repo_remote ?? undefined,
|
|
90
|
+
branch: row.branch,
|
|
91
|
+
slug: row.slug,
|
|
92
|
+
base: row.base ?? undefined,
|
|
93
|
+
head: row.head ?? undefined,
|
|
94
|
+
headSha: row.head_sha ?? undefined,
|
|
95
|
+
reviewedSha: row.reviewed_sha ?? undefined,
|
|
96
|
+
reviewedAt: row.reviewed_at ?? undefined,
|
|
97
|
+
status: (row.status as ReviewRecord["status"]) ?? null,
|
|
98
|
+
startedAt: row.started_at ?? undefined,
|
|
99
|
+
createdAt: row.created_at,
|
|
100
|
+
updatedAt: row.updated_at,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function parseJson<T>(raw: string | null): T | undefined {
|
|
105
|
+
if (!raw) return undefined;
|
|
106
|
+
try {
|
|
107
|
+
return JSON.parse(raw) as T;
|
|
108
|
+
} catch {
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Converte a row de finding (+ histórico) no DTO do domínio. */
|
|
114
|
+
export function findingFromRow(row: FindingRow, history: HistoryRow[]): FindingState {
|
|
115
|
+
return {
|
|
116
|
+
id: row.short_id,
|
|
117
|
+
fingerprint: row.fingerprint,
|
|
118
|
+
watchdogIds: parseJson<string[]>(row.watchdog_ids) ?? [],
|
|
119
|
+
rule: row.rule,
|
|
120
|
+
severity: row.severity as Severity,
|
|
121
|
+
file: row.file,
|
|
122
|
+
line: row.line === null ? null : Number(row.line),
|
|
123
|
+
message: row.message,
|
|
124
|
+
action: row.action,
|
|
125
|
+
evidence: row.evidence ?? undefined,
|
|
126
|
+
anchor: parseJson<FindingAnchor>(row.anchor) ?? null,
|
|
127
|
+
foundAtSha: row.found_at_sha ?? undefined,
|
|
128
|
+
count: Number(row.count),
|
|
129
|
+
present: row.present === 1,
|
|
130
|
+
decision: row.decision as FindingDecision,
|
|
131
|
+
comment: row.comment ?? undefined,
|
|
132
|
+
execState: row.exec_state as FindingExecState,
|
|
133
|
+
createdAt: row.created_at,
|
|
134
|
+
updatedAt: row.updated_at,
|
|
135
|
+
history: history.map((entry) => ({
|
|
136
|
+
at: entry.at,
|
|
137
|
+
decision: entry.decision as FindingDecision,
|
|
138
|
+
execState: entry.exec_state as FindingExecState,
|
|
139
|
+
comment: entry.comment ?? undefined,
|
|
140
|
+
by: entry.by ?? undefined,
|
|
141
|
+
})),
|
|
142
|
+
github: parseJson<GitHubLink>(row.github),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Converte a row de artefato no DTO do contrato. */
|
|
147
|
+
export function artifactFromRow(row: ArtifactRow): ArtifactRecord {
|
|
148
|
+
return {
|
|
149
|
+
id: row.id,
|
|
150
|
+
reviewId: row.review_id,
|
|
151
|
+
name: row.name,
|
|
152
|
+
kind: row.kind as ArtifactKind,
|
|
153
|
+
content: row.content,
|
|
154
|
+
contentType: row.content_type,
|
|
155
|
+
createdAt: row.created_at,
|
|
156
|
+
updatedAt: row.updated_at,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export type { FindingHistoryEntry };
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema do banco do houndsman (SQLite).
|
|
3
|
+
*
|
|
4
|
+
* `PRAGMA user_version` versiona o schema; não há migração dos `.runs` antigos
|
|
5
|
+
* (o consumidor é único). Cada tabela tem PK UUID e FKs com cascade.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Database } from "bun:sqlite";
|
|
9
|
+
|
|
10
|
+
export const SCHEMA_VERSION = 1;
|
|
11
|
+
|
|
12
|
+
const DDL = `
|
|
13
|
+
CREATE TABLE IF NOT EXISTS reviews (
|
|
14
|
+
id TEXT PRIMARY KEY,
|
|
15
|
+
repo_key TEXT NOT NULL,
|
|
16
|
+
repo_root TEXT NOT NULL,
|
|
17
|
+
repo_remote TEXT,
|
|
18
|
+
branch TEXT NOT NULL,
|
|
19
|
+
slug TEXT NOT NULL,
|
|
20
|
+
base TEXT,
|
|
21
|
+
head TEXT,
|
|
22
|
+
head_sha TEXT,
|
|
23
|
+
reviewed_sha TEXT,
|
|
24
|
+
reviewed_at TEXT,
|
|
25
|
+
status TEXT,
|
|
26
|
+
started_at TEXT,
|
|
27
|
+
created_at TEXT NOT NULL,
|
|
28
|
+
updated_at TEXT NOT NULL,
|
|
29
|
+
UNIQUE(repo_key, branch)
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
CREATE INDEX IF NOT EXISTS idx_reviews_repo_updated
|
|
33
|
+
ON reviews(repo_key, updated_at DESC);
|
|
34
|
+
|
|
35
|
+
CREATE TABLE IF NOT EXISTS findings (
|
|
36
|
+
id TEXT PRIMARY KEY,
|
|
37
|
+
review_id TEXT NOT NULL REFERENCES reviews(id) ON DELETE CASCADE,
|
|
38
|
+
short_id TEXT NOT NULL,
|
|
39
|
+
fingerprint TEXT NOT NULL,
|
|
40
|
+
watchdog_ids TEXT NOT NULL,
|
|
41
|
+
rule TEXT NOT NULL,
|
|
42
|
+
severity TEXT NOT NULL,
|
|
43
|
+
file TEXT NOT NULL,
|
|
44
|
+
line INTEGER,
|
|
45
|
+
message TEXT NOT NULL,
|
|
46
|
+
action TEXT NOT NULL,
|
|
47
|
+
evidence TEXT,
|
|
48
|
+
anchor TEXT,
|
|
49
|
+
found_at_sha TEXT,
|
|
50
|
+
count INTEGER NOT NULL DEFAULT 1,
|
|
51
|
+
present INTEGER NOT NULL DEFAULT 1,
|
|
52
|
+
decision TEXT NOT NULL,
|
|
53
|
+
comment TEXT,
|
|
54
|
+
exec_state TEXT NOT NULL,
|
|
55
|
+
github TEXT,
|
|
56
|
+
created_at TEXT NOT NULL,
|
|
57
|
+
updated_at TEXT NOT NULL,
|
|
58
|
+
UNIQUE(review_id, short_id),
|
|
59
|
+
UNIQUE(review_id, fingerprint)
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
CREATE INDEX IF NOT EXISTS idx_findings_review ON findings(review_id);
|
|
63
|
+
|
|
64
|
+
CREATE TABLE IF NOT EXISTS finding_history (
|
|
65
|
+
id TEXT PRIMARY KEY,
|
|
66
|
+
finding_id TEXT NOT NULL REFERENCES findings(id) ON DELETE CASCADE,
|
|
67
|
+
at TEXT NOT NULL,
|
|
68
|
+
decision TEXT NOT NULL,
|
|
69
|
+
exec_state TEXT NOT NULL,
|
|
70
|
+
comment TEXT,
|
|
71
|
+
by TEXT
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
CREATE INDEX IF NOT EXISTS idx_history_finding ON finding_history(finding_id);
|
|
75
|
+
|
|
76
|
+
CREATE TABLE IF NOT EXISTS artifacts (
|
|
77
|
+
id TEXT PRIMARY KEY,
|
|
78
|
+
review_id TEXT NOT NULL REFERENCES reviews(id) ON DELETE CASCADE,
|
|
79
|
+
name TEXT NOT NULL,
|
|
80
|
+
kind TEXT NOT NULL,
|
|
81
|
+
content TEXT NOT NULL,
|
|
82
|
+
content_type TEXT NOT NULL,
|
|
83
|
+
created_at TEXT NOT NULL,
|
|
84
|
+
updated_at TEXT NOT NULL,
|
|
85
|
+
UNIQUE(review_id, name)
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
CREATE INDEX IF NOT EXISTS idx_artifacts_review ON artifacts(review_id);
|
|
89
|
+
`;
|
|
90
|
+
|
|
91
|
+
/** Aplica pragmas e cria o schema quando necessário. */
|
|
92
|
+
export function migrate(db: Database, path: string): void {
|
|
93
|
+
db.exec("PRAGMA foreign_keys = ON;");
|
|
94
|
+
db.exec("PRAGMA busy_timeout = 5000;");
|
|
95
|
+
db.exec("PRAGMA synchronous = NORMAL;");
|
|
96
|
+
if (path !== ":memory:") db.exec("PRAGMA journal_mode = WAL;");
|
|
97
|
+
|
|
98
|
+
const row = db.query("PRAGMA user_version").get() as {
|
|
99
|
+
user_version?: number;
|
|
100
|
+
} | null;
|
|
101
|
+
const version = Number(row?.user_version ?? 0);
|
|
102
|
+
if (version >= SCHEMA_VERSION) return;
|
|
103
|
+
|
|
104
|
+
db.transaction(() => {
|
|
105
|
+
db.exec(DDL);
|
|
106
|
+
db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
|
|
107
|
+
})();
|
|
108
|
+
}
|