@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.
Files changed (91) hide show
  1. package/README.md +25 -39
  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/commands/status-command.ts +46 -0
  8. package/src/cli/help.ts +8 -3
  9. package/src/cli/index.ts +5 -2
  10. package/src/core/commands/context.ts +29 -13
  11. package/src/core/commands/findings.ts +216 -125
  12. package/src/core/commands/review.ts +7 -8
  13. package/src/core/commands/scaffold.ts +15 -16
  14. package/src/core/commands/status.ts +63 -0
  15. package/src/core/config.ts +16 -7
  16. package/src/core/engine/houndsman-engine.ts +15 -1
  17. package/src/core/engine/review-scope.ts +26 -8
  18. package/src/core/engine/review-session.ts +37 -9
  19. package/src/core/findings-state.ts +53 -75
  20. package/src/core/github-review.ts +620 -57
  21. package/src/core/jev/jev-candidates.ts +49 -35
  22. package/src/core/orchestrator.ts +271 -134
  23. package/src/core/paths.ts +26 -17
  24. package/src/core/pr-context.ts +19 -5
  25. package/src/core/project-config.ts +4 -5
  26. package/src/core/renderers/manifest-writer.ts +29 -18
  27. package/src/core/renderers/resume-renderer.ts +151 -0
  28. package/src/core/report-builder.ts +7 -4
  29. package/src/core/run-layout.ts +4 -104
  30. package/src/core/run-status.ts +59 -50
  31. package/src/core/services/git-repository.ts +16 -0
  32. package/src/core/services/project-files.ts +13 -1
  33. package/src/core/storage/factory.ts +24 -0
  34. package/src/core/storage/file/file-storage.ts +271 -0
  35. package/src/core/storage/paths.ts +64 -0
  36. package/src/core/storage/repo-identity.ts +70 -0
  37. package/src/core/storage/sqlite/mappers.ts +162 -0
  38. package/src/core/storage/sqlite/schema.ts +116 -0
  39. package/src/core/storage/sqlite/sqlite-storage.ts +431 -0
  40. package/src/core/storage/types.ts +152 -0
  41. package/src/core/storage/work-area.ts +191 -0
  42. package/src/index.ts +27 -6
  43. package/src/server/artifacts.ts +8 -61
  44. package/src/server/client/App.tsx +26 -9
  45. package/src/server/client/ArtifactsPanel.tsx +144 -23
  46. package/src/server/client/AutoRefresh.tsx +1 -1
  47. package/src/server/client/Filters.tsx +7 -2
  48. package/src/server/client/FindingCard.tsx +50 -15
  49. package/src/server/client/FindingsList.tsx +2 -2
  50. package/src/server/client/MarkdownText.tsx +181 -0
  51. package/src/server/client/ResumePanel.tsx +368 -0
  52. package/src/server/client/RunList.tsx +3 -3
  53. package/src/server/client/SubmitModal.tsx +58 -10
  54. package/src/server/client/SummaryBar.tsx +1 -1
  55. package/src/server/client/icons.tsx +17 -0
  56. package/src/server/client/utils/code-highlight.ts +422 -0
  57. package/src/server/client/utils/constants.ts +226 -0
  58. package/src/server/client/utils/json-highlight.ts +55 -0
  59. package/src/server/client/utils/markdown.ts +319 -0
  60. package/src/server/client/{types.ts → utils/types.ts} +81 -0
  61. package/src/server/index.ts +154 -16
  62. package/src/server/run-repository.ts +153 -136
  63. package/src/skill/OUTPUT-FORMAT.md +6 -4
  64. package/src/skill/README.md +22 -14
  65. package/src/skill/SETUP.md +4 -4
  66. package/src/skill/example-review.md +22 -14
  67. package/src/skill/fix.md +5 -5
  68. package/src/skill/hunt.md +55 -32
  69. package/src/skill/train.md +1 -1
  70. package/src/skill/verify.md +7 -6
  71. package/src/tests/__snapshots__/baseline.test.ts.snap +74 -61
  72. package/src/tests/baseline.test.ts +79 -34
  73. package/src/tests/cli.test.ts +40 -19
  74. package/src/tests/code-highlight.test.ts +123 -0
  75. package/src/tests/domain.test.ts +1 -1
  76. package/src/tests/findings-commands.test.ts +153 -131
  77. package/src/tests/findings-state.test.ts +56 -5
  78. package/src/tests/fixtures/fake-project.ts +0 -1
  79. package/src/tests/github-review.test.ts +409 -60
  80. package/src/tests/infrastructure.test.ts +33 -38
  81. package/src/tests/markdown.test.ts +149 -0
  82. package/src/tests/review-resume.test.ts +101 -0
  83. package/src/tests/review-scope.test.ts +302 -0
  84. package/src/tests/review-scripts.test.ts +81 -5
  85. package/src/tests/run-status-view.test.ts +94 -0
  86. package/src/tests/run-status.test.ts +86 -23
  87. package/src/tests/server.test.ts +184 -96
  88. package/src/tests/storage.test.ts +449 -0
  89. package/src/server/client/constants.ts +0 -91
  90. /package/src/server/client/{api.ts → utils/api.ts} +0 -0
  91. /package/src/server/client/{format.ts → utils/format.ts} +0 -0
@@ -0,0 +1,162 @@
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
+ heartbeat_at: string | null;
34
+ created_at: string;
35
+ updated_at: string;
36
+ };
37
+
38
+ export type FindingRow = {
39
+ id: string;
40
+ review_id: string;
41
+ short_id: string;
42
+ fingerprint: string;
43
+ watchdog_ids: string;
44
+ rule: string;
45
+ severity: string;
46
+ file: string;
47
+ line: number | null;
48
+ message: string;
49
+ action: string;
50
+ evidence: string | null;
51
+ anchor: string | null;
52
+ found_at_sha: string | null;
53
+ count: number;
54
+ present: number;
55
+ decision: string;
56
+ comment: string | null;
57
+ exec_state: string;
58
+ github: string | null;
59
+ created_at: string;
60
+ updated_at: string;
61
+ };
62
+
63
+ export type HistoryRow = {
64
+ id: string;
65
+ finding_id: string;
66
+ at: string;
67
+ decision: string;
68
+ exec_state: string;
69
+ comment: string | null;
70
+ by: string | null;
71
+ };
72
+
73
+ export type ArtifactRow = {
74
+ id: string;
75
+ review_id: string;
76
+ name: string;
77
+ kind: string;
78
+ content: string;
79
+ content_type: string;
80
+ created_at: string;
81
+ updated_at: string;
82
+ };
83
+
84
+ /** Converte a row de review no DTO do contrato. */
85
+ export function reviewFromRow(row: ReviewRow): ReviewRecord {
86
+ return {
87
+ id: row.id,
88
+ repoKey: row.repo_key,
89
+ repoRoot: row.repo_root,
90
+ repoRemote: row.repo_remote ?? undefined,
91
+ branch: row.branch,
92
+ slug: row.slug,
93
+ base: row.base ?? undefined,
94
+ head: row.head ?? undefined,
95
+ headSha: row.head_sha ?? undefined,
96
+ reviewedSha: row.reviewed_sha ?? undefined,
97
+ reviewedAt: row.reviewed_at ?? undefined,
98
+ status: (row.status as ReviewRecord["status"]) ?? null,
99
+ startedAt: row.started_at ?? undefined,
100
+ heartbeatAt: row.heartbeat_at ?? undefined,
101
+ createdAt: row.created_at,
102
+ updatedAt: row.updated_at,
103
+ };
104
+ }
105
+
106
+ function parseJson<T>(raw: string | null): T | undefined {
107
+ if (!raw) return undefined;
108
+ try {
109
+ return JSON.parse(raw) as T;
110
+ } catch {
111
+ return undefined;
112
+ }
113
+ }
114
+
115
+ /** Converte a row de finding (+ histórico) no DTO do domínio. */
116
+ export function findingFromRow(row: FindingRow, history: HistoryRow[]): FindingState {
117
+ return {
118
+ id: row.short_id,
119
+ fingerprint: row.fingerprint,
120
+ watchdogIds: parseJson<string[]>(row.watchdog_ids) ?? [],
121
+ rule: row.rule,
122
+ severity: row.severity as Severity,
123
+ file: row.file,
124
+ line: row.line === null ? null : Number(row.line),
125
+ message: row.message,
126
+ action: row.action,
127
+ evidence: row.evidence ?? undefined,
128
+ anchor: parseJson<FindingAnchor>(row.anchor) ?? null,
129
+ foundAtSha: row.found_at_sha ?? undefined,
130
+ count: Number(row.count),
131
+ present: row.present === 1,
132
+ decision: row.decision as FindingDecision,
133
+ comment: row.comment ?? undefined,
134
+ execState: row.exec_state as FindingExecState,
135
+ createdAt: row.created_at,
136
+ updatedAt: row.updated_at,
137
+ history: history.map((entry) => ({
138
+ at: entry.at,
139
+ decision: entry.decision as FindingDecision,
140
+ execState: entry.exec_state as FindingExecState,
141
+ comment: entry.comment ?? undefined,
142
+ by: entry.by ?? undefined,
143
+ })),
144
+ github: parseJson<GitHubLink>(row.github),
145
+ };
146
+ }
147
+
148
+ /** Converte a row de artefato no DTO do contrato. */
149
+ export function artifactFromRow(row: ArtifactRow): ArtifactRecord {
150
+ return {
151
+ id: row.id,
152
+ reviewId: row.review_id,
153
+ name: row.name,
154
+ kind: row.kind as ArtifactKind,
155
+ content: row.content,
156
+ contentType: row.content_type,
157
+ createdAt: row.created_at,
158
+ updatedAt: row.updated_at,
159
+ };
160
+ }
161
+
162
+ export type { FindingHistoryEntry };
@@ -0,0 +1,116 @@
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 = 2;
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
+ heartbeat_at TEXT,
28
+ created_at TEXT NOT NULL,
29
+ updated_at TEXT NOT NULL,
30
+ UNIQUE(repo_key, branch)
31
+ );
32
+
33
+ CREATE INDEX IF NOT EXISTS idx_reviews_repo_updated
34
+ ON reviews(repo_key, updated_at DESC);
35
+
36
+ CREATE TABLE IF NOT EXISTS findings (
37
+ id TEXT PRIMARY KEY,
38
+ review_id TEXT NOT NULL REFERENCES reviews(id) ON DELETE CASCADE,
39
+ short_id TEXT NOT NULL,
40
+ fingerprint TEXT NOT NULL,
41
+ watchdog_ids TEXT NOT NULL,
42
+ rule TEXT NOT NULL,
43
+ severity TEXT NOT NULL,
44
+ file TEXT NOT NULL,
45
+ line INTEGER,
46
+ message TEXT NOT NULL,
47
+ action TEXT NOT NULL,
48
+ evidence TEXT,
49
+ anchor TEXT,
50
+ found_at_sha TEXT,
51
+ count INTEGER NOT NULL DEFAULT 1,
52
+ present INTEGER NOT NULL DEFAULT 1,
53
+ decision TEXT NOT NULL,
54
+ comment TEXT,
55
+ exec_state TEXT NOT NULL,
56
+ github TEXT,
57
+ created_at TEXT NOT NULL,
58
+ updated_at TEXT NOT NULL,
59
+ UNIQUE(review_id, short_id),
60
+ UNIQUE(review_id, fingerprint)
61
+ );
62
+
63
+ CREATE INDEX IF NOT EXISTS idx_findings_review ON findings(review_id);
64
+
65
+ CREATE TABLE IF NOT EXISTS finding_history (
66
+ id TEXT PRIMARY KEY,
67
+ finding_id TEXT NOT NULL REFERENCES findings(id) ON DELETE CASCADE,
68
+ at TEXT NOT NULL,
69
+ decision TEXT NOT NULL,
70
+ exec_state TEXT NOT NULL,
71
+ comment TEXT,
72
+ by TEXT
73
+ );
74
+
75
+ CREATE INDEX IF NOT EXISTS idx_history_finding ON finding_history(finding_id);
76
+
77
+ CREATE TABLE IF NOT EXISTS artifacts (
78
+ id TEXT PRIMARY KEY,
79
+ review_id TEXT NOT NULL REFERENCES reviews(id) ON DELETE CASCADE,
80
+ name TEXT NOT NULL,
81
+ kind TEXT NOT NULL,
82
+ content TEXT NOT NULL,
83
+ content_type TEXT NOT NULL,
84
+ created_at TEXT NOT NULL,
85
+ updated_at TEXT NOT NULL,
86
+ UNIQUE(review_id, name)
87
+ );
88
+
89
+ CREATE INDEX IF NOT EXISTS idx_artifacts_review ON artifacts(review_id);
90
+ `;
91
+
92
+ /** Aplica pragmas e cria o schema quando necessário. */
93
+ export function migrate(db: Database, path: string): void {
94
+ db.exec("PRAGMA foreign_keys = ON;");
95
+ db.exec("PRAGMA busy_timeout = 5000;");
96
+ db.exec("PRAGMA synchronous = NORMAL;");
97
+ if (path !== ":memory:") db.exec("PRAGMA journal_mode = WAL;");
98
+
99
+ const row = db.query("PRAGMA user_version").get() as {
100
+ user_version?: number;
101
+ } | null;
102
+ const version = Number(row?.user_version ?? 0);
103
+ if (version >= SCHEMA_VERSION) return;
104
+
105
+ db.transaction(() => {
106
+ db.exec(DDL);
107
+ // v2: heartbeat explícito da execução (bancos v1 não tinham a coluna).
108
+ const columns = db.query("PRAGMA table_info(reviews)").all() as {
109
+ name: string;
110
+ }[];
111
+ if (!columns.some((column) => column.name === "heartbeat_at")) {
112
+ db.exec("ALTER TABLE reviews ADD COLUMN heartbeat_at TEXT");
113
+ }
114
+ db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
115
+ })();
116
+ }
@@ -0,0 +1,431 @@
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" | "resetHeartbeat">,
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
+ heartbeatAt: "heartbeat_at",
49
+ updatedAt: "updated_at",
50
+ };
51
+
52
+ /** Provider SQLite (arquivo ou `:memory:`). */
53
+ export class SQLiteStorageProvider implements StorageProvider {
54
+ readonly kind = "sqlite" as const;
55
+ private readonly db: Database;
56
+ private readonly path: string;
57
+
58
+ constructor(path: string) {
59
+ this.path = path;
60
+ if (path !== ":memory:") mkdirSync(dirname(path), { recursive: true });
61
+ this.db = new Database(path);
62
+ migrate(this.db, path);
63
+ }
64
+
65
+ // -----------------------------------------------------------------------
66
+ // Reviews
67
+ // -----------------------------------------------------------------------
68
+
69
+ getOrCreateReview(input: CreateReviewInput): ReviewRecord {
70
+ const existing = this.findReview(input.repo.key, input.branch);
71
+ if (existing) {
72
+ // O repo pode ter sido movido; a identidade continua a mesma.
73
+ if (
74
+ existing.repoRoot !== input.repo.root ||
75
+ existing.repoRemote !== input.repo.remote
76
+ ) {
77
+ this.db.run(
78
+ "UPDATE reviews SET repo_root = ?, repo_remote = ? WHERE id = ?",
79
+ [input.repo.root, input.repo.remote ?? null, existing.id],
80
+ );
81
+ }
82
+ return this.requireReview(existing.id);
83
+ }
84
+
85
+ const now = input.now ?? systemClock();
86
+ const record: ReviewRecord = {
87
+ id: randomUUID(),
88
+ repoKey: input.repo.key,
89
+ repoRoot: input.repo.root,
90
+ repoRemote: input.repo.remote,
91
+ branch: input.branch,
92
+ slug: input.slug,
93
+ base: input.base,
94
+ head: input.head,
95
+ status: null,
96
+ createdAt: now,
97
+ updatedAt: now,
98
+ };
99
+ this.db.run(
100
+ `INSERT INTO reviews
101
+ (id, repo_key, repo_root, repo_remote, branch, slug, base, head,
102
+ created_at, updated_at)
103
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
104
+ [
105
+ record.id,
106
+ record.repoKey,
107
+ record.repoRoot,
108
+ record.repoRemote ?? null,
109
+ record.branch,
110
+ record.slug,
111
+ record.base ?? null,
112
+ record.head ?? null,
113
+ record.createdAt,
114
+ record.updatedAt,
115
+ ],
116
+ );
117
+ return record;
118
+ }
119
+
120
+ findReview(repoKey: string, branch: string): ReviewRecord | null {
121
+ const row = this.db
122
+ .query("SELECT * FROM reviews WHERE repo_key = ? AND branch = ?")
123
+ .get(repoKey, branch) as ReviewRow | null;
124
+ return row ? reviewFromRow(row) : null;
125
+ }
126
+
127
+ getReview(id: string): ReviewRecord | null {
128
+ const row = this.db
129
+ .query("SELECT * FROM reviews WHERE id = ?")
130
+ .get(id) as ReviewRow | null;
131
+ return row ? reviewFromRow(row) : null;
132
+ }
133
+
134
+ listReviews(repoKey?: string): ReviewRecord[] {
135
+ const rows = (
136
+ repoKey
137
+ ? this.db
138
+ .query(
139
+ "SELECT * FROM reviews WHERE repo_key = ? ORDER BY updated_at DESC",
140
+ )
141
+ .all(repoKey)
142
+ : this.db.query("SELECT * FROM reviews ORDER BY updated_at DESC").all()
143
+ ) as ReviewRow[];
144
+ return rows.map(reviewFromRow);
145
+ }
146
+
147
+ updateReview(id: string, patch: ReviewPatch): ReviewRecord | null {
148
+ if (!this.getReview(id)) return null;
149
+ const now = patch.updatedAt ?? systemClock();
150
+ const sets: string[] = [];
151
+ const params: (string | number | null)[] = [];
152
+
153
+ for (const [key, column] of Object.entries(REVIEW_PATCH_COLUMNS)) {
154
+ const value = patch[key as keyof ReviewPatch];
155
+ if (value === undefined) continue;
156
+ sets.push(`${column} = ?`);
157
+ params.push(value as string | number | null);
158
+ }
159
+ if (patch.resetStartedAt) {
160
+ sets.push("started_at = ?");
161
+ params.push(now);
162
+ }
163
+ if (patch.resetHeartbeat) {
164
+ sets.push("heartbeat_at = NULL");
165
+ }
166
+ if (patch.updatedAt === undefined) {
167
+ sets.push("updated_at = ?");
168
+ params.push(now);
169
+ }
170
+
171
+ if (sets.length > 0) {
172
+ params.push(id);
173
+ this.db.run(`UPDATE reviews SET ${sets.join(", ")} WHERE id = ?`, params);
174
+ }
175
+ return this.getReview(id);
176
+ }
177
+
178
+ deleteReview(id: string): void {
179
+ this.db.run("DELETE FROM reviews WHERE id = ?", [id]);
180
+ }
181
+
182
+ // -----------------------------------------------------------------------
183
+ // Findings
184
+ // -----------------------------------------------------------------------
185
+
186
+ readFindings(reviewId: string): FindingsStateFile | null {
187
+ const review = this.getReview(reviewId);
188
+ if (!review) return null;
189
+
190
+ const rows = this.db
191
+ .query("SELECT * FROM findings WHERE review_id = ? ORDER BY rowid")
192
+ .all(reviewId) as FindingRow[];
193
+ if (
194
+ rows.length === 0 &&
195
+ (review.status === null || review.status === "reviewing")
196
+ ) {
197
+ return null;
198
+ }
199
+
200
+ const historyStmt = this.db.query(
201
+ "SELECT * FROM finding_history WHERE finding_id = ? ORDER BY rowid",
202
+ );
203
+ const findings: FindingsStateFile["findings"] = {};
204
+ for (const row of rows) {
205
+ findings[row.short_id] = findingFromRow(
206
+ row,
207
+ historyStmt.all(row.id) as HistoryRow[],
208
+ );
209
+ }
210
+
211
+ const base = emptyState(review.branch);
212
+ const state: FindingsStateFile = {
213
+ ...base,
214
+ slug: review.slug,
215
+ updatedAt: review.updatedAt,
216
+ reviewed: review.reviewedSha
217
+ ? {
218
+ headSha: review.reviewedSha,
219
+ at: review.reviewedAt ?? review.updatedAt,
220
+ }
221
+ : undefined,
222
+ findings,
223
+ };
224
+ reorder(state);
225
+ return state;
226
+ }
227
+
228
+ writeFindings(reviewId: string, state: FindingsStateFile): void {
229
+ if (!this.getReview(reviewId)) {
230
+ throw new Error(`Review '${reviewId}' não encontrada.`);
231
+ }
232
+ const now = state.updatedAt || systemClock();
233
+ const upsert = this.db.query(
234
+ `INSERT INTO findings
235
+ (id, review_id, short_id, fingerprint, watchdog_ids, rule, severity,
236
+ file, line, message, action, evidence, anchor, found_at_sha, count,
237
+ present, decision, comment, exec_state, github, created_at, updated_at)
238
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
239
+ ON CONFLICT(id) DO UPDATE SET
240
+ short_id = excluded.short_id,
241
+ fingerprint = excluded.fingerprint,
242
+ watchdog_ids = excluded.watchdog_ids,
243
+ rule = excluded.rule,
244
+ severity = excluded.severity,
245
+ file = excluded.file,
246
+ line = excluded.line,
247
+ message = excluded.message,
248
+ action = excluded.action,
249
+ evidence = excluded.evidence,
250
+ anchor = excluded.anchor,
251
+ found_at_sha = excluded.found_at_sha,
252
+ count = excluded.count,
253
+ present = excluded.present,
254
+ decision = excluded.decision,
255
+ comment = excluded.comment,
256
+ exec_state = excluded.exec_state,
257
+ github = excluded.github,
258
+ updated_at = excluded.updated_at`,
259
+ );
260
+ const deleteHistory = this.db.query(
261
+ "DELETE FROM finding_history WHERE finding_id = ?",
262
+ );
263
+ const insertHistory = this.db.query(
264
+ `INSERT INTO finding_history
265
+ (id, finding_id, at, decision, exec_state, comment, by)
266
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
267
+ );
268
+
269
+ const transaction = this.db.transaction(() => {
270
+ this.db.run(
271
+ `UPDATE reviews
272
+ SET reviewed_sha = ?, reviewed_at = ?, updated_at = ?
273
+ WHERE id = ?`,
274
+ [
275
+ state.reviewed?.headSha ?? null,
276
+ state.reviewed?.at ?? null,
277
+ now,
278
+ reviewId,
279
+ ],
280
+ );
281
+
282
+ const existing = this.db
283
+ .query("SELECT id, short_id FROM findings WHERE review_id = ?")
284
+ .all(reviewId) as { id: string; short_id: string }[];
285
+ const idByShort = new Map(existing.map((row) => [row.short_id, row.id]));
286
+ const desired = new Set(Object.keys(state.findings));
287
+ for (const row of existing) {
288
+ if (!desired.has(row.short_id)) {
289
+ this.db.run("DELETE FROM findings WHERE id = ?", [row.id]);
290
+ }
291
+ }
292
+
293
+ for (const finding of Object.values(state.findings)) {
294
+ const id = idByShort.get(finding.id) ?? randomUUID();
295
+ upsert.run(
296
+ id,
297
+ reviewId,
298
+ finding.id,
299
+ finding.fingerprint,
300
+ JSON.stringify(finding.watchdogIds),
301
+ finding.rule,
302
+ finding.severity,
303
+ finding.file,
304
+ finding.line,
305
+ finding.message,
306
+ finding.action,
307
+ finding.evidence ?? null,
308
+ finding.anchor ? JSON.stringify(finding.anchor) : null,
309
+ finding.foundAtSha ?? null,
310
+ finding.count,
311
+ finding.present ? 1 : 0,
312
+ finding.decision,
313
+ finding.comment ?? null,
314
+ finding.execState,
315
+ finding.github ? JSON.stringify(finding.github) : null,
316
+ finding.createdAt,
317
+ finding.updatedAt,
318
+ );
319
+ deleteHistory.run(id);
320
+ for (const entry of finding.history) {
321
+ insertHistory.run(
322
+ randomUUID(),
323
+ id,
324
+ entry.at,
325
+ entry.decision,
326
+ entry.execState,
327
+ entry.comment ?? null,
328
+ entry.by ?? null,
329
+ );
330
+ }
331
+ }
332
+ });
333
+ transaction();
334
+ }
335
+
336
+ // -----------------------------------------------------------------------
337
+ // Artefatos
338
+ // -----------------------------------------------------------------------
339
+
340
+ readArtifact(reviewId: string, name: string): ArtifactRecord | null {
341
+ const row = this.db
342
+ .query("SELECT * FROM artifacts WHERE review_id = ? AND name = ?")
343
+ .get(reviewId, name) as ArtifactRow | null;
344
+ return row ? artifactFromRow(row) : null;
345
+ }
346
+
347
+ writeArtifact(reviewId: string, input: WriteArtifactInput): ArtifactRecord {
348
+ if (!this.getReview(reviewId)) {
349
+ throw new Error(`Review '${reviewId}' não encontrada.`);
350
+ }
351
+ const now = systemClock();
352
+ const contentType =
353
+ input.contentType ??
354
+ (input.name.toLowerCase().endsWith(".json")
355
+ ? "application/json; charset=utf-8"
356
+ : "text/markdown; charset=utf-8");
357
+ const existing = this.readArtifact(reviewId, input.name);
358
+ if (existing) {
359
+ this.db.run(
360
+ `UPDATE artifacts
361
+ SET kind = ?, content = ?, content_type = ?, updated_at = ?
362
+ WHERE id = ?`,
363
+ [input.kind, input.content, contentType, now, existing.id],
364
+ );
365
+ return this.readArtifact(reviewId, input.name) as ArtifactRecord;
366
+ }
367
+ const record: ArtifactRecord = {
368
+ id: randomUUID(),
369
+ reviewId,
370
+ name: input.name,
371
+ kind: input.kind,
372
+ content: input.content,
373
+ contentType,
374
+ createdAt: now,
375
+ updatedAt: now,
376
+ };
377
+ this.db.run(
378
+ `INSERT INTO artifacts
379
+ (id, review_id, name, kind, content, content_type, created_at, updated_at)
380
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
381
+ [
382
+ record.id,
383
+ record.reviewId,
384
+ record.name,
385
+ record.kind,
386
+ record.content,
387
+ record.contentType,
388
+ record.createdAt,
389
+ record.updatedAt,
390
+ ],
391
+ );
392
+ return record;
393
+ }
394
+
395
+ listArtifacts(reviewId: string): ArtifactRecord[] {
396
+ const rows = this.db
397
+ .query("SELECT * FROM artifacts WHERE review_id = ? ORDER BY name")
398
+ .all(reviewId) as ArtifactRow[];
399
+ return rows.map(artifactFromRow);
400
+ }
401
+
402
+ deleteArtifact(reviewId: string, name: string): void {
403
+ this.db.run("DELETE FROM artifacts WHERE review_id = ? AND name = ?", [
404
+ reviewId,
405
+ name,
406
+ ]);
407
+ }
408
+
409
+ // -----------------------------------------------------------------------
410
+ // Manutenção / ciclo de vida
411
+ // -----------------------------------------------------------------------
412
+
413
+ maintain(): void {
414
+ this.db.exec("PRAGMA optimize;");
415
+ this.db.exec("VACUUM;");
416
+ }
417
+
418
+ location(reviewId: string): string {
419
+ return `sqlite:${this.path}#${reviewId}`;
420
+ }
421
+
422
+ close(): void {
423
+ this.db.close();
424
+ }
425
+
426
+ private requireReview(id: string): ReviewRecord {
427
+ const review = this.getReview(id);
428
+ if (!review) throw new Error(`Review '${id}' não encontrada.`);
429
+ return review;
430
+ }
431
+ }