@oscharko-dev/keiko-evidence 0.2.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 (92) hide show
  1. package/dist/.tsbuildinfo +1 -0
  2. package/dist/aggregate.d.ts +4 -0
  3. package/dist/aggregate.d.ts.map +1 -0
  4. package/dist/aggregate.js +21 -0
  5. package/dist/build.d.ts +3 -0
  6. package/dist/build.d.ts.map +1 -0
  7. package/dist/build.js +227 -0
  8. package/dist/connected-context-evidence.d.ts +47 -0
  9. package/dist/connected-context-evidence.d.ts.map +1 -0
  10. package/dist/connected-context-evidence.js +197 -0
  11. package/dist/errors.d.ts +3 -0
  12. package/dist/errors.d.ts.map +1 -0
  13. package/dist/errors.js +4 -0
  14. package/dist/index-api.d.ts +15 -0
  15. package/dist/index-api.d.ts.map +1 -0
  16. package/dist/index-api.js +136 -0
  17. package/dist/index.d.ts +20 -0
  18. package/dist/index.d.ts.map +1 -0
  19. package/dist/index.js +34 -0
  20. package/dist/persist.d.ts +9 -0
  21. package/dist/persist.d.ts.map +1 -0
  22. package/dist/persist.js +40 -0
  23. package/dist/promptEnhancement/index.d.ts +7 -0
  24. package/dist/promptEnhancement/index.d.ts.map +1 -0
  25. package/dist/promptEnhancement/index.js +10 -0
  26. package/dist/promptEnhancement/manifestSchema.d.ts +71 -0
  27. package/dist/promptEnhancement/manifestSchema.d.ts.map +1 -0
  28. package/dist/promptEnhancement/manifestSchema.js +307 -0
  29. package/dist/promptEnhancement/redaction.d.ts +17 -0
  30. package/dist/promptEnhancement/redaction.d.ts.map +1 -0
  31. package/dist/promptEnhancement/redaction.js +66 -0
  32. package/dist/promptEnhancement/store.d.ts +64 -0
  33. package/dist/promptEnhancement/store.d.ts.map +1 -0
  34. package/dist/promptEnhancement/store.js +409 -0
  35. package/dist/qualityIntelligence/candidatesArtifact.d.ts +74 -0
  36. package/dist/qualityIntelligence/candidatesArtifact.d.ts.map +1 -0
  37. package/dist/qualityIntelligence/candidatesArtifact.js +258 -0
  38. package/dist/qualityIntelligence/companionStore.d.ts +37 -0
  39. package/dist/qualityIntelligence/companionStore.d.ts.map +1 -0
  40. package/dist/qualityIntelligence/companionStore.js +158 -0
  41. package/dist/qualityIntelligence/figmaSnapshot/schema.d.ts +123 -0
  42. package/dist/qualityIntelligence/figmaSnapshot/schema.d.ts.map +1 -0
  43. package/dist/qualityIntelligence/figmaSnapshot/schema.js +163 -0
  44. package/dist/qualityIntelligence/figmaSnapshot/store.d.ts +144 -0
  45. package/dist/qualityIntelligence/figmaSnapshot/store.d.ts.map +1 -0
  46. package/dist/qualityIntelligence/figmaSnapshot/store.js +898 -0
  47. package/dist/qualityIntelligence/index.d.ts +18 -0
  48. package/dist/qualityIntelligence/index.d.ts.map +1 -0
  49. package/dist/qualityIntelligence/index.js +21 -0
  50. package/dist/qualityIntelligence/manifestSchema.d.ts +154 -0
  51. package/dist/qualityIntelligence/manifestSchema.d.ts.map +1 -0
  52. package/dist/qualityIntelligence/manifestSchema.js +70 -0
  53. package/dist/qualityIntelligence/redaction.d.ts +10 -0
  54. package/dist/qualityIntelligence/redaction.d.ts.map +1 -0
  55. package/dist/qualityIntelligence/redaction.js +103 -0
  56. package/dist/qualityIntelligence/retention.d.ts +71 -0
  57. package/dist/qualityIntelligence/retention.d.ts.map +1 -0
  58. package/dist/qualityIntelligence/retention.js +287 -0
  59. package/dist/qualityIntelligence/retentionPolicy.d.ts +10 -0
  60. package/dist/qualityIntelligence/retentionPolicy.d.ts.map +1 -0
  61. package/dist/qualityIntelligence/retentionPolicy.js +38 -0
  62. package/dist/qualityIntelligence/store.d.ts +95 -0
  63. package/dist/qualityIntelligence/store.d.ts.map +1 -0
  64. package/dist/qualityIntelligence/store.js +483 -0
  65. package/dist/redaction.d.ts +2 -0
  66. package/dist/redaction.d.ts.map +1 -0
  67. package/dist/redaction.js +4 -0
  68. package/dist/report.d.ts +17 -0
  69. package/dist/report.d.ts.map +1 -0
  70. package/dist/report.js +50 -0
  71. package/dist/retention.d.ts +4 -0
  72. package/dist/retention.d.ts.map +1 -0
  73. package/dist/retention.js +95 -0
  74. package/dist/runid.d.ts +2 -0
  75. package/dist/runid.d.ts.map +1 -0
  76. package/dist/runid.js +4 -0
  77. package/dist/side-file.d.ts +9 -0
  78. package/dist/side-file.d.ts.map +1 -0
  79. package/dist/side-file.js +102 -0
  80. package/dist/store.d.ts +8 -0
  81. package/dist/store.d.ts.map +1 -0
  82. package/dist/store.js +332 -0
  83. package/dist/types.d.ts +3 -0
  84. package/dist/types.d.ts.map +1 -0
  85. package/dist/types.js +5 -0
  86. package/dist/version.d.ts +2 -0
  87. package/dist/version.d.ts.map +1 -0
  88. package/dist/version.js +1 -0
  89. package/dist/workflow-evidence.d.ts +36 -0
  90. package/dist/workflow-evidence.d.ts.map +1 -0
  91. package/dist/workflow-evidence.js +158 -0
  92. package/package.json +32 -0
@@ -0,0 +1,898 @@
1
+ // Immutable Figma Snapshot evidence store (Epic #750, Issue #753, ADR-0023 "extend, don't fork").
2
+ //
3
+ // Persists an assembled Figma Snapshot as a WRITE-ONCE JSON record `<runId>.figma-snapshot.json`
4
+ // under the evidence `qi/` subdir, with the rendered PNG bytes written as binary side-files under
5
+ // `qi/figma-snapshots/<runId>/`. It reuses the existing keiko-evidence discipline verbatim — the
6
+ // realpath-contained QI dir, the atomic O_EXCL side-file writer, the QI redaction wrapper, and the
7
+ // runId validator — and adds NO new persistence layer.
8
+ //
9
+ // Immutability: unlike the MUTABLE candidate companion, this record is the evidence artifact, so it
10
+ // is write-once. `record` refuses to overwrite an existing snapshot (O_EXCL on the JSON temp +
11
+ // an explicit pre-check) — a re-snapshot is a new run, never a mutation of an old one.
12
+ //
13
+ // Redaction: the whole record (including the design-content IR) is passed through
14
+ // `redactQualityIntelligenceEvidence` before write. The token is never present by construction
15
+ // (the server builder never places it on the in-memory snapshot); redaction is defense-in-depth.
16
+ //
17
+ // Integrity: load() recomputes each persisted screen hash from the stored, redacted IR + image
18
+ // sha256, verifies each PNG side-file against the stored sha256/byteLength, and then recomputes the
19
+ // snapshot integrity hash. Tampered or truncated records are rejected at the read boundary. The
20
+ // optional artifacts (`links`, `tokens`, `metrics`) stay out of the drift hash but carry separate
21
+ // artifact hashes when present; old un-hashed optional artifacts are omitted on load instead of being
22
+ // trusted.
23
+ //
24
+ // Retention: `enforceFigmaSnapshotRetention` deletes snapshot records + their side-file dirs in
25
+ // lock-step with the provided policy. Wiring: call it where the other QI retention enforcement
26
+ // runs (the orchestrator that calls `deleteQualityIntelligenceRun` for each expired run).
27
+ //
28
+ // Orphan cleanup: `sweepOrphanedFigmaSnapshotSideDirs` removes side-file dirs (and stray *.tmp
29
+ // files) that have no matching record. It is lazy/once — the store calls it on first use so stale
30
+ // dirs from a previously interrupted record() are cleaned up without a separate boot step.
31
+ import { createHash, randomUUID } from "node:crypto";
32
+ import { chmodSync, linkSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync, } from "node:fs";
33
+ import { join, sep } from "node:path";
34
+ import { assertContainedRealPath, resolveWithinWorkspace, } from "@oscharko-dev/keiko-workspace";
35
+ import { nodeWorkspaceFs } from "@oscharko-dev/keiko-workspace/internal/fs";
36
+ import { assertValidRunId } from "@oscharko-dev/keiko-security";
37
+ import { EvidenceReadError, EvidenceWriteError } from "../../errors.js";
38
+ import { writeSideFile } from "../../side-file.js";
39
+ import { redactQualityIntelligenceEvidence } from "../redaction.js";
40
+ import { QI_SUBDIR } from "../store.js";
41
+ import { FIGMA_SNAPSHOT_SCHEMA_VERSION, validateFigmaSnapshotRecord, } from "./schema.js";
42
+ const QI_DIR_MODE = 0o700;
43
+ const SNAPSHOT_SUFFIX = ".figma-snapshot.json";
44
+ const SNAPSHOT_MANAGEMENT_SUFFIX = ".figma-snapshot.management.json";
45
+ const FIGMA_SNAPSHOT_MANAGEMENT_SCHEMA_VERSION = 1;
46
+ const SIDE_FILE_SUBDIR = "figma-snapshots";
47
+ const MAX_SIDE_FILE_NAME_LENGTH = 128;
48
+ const SIDE_FILE_NAME_PATTERN = /^[A-Za-z0-9._-]+$/u;
49
+ const MAX_SNAPSHOT_DISPLAY_NAME_LENGTH = 120;
50
+ /**
51
+ * Conservative default Figma-snapshot retention cap. Chosen to match the QI `qi:standard-90d`
52
+ * profile's `maxRunArtifacts` (500) — the least-destructive value that still bounds growth, so
53
+ * wiring retention on by default does NOT surprise an operator with an aggressive small cap (ADR-0048).
54
+ * Deployments raise/lower it via `FigmaSnapshotStoreOptions.retention.maxRecords`.
55
+ */
56
+ export const DEFAULT_FIGMA_SNAPSHOT_MAX_RECORDS = 500;
57
+ // ─── Integrity hash (mirrors figmaSnapshotHash.ts — inlined so keiko-evidence does not depend
58
+ // on the private keiko-server package). MUST stay bit-identical with the server builder. ────
59
+ const sha256Hex = (input) => createHash("sha256").update(input, "utf8").digest("hex");
60
+ const sha256Bytes = (input) => createHash("sha256").update(input).digest("hex");
61
+ // Stable stringify: keys emitted in sorted order at every depth (mirrors canonical() in hash.ts).
62
+ function canonical(value) {
63
+ if (value === undefined)
64
+ return "null";
65
+ if (value === null || typeof value !== "object")
66
+ return JSON.stringify(value);
67
+ if (Array.isArray(value))
68
+ return `[${value.map(canonical).join(",")}]`;
69
+ const record = value;
70
+ const entries = Object.keys(record)
71
+ .sort()
72
+ .map((key) => `${JSON.stringify(key)}:${canonical(record[key])}`);
73
+ return `{${entries.join(",")}}`;
74
+ }
75
+ const hashArtifact = (value) => sha256Hex(canonical(value));
76
+ // Hash-neutral IR projection (mirrors figmaSnapshotHash.ts in keiko-server). The store accepts
77
+ // `irJson` as opaque JSON, so malformed legacy/tampered shapes fall back to the raw JSON projection;
78
+ // valid Screen-IR gets the exact same hash-neutral field pruning as the builder.
79
+ const HASH_NEUTRAL_IR_KEYS = new Set([
80
+ "textColor",
81
+ "backgroundColor",
82
+ "layout",
83
+ "sizing",
84
+ "cornerRadius",
85
+ "typography",
86
+ ]);
87
+ const isJsonObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
88
+ const stripHashNeutralChild = (child) => isJsonObject(child) ? stripHashNeutralFields(child) : child;
89
+ const stripHashNeutralFields = (node) => {
90
+ const entries = Object.entries(node).filter(([key]) => key !== "children" && !HASH_NEUTRAL_IR_KEYS.has(key));
91
+ const rawChildren = node.children;
92
+ return {
93
+ ...Object.fromEntries(entries),
94
+ children: Array.isArray(rawChildren)
95
+ ? rawChildren.map(stripHashNeutralChild)
96
+ : [],
97
+ };
98
+ };
99
+ const hashStableIr = (irJson) => {
100
+ if (!isJsonObject(irJson) || !isJsonObject(irJson.root))
101
+ return irJson;
102
+ return { ...irJson, root: stripHashNeutralFields(irJson.root) };
103
+ };
104
+ const recomputeScreenIntegrityHash = (screen) => sha256Hex(canonical({
105
+ imageSha256: screen.image.sha256,
106
+ ir: hashStableIr(screen.irJson),
107
+ screenId: screen.screenId,
108
+ }));
109
+ const recomputeStructuralScreenIntegrityHash = (screen) => sha256Hex(canonical({
110
+ ir: hashStableIr(screen.irJson),
111
+ screenId: screen.screenId,
112
+ structuralOnly: true,
113
+ }));
114
+ const artifactHashesFor = (record) => {
115
+ const hashes = {
116
+ ...(record.links !== undefined ? { links: hashArtifact(record.links) } : {}),
117
+ ...(record.tokens !== undefined ? { tokens: hashArtifact(record.tokens) } : {}),
118
+ ...(record.metrics !== undefined ? { metrics: hashArtifact(record.metrics) } : {}),
119
+ };
120
+ return hashes.links === undefined && hashes.tokens === undefined && hashes.metrics === undefined
121
+ ? undefined
122
+ : hashes;
123
+ };
124
+ // Recompute the snapshot-level integrity hash from a loaded record.
125
+ // This exactly mirrors hashSnapshot() in figmaSnapshotHash.ts:
126
+ // sha256( canonical({ screens: sorted [{integrityHash,screenId}], snapshotSchemaVersion, version }) )
127
+ // fetchedAt and links/tokens are excluded by design (non-identity metadata).
128
+ function recomputeSnapshotIntegrityHash(record) {
129
+ const screens = [...record.screens, ...(record.structuralScreens ?? [])]
130
+ .sort((a, b) => (a.screenId < b.screenId ? -1 : a.screenId > b.screenId ? 1 : 0))
131
+ .map((s) => ({ integrityHash: s.integrityHash, screenId: s.screenId }));
132
+ return sha256Hex(canonical({
133
+ screens,
134
+ snapshotSchemaVersion: record.figmaSnapshotSchemaVersion,
135
+ version: record.provenance.version ?? null,
136
+ }));
137
+ }
138
+ const rehashRecord = (record) => {
139
+ const screens = record.screens.map((screen) => ({
140
+ ...screen,
141
+ integrityHash: recomputeScreenIntegrityHash(screen),
142
+ }));
143
+ const structuralScreens = record.structuralScreens?.map((screen) => ({
144
+ ...screen,
145
+ integrityHash: recomputeStructuralScreenIntegrityHash(screen),
146
+ }));
147
+ const rehashed = {
148
+ ...record,
149
+ screens,
150
+ ...(structuralScreens !== undefined ? { structuralScreens } : {}),
151
+ };
152
+ return { ...rehashed, integrityHash: recomputeSnapshotIntegrityHash(rehashed) };
153
+ };
154
+ const EXTERNAL_LINK_TARGET = /^(?:[a-z][a-z0-9+.-]*:\/\/|mailto:|tel:|data:|javascript:)/iu;
155
+ const isExternalLinkTarget = (targetNodeId) => targetNodeId.startsWith("url:") || EXTERNAL_LINK_TARGET.test(targetNodeId);
156
+ const safeLinkRows = (links) => links?.filter((link) => !isExternalLinkTarget(link.targetNodeId));
157
+ const omitUnverifiedArtifacts = (record, omitLinks, omitTokens, omitMetrics) => {
158
+ if (!omitLinks && !omitTokens && !omitMetrics)
159
+ return record;
160
+ const omitted = new Set([
161
+ ...(omitLinks ? ["links"] : []),
162
+ ...(omitTokens ? ["tokens"] : []),
163
+ ...(omitMetrics ? ["metrics"] : []),
164
+ ]);
165
+ return Object.fromEntries(Object.entries(record).filter(([key]) => !omitted.has(key)));
166
+ };
167
+ function verifyArtifactHash(kind, value, actualHash, runId) {
168
+ if (value === undefined) {
169
+ if (actualHash !== undefined) {
170
+ throw new EvidenceReadError(`Figma snapshot integrity check failed for run ${runId}: ${kind} artifact missing`);
171
+ }
172
+ return false;
173
+ }
174
+ if (actualHash === undefined)
175
+ return true;
176
+ if (actualHash !== hashArtifact(value)) {
177
+ throw new EvidenceReadError(`Figma snapshot integrity check failed for run ${runId}: ${kind} artifact hash mismatch`);
178
+ }
179
+ return false;
180
+ }
181
+ function verifyOptionalArtifactHashes(record, runId) {
182
+ const actual = record.artifactHashes;
183
+ const omitLinks = verifyArtifactHash("links", record.links, actual?.links, runId);
184
+ const omitTokens = verifyArtifactHash("tokens", record.tokens, actual?.tokens, runId);
185
+ const omitMetrics = verifyArtifactHash("metrics", record.metrics, actual?.metrics, runId);
186
+ // Older records predate artifact hashes. The optional fields remain schema-valid, but we do not
187
+ // trust them for downstream generation without their own tamper evidence.
188
+ return omitUnverifiedArtifacts(record, omitLinks, omitTokens, omitMetrics);
189
+ }
190
+ // ─── Helpers ─────────────────────────────────────────────────────────────────────────────────
191
+ function realBaseForWrite(baseDir, fs) {
192
+ try {
193
+ mkdirSync(baseDir, { recursive: true, mode: QI_DIR_MODE });
194
+ return fs.realPath(baseDir);
195
+ }
196
+ catch (error) {
197
+ throw new EvidenceWriteError(`cannot create Figma snapshot directory: ${error instanceof Error ? error.message : "unknown"}`);
198
+ }
199
+ }
200
+ function realBaseForRead(baseDir, fs) {
201
+ if (!fs.exists(baseDir))
202
+ return undefined;
203
+ try {
204
+ return fs.realPath(baseDir);
205
+ }
206
+ catch (error) {
207
+ throw new EvidenceReadError(`cannot read Figma snapshot directory: ${error instanceof Error ? error.message : "unknown"}`);
208
+ }
209
+ }
210
+ function containedRecordPath(runId, realBase, fs) {
211
+ assertValidRunId(runId);
212
+ const name = `${runId}${SNAPSHOT_SUFFIX}`;
213
+ const lexical = resolveWithinWorkspace(realBase, name);
214
+ return assertContainedRealPath(fs, realBase, lexical, name);
215
+ }
216
+ function containedManagementPath(runId, realBase, fs) {
217
+ assertValidRunId(runId);
218
+ const name = `${runId}${SNAPSHOT_MANAGEMENT_SUFFIX}`;
219
+ const lexical = resolveWithinWorkspace(realBase, name);
220
+ return assertContainedRealPath(fs, realBase, lexical, name);
221
+ }
222
+ function containedSideFileRunDir(ctx, runId) {
223
+ assertValidRunId(runId);
224
+ const realQiBase = realBaseForRead(ctx.qiDir, ctx.fs) ?? realBaseForWrite(ctx.qiDir, ctx.fs);
225
+ const sideBaseLexical = resolveWithinWorkspace(realQiBase, SIDE_FILE_SUBDIR);
226
+ const realSideBase = assertContainedRealPath(ctx.fs, realQiBase, sideBaseLexical, SIDE_FILE_SUBDIR);
227
+ const runDirLexical = resolveWithinWorkspace(realSideBase, runId);
228
+ return assertContainedRealPath(ctx.fs, realSideBase, runDirLexical, runId);
229
+ }
230
+ function assertSnapshotAbsent(target) {
231
+ if (lstatSync(target, { throwIfNoEntry: false }) !== undefined) {
232
+ throw new EvidenceWriteError("Figma snapshot already exists for this run (write-once)");
233
+ }
234
+ }
235
+ function runIdFromSnapshotName(name) {
236
+ if (!name.endsWith(SNAPSHOT_SUFFIX))
237
+ return undefined;
238
+ const runId = name.slice(0, -SNAPSHOT_SUFFIX.length);
239
+ try {
240
+ assertValidRunId(runId);
241
+ return runId;
242
+ }
243
+ catch {
244
+ return undefined;
245
+ }
246
+ }
247
+ function runIdFromManagementName(name) {
248
+ if (!name.endsWith(SNAPSHOT_MANAGEMENT_SUFFIX))
249
+ return undefined;
250
+ const runId = name.slice(0, -SNAPSHOT_MANAGEMENT_SUFFIX.length);
251
+ try {
252
+ assertValidRunId(runId);
253
+ return runId;
254
+ }
255
+ catch {
256
+ return undefined;
257
+ }
258
+ }
259
+ function snapshotRecordFiles(baseDir) {
260
+ let entries;
261
+ try {
262
+ entries = readdirSync(baseDir, { withFileTypes: true });
263
+ }
264
+ catch {
265
+ return [];
266
+ }
267
+ const files = [];
268
+ for (const entry of entries) {
269
+ if (!entry.isFile() || entry.isSymbolicLink())
270
+ continue;
271
+ const runId = runIdFromSnapshotName(entry.name);
272
+ if (runId === undefined)
273
+ continue;
274
+ files.push({ runId, path: join(baseDir, entry.name) });
275
+ }
276
+ return files;
277
+ }
278
+ // Write-once: create a temp file, then hard-link it into the final target. `linkSync` is the
279
+ // exclusive commit: it fails with EEXIST if another recorder created the target after the
280
+ // pre-check, unlike `rename`, which would overwrite the winner on POSIX.
281
+ function atomicWriteOnce(target, json, randomSuffix) {
282
+ assertSnapshotAbsent(target);
283
+ const temp = `${target}.${randomSuffix()}.tmp`;
284
+ try {
285
+ writeFileSync(temp, json, { encoding: "utf8", flag: "wx" });
286
+ try {
287
+ chmodSync(temp, 0o600);
288
+ }
289
+ catch {
290
+ // non-fatal: not every filesystem supports chmod (e.g. Windows)
291
+ }
292
+ try {
293
+ linkSync(temp, target);
294
+ }
295
+ catch (error) {
296
+ if (error.code === "EEXIST") {
297
+ throw new EvidenceWriteError("Figma snapshot already exists for this run (write-once)");
298
+ }
299
+ throw error;
300
+ }
301
+ rmSync(temp, { force: true });
302
+ }
303
+ catch (error) {
304
+ rmSync(temp, { force: true });
305
+ if (error instanceof EvidenceWriteError)
306
+ throw error;
307
+ throw new EvidenceWriteError(`Figma snapshot write failed: ${error instanceof Error ? error.message : "unknown"}`);
308
+ }
309
+ }
310
+ function atomicWriteMutable(target, json, randomSuffix) {
311
+ const temp = `${target}.${randomSuffix()}.tmp`;
312
+ try {
313
+ writeFileSync(temp, json, { encoding: "utf8", flag: "wx" });
314
+ try {
315
+ chmodSync(temp, 0o600);
316
+ }
317
+ catch {
318
+ // non-fatal: not every filesystem supports chmod (e.g. Windows)
319
+ }
320
+ renameSync(temp, target);
321
+ }
322
+ catch (error) {
323
+ rmSync(temp, { force: true });
324
+ throw new EvidenceWriteError(`Figma snapshot management metadata write failed: ${error instanceof Error ? error.message : "unknown"}`);
325
+ }
326
+ }
327
+ function normalizeDisplayName(value) {
328
+ if (value === undefined || value === null)
329
+ return undefined;
330
+ const trimmed = value.trim().replace(/\s+/gu, " ");
331
+ if (trimmed.length === 0)
332
+ return undefined;
333
+ if (trimmed.length > MAX_SNAPSHOT_DISPLAY_NAME_LENGTH) {
334
+ throw new EvidenceWriteError("Figma snapshot display name is too long");
335
+ }
336
+ if (hasControlCharacter(trimmed)) {
337
+ throw new EvidenceWriteError("Figma snapshot display name contains control characters");
338
+ }
339
+ return trimmed;
340
+ }
341
+ function hasControlCharacter(value) {
342
+ for (let i = 0; i < value.length; i += 1) {
343
+ const code = value.charCodeAt(i);
344
+ if (code <= 0x1f || code === 0x7f)
345
+ return true;
346
+ }
347
+ return false;
348
+ }
349
+ function userMetadataRecord(value, expectedRunId) {
350
+ if (typeof value !== "object" || value === null || Array.isArray(value))
351
+ return undefined;
352
+ const record = value;
353
+ if (record.figmaSnapshotManagementSchemaVersion !== FIGMA_SNAPSHOT_MANAGEMENT_SCHEMA_VERSION) {
354
+ return undefined;
355
+ }
356
+ return record.runId === expectedRunId ? record : undefined;
357
+ }
358
+ function metadataUpdatedAt(record) {
359
+ return typeof record.updatedAt === "string" && record.updatedAt.length > 0
360
+ ? record.updatedAt
361
+ : undefined;
362
+ }
363
+ function metadataDisplayName(record) {
364
+ if (record.displayName === undefined)
365
+ return undefined;
366
+ if (typeof record.displayName !== "string")
367
+ return null;
368
+ try {
369
+ return normalizeDisplayName(record.displayName);
370
+ }
371
+ catch {
372
+ return null;
373
+ }
374
+ }
375
+ function parseUserMetadata(value, expectedRunId) {
376
+ const record = userMetadataRecord(value, expectedRunId);
377
+ if (record === undefined)
378
+ return undefined;
379
+ const updatedAt = metadataUpdatedAt(record);
380
+ if (updatedAt === undefined)
381
+ return undefined;
382
+ const displayName = metadataDisplayName(record);
383
+ if (displayName === null)
384
+ return undefined;
385
+ return displayName === undefined ? { updatedAt } : { displayName, updatedAt };
386
+ }
387
+ function metadataToJson(runId, metadata) {
388
+ return JSON.stringify({
389
+ figmaSnapshotManagementSchemaVersion: FIGMA_SNAPSHOT_MANAGEMENT_SCHEMA_VERSION,
390
+ runId,
391
+ ...(metadata.displayName !== undefined ? { displayName: metadata.displayName } : {}),
392
+ updatedAt: metadata.updatedAt,
393
+ });
394
+ }
395
+ function writeScreenSideFiles(sideFileBase, runId, screens, fs, randomSuffix) {
396
+ return screens.map((screen, index) => {
397
+ const name = `screen-${String(index).padStart(4, "0")}.png`;
398
+ const written = writeSideFile(sideFileBase, runId, name, Buffer.from(screen.image.bytes), {
399
+ fs,
400
+ randomSuffix,
401
+ });
402
+ return {
403
+ screenId: screen.screenId,
404
+ irJson: screen.irJson,
405
+ integrityHash: screen.integrityHash,
406
+ image: {
407
+ mimeType: "image/png",
408
+ relativePath: written.relativePath,
409
+ sha256: written.sha256,
410
+ byteLength: written.bytes,
411
+ },
412
+ };
413
+ });
414
+ }
415
+ function assembleRecord(input, screenRows) {
416
+ const links = safeLinkRows(input.links);
417
+ const draft = {
418
+ figmaSnapshotSchemaVersion: FIGMA_SNAPSHOT_SCHEMA_VERSION,
419
+ runId: input.runId,
420
+ provenance: {
421
+ fileKey: input.provenance.fileKey,
422
+ nodeId: input.provenance.nodeId,
423
+ version: input.provenance.version,
424
+ fetchedAt: input.provenance.fetchedAt,
425
+ },
426
+ screens: screenRows,
427
+ skippedScreens: input.skippedScreens,
428
+ ...(input.structuralScreens !== undefined
429
+ ? {
430
+ structuralScreens: input.structuralScreens,
431
+ }
432
+ : {}),
433
+ // Omit `links`/`tokens` entirely when absent so an older snapshot stays byte-minimal and the
434
+ // optional fields never serialise as `undefined` (exactOptionalPropertyTypes-safe).
435
+ ...(links !== undefined ? { links } : {}),
436
+ ...(input.tokens !== undefined ? { tokens: input.tokens } : {}),
437
+ ...(input.metrics !== undefined ? { metrics: input.metrics } : {}),
438
+ integrityHash: input.integrityHash,
439
+ redactionSummary: { totalStringsScanned: 0, stringsRedacted: 0, patternsMatched: {} },
440
+ };
441
+ const { redacted, summary } = redactQualityIntelligenceEvidence(draft);
442
+ const rehashed = rehashRecord(redacted);
443
+ const artifactHashes = artifactHashesFor(rehashed);
444
+ return {
445
+ ...rehashed,
446
+ ...(artifactHashes !== undefined ? { artifactHashes } : {}),
447
+ redactionSummary: summary,
448
+ };
449
+ }
450
+ function isAllowedSideFileName(name) {
451
+ if (name.length === 0 || name.length > MAX_SIDE_FILE_NAME_LENGTH)
452
+ return false;
453
+ return !name.startsWith(".") && SIDE_FILE_NAME_PATTERN.test(name);
454
+ }
455
+ function verifyScreenIntegrity(screen, runId) {
456
+ const expected = recomputeScreenIntegrityHash(screen);
457
+ if (screen.integrityHash !== expected) {
458
+ throw new EvidenceReadError(`Figma snapshot integrity check failed for run ${runId}: screen ${screen.screenId} hash mismatch`);
459
+ }
460
+ }
461
+ function verifyStructuralScreenIntegrity(screen, runId) {
462
+ const expected = recomputeStructuralScreenIntegrityHash(screen);
463
+ if (screen.integrityHash !== expected) {
464
+ throw new EvidenceReadError(`Figma snapshot integrity check failed for run ${runId}: structural screen ${screen.screenId} hash mismatch`);
465
+ }
466
+ }
467
+ function readVerifiedScreenImageSideFile(ctx, runId, image) {
468
+ const name = image.relativePath;
469
+ if (!isAllowedSideFileName(name)) {
470
+ throw new EvidenceReadError(`Figma snapshot integrity check failed for run ${runId}: invalid image side-file path`);
471
+ }
472
+ const runDir = join(ctx.sideFileBase, runId);
473
+ let realRunDir;
474
+ try {
475
+ realRunDir = ctx.fs.realPath(runDir);
476
+ }
477
+ catch {
478
+ throw new EvidenceReadError(`Figma snapshot integrity check failed for run ${runId}: image side-file directory missing`);
479
+ }
480
+ const lexical = resolveWithinWorkspace(realRunDir, name);
481
+ const absolute = assertContainedRealPath(ctx.fs, realRunDir, lexical, name);
482
+ const stat = lstatSync(absolute, { throwIfNoEntry: false });
483
+ if (stat === undefined || !stat.isFile() || stat.isSymbolicLink()) {
484
+ throw new EvidenceReadError(`Figma snapshot integrity check failed for run ${runId}: image side-file missing`);
485
+ }
486
+ const bytes = readFileSync(absolute);
487
+ if (bytes.byteLength !== image.byteLength || sha256Bytes(bytes) !== image.sha256) {
488
+ throw new EvidenceReadError(`Figma snapshot integrity check failed for run ${runId}: image side-file hash mismatch`);
489
+ }
490
+ return bytes;
491
+ }
492
+ function verifyScreenImageSideFile(ctx, runId, screen) {
493
+ readVerifiedScreenImageSideFile(ctx, runId, screen.image);
494
+ }
495
+ function verifyPersistedScreens(ctx, rec, runId) {
496
+ for (const screen of rec.screens) {
497
+ verifyScreenIntegrity(screen, runId);
498
+ verifyScreenImageSideFile(ctx, runId, screen);
499
+ }
500
+ for (const screen of rec.structuralScreens ?? [])
501
+ verifyStructuralScreenIntegrity(screen, runId);
502
+ }
503
+ function verifyPersistedScreenMetadata(rec, runId) {
504
+ for (const screen of rec.screens)
505
+ verifyScreenIntegrity(screen, runId);
506
+ for (const screen of rec.structuralScreens ?? [])
507
+ verifyStructuralScreenIntegrity(screen, runId);
508
+ }
509
+ // Parse one raw JSON string from a snapshot record file into a scope entry, or null when the
510
+ // file does not belong to the requested scope or cannot be parsed.
511
+ function parseScopeEntry(filePath, fileKey, nodeId) {
512
+ try {
513
+ const parsed = JSON.parse(readFileSync(filePath, "utf8"));
514
+ const prov = parsed.provenance;
515
+ if (!prov?.fileKey || prov.fileKey !== fileKey || prov.nodeId !== nodeId)
516
+ return null;
517
+ const runId = typeof parsed.runId === "string" ? parsed.runId : undefined;
518
+ if (runId === undefined)
519
+ return null;
520
+ const fetchedAt = typeof prov.fetchedAt === "string" ? prov.fetchedAt : "";
521
+ const integrityHash = typeof parsed.integrityHash === "string" ? parsed.integrityHash : "";
522
+ return { runId, fetchedAt, integrityHash };
523
+ }
524
+ catch {
525
+ return null;
526
+ }
527
+ }
528
+ // ─── Orphan sweep ─────────────────────────────────────────────────────────────────────────────
529
+ function snapshotRecordExists(qiDir, runId) {
530
+ const recordPath = join(qiDir, `${runId}${SNAPSHOT_SUFFIX}`);
531
+ return lstatSync(recordPath, { throwIfNoEntry: false })?.isFile() === true;
532
+ }
533
+ function sweepSideFileBaseEntry(qiDir, sideFileBase, name) {
534
+ if (name.endsWith(".tmp")) {
535
+ rmSync(join(sideFileBase, name), { force: true });
536
+ return;
537
+ }
538
+ if (snapshotRecordExists(qiDir, name))
539
+ return;
540
+ const runDir = join(sideFileBase, name);
541
+ const stat = lstatSync(runDir, { throwIfNoEntry: false });
542
+ if (stat?.isDirectory() === true)
543
+ rmSync(runDir, { recursive: true, force: true });
544
+ }
545
+ function sweepSideFileBaseEntries(qiDir, sideFileBase) {
546
+ const sideBaseStat = lstatSync(sideFileBase, { throwIfNoEntry: false });
547
+ if (!sideBaseStat?.isDirectory())
548
+ return;
549
+ let entries;
550
+ try {
551
+ entries = readdirSync(sideFileBase);
552
+ }
553
+ catch {
554
+ return; // non-fatal: best-effort sweep
555
+ }
556
+ for (const name of entries)
557
+ sweepSideFileBaseEntry(qiDir, sideFileBase, name);
558
+ }
559
+ function sweepManagementMetadataFiles(qiDir) {
560
+ let qiEntries;
561
+ try {
562
+ qiEntries = readdirSync(qiDir, { withFileTypes: true });
563
+ }
564
+ catch {
565
+ return;
566
+ }
567
+ for (const entry of qiEntries) {
568
+ if (!entry.isFile() || entry.isSymbolicLink())
569
+ continue;
570
+ const runId = runIdFromManagementName(entry.name);
571
+ if (runId === undefined)
572
+ continue;
573
+ if (!snapshotRecordExists(qiDir, runId))
574
+ rmSync(join(qiDir, entry.name), { force: true });
575
+ }
576
+ }
577
+ // Removes side-file dirs, stray *.tmp files, and orphaned mutable management sidecars that have no
578
+ // matching record in qiDir. Called lazily once per store instance to clean up interrupted writes.
579
+ function sweepOrphanedSideDirs(qiDir, sideFileBase) {
580
+ sweepSideFileBaseEntries(qiDir, sideFileBase);
581
+ sweepManagementMetadataFiles(qiDir);
582
+ }
583
+ // Read the fetchedAt timestamp from one snapshot file, or undefined when missing/unparseable.
584
+ function readFetchedAt(filePath) {
585
+ try {
586
+ const parsed = JSON.parse(readFileSync(filePath, "utf8"));
587
+ const prov = parsed.provenance;
588
+ if (typeof prov?.fetchedAt !== "string" || prov.fetchedAt.trim() === "") {
589
+ return undefined;
590
+ }
591
+ return Number.isNaN(Date.parse(prov.fetchedAt)) ? undefined : prov.fetchedAt;
592
+ }
593
+ catch {
594
+ return undefined;
595
+ }
596
+ }
597
+ function listRecentOp(ctx, limit = 12) {
598
+ ctx.ensureSwept();
599
+ const realBase = realBaseForRead(ctx.qiDir, ctx.fs);
600
+ if (realBase === undefined)
601
+ return [];
602
+ const boundedLimit = Number.isInteger(limit) && limit > 0 ? limit : 12;
603
+ const records = [];
604
+ for (const file of snapshotRecordFiles(realBase)) {
605
+ const fetchedAt = readFetchedAt(file.path);
606
+ if (fetchedAt === undefined)
607
+ continue;
608
+ records.push({ runId: file.runId, fetchedAt });
609
+ }
610
+ records.sort((a, b) => (a.fetchedAt > b.fetchedAt ? -1 : a.fetchedAt < b.fetchedAt ? 1 : 0));
611
+ return records.slice(0, boundedLimit).map((record) => record.runId);
612
+ }
613
+ function containedPath(path, root) {
614
+ return path === root || path.startsWith(root + sep);
615
+ }
616
+ function realSideFileBaseForRetention(qiDir, sideFileBase) {
617
+ const sideStat = lstatSync(sideFileBase, { throwIfNoEntry: false });
618
+ if (sideStat === undefined) {
619
+ return undefined;
620
+ }
621
+ if (sideStat.isSymbolicLink() || !sideStat.isDirectory()) {
622
+ throw new EvidenceWriteError("Figma snapshot side-file root is not a real directory");
623
+ }
624
+ const realQiDir = realpathSync(qiDir);
625
+ const realSideFileBase = realpathSync(sideFileBase);
626
+ if (!containedPath(realSideFileBase, realQiDir)) {
627
+ throw new EvidenceWriteError("Figma snapshot side-file root escapes the QI evidence directory");
628
+ }
629
+ return realSideFileBase;
630
+ }
631
+ function sideDirForRetention(realSideFileBase, runId) {
632
+ if (realSideFileBase === undefined) {
633
+ return undefined;
634
+ }
635
+ const runDir = join(realSideFileBase, runId);
636
+ if (!containedPath(runDir, realSideFileBase)) {
637
+ throw new EvidenceWriteError("Figma snapshot side-file dir escapes retention root");
638
+ }
639
+ const stat = lstatSync(runDir, { throwIfNoEntry: false });
640
+ if (stat?.isSymbolicLink() === true) {
641
+ throw new EvidenceWriteError("Figma snapshot side-file dir is a symlink");
642
+ }
643
+ if (stat !== undefined && !stat.isDirectory()) {
644
+ throw new EvidenceWriteError("Figma snapshot side-file dir is not a real directory");
645
+ }
646
+ return stat === undefined ? undefined : runDir;
647
+ }
648
+ /**
649
+ * Enforce store-level retention for Figma snapshot records. Deletes the RECORD first, then the
650
+ * side-file dir, so a partially-retained snapshot is never in a state where the record is gone
651
+ * but the side-files remain (the side-files are unreachable without the record). A retained
652
+ * record's side-dir is never touched.
653
+ *
654
+ * Wiring: call this alongside `deleteQualityIntelligenceRun` in the QI retention orchestrator
655
+ * (#274). The profiles are intentionally separate because snapshot retention may differ from
656
+ * run-manifest retention (snapshots are larger, longer-lived evidence artifacts).
657
+ */
658
+ export function enforceFigmaSnapshotRetention(evidenceDir, profile) {
659
+ const qiDir = join(evidenceDir, QI_SUBDIR);
660
+ const sideFileBase = join(qiDir, SIDE_FILE_SUBDIR);
661
+ const dirStat = lstatSync(qiDir, { throwIfNoEntry: false });
662
+ if (!dirStat?.isDirectory())
663
+ return;
664
+ const realSideFileBase = realSideFileBaseForRetention(qiDir, sideFileBase);
665
+ // Scan for snapshot records and sort by fetchedAt ascending so we remove the oldest first.
666
+ const records = [];
667
+ for (const file of snapshotRecordFiles(qiDir)) {
668
+ const fetchedAt = readFetchedAt(file.path);
669
+ // Unparseable records are skipped — do not evict conservatively.
670
+ if (fetchedAt !== undefined)
671
+ records.push({ runId: file.runId, fetchedAt });
672
+ }
673
+ // Sort oldest first (ascending fetchedAt) so we evict the oldest beyond the cap.
674
+ records.sort((a, b) => (a.fetchedAt < b.fetchedAt ? -1 : a.fetchedAt > b.fetchedAt ? 1 : 0));
675
+ const toEvict = records.slice(0, Math.max(0, records.length - profile.maxRecords));
676
+ for (const { runId } of toEvict) {
677
+ const sideDir = sideDirForRetention(realSideFileBase, runId);
678
+ // After side-dir preflight succeeds, delete the record first — after this the side-dir is
679
+ // unreachable by any normal path.
680
+ rmSync(join(qiDir, `${runId}${SNAPSHOT_SUFFIX}`), { force: true });
681
+ rmSync(join(qiDir, `${runId}${SNAPSHOT_MANAGEMENT_SUFFIX}`), { force: true });
682
+ // Best-effort: remove the side-file dir; failure is non-fatal for lazy store reads because
683
+ // ensureSwept catches retention errors and retries on the next store instance.
684
+ if (sideDir !== undefined) {
685
+ rmSync(sideDir, { recursive: true, force: true });
686
+ }
687
+ }
688
+ }
689
+ function recordOp(ctx, input) {
690
+ assertValidRunId(input.runId);
691
+ ctx.ensureSwept();
692
+ const realBase = realBaseForWrite(ctx.qiDir, ctx.fs);
693
+ const recordPath = containedRecordPath(input.runId, realBase, ctx.fs);
694
+ // Write-once pre-check BEFORE any side-file is written so a rejected re-record leaves no
695
+ // partial render bytes behind. `atomicWriteOnce` re-checks via O_EXCL to close the TOCTOU gap.
696
+ assertSnapshotAbsent(recordPath);
697
+ let rows;
698
+ try {
699
+ rows = writeScreenSideFiles(ctx.sideFileBase, input.runId, input.screens, ctx.fs, ctx.randomSuffix);
700
+ }
701
+ catch (error) {
702
+ // Side-file write failed: best-effort remove the run's side-dir so it is not orphaned.
703
+ rmSync(join(ctx.sideFileBase, input.runId), { recursive: true, force: true });
704
+ throw error;
705
+ }
706
+ try {
707
+ atomicWriteOnce(recordPath, JSON.stringify(assembleRecord(input, rows)), ctx.randomSuffix);
708
+ }
709
+ catch (error) {
710
+ // Record write failed after side-files succeeded: remove side-dir to avoid orphaning.
711
+ rmSync(join(ctx.sideFileBase, input.runId), { recursive: true, force: true });
712
+ throw error;
713
+ }
714
+ return { recordPath, sideFileDir: join(ctx.sideFileBase, input.runId) };
715
+ }
716
+ function loadOp(ctx, runId) {
717
+ assertValidRunId(runId);
718
+ ctx.ensureSwept();
719
+ const realBase = realBaseForRead(ctx.qiDir, ctx.fs);
720
+ if (realBase === undefined)
721
+ return undefined;
722
+ const target = join(realBase, `${runId}${SNAPSHOT_SUFFIX}`);
723
+ if (lstatSync(target, { throwIfNoEntry: false })?.isFile() !== true)
724
+ return undefined;
725
+ let parsed;
726
+ try {
727
+ parsed = JSON.parse(readFileSync(target, "utf8"));
728
+ }
729
+ catch (error) {
730
+ throw new EvidenceReadError(`Figma snapshot is not valid JSON: ${error instanceof Error ? error.message : "unknown"}`);
731
+ }
732
+ if (!validateFigmaSnapshotRecord(parsed).ok)
733
+ return undefined;
734
+ const rec = parsed;
735
+ // Integrity check: recompute and reject on mismatch. Screen rows are verified before the
736
+ // snapshot-level hash so stale/tampered IR or image refs cannot hide behind an old screen hash.
737
+ verifyPersistedScreens(ctx, rec, runId);
738
+ const expected = recomputeSnapshotIntegrityHash(rec);
739
+ if (rec.integrityHash !== expected) {
740
+ throw new EvidenceReadError(`Figma snapshot integrity check failed for run ${runId}: hash mismatch`);
741
+ }
742
+ return verifyOptionalArtifactHashes(rec, runId);
743
+ }
744
+ function loadMetadataOp(ctx, runId) {
745
+ assertValidRunId(runId);
746
+ ctx.ensureSwept();
747
+ const realBase = realBaseForRead(ctx.qiDir, ctx.fs);
748
+ if (realBase === undefined)
749
+ return undefined;
750
+ const target = join(realBase, `${runId}${SNAPSHOT_SUFFIX}`);
751
+ if (lstatSync(target, { throwIfNoEntry: false })?.isFile() !== true)
752
+ return undefined;
753
+ let parsed;
754
+ try {
755
+ parsed = JSON.parse(readFileSync(target, "utf8"));
756
+ }
757
+ catch (error) {
758
+ throw new EvidenceReadError(`Figma snapshot is not valid JSON: ${error instanceof Error ? error.message : "unknown"}`);
759
+ }
760
+ if (!validateFigmaSnapshotRecord(parsed).ok)
761
+ return undefined;
762
+ const rec = parsed;
763
+ verifyPersistedScreenMetadata(rec, runId);
764
+ const expected = recomputeSnapshotIntegrityHash(rec);
765
+ if (rec.integrityHash !== expected) {
766
+ throw new EvidenceReadError(`Figma snapshot integrity check failed for run ${runId}: hash mismatch`);
767
+ }
768
+ return verifyOptionalArtifactHashes(rec, runId);
769
+ }
770
+ function loadImageOp(ctx, runId, image) {
771
+ assertValidRunId(runId);
772
+ ctx.ensureSwept();
773
+ const bytes = readVerifiedScreenImageSideFile(ctx, runId, image);
774
+ return {
775
+ mimeType: image.mimeType,
776
+ bytes,
777
+ sha256: image.sha256,
778
+ byteLength: image.byteLength,
779
+ };
780
+ }
781
+ function loadUserMetadataOp(ctx, runId) {
782
+ assertValidRunId(runId);
783
+ ctx.ensureSwept();
784
+ const realBase = realBaseForRead(ctx.qiDir, ctx.fs);
785
+ if (realBase === undefined)
786
+ return undefined;
787
+ const target = containedManagementPath(runId, realBase, ctx.fs);
788
+ if (lstatSync(target, { throwIfNoEntry: false })?.isFile() !== true)
789
+ return undefined;
790
+ try {
791
+ return parseUserMetadata(JSON.parse(readFileSync(target, "utf8")), runId);
792
+ }
793
+ catch {
794
+ return undefined;
795
+ }
796
+ }
797
+ function updateUserMetadataOp(ctx, runId, input) {
798
+ assertValidRunId(runId);
799
+ const record = loadMetadataOp(ctx, runId);
800
+ if (record === undefined) {
801
+ throw new EvidenceWriteError("Figma snapshot does not exist");
802
+ }
803
+ const existing = loadUserMetadataOp(ctx, runId);
804
+ const nextDisplayName = input.displayName === undefined
805
+ ? existing?.displayName
806
+ : normalizeDisplayName(input.displayName);
807
+ const next = {
808
+ ...(nextDisplayName !== undefined ? { displayName: nextDisplayName } : {}),
809
+ updatedAt: input.updatedAt ?? new Date().toISOString(),
810
+ };
811
+ const realBase = realBaseForWrite(ctx.qiDir, ctx.fs);
812
+ atomicWriteMutable(containedManagementPath(record.runId, realBase, ctx.fs), metadataToJson(runId, next), ctx.randomSuffix);
813
+ return next;
814
+ }
815
+ function deleteSnapshotOp(ctx, runId) {
816
+ assertValidRunId(runId);
817
+ ctx.ensureSwept();
818
+ const realBase = realBaseForRead(ctx.qiDir, ctx.fs);
819
+ if (realBase === undefined) {
820
+ return { runId, recordDeleted: false, sideFileDirDeleted: false, metadataDeleted: false };
821
+ }
822
+ const recordPath = containedRecordPath(runId, realBase, ctx.fs);
823
+ const metadataPath = containedManagementPath(runId, realBase, ctx.fs);
824
+ const sideFileDir = containedSideFileRunDir(ctx, runId);
825
+ const recordDeleted = lstatSync(recordPath, { throwIfNoEntry: false })?.isFile() === true;
826
+ const metadataDeleted = lstatSync(metadataPath, { throwIfNoEntry: false })?.isFile() === true;
827
+ const sideStat = lstatSync(sideFileDir, { throwIfNoEntry: false });
828
+ const sideFileDirDeleted = sideStat !== undefined;
829
+ rmSync(recordPath, { force: true });
830
+ rmSync(metadataPath, { force: true });
831
+ if (sideStat !== undefined)
832
+ rmSync(sideFileDir, { recursive: true, force: true });
833
+ return { runId, recordDeleted, sideFileDirDeleted, metadataDeleted };
834
+ }
835
+ function listByScopeOp(ctx, fileKey, nodeId) {
836
+ ctx.ensureSwept();
837
+ const realBase = realBaseForRead(ctx.qiDir, ctx.fs);
838
+ if (realBase === undefined)
839
+ return [];
840
+ const results = [];
841
+ for (const file of snapshotRecordFiles(realBase)) {
842
+ const entry = parseScopeEntry(file.path, fileKey, nodeId);
843
+ if (entry !== null)
844
+ results.push(entry);
845
+ }
846
+ results.sort((a, b) => (a.fetchedAt > b.fetchedAt ? -1 : a.fetchedAt < b.fetchedAt ? 1 : 0));
847
+ return results;
848
+ }
849
+ // ─── Store factory ────────────────────────────────────────────────────────────────────────────
850
+ export function createNodeFigmaSnapshotStore(evidenceDir, options = {}) {
851
+ const qiDir = join(evidenceDir, QI_SUBDIR);
852
+ const sideFileBase = join(qiDir, SIDE_FILE_SUBDIR);
853
+ const maxRecords = options.retention?.maxRecords ?? DEFAULT_FIGMA_SNAPSHOT_MAX_RECORDS;
854
+ let swept = false;
855
+ const ctx = {
856
+ qiDir,
857
+ sideFileBase,
858
+ fs: options.fs ?? nodeWorkspaceFs,
859
+ randomSuffix: options.randomSuffix ?? randomUUID,
860
+ ensureSwept() {
861
+ if (swept)
862
+ return;
863
+ swept = true;
864
+ sweepOrphanedSideDirs(qiDir, sideFileBase);
865
+ // Issue #1323 AC4 — make retention operational. Runs once per store instance (the sweep is
866
+ // already guarded against concurrent re-entry by `swept`). A non-positive cap disables it so
867
+ // a deployment can opt out without removing the call site. Best-effort: `ensureSwept` runs at
868
+ // the head of read ops too, so a transient eviction fault (e.g. rmSync EPERM/EBUSY) must never
869
+ // surface as a read error; it is swallowed and retried on the next store instance.
870
+ if (Number.isFinite(maxRecords) && maxRecords > 0) {
871
+ try {
872
+ enforceFigmaSnapshotRetention(evidenceDir, { maxRecords });
873
+ }
874
+ catch {
875
+ // ignore; retention is best-effort and re-runs once per fresh store instance
876
+ }
877
+ }
878
+ },
879
+ };
880
+ return {
881
+ record: (input) => recordOp(ctx, input),
882
+ load: (runId) => loadOp(ctx, runId),
883
+ loadMetadata: (runId) => loadMetadataOp(ctx, runId),
884
+ loadImage: (runId, image) => loadImageOp(ctx, runId, image),
885
+ loadUserMetadata: (runId) => loadUserMetadataOp(ctx, runId),
886
+ updateUserMetadata: (runId, input) => updateUserMetadataOp(ctx, runId, input),
887
+ deleteSnapshot: (runId) => deleteSnapshotOp(ctx, runId),
888
+ location: (runId) => {
889
+ assertValidRunId(runId);
890
+ const realBase = realBaseForRead(qiDir, ctx.fs);
891
+ return realBase === undefined
892
+ ? join(qiDir, `${runId}${SNAPSHOT_SUFFIX}`)
893
+ : containedRecordPath(runId, realBase, ctx.fs);
894
+ },
895
+ listByScope: (fileKey, nodeId) => listByScopeOp(ctx, fileKey, nodeId),
896
+ listRecent: (limit) => listRecentOp(ctx, limit),
897
+ };
898
+ }