@xaccefy/pi-casefile 0.9.4 → 0.10.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.
@@ -0,0 +1,321 @@
1
+ /**
2
+ * Internal shared plumbing for the casefile ledger modules.
3
+ *
4
+ * The single home for the DB handle, record builder, validation, transaction
5
+ * wrapper, and text helpers. Both ledger.ts and its sibling modules
6
+ * (confirmation.ts) import these from here — one copy, no drift. NOT a public
7
+ * API — external callers import from ledger.ts, which re-exports what they need.
8
+ *
9
+ * Circular-import note: this module must stay leaf-like. It may import types
10
+ * from ledger.ts but no runtime values, or the ledger ⇄ sibling cycle gains
11
+ * an edge.
12
+ */
13
+
14
+ import { createHash, randomUUID } from "node:crypto";
15
+ import type { CaseRecord, EvidenceItem, NormalizedCaseInput } from "./ledger.ts";
16
+ import type { DatabaseSync } from "./sqlite-compat/index.ts";
17
+
18
+ // ── Text helpers ─────────────────────────────────────────────────────
19
+
20
+ export function normalizeList(values: string[] | undefined): string[] {
21
+ return Array.from(new Set((values ?? []).map((v) => v.trim()).filter(Boolean)));
22
+ }
23
+
24
+ export function normalizeText(value: string | undefined): string | undefined {
25
+ const trimmed = value?.trim();
26
+ return trimmed || undefined;
27
+ }
28
+
29
+ export function stableShortId(input: string): string {
30
+ return createHash("sha1").update(input).digest("hex").slice(0, 10);
31
+ }
32
+
33
+ export { normalizeMatchText };
34
+
35
+ function normalizeMatchText(value: string | undefined): string {
36
+ return normalizeText(value)?.toLowerCase().replace(/\s+/g, " ") ?? "";
37
+ }
38
+
39
+ // ── DB handle (module-global; ledger.ts owns lifecycle) ─────────────
40
+
41
+ let dbInstance: DatabaseSync | undefined;
42
+ let opener: (() => DatabaseSync) | undefined;
43
+
44
+ /** ledger.ts registers its schema-init opener here at module load. */
45
+ export function setDbOpener(impl: () => DatabaseSync): void {
46
+ opener = impl;
47
+ }
48
+
49
+ export function getDb(): DatabaseSync {
50
+ if (!dbInstance && opener) {
51
+ // Lazy open through the owner (schema init + safe-state checks) so any
52
+ // sibling module can start the ledger, not just ledger.ts call sites.
53
+ dbInstance = opener();
54
+ }
55
+ if (!dbInstance) {
56
+ throw new Error("Ledger database not initialized — open it via ledger.ts first");
57
+ }
58
+ return dbInstance;
59
+ }
60
+
61
+ /** Called by ledger.ts's getDb() after opening (or reopening) the database. */
62
+ export function setDbInstance(db: DatabaseSync): void {
63
+ dbInstance = db;
64
+ }
65
+
66
+ export function hasDbInstance(): boolean {
67
+ return dbInstance !== undefined;
68
+ }
69
+
70
+ export function closeDb(): void {
71
+ if (!dbInstance) return;
72
+ try {
73
+ dbInstance.close();
74
+ } catch {
75
+ // Best-effort close.
76
+ }
77
+ dbInstance = undefined;
78
+ }
79
+
80
+ // ── Record building & persistence primitives ────────────────────────
81
+
82
+ export function buildRecord(input: NormalizedCaseInput, existing?: CaseRecord): CaseRecord {
83
+ const timestamp = new Date().toISOString();
84
+ const title = ("title" in input ? input.title : existing?.title)?.trim() ?? "";
85
+ const id = existing?.id ?? `case_${stableShortId(`${title}\n${timestamp}\n${randomUUID()}`)}`;
86
+
87
+ return {
88
+ id,
89
+ title,
90
+ status: input.status ?? existing?.status ?? "hypothesis",
91
+ // Once a case has been investigating/confirmed it never forgets — the kill
92
+ // gate must not be defeatable by demoting first.
93
+ everAdvanced:
94
+ existing?.everAdvanced === true ||
95
+ input.status === "investigating" ||
96
+ input.status === "confirmed",
97
+ confidence: input.confidence ?? existing?.confidence ?? "low",
98
+ severity: input.severity ?? existing?.severity,
99
+ priority: input.priority ?? existing?.priority,
100
+ target: input.target !== undefined ? normalizeText(input.target) : existing?.target,
101
+ endpoint: input.endpoint !== undefined ? normalizeText(input.endpoint) : existing?.endpoint,
102
+ bugClass: input.bugClass !== undefined ? normalizeText(input.bugClass) : existing?.bugClass,
103
+ summary: input.summary !== undefined ? normalizeText(input.summary) : existing?.summary,
104
+ evidence: input.evidence !== undefined ? normalizeText(input.evidence) : existing?.evidence,
105
+ impact: input.impact !== undefined ? normalizeText(input.impact) : existing?.impact,
106
+ nextStep: input.nextStep !== undefined ? normalizeText(input.nextStep) : existing?.nextStep,
107
+ poc: input.poc !== undefined ? normalizeText(input.poc) : existing?.poc,
108
+ remediation:
109
+ input.remediation !== undefined ? normalizeText(input.remediation) : existing?.remediation,
110
+ references: normalizeList(input.references ?? existing?.references),
111
+ blockers: normalizeList(input.blockers ?? existing?.blockers),
112
+ tags: normalizeList(input.tags ?? existing?.tags),
113
+ assumptions: normalizeList(input.assumptions ?? existing?.assumptions),
114
+ disproveIf: normalizeList(input.disproveIf ?? existing?.disproveIf),
115
+ pocVerified: input.pocVerified ?? existing?.pocVerified,
116
+ disconfirmation:
117
+ input.disconfirmation !== undefined
118
+ ? normalizeText(input.disconfirmation)
119
+ : existing?.disconfirmation,
120
+ invariant: input.invariant !== undefined ? normalizeText(input.invariant) : existing?.invariant,
121
+ disconfirmationVerified: input.disconfirmationVerified ?? existing?.disconfirmationVerified,
122
+ controlVerified: input.controlVerified ?? existing?.controlVerified,
123
+ pendingConfirmation: input.pendingConfirmation ?? existing?.pendingConfirmation,
124
+ confirmerVerdict: input.confirmerVerdict ?? existing?.confirmerVerdict,
125
+ reportedAt: input.reportedAt ?? existing?.reportedAt,
126
+ reportPath: input.reportPath ?? existing?.reportPath,
127
+ evidenceItems: existing?.evidenceItems ?? [],
128
+ coverageItems: existing?.coverageItems ?? [],
129
+ linkedCases: existing?.linkedCases ?? [],
130
+ createdAt: existing?.createdAt ?? timestamp,
131
+ updatedAt: timestamp,
132
+ };
133
+ }
134
+
135
+ export function validateCase(record: CaseRecord): void {
136
+ if (!record.title.trim()) throw new Error("Case title cannot be empty");
137
+ // Falsification conditions are load-bearing: they are required at creation
138
+ // and must not be erasable later (CaseUpdate({ disproveIf: [] }) would wipe
139
+ // the hypothesis's falsifiability). Re-check on every write.
140
+ if (record.status !== "reported" && !(record.disproveIf ?? []).some((d) => d.trim())) {
141
+ throw new Error(
142
+ "Cases require disproveIf — falsification conditions (what would disprove this hypothesis). " +
143
+ "They cannot be cleared once set.",
144
+ );
145
+ }
146
+ // Keep this gate in lockstep with the confirmation module: a case may only
147
+ // be CONFIRMED when it has evidence, a PoC, demonstrated impact, a severity,
148
+ // and a named target (what host/repo/scope this affects).
149
+ if (
150
+ record.status === "confirmed" &&
151
+ (!record.evidence ||
152
+ !record.poc ||
153
+ !record.impact ||
154
+ !record.severity ||
155
+ !record.target ||
156
+ !record.disconfirmation)
157
+ ) {
158
+ throw new Error(
159
+ "Confirmed cases require evidence, poc, impact, severity, target, and disconfirmation",
160
+ );
161
+ }
162
+ if (record.status === "blocked" && (record.blockers ?? []).length === 0) {
163
+ throw new Error("Blocked cases require at least one blocker");
164
+ }
165
+ if (
166
+ record.status === "killed" &&
167
+ !record.evidence &&
168
+ !record.nextStep &&
169
+ (record.blockers ?? []).length === 0 &&
170
+ (record.assumptions ?? []).length === 0
171
+ ) {
172
+ throw new Error(
173
+ "Killed cases require evidence, next step, blockers, or assumptions explaining why",
174
+ );
175
+ }
176
+ // A case becomes REPORTED only after a report FILE that passes the content
177
+ // gate exists on disk. Existence is not enough: any non-empty file — or a
178
+ // directory — would otherwise flip the case to a permanent, immutable state.
179
+ if (record.status === "reported") {
180
+ // Imported lazily as a type-only dependency: validateReportFile is defined
181
+ // in format helpers within ledger.ts and injected here to avoid a runtime
182
+ // cycle. The setter runs at module init in ledger.ts.
183
+ if (validateReportFileImpl === undefined) {
184
+ throw new Error("Report validation unavailable — ledger not fully initialized");
185
+ }
186
+ const reportError = validateReportFileImpl(record.reportPath, record);
187
+ if (reportError) {
188
+ throw new Error(`Reported cases require a valid report file: ${reportError}`);
189
+ }
190
+ }
191
+ }
192
+
193
+ let validateReportFileImpl:
194
+ | ((reportPath: string | undefined, record: CaseRecord) => string | null)
195
+ | undefined;
196
+
197
+ export function setValidateReportFile(
198
+ impl: (reportPath: string | undefined, record: CaseRecord) => string | null,
199
+ ): void {
200
+ validateReportFileImpl = impl;
201
+ }
202
+
203
+ export function withImmediateTransaction<T>(db: DatabaseSync, fn: () => T): T {
204
+ db.exec("BEGIN IMMEDIATE");
205
+ try {
206
+ const value = fn();
207
+ db.exec("COMMIT");
208
+ return value;
209
+ } catch (err) {
210
+ try {
211
+ db.exec("ROLLBACK");
212
+ } catch {
213
+ // ignore rollback errors
214
+ }
215
+ throw err;
216
+ }
217
+ }
218
+
219
+ export function upsertCase(db: DatabaseSync, record: CaseRecord) {
220
+ // Use ON CONFLICT DO UPDATE (not INSERT OR REPLACE) so FK CASCADE does not
221
+ // wipe case_links when updating an existing primary key.
222
+ const stmt = db.prepare(`
223
+ INSERT INTO cases (
224
+ id, title, status, ever_advanced, confidence, severity, priority, target, endpoint, bugClass,
225
+ summary, evidence, impact, nextStep, poc, remediation,
226
+ references_json, blockers_json, tags_json, assumptions_json, poc_verified_json,
227
+ disconfirmation, disconfirmation_verified_json, disprove_if_json, control_verified_json,
228
+ pending_confirmation_json, confirmer_verdict_json,
229
+ reported_at, report_path, invariant, created_at, updated_at
230
+ ) VALUES (
231
+ ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
232
+ ?, ?, ?, ?, ?, ?,
233
+ ?, ?, ?, ?, ?,
234
+ ?, ?, ?, ?,
235
+ ?, ?,
236
+ ?, ?, ?, ?, ?
237
+ )
238
+ ON CONFLICT(id) DO UPDATE SET
239
+ title = excluded.title,
240
+ status = excluded.status,
241
+ ever_advanced = excluded.ever_advanced,
242
+ confidence = excluded.confidence,
243
+ severity = excluded.severity,
244
+ priority = excluded.priority,
245
+ target = excluded.target,
246
+ endpoint = excluded.endpoint,
247
+ bugClass = excluded.bugClass,
248
+ summary = excluded.summary,
249
+ evidence = excluded.evidence,
250
+ impact = excluded.impact,
251
+ nextStep = excluded.nextStep,
252
+ poc = excluded.poc,
253
+ remediation = excluded.remediation,
254
+ references_json = excluded.references_json,
255
+ blockers_json = excluded.blockers_json,
256
+ tags_json = excluded.tags_json,
257
+ assumptions_json = excluded.assumptions_json,
258
+ poc_verified_json = excluded.poc_verified_json,
259
+ disconfirmation = excluded.disconfirmation,
260
+ disconfirmation_verified_json = excluded.disconfirmation_verified_json,
261
+ disprove_if_json = excluded.disprove_if_json,
262
+ control_verified_json = excluded.control_verified_json,
263
+ pending_confirmation_json = excluded.pending_confirmation_json,
264
+ confirmer_verdict_json = excluded.confirmer_verdict_json,
265
+ invariant = excluded.invariant,
266
+ reported_at = excluded.reported_at,
267
+ report_path = excluded.report_path,
268
+ created_at = excluded.created_at,
269
+ updated_at = excluded.updated_at
270
+ `);
271
+
272
+ stmt.run(
273
+ record.id,
274
+ record.title,
275
+ record.status,
276
+ record.everAdvanced ? 1 : 0,
277
+ record.confidence,
278
+ record.severity || null,
279
+ record.priority || null,
280
+ record.target || null,
281
+ record.endpoint || null,
282
+ record.bugClass || null,
283
+ record.summary || null,
284
+ record.evidence || null,
285
+ record.impact || null,
286
+ record.nextStep || null,
287
+ record.poc || null,
288
+ record.remediation || null,
289
+ JSON.stringify(record.references),
290
+ JSON.stringify(record.blockers),
291
+ JSON.stringify(record.tags),
292
+ JSON.stringify(record.assumptions),
293
+ record.pocVerified ? JSON.stringify(record.pocVerified) : null,
294
+ record.disconfirmation || null,
295
+ record.disconfirmationVerified ? JSON.stringify(record.disconfirmationVerified) : null,
296
+ JSON.stringify(record.disproveIf),
297
+ record.controlVerified ? JSON.stringify(record.controlVerified) : null,
298
+ record.pendingConfirmation ? JSON.stringify(record.pendingConfirmation) : null,
299
+ record.confirmerVerdict ? JSON.stringify(record.confirmerVerdict) : null,
300
+ record.reportedAt || null,
301
+ record.reportPath || null,
302
+ record.invariant || null,
303
+ record.createdAt,
304
+ record.updatedAt,
305
+ );
306
+ }
307
+
308
+ export function insertEvidenceItem(db: DatabaseSync, item: EvidenceItem): void {
309
+ db.prepare(
310
+ `INSERT INTO evidence_items (id, case_id, role, artifact_path, sha256, summary, created_at)
311
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
312
+ ).run(
313
+ item.id,
314
+ item.caseId,
315
+ item.role,
316
+ item.artifactPath ?? null,
317
+ item.sha256 ?? null,
318
+ item.summary,
319
+ item.createdAt,
320
+ );
321
+ }