@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.
- package/README.md +18 -6
- package/package.json +1 -1
- package/src/cli/arg-parser.ts +8 -0
- package/src/cli/commands/command.ts +23 -0
- package/src/cli/commands/context-command.ts +40 -0
- package/src/cli/commands/doctor-command.ts +60 -0
- package/src/cli/commands/findings-command.ts +38 -0
- package/src/cli/commands/help-command.ts +13 -0
- package/src/cli/commands/registry.ts +34 -0
- package/src/cli/commands/review-command.ts +21 -0
- package/src/cli/commands/server-command.ts +37 -0
- package/src/cli/commands/setup-command.ts +34 -0
- package/src/cli/commands/watchdog-command.ts +34 -0
- package/src/cli/help.ts +47 -0
- package/src/cli/index.ts +60 -268
- package/src/core/arg-parser.ts +48 -0
- package/src/core/commands/context.ts +29 -13
- package/src/core/commands/findings.ts +185 -260
- package/src/core/commands/review.ts +21 -39
- package/src/core/commands/scaffold.ts +189 -80
- package/src/core/commands/setup.ts +16 -11
- package/src/core/commands/update.ts +7 -7
- package/src/core/commands/watchdog.ts +8 -80
- package/src/core/config.ts +24 -25
- package/src/core/defaults.ts +20 -0
- package/src/core/diff-source.ts +1 -13
- package/src/core/doc-discoverer.ts +25 -15
- package/src/core/domain/budget.ts +42 -0
- package/src/core/domain/classification.ts +94 -0
- package/src/core/domain/diff.ts +53 -0
- package/src/core/domain/watchdog.ts +96 -0
- package/src/core/engine/carryover.ts +67 -0
- package/src/core/engine/houndsman-engine.ts +65 -0
- package/src/core/engine/review-scope.ts +95 -0
- package/src/core/engine/review-session.ts +79 -0
- package/src/core/file-classifier.ts +11 -67
- package/src/core/findings-state.ts +193 -100
- package/src/core/git-revision.ts +14 -40
- package/src/core/git-runner.ts +46 -0
- package/src/core/github-review.ts +4 -33
- package/src/core/jev/jev-candidates.ts +49 -35
- package/src/core/orchestrator.ts +247 -421
- package/src/core/paths.ts +26 -11
- package/src/core/pr-context.ts +39 -51
- package/src/core/project-config.ts +56 -0
- package/src/core/renderers/batch-presenter.ts +40 -0
- package/src/core/renderers/brief-renderer.ts +23 -0
- package/src/core/renderers/findings-presenter.ts +74 -0
- package/src/core/renderers/manifest-writer.ts +123 -0
- package/src/core/renderers/report-renderer.ts +66 -0
- package/src/core/renderers/verify-presenter.ts +50 -0
- package/src/core/renderers/watchdog-presenter.ts +100 -0
- package/src/core/report-builder.ts +12 -7
- package/src/core/run-layout.ts +8 -0
- package/src/core/run-status.ts +29 -59
- package/src/core/services/clock.ts +12 -0
- package/src/core/services/file-classifier.ts +34 -0
- package/src/core/services/fix-batch-builder.ts +13 -0
- package/src/core/services/gh-client.ts +54 -0
- package/src/core/services/git-repository.ts +139 -0
- package/src/core/services/project-files.ts +91 -0
- package/src/core/services/verifier.ts +58 -0
- 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/core/verify.ts +2 -1
- package/src/core/watchdog-registry.ts +58 -16
- package/src/index.ts +57 -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 +155 -245
- package/src/server/run-repository.ts +299 -0
- 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 +825 -0
- package/src/tests/baseline.test.ts +555 -493
- package/src/tests/cli.test.ts +40 -19
- package/src/tests/code-highlight.test.ts +123 -0
- package/src/tests/domain.test.ts +328 -0
- 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 +205 -0
- 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,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
|
+
}
|
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider SQLite do houndsman (biblioteca) — implementação principal.
|
|
3
|
+
*
|
|
4
|
+
* Sync (`bun:sqlite`), WAL e escrita transacional do estado de triagem. O
|
|
5
|
+
* staging de briefs/findings fica fora do banco (`storage/work-area.ts`); aqui
|
|
6
|
+
* vivem reviews, findings e artefatos.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Database } from "bun:sqlite";
|
|
10
|
+
import { randomUUID } from "node:crypto";
|
|
11
|
+
import { mkdirSync } from "node:fs";
|
|
12
|
+
import { dirname } from "node:path";
|
|
13
|
+
import { emptyState, type FindingsStateFile, reorder } from "../../findings-state";
|
|
14
|
+
import { systemClock } from "../../services/clock";
|
|
15
|
+
import type {
|
|
16
|
+
ArtifactRecord,
|
|
17
|
+
CreateReviewInput,
|
|
18
|
+
ReviewPatch,
|
|
19
|
+
ReviewRecord,
|
|
20
|
+
StorageProvider,
|
|
21
|
+
WriteArtifactInput,
|
|
22
|
+
} from "../types";
|
|
23
|
+
import {
|
|
24
|
+
type ArtifactRow,
|
|
25
|
+
artifactFromRow,
|
|
26
|
+
type FindingRow,
|
|
27
|
+
findingFromRow,
|
|
28
|
+
type HistoryRow,
|
|
29
|
+
type ReviewRow,
|
|
30
|
+
reviewFromRow,
|
|
31
|
+
} from "./mappers";
|
|
32
|
+
import { migrate } from "./schema";
|
|
33
|
+
|
|
34
|
+
/** Colunas mutáveis de review (camel → snake). */
|
|
35
|
+
const REVIEW_PATCH_COLUMNS: Record<
|
|
36
|
+
Exclude<keyof ReviewPatch, "resetStartedAt">,
|
|
37
|
+
string
|
|
38
|
+
> = {
|
|
39
|
+
branch: "branch",
|
|
40
|
+
slug: "slug",
|
|
41
|
+
base: "base",
|
|
42
|
+
head: "head",
|
|
43
|
+
headSha: "head_sha",
|
|
44
|
+
reviewedSha: "reviewed_sha",
|
|
45
|
+
reviewedAt: "reviewed_at",
|
|
46
|
+
status: "status",
|
|
47
|
+
startedAt: "started_at",
|
|
48
|
+
updatedAt: "updated_at",
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** Provider SQLite (arquivo ou `:memory:`). */
|
|
52
|
+
export class SQLiteStorageProvider implements StorageProvider {
|
|
53
|
+
readonly kind = "sqlite" as const;
|
|
54
|
+
private readonly db: Database;
|
|
55
|
+
private readonly path: string;
|
|
56
|
+
|
|
57
|
+
constructor(path: string) {
|
|
58
|
+
this.path = path;
|
|
59
|
+
if (path !== ":memory:") mkdirSync(dirname(path), { recursive: true });
|
|
60
|
+
this.db = new Database(path);
|
|
61
|
+
migrate(this.db, path);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// -----------------------------------------------------------------------
|
|
65
|
+
// Reviews
|
|
66
|
+
// -----------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
getOrCreateReview(input: CreateReviewInput): ReviewRecord {
|
|
69
|
+
const existing = this.findReview(input.repo.key, input.branch);
|
|
70
|
+
if (existing) {
|
|
71
|
+
// O repo pode ter sido movido; a identidade continua a mesma.
|
|
72
|
+
if (
|
|
73
|
+
existing.repoRoot !== input.repo.root ||
|
|
74
|
+
existing.repoRemote !== input.repo.remote
|
|
75
|
+
) {
|
|
76
|
+
this.db.run(
|
|
77
|
+
"UPDATE reviews SET repo_root = ?, repo_remote = ? WHERE id = ?",
|
|
78
|
+
[input.repo.root, input.repo.remote ?? null, existing.id],
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
return this.requireReview(existing.id);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const now = input.now ?? systemClock();
|
|
85
|
+
const record: ReviewRecord = {
|
|
86
|
+
id: randomUUID(),
|
|
87
|
+
repoKey: input.repo.key,
|
|
88
|
+
repoRoot: input.repo.root,
|
|
89
|
+
repoRemote: input.repo.remote,
|
|
90
|
+
branch: input.branch,
|
|
91
|
+
slug: input.slug,
|
|
92
|
+
base: input.base,
|
|
93
|
+
head: input.head,
|
|
94
|
+
status: null,
|
|
95
|
+
createdAt: now,
|
|
96
|
+
updatedAt: now,
|
|
97
|
+
};
|
|
98
|
+
this.db.run(
|
|
99
|
+
`INSERT INTO reviews
|
|
100
|
+
(id, repo_key, repo_root, repo_remote, branch, slug, base, head,
|
|
101
|
+
created_at, updated_at)
|
|
102
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
103
|
+
[
|
|
104
|
+
record.id,
|
|
105
|
+
record.repoKey,
|
|
106
|
+
record.repoRoot,
|
|
107
|
+
record.repoRemote ?? null,
|
|
108
|
+
record.branch,
|
|
109
|
+
record.slug,
|
|
110
|
+
record.base ?? null,
|
|
111
|
+
record.head ?? null,
|
|
112
|
+
record.createdAt,
|
|
113
|
+
record.updatedAt,
|
|
114
|
+
],
|
|
115
|
+
);
|
|
116
|
+
return record;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
findReview(repoKey: string, branch: string): ReviewRecord | null {
|
|
120
|
+
const row = this.db
|
|
121
|
+
.query("SELECT * FROM reviews WHERE repo_key = ? AND branch = ?")
|
|
122
|
+
.get(repoKey, branch) as ReviewRow | null;
|
|
123
|
+
return row ? reviewFromRow(row) : null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
getReview(id: string): ReviewRecord | null {
|
|
127
|
+
const row = this.db
|
|
128
|
+
.query("SELECT * FROM reviews WHERE id = ?")
|
|
129
|
+
.get(id) as ReviewRow | null;
|
|
130
|
+
return row ? reviewFromRow(row) : null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
listReviews(repoKey?: string): ReviewRecord[] {
|
|
134
|
+
const rows = (
|
|
135
|
+
repoKey
|
|
136
|
+
? this.db
|
|
137
|
+
.query(
|
|
138
|
+
"SELECT * FROM reviews WHERE repo_key = ? ORDER BY updated_at DESC",
|
|
139
|
+
)
|
|
140
|
+
.all(repoKey)
|
|
141
|
+
: this.db.query("SELECT * FROM reviews ORDER BY updated_at DESC").all()
|
|
142
|
+
) as ReviewRow[];
|
|
143
|
+
return rows.map(reviewFromRow);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
updateReview(id: string, patch: ReviewPatch): ReviewRecord | null {
|
|
147
|
+
if (!this.getReview(id)) return null;
|
|
148
|
+
const now = patch.updatedAt ?? systemClock();
|
|
149
|
+
const sets: string[] = [];
|
|
150
|
+
const params: (string | number | null)[] = [];
|
|
151
|
+
|
|
152
|
+
for (const [key, column] of Object.entries(REVIEW_PATCH_COLUMNS)) {
|
|
153
|
+
const value = patch[key as keyof ReviewPatch];
|
|
154
|
+
if (value === undefined) continue;
|
|
155
|
+
sets.push(`${column} = ?`);
|
|
156
|
+
params.push(value as string | number | null);
|
|
157
|
+
}
|
|
158
|
+
if (patch.resetStartedAt) {
|
|
159
|
+
sets.push("started_at = ?");
|
|
160
|
+
params.push(now);
|
|
161
|
+
}
|
|
162
|
+
if (patch.updatedAt === undefined) {
|
|
163
|
+
sets.push("updated_at = ?");
|
|
164
|
+
params.push(now);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (sets.length > 0) {
|
|
168
|
+
params.push(id);
|
|
169
|
+
this.db.run(`UPDATE reviews SET ${sets.join(", ")} WHERE id = ?`, params);
|
|
170
|
+
}
|
|
171
|
+
return this.getReview(id);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
deleteReview(id: string): void {
|
|
175
|
+
this.db.run("DELETE FROM reviews WHERE id = ?", [id]);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// -----------------------------------------------------------------------
|
|
179
|
+
// Findings
|
|
180
|
+
// -----------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
readFindings(reviewId: string): FindingsStateFile | null {
|
|
183
|
+
const review = this.getReview(reviewId);
|
|
184
|
+
if (!review) return null;
|
|
185
|
+
|
|
186
|
+
const rows = this.db
|
|
187
|
+
.query("SELECT * FROM findings WHERE review_id = ? ORDER BY rowid")
|
|
188
|
+
.all(reviewId) as FindingRow[];
|
|
189
|
+
if (
|
|
190
|
+
rows.length === 0 &&
|
|
191
|
+
(review.status === null || review.status === "reviewing")
|
|
192
|
+
) {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const historyStmt = this.db.query(
|
|
197
|
+
"SELECT * FROM finding_history WHERE finding_id = ? ORDER BY rowid",
|
|
198
|
+
);
|
|
199
|
+
const findings: FindingsStateFile["findings"] = {};
|
|
200
|
+
for (const row of rows) {
|
|
201
|
+
findings[row.short_id] = findingFromRow(
|
|
202
|
+
row,
|
|
203
|
+
historyStmt.all(row.id) as HistoryRow[],
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const base = emptyState(review.branch);
|
|
208
|
+
const state: FindingsStateFile = {
|
|
209
|
+
...base,
|
|
210
|
+
slug: review.slug,
|
|
211
|
+
updatedAt: review.updatedAt,
|
|
212
|
+
reviewed: review.reviewedSha
|
|
213
|
+
? {
|
|
214
|
+
headSha: review.reviewedSha,
|
|
215
|
+
at: review.reviewedAt ?? review.updatedAt,
|
|
216
|
+
}
|
|
217
|
+
: undefined,
|
|
218
|
+
findings,
|
|
219
|
+
};
|
|
220
|
+
reorder(state);
|
|
221
|
+
return state;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
writeFindings(reviewId: string, state: FindingsStateFile): void {
|
|
225
|
+
if (!this.getReview(reviewId)) {
|
|
226
|
+
throw new Error(`Review '${reviewId}' não encontrada.`);
|
|
227
|
+
}
|
|
228
|
+
const now = state.updatedAt || systemClock();
|
|
229
|
+
const upsert = this.db.query(
|
|
230
|
+
`INSERT INTO findings
|
|
231
|
+
(id, review_id, short_id, fingerprint, watchdog_ids, rule, severity,
|
|
232
|
+
file, line, message, action, evidence, anchor, found_at_sha, count,
|
|
233
|
+
present, decision, comment, exec_state, github, created_at, updated_at)
|
|
234
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
235
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
236
|
+
short_id = excluded.short_id,
|
|
237
|
+
fingerprint = excluded.fingerprint,
|
|
238
|
+
watchdog_ids = excluded.watchdog_ids,
|
|
239
|
+
rule = excluded.rule,
|
|
240
|
+
severity = excluded.severity,
|
|
241
|
+
file = excluded.file,
|
|
242
|
+
line = excluded.line,
|
|
243
|
+
message = excluded.message,
|
|
244
|
+
action = excluded.action,
|
|
245
|
+
evidence = excluded.evidence,
|
|
246
|
+
anchor = excluded.anchor,
|
|
247
|
+
found_at_sha = excluded.found_at_sha,
|
|
248
|
+
count = excluded.count,
|
|
249
|
+
present = excluded.present,
|
|
250
|
+
decision = excluded.decision,
|
|
251
|
+
comment = excluded.comment,
|
|
252
|
+
exec_state = excluded.exec_state,
|
|
253
|
+
github = excluded.github,
|
|
254
|
+
updated_at = excluded.updated_at`,
|
|
255
|
+
);
|
|
256
|
+
const deleteHistory = this.db.query(
|
|
257
|
+
"DELETE FROM finding_history WHERE finding_id = ?",
|
|
258
|
+
);
|
|
259
|
+
const insertHistory = this.db.query(
|
|
260
|
+
`INSERT INTO finding_history
|
|
261
|
+
(id, finding_id, at, decision, exec_state, comment, by)
|
|
262
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
const transaction = this.db.transaction(() => {
|
|
266
|
+
this.db.run(
|
|
267
|
+
`UPDATE reviews
|
|
268
|
+
SET reviewed_sha = ?, reviewed_at = ?, updated_at = ?
|
|
269
|
+
WHERE id = ?`,
|
|
270
|
+
[
|
|
271
|
+
state.reviewed?.headSha ?? null,
|
|
272
|
+
state.reviewed?.at ?? null,
|
|
273
|
+
now,
|
|
274
|
+
reviewId,
|
|
275
|
+
],
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
const existing = this.db
|
|
279
|
+
.query("SELECT id, short_id FROM findings WHERE review_id = ?")
|
|
280
|
+
.all(reviewId) as { id: string; short_id: string }[];
|
|
281
|
+
const idByShort = new Map(existing.map((row) => [row.short_id, row.id]));
|
|
282
|
+
const desired = new Set(Object.keys(state.findings));
|
|
283
|
+
for (const row of existing) {
|
|
284
|
+
if (!desired.has(row.short_id)) {
|
|
285
|
+
this.db.run("DELETE FROM findings WHERE id = ?", [row.id]);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
for (const finding of Object.values(state.findings)) {
|
|
290
|
+
const id = idByShort.get(finding.id) ?? randomUUID();
|
|
291
|
+
upsert.run(
|
|
292
|
+
id,
|
|
293
|
+
reviewId,
|
|
294
|
+
finding.id,
|
|
295
|
+
finding.fingerprint,
|
|
296
|
+
JSON.stringify(finding.watchdogIds),
|
|
297
|
+
finding.rule,
|
|
298
|
+
finding.severity,
|
|
299
|
+
finding.file,
|
|
300
|
+
finding.line,
|
|
301
|
+
finding.message,
|
|
302
|
+
finding.action,
|
|
303
|
+
finding.evidence ?? null,
|
|
304
|
+
finding.anchor ? JSON.stringify(finding.anchor) : null,
|
|
305
|
+
finding.foundAtSha ?? null,
|
|
306
|
+
finding.count,
|
|
307
|
+
finding.present ? 1 : 0,
|
|
308
|
+
finding.decision,
|
|
309
|
+
finding.comment ?? null,
|
|
310
|
+
finding.execState,
|
|
311
|
+
finding.github ? JSON.stringify(finding.github) : null,
|
|
312
|
+
finding.createdAt,
|
|
313
|
+
finding.updatedAt,
|
|
314
|
+
);
|
|
315
|
+
deleteHistory.run(id);
|
|
316
|
+
for (const entry of finding.history) {
|
|
317
|
+
insertHistory.run(
|
|
318
|
+
randomUUID(),
|
|
319
|
+
id,
|
|
320
|
+
entry.at,
|
|
321
|
+
entry.decision,
|
|
322
|
+
entry.execState,
|
|
323
|
+
entry.comment ?? null,
|
|
324
|
+
entry.by ?? null,
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
transaction();
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// -----------------------------------------------------------------------
|
|
333
|
+
// Artefatos
|
|
334
|
+
// -----------------------------------------------------------------------
|
|
335
|
+
|
|
336
|
+
readArtifact(reviewId: string, name: string): ArtifactRecord | null {
|
|
337
|
+
const row = this.db
|
|
338
|
+
.query("SELECT * FROM artifacts WHERE review_id = ? AND name = ?")
|
|
339
|
+
.get(reviewId, name) as ArtifactRow | null;
|
|
340
|
+
return row ? artifactFromRow(row) : null;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
writeArtifact(reviewId: string, input: WriteArtifactInput): ArtifactRecord {
|
|
344
|
+
if (!this.getReview(reviewId)) {
|
|
345
|
+
throw new Error(`Review '${reviewId}' não encontrada.`);
|
|
346
|
+
}
|
|
347
|
+
const now = systemClock();
|
|
348
|
+
const contentType =
|
|
349
|
+
input.contentType ??
|
|
350
|
+
(input.name.toLowerCase().endsWith(".json")
|
|
351
|
+
? "application/json; charset=utf-8"
|
|
352
|
+
: "text/markdown; charset=utf-8");
|
|
353
|
+
const existing = this.readArtifact(reviewId, input.name);
|
|
354
|
+
if (existing) {
|
|
355
|
+
this.db.run(
|
|
356
|
+
`UPDATE artifacts
|
|
357
|
+
SET kind = ?, content = ?, content_type = ?, updated_at = ?
|
|
358
|
+
WHERE id = ?`,
|
|
359
|
+
[input.kind, input.content, contentType, now, existing.id],
|
|
360
|
+
);
|
|
361
|
+
return this.readArtifact(reviewId, input.name) as ArtifactRecord;
|
|
362
|
+
}
|
|
363
|
+
const record: ArtifactRecord = {
|
|
364
|
+
id: randomUUID(),
|
|
365
|
+
reviewId,
|
|
366
|
+
name: input.name,
|
|
367
|
+
kind: input.kind,
|
|
368
|
+
content: input.content,
|
|
369
|
+
contentType,
|
|
370
|
+
createdAt: now,
|
|
371
|
+
updatedAt: now,
|
|
372
|
+
};
|
|
373
|
+
this.db.run(
|
|
374
|
+
`INSERT INTO artifacts
|
|
375
|
+
(id, review_id, name, kind, content, content_type, created_at, updated_at)
|
|
376
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
377
|
+
[
|
|
378
|
+
record.id,
|
|
379
|
+
record.reviewId,
|
|
380
|
+
record.name,
|
|
381
|
+
record.kind,
|
|
382
|
+
record.content,
|
|
383
|
+
record.contentType,
|
|
384
|
+
record.createdAt,
|
|
385
|
+
record.updatedAt,
|
|
386
|
+
],
|
|
387
|
+
);
|
|
388
|
+
return record;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
listArtifacts(reviewId: string): ArtifactRecord[] {
|
|
392
|
+
const rows = this.db
|
|
393
|
+
.query("SELECT * FROM artifacts WHERE review_id = ? ORDER BY name")
|
|
394
|
+
.all(reviewId) as ArtifactRow[];
|
|
395
|
+
return rows.map(artifactFromRow);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
deleteArtifact(reviewId: string, name: string): void {
|
|
399
|
+
this.db.run("DELETE FROM artifacts WHERE review_id = ? AND name = ?", [
|
|
400
|
+
reviewId,
|
|
401
|
+
name,
|
|
402
|
+
]);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// -----------------------------------------------------------------------
|
|
406
|
+
// Manutenção / ciclo de vida
|
|
407
|
+
// -----------------------------------------------------------------------
|
|
408
|
+
|
|
409
|
+
maintain(): void {
|
|
410
|
+
this.db.exec("PRAGMA optimize;");
|
|
411
|
+
this.db.exec("VACUUM;");
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
location(reviewId: string): string {
|
|
415
|
+
return `sqlite:${this.path}#${reviewId}`;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
close(): void {
|
|
419
|
+
this.db.close();
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
private requireReview(id: string): ReviewRecord {
|
|
423
|
+
const review = this.getReview(id);
|
|
424
|
+
if (!review) throw new Error(`Review '${id}' não encontrada.`);
|
|
425
|
+
return review;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contrato de persistência do houndsman (biblioteca).
|
|
3
|
+
*
|
|
4
|
+
* Tudo que hoje é "run" (reviews, findings, artefatos) passa por aqui. As
|
|
5
|
+
* implementações (`SQLiteStorageProvider` e `FileStorageProvider`) compartilham
|
|
6
|
+
* esta interface — trocar o backend é só trocar a config.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { FindingsStateFile } from "../findings-state";
|
|
10
|
+
|
|
11
|
+
/** Estados de execução de uma run. */
|
|
12
|
+
export type ReviewStatus = "reviewing" | "review_done" | "fixing" | "fix_done";
|
|
13
|
+
|
|
14
|
+
/** Identidade estável de um repositório (remote normalizado ou raiz git). */
|
|
15
|
+
export type RepoIdentity = {
|
|
16
|
+
/** Chave usada para escopar as runs (`remote:<...>` ou `path:<...>`). */
|
|
17
|
+
key: string;
|
|
18
|
+
/** Raiz absoluta do repositório no disco. */
|
|
19
|
+
root: string;
|
|
20
|
+
/** Remote `origin` cru, quando existir. */
|
|
21
|
+
remote?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** Registro de uma revisão (run de uma branch). */
|
|
25
|
+
export type ReviewRecord = {
|
|
26
|
+
id: string;
|
|
27
|
+
repoKey: string;
|
|
28
|
+
repoRoot: string;
|
|
29
|
+
repoRemote?: string;
|
|
30
|
+
branch: string;
|
|
31
|
+
slug: string;
|
|
32
|
+
base?: string;
|
|
33
|
+
head?: string;
|
|
34
|
+
headSha?: string;
|
|
35
|
+
reviewedSha?: string;
|
|
36
|
+
reviewedAt?: string;
|
|
37
|
+
status: ReviewStatus | null;
|
|
38
|
+
startedAt?: string;
|
|
39
|
+
createdAt: string;
|
|
40
|
+
updatedAt: string;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** Campos mutáveis de uma review (os demais são identidade). */
|
|
44
|
+
export type ReviewPatch = Partial<
|
|
45
|
+
Pick<
|
|
46
|
+
ReviewRecord,
|
|
47
|
+
| "branch"
|
|
48
|
+
| "slug"
|
|
49
|
+
| "base"
|
|
50
|
+
| "head"
|
|
51
|
+
| "headSha"
|
|
52
|
+
| "reviewedSha"
|
|
53
|
+
| "reviewedAt"
|
|
54
|
+
| "status"
|
|
55
|
+
| "startedAt"
|
|
56
|
+
| "updatedAt"
|
|
57
|
+
>
|
|
58
|
+
> & {
|
|
59
|
+
/** Reinicia o `startedAt` no início de um novo ciclo de revisão. */
|
|
60
|
+
resetStartedAt?: boolean;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** Entrada de `getOrCreateReview`. */
|
|
64
|
+
export type CreateReviewInput = {
|
|
65
|
+
repo: RepoIdentity;
|
|
66
|
+
branch: string;
|
|
67
|
+
slug: string;
|
|
68
|
+
base?: string;
|
|
69
|
+
head?: string;
|
|
70
|
+
now?: string;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** Espécie de artefato (orienta a UI e o manifesto). */
|
|
74
|
+
export type ArtifactKind =
|
|
75
|
+
| "brief"
|
|
76
|
+
| "classification"
|
|
77
|
+
| "manifest"
|
|
78
|
+
| "candidates"
|
|
79
|
+
| "context"
|
|
80
|
+
| "context-brief"
|
|
81
|
+
| "context-summary"
|
|
82
|
+
| "finding-report"
|
|
83
|
+
| "review"
|
|
84
|
+
| "domain-review"
|
|
85
|
+
| "verify"
|
|
86
|
+
| "other";
|
|
87
|
+
|
|
88
|
+
/** Artefato persistido de uma review. */
|
|
89
|
+
export type ArtifactRecord = {
|
|
90
|
+
id: string;
|
|
91
|
+
reviewId: string;
|
|
92
|
+
name: string;
|
|
93
|
+
kind: ArtifactKind;
|
|
94
|
+
content: string;
|
|
95
|
+
contentType: string;
|
|
96
|
+
createdAt: string;
|
|
97
|
+
updatedAt: string;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/** Entrada de `writeArtifact`. */
|
|
101
|
+
export type WriteArtifactInput = {
|
|
102
|
+
name: string;
|
|
103
|
+
kind: ArtifactKind;
|
|
104
|
+
content: string;
|
|
105
|
+
contentType?: string;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Persistência síncrona de reviews, findings e artefatos.
|
|
110
|
+
*
|
|
111
|
+
* Síncrona de propósito: `bun:sqlite` e o filesystem são síncronos, e o motor
|
|
112
|
+
* inteiro já trabalha nesse modelo.
|
|
113
|
+
*/
|
|
114
|
+
export interface StorageProvider {
|
|
115
|
+
readonly kind: "sqlite" | "file";
|
|
116
|
+
|
|
117
|
+
/** Recupera ou cria a review de uma branch (identidade = repo + branch). */
|
|
118
|
+
getOrCreateReview(input: CreateReviewInput): ReviewRecord;
|
|
119
|
+
findReview(repoKey: string, branch: string): ReviewRecord | null;
|
|
120
|
+
getReview(id: string): ReviewRecord | null;
|
|
121
|
+
/** Reviews do repositório (mais recentes primeiro); sem chave = todas. */
|
|
122
|
+
listReviews(repoKey?: string): ReviewRecord[];
|
|
123
|
+
updateReview(id: string, patch: ReviewPatch): ReviewRecord | null;
|
|
124
|
+
deleteReview(id: string): void;
|
|
125
|
+
|
|
126
|
+
/** Estado de triagem completo; null se a review ainda não tem findings. */
|
|
127
|
+
readFindings(reviewId: string): FindingsStateFile | null;
|
|
128
|
+
/** Substitui o estado de triagem (transacional no SQLite). */
|
|
129
|
+
writeFindings(reviewId: string, state: FindingsStateFile): void;
|
|
130
|
+
|
|
131
|
+
readArtifact(reviewId: string, name: string): ArtifactRecord | null;
|
|
132
|
+
writeArtifact(reviewId: string, input: WriteArtifactInput): ArtifactRecord;
|
|
133
|
+
listArtifacts(reviewId: string): ArtifactRecord[];
|
|
134
|
+
deleteArtifact(reviewId: string, name: string): void;
|
|
135
|
+
|
|
136
|
+
/** Manutenção do backend (SQLite: `PRAGMA optimize` + `VACUUM`). */
|
|
137
|
+
maintain(): void;
|
|
138
|
+
|
|
139
|
+
/** Referência legível do armazenamento de uma review (diagnóstico/logs). */
|
|
140
|
+
location(reviewId: string): string;
|
|
141
|
+
|
|
142
|
+
close(): void;
|
|
143
|
+
}
|