@xaccefy/pi-casefile 0.1.6 → 0.1.8
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 +37 -54
- package/package.json +3 -6
- package/src/index.ts +94 -206
- package/src/ledger.ts +151 -128
- package/src/poc-runner.ts +327 -46
- package/src/sqlite-compat/index.ts +5 -0
- package/mcp/server.ts +0 -371
- package/src/sqlite-compat.ts +0 -33
package/src/ledger.ts
CHANGED
|
@@ -10,15 +10,21 @@
|
|
|
10
10
|
* - Auto-indexing on target, status, priority, severity.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { DatabaseSync } from "./sqlite-compat.ts";
|
|
14
13
|
import { createHash, randomUUID } from "node:crypto";
|
|
15
14
|
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
16
|
-
import { dirname, join, resolve } from "path";
|
|
17
|
-
import {
|
|
15
|
+
import { dirname, join, resolve } from "node:path";
|
|
16
|
+
import { DatabaseSync } from "./sqlite-compat/index.ts";
|
|
18
17
|
|
|
19
18
|
// ── Types ────────────────────────────────────────────────────────────
|
|
20
19
|
|
|
21
|
-
export const STATUS_VALUES = [
|
|
20
|
+
export const STATUS_VALUES = [
|
|
21
|
+
"hypothesis",
|
|
22
|
+
"investigating",
|
|
23
|
+
"confirmed",
|
|
24
|
+
"blocked",
|
|
25
|
+
"killed",
|
|
26
|
+
"reported",
|
|
27
|
+
] as const;
|
|
22
28
|
export type CaseStatus = (typeof STATUS_VALUES)[number];
|
|
23
29
|
|
|
24
30
|
export const CONFIDENCE_VALUES = ["low", "medium", "high"] as const;
|
|
@@ -30,7 +36,16 @@ export type CaseSeverity = (typeof SEVERITY_VALUES)[number];
|
|
|
30
36
|
export const PRIORITY_VALUES = ["P0", "P1", "P2", "P3", "P4"] as const;
|
|
31
37
|
export type CasePriority = (typeof PRIORITY_VALUES)[number];
|
|
32
38
|
|
|
33
|
-
export const SEARCH_FIELD_VALUES = [
|
|
39
|
+
export const SEARCH_FIELD_VALUES = [
|
|
40
|
+
"title",
|
|
41
|
+
"summary",
|
|
42
|
+
"evidence",
|
|
43
|
+
"impact",
|
|
44
|
+
"target",
|
|
45
|
+
"endpoint",
|
|
46
|
+
"bugClass",
|
|
47
|
+
"poc",
|
|
48
|
+
] as const;
|
|
34
49
|
export type CaseSearchField = (typeof SEARCH_FIELD_VALUES)[number];
|
|
35
50
|
|
|
36
51
|
export type CaseRecord = {
|
|
@@ -55,7 +70,13 @@ export type CaseRecord = {
|
|
|
55
70
|
/** Explicit assumptions or unknowns to avoid overstating exploitability. */
|
|
56
71
|
assumptions?: string[];
|
|
57
72
|
/** Verification of an on-disk PoC run (set only by promoteFindingResult). */
|
|
58
|
-
pocVerified?: {
|
|
73
|
+
pocVerified?: {
|
|
74
|
+
path: string;
|
|
75
|
+
exitCode: number;
|
|
76
|
+
ranAt: string;
|
|
77
|
+
output?: string;
|
|
78
|
+
sandbox: boolean;
|
|
79
|
+
};
|
|
59
80
|
/** ISO timestamp when CaseReport first wrote the markdown report. */
|
|
60
81
|
reportedAt?: string;
|
|
61
82
|
/** Path to the generated markdown report (set only by writeCaseReport). */
|
|
@@ -131,14 +152,8 @@ export type CaseSearchOptions = {
|
|
|
131
152
|
let ledgerPathOverride: string | undefined;
|
|
132
153
|
let dbInstance: DatabaseSync | undefined;
|
|
133
154
|
|
|
134
|
-
function nowIso(): string {
|
|
135
|
-
return new Date().toISOString();
|
|
136
|
-
}
|
|
137
|
-
|
|
138
155
|
function normalizeList(values: string[] | undefined): string[] {
|
|
139
|
-
return Array.from(
|
|
140
|
-
new Set((values ?? []).map((v) => v.trim()).filter(Boolean)),
|
|
141
|
-
);
|
|
156
|
+
return Array.from(new Set((values ?? []).map((v) => v.trim()).filter(Boolean)));
|
|
142
157
|
}
|
|
143
158
|
|
|
144
159
|
function normalizeText(value: string | undefined): string | undefined {
|
|
@@ -154,14 +169,6 @@ function stableShortId(input: string): string {
|
|
|
154
169
|
return createHash("sha1").update(input).digest("hex").slice(0, 10);
|
|
155
170
|
}
|
|
156
171
|
|
|
157
|
-
function firstEnv(...names: string[]): { name: string; value: string } | undefined {
|
|
158
|
-
for (const name of names) {
|
|
159
|
-
const value = process.env[name]?.trim();
|
|
160
|
-
if (value) return { name, value };
|
|
161
|
-
}
|
|
162
|
-
return undefined;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
172
|
function detectWorkspaceRoot(): string {
|
|
166
173
|
const envs = ["CASEFILE_WORKSPACE_ROOT", "PI_WORKSPACE_ROOT", "GITHUB_WORKSPACE", "PWD"];
|
|
167
174
|
for (const e of envs) if (process.env[e]) return resolve(process.env[e]!);
|
|
@@ -178,25 +185,8 @@ function detectWorkspaceRoot(): string {
|
|
|
178
185
|
|
|
179
186
|
export function getCasefilePath(): string {
|
|
180
187
|
if (ledgerPathOverride) return ledgerPathOverride;
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
if (explicitPath) return resolve(explicitPath.value);
|
|
184
|
-
|
|
185
|
-
const scopeConfig = firstEnv("CASEFILE_SCOPE", "PI_CASEFILE_SCOPE");
|
|
186
|
-
const scope = (scopeConfig?.value.toLowerCase() || "project");
|
|
187
|
-
|
|
188
|
-
if (scope === "global" && scopeConfig?.name === "PI_CASEFILE_SCOPE") {
|
|
189
|
-
return join(homedir(), ".pi", "casefile", "casefile.db");
|
|
190
|
-
}
|
|
191
|
-
if (scope === "global") {
|
|
192
|
-
return join(homedir(), ".casefile", "casefile.db");
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
const projectDir = scopeConfig?.name === "CASEFILE_SCOPE"
|
|
196
|
-
? ".casefile"
|
|
197
|
-
: ".pi";
|
|
198
|
-
|
|
199
|
-
return join(detectWorkspaceRoot(), projectDir, "casefile.db");
|
|
188
|
+
if (process.env.PI_CASEFILE_PATH) return resolve(process.env.PI_CASEFILE_PATH.trim());
|
|
189
|
+
return join(detectWorkspaceRoot(), ".pi", "casefile.db");
|
|
200
190
|
}
|
|
201
191
|
|
|
202
192
|
export function setCasefilePath(path: string | undefined): void {
|
|
@@ -220,7 +210,7 @@ function getDb(): DatabaseSync {
|
|
|
220
210
|
}
|
|
221
211
|
|
|
222
212
|
const db = new DatabaseSync(dbPath);
|
|
223
|
-
|
|
213
|
+
|
|
224
214
|
// Create tables
|
|
225
215
|
db.exec(`
|
|
226
216
|
CREATE TABLE IF NOT EXISTS cases (
|
|
@@ -326,7 +316,7 @@ function mapRow(row: any, linkedCaseIds: string[] = []): CaseRecord {
|
|
|
326
316
|
|
|
327
317
|
export function readCasefile(): CaseRecord[] {
|
|
328
318
|
const db = getDb();
|
|
329
|
-
|
|
319
|
+
|
|
330
320
|
// Read all cases
|
|
331
321
|
const stmt = db.prepare("SELECT * FROM cases");
|
|
332
322
|
const rows = stmt.all();
|
|
@@ -334,11 +324,11 @@ export function readCasefile(): CaseRecord[] {
|
|
|
334
324
|
// Read all links to construct linkedCaseIds map
|
|
335
325
|
const linkStmt = db.prepare("SELECT source_id, target_id FROM case_links");
|
|
336
326
|
const links = linkStmt.all() as { source_id: string; target_id: string }[];
|
|
337
|
-
|
|
327
|
+
|
|
338
328
|
const linkMap = new Map<string, string[]>();
|
|
339
329
|
for (const link of links) {
|
|
340
330
|
if (!linkMap.has(link.source_id)) linkMap.set(link.source_id, []);
|
|
341
|
-
linkMap.get(link.source_id)
|
|
331
|
+
linkMap.get(link.source_id)?.push(link.target_id);
|
|
342
332
|
}
|
|
343
333
|
|
|
344
334
|
return rows.map((row: any) => mapRow(row, linkMap.get(row.id) ?? []));
|
|
@@ -352,8 +342,11 @@ export function getCaseById(id: string): CaseRecord | undefined {
|
|
|
352
342
|
|
|
353
343
|
const linkStmt = db.prepare("SELECT target_id FROM case_links WHERE source_id = ?");
|
|
354
344
|
const links = linkStmt.all(id) as { target_id: string }[];
|
|
355
|
-
|
|
356
|
-
return mapRow(
|
|
345
|
+
|
|
346
|
+
return mapRow(
|
|
347
|
+
row,
|
|
348
|
+
links.map((l) => l.target_id),
|
|
349
|
+
);
|
|
357
350
|
}
|
|
358
351
|
|
|
359
352
|
// ── Validation ────────────────────────────────────────────────────────
|
|
@@ -373,9 +366,16 @@ function validateCase(record: CaseRecord): void {
|
|
|
373
366
|
(record.blockers ?? []).length === 0 &&
|
|
374
367
|
(record.assumptions ?? []).length === 0
|
|
375
368
|
) {
|
|
376
|
-
throw new Error(
|
|
369
|
+
throw new Error(
|
|
370
|
+
"Killed cases require evidence, next step, blockers, or assumptions explaining why",
|
|
371
|
+
);
|
|
377
372
|
}
|
|
378
|
-
if (
|
|
373
|
+
if (
|
|
374
|
+
record.status === "reported" &&
|
|
375
|
+
!record.poc &&
|
|
376
|
+
!record.remediation &&
|
|
377
|
+
(record.references ?? []).length === 0
|
|
378
|
+
) {
|
|
379
379
|
throw new Error("Reported cases require poc, remediation, or references");
|
|
380
380
|
}
|
|
381
381
|
}
|
|
@@ -389,10 +389,14 @@ function validateTransition(
|
|
|
389
389
|
if (from === to) return;
|
|
390
390
|
|
|
391
391
|
if (from === "killed") {
|
|
392
|
-
throw new Error(
|
|
392
|
+
throw new Error(
|
|
393
|
+
`Cannot revive a killed case; open a new case if the lead is revived (was ${from} → ${to})`,
|
|
394
|
+
);
|
|
393
395
|
}
|
|
394
396
|
if (from === "reported") {
|
|
395
|
-
throw new Error(
|
|
397
|
+
throw new Error(
|
|
398
|
+
`Cannot mutate a reported case; file a follow-up case instead (was ${from} → ${to})`,
|
|
399
|
+
);
|
|
396
400
|
}
|
|
397
401
|
|
|
398
402
|
if (to === "killed") return;
|
|
@@ -402,14 +406,17 @@ function validateTransition(
|
|
|
402
406
|
const transitions: Partial<Record<CaseStatus, Partial<Record<CaseStatus, Rule>>>> = {
|
|
403
407
|
hypothesis: {
|
|
404
408
|
investigating: (u) =>
|
|
405
|
-
!u.evidence
|
|
406
|
-
|
|
407
|
-
|
|
409
|
+
!u.evidence
|
|
410
|
+
? "INVESTIGATING requires evidence (source→sink trace)"
|
|
411
|
+
: !u.confidence
|
|
412
|
+
? "INVESTIGATING requires confidence level"
|
|
413
|
+
: null,
|
|
408
414
|
confirmed: () => "Cannot jump hypothesis → confirmed; promote to investigating first",
|
|
409
415
|
reported: () => "Cannot jump hypothesis → reported; confirm first",
|
|
410
416
|
},
|
|
411
417
|
investigating: {
|
|
412
|
-
confirmed: () =>
|
|
418
|
+
confirmed: () =>
|
|
419
|
+
"investigating → confirmed requires a verified PoC run; use the promote_finding tool",
|
|
413
420
|
hypothesis: () => null,
|
|
414
421
|
},
|
|
415
422
|
confirmed: {
|
|
@@ -421,9 +428,11 @@ function validateTransition(
|
|
|
421
428
|
},
|
|
422
429
|
blocked: {
|
|
423
430
|
investigating: (u) =>
|
|
424
|
-
!u.evidence
|
|
425
|
-
|
|
426
|
-
|
|
431
|
+
!u.evidence
|
|
432
|
+
? "INVESTIGATING requires evidence (source→sink trace)"
|
|
433
|
+
: !u.confidence
|
|
434
|
+
? "INVESTIGATING requires confidence level"
|
|
435
|
+
: null,
|
|
427
436
|
hypothesis: () => null,
|
|
428
437
|
},
|
|
429
438
|
};
|
|
@@ -440,7 +449,9 @@ function validateTransition(
|
|
|
440
449
|
|
|
441
450
|
function validateNewCaseInput(input: CaseInput): void {
|
|
442
451
|
if (input.status && input.status !== "hypothesis" && input.status !== "investigating") {
|
|
443
|
-
throw new Error(
|
|
452
|
+
throw new Error(
|
|
453
|
+
"New cases must start as hypothesis or investigating; promote with CaseUpdate after validation",
|
|
454
|
+
);
|
|
444
455
|
}
|
|
445
456
|
if (input.status === "investigating") {
|
|
446
457
|
if (!input.evidence) {
|
|
@@ -452,15 +463,10 @@ function validateNewCaseInput(input: CaseInput): void {
|
|
|
452
463
|
}
|
|
453
464
|
}
|
|
454
465
|
|
|
455
|
-
function buildRecord(
|
|
456
|
-
|
|
457
|
-
existing?: CaseRecord,
|
|
458
|
-
): CaseRecord {
|
|
459
|
-
const timestamp = nowIso();
|
|
466
|
+
function buildRecord(input: NormalizedCaseInput, existing?: CaseRecord): CaseRecord {
|
|
467
|
+
const timestamp = new Date().toISOString();
|
|
460
468
|
const title = ("title" in input ? input.title : existing?.title)?.trim() ?? "";
|
|
461
|
-
const id =
|
|
462
|
-
existing?.id ??
|
|
463
|
-
`case_${stableShortId(`${title}\n${timestamp}\n${randomUUID()}`)}`;
|
|
469
|
+
const id = existing?.id ?? `case_${stableShortId(`${title}\n${timestamp}\n${randomUUID()}`)}`;
|
|
464
470
|
|
|
465
471
|
return {
|
|
466
472
|
id,
|
|
@@ -477,7 +483,8 @@ function buildRecord(
|
|
|
477
483
|
impact: input.impact !== undefined ? normalizeText(input.impact) : existing?.impact,
|
|
478
484
|
nextStep: input.nextStep !== undefined ? normalizeText(input.nextStep) : existing?.nextStep,
|
|
479
485
|
poc: input.poc !== undefined ? normalizeText(input.poc) : existing?.poc,
|
|
480
|
-
remediation:
|
|
486
|
+
remediation:
|
|
487
|
+
input.remediation !== undefined ? normalizeText(input.remediation) : existing?.remediation,
|
|
481
488
|
references: normalizeList(input.references ?? existing?.references),
|
|
482
489
|
blockers: normalizeList(input.blockers ?? existing?.blockers),
|
|
483
490
|
tags: normalizeList(input.tags ?? existing?.tags),
|
|
@@ -513,7 +520,10 @@ function findDuplicateCaseInDb(db: DatabaseSync, candidate: CaseRecord): CaseRec
|
|
|
513
520
|
// Find links
|
|
514
521
|
const linkStmt = db.prepare("SELECT target_id FROM case_links WHERE source_id = ?");
|
|
515
522
|
const links = linkStmt.all(row.id) as { target_id: string }[];
|
|
516
|
-
return mapRow(
|
|
523
|
+
return mapRow(
|
|
524
|
+
row,
|
|
525
|
+
links.map((l) => l.target_id),
|
|
526
|
+
);
|
|
517
527
|
}
|
|
518
528
|
}
|
|
519
529
|
return undefined;
|
|
@@ -560,7 +570,7 @@ function insertOrReplaceCase(db: DatabaseSync, record: CaseRecord) {
|
|
|
560
570
|
record.reportedAt || null,
|
|
561
571
|
record.reportPath || null,
|
|
562
572
|
record.createdAt,
|
|
563
|
-
record.updatedAt
|
|
573
|
+
record.updatedAt,
|
|
564
574
|
);
|
|
565
575
|
}
|
|
566
576
|
|
|
@@ -584,17 +594,25 @@ export function addCaseResult(input: CaseInput): CaseAddResult {
|
|
|
584
594
|
return { record, created: true };
|
|
585
595
|
}
|
|
586
596
|
|
|
587
|
-
export function updateCaseResult(
|
|
588
|
-
id: string,
|
|
589
|
-
update: CaseUpdate,
|
|
590
|
-
): CaseUpdateResult {
|
|
597
|
+
export function updateCaseResult(id: string, update: CaseUpdate): CaseUpdateResult {
|
|
591
598
|
const db = getDb();
|
|
592
599
|
const current = getCaseById(id);
|
|
593
600
|
if (!current) {
|
|
594
601
|
throw new Error(`Case not found: ${id}`);
|
|
595
602
|
}
|
|
596
603
|
|
|
597
|
-
const optionalFields = [
|
|
604
|
+
const optionalFields = [
|
|
605
|
+
"title",
|
|
606
|
+
"target",
|
|
607
|
+
"endpoint",
|
|
608
|
+
"bugClass",
|
|
609
|
+
"summary",
|
|
610
|
+
"evidence",
|
|
611
|
+
"impact",
|
|
612
|
+
"nextStep",
|
|
613
|
+
"poc",
|
|
614
|
+
"remediation",
|
|
615
|
+
] as const;
|
|
598
616
|
const optionalPatch: Record<string, unknown> = {};
|
|
599
617
|
for (const field of optionalFields) {
|
|
600
618
|
if (field in update && update[field] !== undefined) {
|
|
@@ -614,7 +632,7 @@ export function updateCaseResult(
|
|
|
614
632
|
tags: update.tags ?? current.tags,
|
|
615
633
|
assumptions: update.assumptions ?? current.assumptions,
|
|
616
634
|
},
|
|
617
|
-
current
|
|
635
|
+
current,
|
|
618
636
|
);
|
|
619
637
|
|
|
620
638
|
if (update.status && update.status !== current.status) {
|
|
@@ -623,11 +641,13 @@ export function updateCaseResult(
|
|
|
623
641
|
validateCase(next);
|
|
624
642
|
|
|
625
643
|
// Check material equality (we ignore links since links are mutated via CaseLink)
|
|
626
|
-
const norm = (r: CaseRecord) =>
|
|
644
|
+
const norm = (r: CaseRecord) =>
|
|
645
|
+
JSON.stringify({ ...r, updatedAt: "", createdAt: "", linkedCaseIds: [] });
|
|
627
646
|
if (norm(current) === norm(next)) {
|
|
628
|
-
const reason =
|
|
629
|
-
|
|
630
|
-
|
|
647
|
+
const reason =
|
|
648
|
+
update.status && update.status === current.status
|
|
649
|
+
? `Case is already ${current.status}; no material fields changed.`
|
|
650
|
+
: "No material fields changed.";
|
|
631
651
|
return { record: current, changed: false, reason };
|
|
632
652
|
}
|
|
633
653
|
|
|
@@ -649,7 +669,10 @@ export function updateCaseResult(
|
|
|
649
669
|
) {
|
|
650
670
|
const linkStmt = db.prepare("SELECT target_id FROM case_links WHERE source_id = ?");
|
|
651
671
|
const links = linkStmt.all(row.id) as { target_id: string }[];
|
|
652
|
-
duplicate = mapRow(
|
|
672
|
+
duplicate = mapRow(
|
|
673
|
+
row,
|
|
674
|
+
links.map((l) => l.target_id),
|
|
675
|
+
);
|
|
653
676
|
break;
|
|
654
677
|
}
|
|
655
678
|
}
|
|
@@ -674,10 +697,7 @@ type PocVerification = {
|
|
|
674
697
|
sandbox: boolean;
|
|
675
698
|
};
|
|
676
699
|
|
|
677
|
-
export function promoteFindingResult(
|
|
678
|
-
id: string,
|
|
679
|
-
verification: PocVerification,
|
|
680
|
-
): CaseUpdateResult {
|
|
700
|
+
export function promoteFindingResult(id: string, verification: PocVerification): CaseUpdateResult {
|
|
681
701
|
const db = getDb();
|
|
682
702
|
const current = getCaseById(id);
|
|
683
703
|
if (!current) {
|
|
@@ -699,7 +719,9 @@ export function promoteFindingResult(
|
|
|
699
719
|
throw new Error("CONFIRMED requires severity; set severity on the case first");
|
|
700
720
|
}
|
|
701
721
|
if (verification.exitCode !== 0) {
|
|
702
|
-
throw new Error(
|
|
722
|
+
throw new Error(
|
|
723
|
+
`PoC verification failed (exit ${verification.exitCode}); cannot promote to confirmed`,
|
|
724
|
+
);
|
|
703
725
|
}
|
|
704
726
|
|
|
705
727
|
const next = buildRecord(
|
|
@@ -707,7 +729,7 @@ export function promoteFindingResult(
|
|
|
707
729
|
status: "confirmed",
|
|
708
730
|
pocVerified: verification,
|
|
709
731
|
},
|
|
710
|
-
current
|
|
732
|
+
current,
|
|
711
733
|
);
|
|
712
734
|
validateCase(next);
|
|
713
735
|
|
|
@@ -717,10 +739,7 @@ export function promoteFindingResult(
|
|
|
717
739
|
|
|
718
740
|
// ── Link operations ──────────────────────────────────────────────────
|
|
719
741
|
|
|
720
|
-
export function linkCasesResult(
|
|
721
|
-
sourceId: string,
|
|
722
|
-
targetId: string,
|
|
723
|
-
): CaseLinkResult {
|
|
742
|
+
export function linkCasesResult(sourceId: string, targetId: string): CaseLinkResult {
|
|
724
743
|
const db = getDb();
|
|
725
744
|
if (sourceId === targetId) {
|
|
726
745
|
throw new Error("Cannot link a case to itself");
|
|
@@ -742,7 +761,7 @@ export function linkCasesResult(
|
|
|
742
761
|
linkStmt.run(sourceId, targetId);
|
|
743
762
|
linkStmt.run(targetId, sourceId);
|
|
744
763
|
|
|
745
|
-
const now =
|
|
764
|
+
const now = new Date().toISOString();
|
|
746
765
|
const updateTimeStmt = db.prepare("UPDATE cases SET updated_at = ? WHERE id = ?");
|
|
747
766
|
updateTimeStmt.run(now, sourceId);
|
|
748
767
|
updateTimeStmt.run(now, targetId);
|
|
@@ -752,10 +771,7 @@ export function linkCasesResult(
|
|
|
752
771
|
return { source: finalSource, target: finalTarget, changed: true };
|
|
753
772
|
}
|
|
754
773
|
|
|
755
|
-
export function unlinkCasesResult(
|
|
756
|
-
sourceId: string,
|
|
757
|
-
targetId: string,
|
|
758
|
-
): CaseLinkResult {
|
|
774
|
+
export function unlinkCasesResult(sourceId: string, targetId: string): CaseLinkResult {
|
|
759
775
|
const db = getDb();
|
|
760
776
|
const source = getCaseById(sourceId);
|
|
761
777
|
const target = getCaseById(targetId);
|
|
@@ -769,10 +785,12 @@ export function unlinkCasesResult(
|
|
|
769
785
|
return { source, target, changed: false, reason: "Cases are not linked" };
|
|
770
786
|
}
|
|
771
787
|
|
|
772
|
-
const unlinkStmt = db.prepare(
|
|
788
|
+
const unlinkStmt = db.prepare(
|
|
789
|
+
"DELETE FROM case_links WHERE (source_id = ? AND target_id = ?) OR (source_id = ? AND target_id = ?)",
|
|
790
|
+
);
|
|
773
791
|
unlinkStmt.run(sourceId, targetId, targetId, sourceId);
|
|
774
792
|
|
|
775
|
-
const now =
|
|
793
|
+
const now = new Date().toISOString();
|
|
776
794
|
const updateTimeStmt = db.prepare("UPDATE cases SET updated_at = ? WHERE id = ?");
|
|
777
795
|
updateTimeStmt.run(now, sourceId);
|
|
778
796
|
updateTimeStmt.run(now, targetId);
|
|
@@ -784,10 +802,7 @@ export function unlinkCasesResult(
|
|
|
784
802
|
|
|
785
803
|
// ── Search & Queries ─────────────────────────────────────────────────
|
|
786
804
|
|
|
787
|
-
function caseHaystack(
|
|
788
|
-
record: CaseRecord,
|
|
789
|
-
field?: CaseSearchField,
|
|
790
|
-
): string {
|
|
805
|
+
function caseHaystack(record: CaseRecord, field?: CaseSearchField): string {
|
|
791
806
|
if (field) {
|
|
792
807
|
const val = record[field];
|
|
793
808
|
if (Array.isArray(val)) return val.join(" ").toLowerCase();
|
|
@@ -801,9 +816,10 @@ function caseHaystack(
|
|
|
801
816
|
.toLowerCase();
|
|
802
817
|
}
|
|
803
818
|
|
|
804
|
-
export function searchCases(
|
|
805
|
-
|
|
806
|
-
|
|
819
|
+
export function searchCases(options: CaseSearchOptions = {}): {
|
|
820
|
+
cases: CaseRecord[];
|
|
821
|
+
total: number;
|
|
822
|
+
} {
|
|
807
823
|
const query = options.query?.trim().toLowerCase();
|
|
808
824
|
const field = options.field;
|
|
809
825
|
const tag = options.tag?.trim().toLowerCase();
|
|
@@ -824,9 +840,7 @@ export function searchCases(
|
|
|
824
840
|
.filter((r) => !options.confidence || r.confidence === options.confidence)
|
|
825
841
|
.filter((r) => !options.severity || r.severity === options.severity)
|
|
826
842
|
.filter((r) => !options.priority || r.priority === options.priority)
|
|
827
|
-
.filter(
|
|
828
|
-
(r) => !tag || r.tags?.some((t) => t.toLowerCase() === tag),
|
|
829
|
-
)
|
|
843
|
+
.filter((r) => !tag || r.tags?.some((t) => t.toLowerCase() === tag))
|
|
830
844
|
.filter((r) => !query || caseHaystack(r, field).includes(query))
|
|
831
845
|
.sort((a, b) => {
|
|
832
846
|
const aStatus = STATUS_ORDER.indexOf(a.status);
|
|
@@ -868,9 +882,7 @@ export function formatCase(record: CaseRecord): string {
|
|
|
868
882
|
record.endpoint ? `endpoint=${record.endpoint}` : undefined,
|
|
869
883
|
record.target ? `target=${record.target}` : undefined,
|
|
870
884
|
record.tags?.length ? `tags=${record.tags.join(",")}` : undefined,
|
|
871
|
-
record.linkedCaseIds.length
|
|
872
|
-
? `links=${record.linkedCaseIds.join(",")}`
|
|
873
|
-
: undefined,
|
|
885
|
+
record.linkedCaseIds.length ? `links=${record.linkedCaseIds.join(",")}` : undefined,
|
|
874
886
|
record.nextStep ? `next=${record.nextStep}` : undefined,
|
|
875
887
|
].filter(Boolean);
|
|
876
888
|
return bits.join(" | ");
|
|
@@ -884,7 +896,12 @@ export function formatCases(records: CaseRecord[]): string {
|
|
|
884
896
|
export function formatCaseDetail(record: CaseRecord): string {
|
|
885
897
|
const lines = [`═══ ${record.id} ═══`];
|
|
886
898
|
for (const [key, val] of Object.entries(record)) {
|
|
887
|
-
if (
|
|
899
|
+
if (
|
|
900
|
+
!val ||
|
|
901
|
+
(Array.isArray(val) && !val.length) ||
|
|
902
|
+
["id", "createdAt", "updatedAt"].includes(key)
|
|
903
|
+
)
|
|
904
|
+
continue;
|
|
888
905
|
const label = key.charAt(0).toUpperCase() + key.slice(1).replace(/([A-Z])/g, " $1");
|
|
889
906
|
const display = Array.isArray(val)
|
|
890
907
|
? val.join(", ")
|
|
@@ -893,15 +910,9 @@ export function formatCaseDetail(record: CaseRecord): string {
|
|
|
893
910
|
: val;
|
|
894
911
|
lines.push(`${label.padEnd(12)} ${display}`);
|
|
895
912
|
}
|
|
896
|
-
return lines
|
|
897
|
-
}
|
|
898
|
-
|
|
899
|
-
function slugify(value: string): string {
|
|
900
|
-
return value
|
|
901
|
-
.toLowerCase()
|
|
902
|
-
.replace(/[^a-z0-9]+/g, "-")
|
|
903
|
-
.replace(/^-+|-+$/g, "")
|
|
904
|
-
.slice(0, 70) || "case";
|
|
913
|
+
return lines
|
|
914
|
+
.concat([`Created: ${record.createdAt}`, `Updated: ${record.updatedAt}`])
|
|
915
|
+
.join("\n");
|
|
905
916
|
}
|
|
906
917
|
|
|
907
918
|
function mdSection(title: string, body?: string): string {
|
|
@@ -917,13 +928,23 @@ export function writeCaseReport(id: string): { path: string; record: CaseRecord
|
|
|
917
928
|
|
|
918
929
|
const db = getDb();
|
|
919
930
|
const dbPath = getCasefilePath();
|
|
920
|
-
|
|
931
|
+
|
|
921
932
|
const reportDir = join(dirname(dbPath), "report");
|
|
922
933
|
mkdirSync(reportDir, { recursive: true });
|
|
923
934
|
|
|
924
|
-
const
|
|
925
|
-
|
|
926
|
-
|
|
935
|
+
const slug =
|
|
936
|
+
current.title
|
|
937
|
+
.toLowerCase()
|
|
938
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
939
|
+
.replace(/^-+|-+$/g, "")
|
|
940
|
+
.slice(0, 70) || "case";
|
|
941
|
+
const reportPath = join(reportDir, `${slug}-${current.id}.md`);
|
|
942
|
+
const references = current.references?.length
|
|
943
|
+
? current.references.map((r) => `- ${r}`).join("\n")
|
|
944
|
+
: undefined;
|
|
945
|
+
const assumptions = current.assumptions?.length
|
|
946
|
+
? current.assumptions.map((a) => `- ${a}`).join("\n")
|
|
947
|
+
: undefined;
|
|
927
948
|
const body = [
|
|
928
949
|
`# ${current.title}`,
|
|
929
950
|
`**Severity:** ${current.severity ?? "Not assessed"}`,
|
|
@@ -941,15 +962,17 @@ export function writeCaseReport(id: string): { path: string; record: CaseRecord
|
|
|
941
962
|
mdSection("Remediation", current.remediation),
|
|
942
963
|
mdSection("Assumptions and Uncertainty", assumptions),
|
|
943
964
|
mdSection("References", references),
|
|
944
|
-
]
|
|
945
|
-
|
|
965
|
+
]
|
|
966
|
+
.filter(Boolean)
|
|
967
|
+
.join("\n");
|
|
968
|
+
|
|
946
969
|
writeFileSync(reportPath, body, "utf8");
|
|
947
970
|
|
|
948
971
|
const next: CaseRecord = {
|
|
949
972
|
...current,
|
|
950
973
|
reportPath,
|
|
951
|
-
reportedAt: current.reportedAt ??
|
|
952
|
-
updatedAt:
|
|
974
|
+
reportedAt: current.reportedAt ?? new Date().toISOString(),
|
|
975
|
+
updatedAt: new Date().toISOString(),
|
|
953
976
|
};
|
|
954
977
|
|
|
955
978
|
insertOrReplaceCase(db, next);
|