@penpair/core 0.1.3 → 0.3.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 (149) hide show
  1. package/dist/adapters.d.ts +16 -1
  2. package/dist/adapters.js +62 -19
  3. package/dist/approvals.d.ts +32 -0
  4. package/dist/approvals.js +213 -0
  5. package/dist/atomic-output.d.ts +5 -0
  6. package/dist/atomic-output.js +33 -0
  7. package/dist/audit-rules.d.ts +60 -0
  8. package/dist/audit-rules.js +443 -0
  9. package/dist/backup-note.d.ts +4 -0
  10. package/dist/backup-note.js +49 -0
  11. package/dist/baseline-coverage.d.ts +25 -0
  12. package/dist/baseline-coverage.js +248 -0
  13. package/dist/baselines-config.d.ts +25 -0
  14. package/dist/baselines-config.js +150 -11
  15. package/dist/baselines.d.ts +37 -8
  16. package/dist/baselines.js +362 -59
  17. package/dist/cli-arguments.d.ts +7 -0
  18. package/dist/cli-arguments.js +47 -0
  19. package/dist/cli-main.d.ts +13 -1
  20. package/dist/cli-main.js +2107 -247
  21. package/dist/client.d.ts +30 -6
  22. package/dist/client.js +160 -35
  23. package/dist/command-result.d.ts +159 -0
  24. package/dist/command-result.js +468 -0
  25. package/dist/coordination.d.ts +15 -0
  26. package/dist/coordination.js +184 -0
  27. package/dist/credentials.d.ts +21 -0
  28. package/dist/credentials.js +105 -0
  29. package/dist/deep-check.js +57 -9
  30. package/dist/design-spec.d.ts +55 -0
  31. package/dist/design-spec.js +275 -0
  32. package/dist/endpoint.d.ts +8 -1
  33. package/dist/endpoint.js +133 -21
  34. package/dist/errors.d.ts +2 -1
  35. package/dist/errors.js +8 -4
  36. package/dist/export.d.ts +18 -0
  37. package/dist/export.js +113 -10
  38. package/dist/file-archive.d.ts +16 -0
  39. package/dist/file-archive.js +58 -0
  40. package/dist/file-guard.d.ts +8 -0
  41. package/dist/file-guard.js +58 -0
  42. package/dist/formats.d.ts +4 -0
  43. package/dist/formats.js +229 -0
  44. package/dist/index.d.ts +34 -1
  45. package/dist/index.js +34 -1
  46. package/dist/inspection.d.ts +106 -0
  47. package/dist/inspection.js +657 -0
  48. package/dist/instance/config-schema.d.ts +422 -0
  49. package/dist/instance/config-schema.js +435 -0
  50. package/dist/instance/config.d.ts +29 -0
  51. package/dist/instance/config.js +191 -29
  52. package/dist/instance/connect.d.ts +10 -1
  53. package/dist/instance/connect.js +178 -30
  54. package/dist/instance/connection.d.ts +39 -4
  55. package/dist/instance/connection.js +198 -14
  56. package/dist/instance/gitignore.js +5 -2
  57. package/dist/instance/init.js +32 -7
  58. package/dist/instance/runner.js +32 -10
  59. package/dist/instance/setup.js +46 -13
  60. package/dist/instance/tty.js +28 -8
  61. package/dist/journal.d.ts +87 -0
  62. package/dist/journal.js +423 -0
  63. package/dist/kit-smoke.d.ts +31 -0
  64. package/dist/kit-smoke.js +145 -0
  65. package/dist/kit-source.d.ts +2 -0
  66. package/dist/kit-source.js +279 -0
  67. package/dist/kit.d.ts +25 -0
  68. package/dist/kit.js +158 -0
  69. package/dist/layout-policy.d.ts +2 -0
  70. package/dist/layout-policy.js +86 -0
  71. package/dist/layout.d.ts +21 -0
  72. package/dist/layout.js +36 -0
  73. package/dist/live-smoke.d.ts +8 -0
  74. package/dist/live-smoke.js +23 -0
  75. package/dist/local-profile.js +2 -1
  76. package/dist/mcp-server.d.ts +12 -0
  77. package/dist/mcp-server.js +415 -0
  78. package/dist/native-api.d.ts +15 -0
  79. package/dist/native-api.js +78 -0
  80. package/dist/onboard.d.ts +10 -0
  81. package/dist/onboard.js +41 -0
  82. package/dist/operation.d.ts +22 -3
  83. package/dist/operation.js +341 -31
  84. package/dist/page-session.d.ts +23 -0
  85. package/dist/page-session.js +142 -0
  86. package/dist/png.d.ts +5 -0
  87. package/dist/png.js +22 -0
  88. package/dist/portable-tokens.d.ts +22 -0
  89. package/dist/portable-tokens.js +331 -0
  90. package/dist/probe.d.ts +6 -2
  91. package/dist/probe.js +64 -11
  92. package/dist/project-input.d.ts +6 -0
  93. package/dist/project-input.js +65 -0
  94. package/dist/project-snippets.js +29 -12
  95. package/dist/project-status.d.ts +24 -0
  96. package/dist/project-status.js +188 -0
  97. package/dist/registry.d.ts +2 -1
  98. package/dist/registry.js +224 -53
  99. package/dist/run-file.d.ts +26 -0
  100. package/dist/run-file.js +199 -0
  101. package/dist/shots.d.ts +43 -0
  102. package/dist/shots.js +172 -0
  103. package/dist/skill-instructions.d.ts +13 -0
  104. package/dist/skill-instructions.js +144 -0
  105. package/dist/skills.d.ts +7 -0
  106. package/dist/skills.js +163 -46
  107. package/dist/snippet.d.ts +3 -2
  108. package/dist/snippet.js +33 -9
  109. package/dist/snippets/archive.d.ts +9 -0
  110. package/dist/snippets/archive.js +10 -4
  111. package/dist/snippets/read.d.ts +1 -1
  112. package/dist/snippets/read.js +78 -41
  113. package/dist/structure-snapshot.d.ts +85 -0
  114. package/dist/structure-snapshot.js +525 -0
  115. package/dist/token-edit.d.ts +53 -0
  116. package/dist/token-edit.js +352 -0
  117. package/dist/token-projection.d.ts +56 -0
  118. package/dist/token-projection.js +286 -0
  119. package/dist/tokens-diff.d.ts +19 -6
  120. package/dist/tokens-diff.js +182 -18
  121. package/dist/version.d.ts +1 -1
  122. package/dist/version.js +1 -1
  123. package/package.json +1 -1
  124. package/skills/penpair-design/SKILL.md +20 -45
  125. package/skills/penpair-design/references/penpot-plugin-gotchas.md +58 -2
  126. package/skills/penpair-design/references/project-facts.md +3 -1
  127. package/skills/penpair-design/references/write-protocol.md +15 -3
  128. package/skills/penpair-handoff/SKILL.md +16 -0
  129. package/skills/penpair-handoff/references/comparison-companion.md +13 -0
  130. package/skills/penpair-handoff/references/handoff-flow.md +13 -0
  131. package/skills/penpair-recipes/SKILL.md +21 -0
  132. package/skills/penpair-recipes/references/build-flow.md +11 -0
  133. package/skills/penpair-setup/SKILL.md +8 -2
  134. package/dist/cli.d.ts +0 -2
  135. package/dist/cli.js +0 -3
  136. package/dist/docker/lifecycle.d.ts +0 -96
  137. package/dist/docker/lifecycle.js +0 -273
  138. package/dist/docker/secrets.d.ts +0 -11
  139. package/dist/docker/secrets.js +0 -38
  140. package/dist/docker/template.d.ts +0 -8
  141. package/dist/docker/template.js +0 -158
  142. package/dist/docker-profile.d.ts +0 -114
  143. package/dist/docker-profile.js +0 -18
  144. package/dist/instance/lifecycle.d.ts +0 -129
  145. package/dist/instance/lifecycle.js +0 -336
  146. package/dist/instance/secrets.d.ts +0 -11
  147. package/dist/instance/secrets.js +0 -38
  148. package/dist/instance/template.d.ts +0 -8
  149. package/dist/instance/template.js +0 -158
@@ -0,0 +1,443 @@
1
+ import { validateDesignInspection } from "./design-spec.js";
2
+ import { observationHash } from "./journal.js";
3
+ export function validateAuditRules(raw) {
4
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
5
+ throw new Error("audit must be an object");
6
+ }
7
+ const v = raw;
8
+ const keys = [
9
+ "contrast",
10
+ "library",
11
+ "touchTargets",
12
+ "names",
13
+ "blankText",
14
+ "interactiveIds",
15
+ "allowBlank",
16
+ "nameSegments",
17
+ "suppressions",
18
+ ];
19
+ if (Object.keys(v).some((k) => !keys.includes(k))) {
20
+ throw new Error("Unknown audit setting");
21
+ }
22
+ for (const key of [
23
+ "contrast",
24
+ "library",
25
+ "touchTargets",
26
+ "names",
27
+ "blankText",
28
+ ]) {
29
+ if (v[key] !== undefined && typeof v[key] !== "boolean") {
30
+ throw new Error("Audit rule toggles must be booleans");
31
+ }
32
+ }
33
+ for (const entries of [
34
+ v.interactiveIds,
35
+ v.allowBlank,
36
+ ]) {
37
+ if (entries !== undefined &&
38
+ (!Array.isArray(entries) ||
39
+ entries.length > 1000 ||
40
+ entries.some((s) => typeof s !== "string" || !s || s.length > 200))) {
41
+ throw new Error("Audit ID lists must have at most 1000 explicit IDs");
42
+ }
43
+ }
44
+ if (v.nameSegments !== undefined &&
45
+ (!Number.isInteger(v.nameSegments) || v.nameSegments < 1 || v.nameSegments > 10)) {
46
+ throw new Error("Audit nameSegments must be 1–10");
47
+ }
48
+ if (v.suppressions !== undefined &&
49
+ (!Array.isArray(v.suppressions) ||
50
+ v.suppressions.length > 1000 ||
51
+ v.suppressions.some((s) => !s ||
52
+ ![
53
+ "contrast",
54
+ "library",
55
+ "touch-target",
56
+ "names",
57
+ "blank-text",
58
+ ].includes(s.rule) ||
59
+ typeof s.nodeId !== "string" ||
60
+ !s.nodeId ||
61
+ typeof s.reason !== "string" ||
62
+ !s.reason.trim() ||
63
+ s.reason.length > 1000))) {
64
+ throw new Error("Audit suppressions require exact rule/node ID and reason");
65
+ }
66
+ return structuredClone(v);
67
+ }
68
+ /** Opaque sRGB subset only. Alpha/multiple paints/images require a richer compositing model. */
69
+ export function opaqueRgb(raw) {
70
+ if (typeof raw !== "string") {
71
+ return;
72
+ }
73
+ const match = /^#([a-f0-9]{6})(ff)?$/i.exec(raw);
74
+ if (!match) {
75
+ return;
76
+ }
77
+ return [
78
+ 0,
79
+ 2,
80
+ 4,
81
+ ].map((i) => parseInt(match[1].slice(i, i + 2), 16) / 255);
82
+ }
83
+ export function contrastRatio(a, b) {
84
+ const luminance = (rgb) => rgb
85
+ .map((c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4))
86
+ .reduce((n, c, i) => n +
87
+ c *
88
+ [
89
+ 0.2126,
90
+ 0.7152,
91
+ 0.0722,
92
+ ][i], 0);
93
+ const x = luminance(a);
94
+ const y = luminance(b);
95
+ return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05);
96
+ }
97
+ function resolvedColors(tokens, shapes) {
98
+ const result = new Map();
99
+ if (!tokens ||
100
+ tokens.schemaVersion !== 1 ||
101
+ tokens.kind !== "token-inspection" ||
102
+ tokens.complete !== true ||
103
+ tokens.redacted === true ||
104
+ tokens.fileId !== shapes.fileId ||
105
+ tokens.revision !== shapes.revision ||
106
+ !Array.isArray(tokens.sets)) {
107
+ return result;
108
+ }
109
+ for (const set of [
110
+ ...tokens.sets,
111
+ ].sort((a, b) => Number(a.order) - Number(b.order))) {
112
+ if (set.active !== true || !Array.isArray(set.tokens)) {
113
+ continue;
114
+ }
115
+ for (const token of set.tokens) {
116
+ if (typeof token.name !== "string") {
117
+ continue;
118
+ }
119
+ result.delete(token.name);
120
+ if (token.type === "color" &&
121
+ (!Array.isArray(token.unknowns) ||
122
+ !token.unknowns.some((k) => k === "resolvedValue" || k === "type"))) {
123
+ const rgb = opaqueRgb(token.resolvedValue);
124
+ if (rgb) {
125
+ result.set(token.name, rgb);
126
+ }
127
+ }
128
+ }
129
+ }
130
+ return result;
131
+ }
132
+ export function auditDesignRules(raw, rawRules, tokenInspection, limit = 1000) {
133
+ const source = validateDesignInspection(raw);
134
+ const rules = validateAuditRules(rawRules);
135
+ if (!Number.isInteger(limit) || limit < 1 || limit > 10000) {
136
+ throw new Error("Audit finding limit must be 1–10000");
137
+ }
138
+ const findings = [];
139
+ let totalFindings = 0;
140
+ let suppressed = 0;
141
+ let contrastMeasured = 0;
142
+ let contrastUnknown = 0;
143
+ let copies = 0;
144
+ let knownComponents = 0;
145
+ let unknownComponents = 0;
146
+ const rows = new Map(source.nodes.map((n) => [
147
+ String(n.id),
148
+ n,
149
+ ]));
150
+ const colors = resolvedColors(tokenInspection, source);
151
+ const add = (finding) => {
152
+ totalFindings++;
153
+ const suppression = rules.suppressions?.find((s) => s.rule === finding.rule && s.nodeId === finding.nodeId);
154
+ if (suppression) {
155
+ suppressed++;
156
+ }
157
+ if (findings.length < limit) {
158
+ findings.push({
159
+ ...finding,
160
+ ...(suppression
161
+ ? {
162
+ suppression: suppression.reason,
163
+ }
164
+ : {}),
165
+ });
166
+ }
167
+ };
168
+ const paint = (node) => {
169
+ const style = node.style;
170
+ const bindings = node.tokens;
171
+ if (!style ||
172
+ (style.opacity !== 1 && style.opacity !== undefined) ||
173
+ (style.blendMode !== "normal" && style.blendMode !== undefined)) {
174
+ return {
175
+ reason: "Unknown/transparent/blended node style",
176
+ };
177
+ }
178
+ const fills = style.fills;
179
+ if (!Array.isArray(fills)) {
180
+ return {
181
+ reason: "Fill observation unavailable",
182
+ };
183
+ }
184
+ if (!fills.length) {
185
+ return {
186
+ absent: true,
187
+ };
188
+ }
189
+ if (fills.length !== 1 || !fills[0] || typeof fills[0] !== "object") {
190
+ return {
191
+ reason: "Multiple or unsupported paints",
192
+ };
193
+ }
194
+ const fill = fills[0];
195
+ if ((fill.fillOpacity !== undefined && fill.fillOpacity !== 1) ||
196
+ fill.fillColorGradient ||
197
+ fill.fillImage ||
198
+ (fill.fillType && fill.fillType !== "solid")) {
199
+ return {
200
+ reason: "Gradient/image/alpha paint",
201
+ };
202
+ }
203
+ const name = bindings?.fill;
204
+ if (typeof name !== "string" || !colors.has(name)) {
205
+ return {
206
+ reason: "No independently resolved opaque bound color",
207
+ };
208
+ }
209
+ const actual = opaqueRgb(fill.fillColor);
210
+ if (!actual || observationHash(actual) !== observationHash(colors.get(name))) {
211
+ return {
212
+ reason: "Observed paint differs from resolved bound color",
213
+ };
214
+ }
215
+ return {
216
+ rgb: colors.get(name),
217
+ };
218
+ };
219
+ const backgroundCache = new Map();
220
+ const backgroundFor = (parent) => {
221
+ const visited = [];
222
+ let result = {
223
+ reason: "No opaque ancestor background",
224
+ };
225
+ while (typeof parent === "string") {
226
+ if (backgroundCache.has(parent)) {
227
+ result = backgroundCache.get(parent);
228
+ break;
229
+ }
230
+ const ancestor = rows.get(parent);
231
+ result = paint(ancestor);
232
+ visited.push(parent);
233
+ if (!result.absent) {
234
+ break;
235
+ }
236
+ parent = ancestor.parentId;
237
+ }
238
+ if (result.absent) {
239
+ result = {
240
+ reason: "No opaque ancestor background",
241
+ };
242
+ }
243
+ for (const id of visited) {
244
+ backgroundCache.set(id, result);
245
+ }
246
+ return result;
247
+ };
248
+ const rootId = source.nodes.find((n) => n.parentId === null)?.id;
249
+ const largest = [];
250
+ const considered = source.nodes.filter((n) => n.parentId !== null &&
251
+ n.geometry?.hidden !== true);
252
+ for (const node of considered) {
253
+ const id = String(node.id);
254
+ const geometry = node.geometry;
255
+ if (rules.names) {
256
+ if ([
257
+ "Board",
258
+ "Rectangle",
259
+ "Group",
260
+ ].includes(String(node.name))) {
261
+ add({
262
+ rule: "names",
263
+ nodeId: id,
264
+ severity: "info",
265
+ state: "finding",
266
+ detail: "Default layer name",
267
+ });
268
+ }
269
+ if (node.type === "board" &&
270
+ node.parentId === rootId &&
271
+ (String(node.name).split("/").length !== (rules.nameSegments ?? 3) ||
272
+ String(node.name)
273
+ .split("/")
274
+ .some((s) => !s.trim()))) {
275
+ add({
276
+ rule: "names",
277
+ nodeId: id,
278
+ severity: "info",
279
+ state: "finding",
280
+ detail: "Frame name differs from declared slash-separated page/state/device convention",
281
+ });
282
+ }
283
+ }
284
+ if (rules.blankText && node.type === "text" && !rules.allowBlank?.includes(id)) {
285
+ const text = node.text?.characters;
286
+ if (typeof text !== "string") {
287
+ add({
288
+ rule: "blank-text",
289
+ nodeId: id,
290
+ severity: "info",
291
+ state: "unknown",
292
+ detail: "Text not observed",
293
+ });
294
+ }
295
+ else if (!text.trim() || /^(?:lorem ipsum|placeholder|todo)(?:\b|$)/i.test(text.trim())) {
296
+ add({
297
+ rule: "blank-text",
298
+ nodeId: id,
299
+ severity: "info",
300
+ state: "finding",
301
+ detail: "Blank or placeholder text",
302
+ });
303
+ }
304
+ }
305
+ if (rules.library) {
306
+ const component = node.component;
307
+ if (typeof component?.isComponentCopyInstance !== "boolean") {
308
+ unknownComponents++;
309
+ }
310
+ else {
311
+ knownComponents++;
312
+ if (component.isComponentCopyInstance) {
313
+ copies++;
314
+ }
315
+ else if ([
316
+ "board",
317
+ "group",
318
+ ].includes(String(node.type)) &&
319
+ typeof geometry?.width === "number" &&
320
+ typeof geometry?.height === "number") {
321
+ largest.push({
322
+ id,
323
+ area: geometry.width * geometry.height,
324
+ });
325
+ }
326
+ }
327
+ }
328
+ if (rules.contrast && node.type === "text") {
329
+ let foreground = paint(node);
330
+ const background = backgroundFor(node.parentId);
331
+ const unknowns = node.unknowns;
332
+ if (unknowns?.includes("text.mixedRangeStyles")) {
333
+ foreground = {
334
+ reason: "Mixed text range styles",
335
+ };
336
+ }
337
+ if (!foreground.rgb || !background.rgb) {
338
+ contrastUnknown++;
339
+ add({
340
+ rule: "contrast",
341
+ nodeId: id,
342
+ severity: "info",
343
+ state: "unknown",
344
+ detail: foreground.reason ?? background.reason ?? "Opaque bound colors unavailable",
345
+ });
346
+ }
347
+ else {
348
+ contrastMeasured++;
349
+ const typography = node.text?.typography;
350
+ const size = Number(typography?.fontSize);
351
+ const weight = Number(typography?.fontWeight);
352
+ const threshold = Number.isFinite(size) && (size >= 24 || (size >= 56 / 3 && weight >= 700)) ? 3 : 4.5;
353
+ const ratio = contrastRatio(foreground.rgb, background.rgb);
354
+ if (ratio < threshold) {
355
+ add({
356
+ rule: "contrast",
357
+ nodeId: id,
358
+ severity: "warning",
359
+ state: "finding",
360
+ detail: "Observed opaque text/background contrast below the review threshold",
361
+ evidence: {
362
+ ratio,
363
+ threshold,
364
+ assumption: "Design units treated as CSS px; exceptions and implementation rendering unassessed",
365
+ },
366
+ });
367
+ }
368
+ }
369
+ }
370
+ }
371
+ if (rules.touchTargets) {
372
+ for (const id of rules.interactiveIds ?? []) {
373
+ const node = rows.get(id);
374
+ const g = node?.geometry;
375
+ if (!node ||
376
+ typeof g?.width !== "number" ||
377
+ typeof g.height !== "number" ||
378
+ !Number.isFinite(g.width) ||
379
+ !Number.isFinite(g.height) ||
380
+ g.width < 0 ||
381
+ g.height < 0) {
382
+ add({
383
+ rule: "touch-target",
384
+ nodeId: id,
385
+ severity: "info",
386
+ state: "unknown",
387
+ detail: "Explicit interactive target dimensions unavailable",
388
+ });
389
+ }
390
+ else if (g.width < 44 || g.height < 44) {
391
+ add({
392
+ rule: "touch-target",
393
+ nodeId: id,
394
+ severity: g.width < 24 || g.height < 24 ? "warning" : "info",
395
+ state: "finding",
396
+ detail: g.width < 24 || g.height < 24
397
+ ? "Below 24-unit size signal"
398
+ : "Below 44-unit preference",
399
+ evidence: {
400
+ width: g.width,
401
+ height: g.height,
402
+ assessment: "size-signal-only; spacing, exceptions, input semantics and CSS scale unassessed",
403
+ },
404
+ });
405
+ }
406
+ }
407
+ }
408
+ largest.sort((a, b) => b.area - a.area || a.id.localeCompare(b.id));
409
+ return {
410
+ schemaVersion: 1,
411
+ kind: "design-audit",
412
+ source: {
413
+ fileId: source.fileId,
414
+ pageId: source.pageId,
415
+ revision: source.revision,
416
+ hash: observationHash(source.nodes),
417
+ },
418
+ advisory: true,
419
+ accessibilityCompliance: "not-assessed",
420
+ complete: totalFindings <= limit,
421
+ totalFindings,
422
+ suppressed,
423
+ findings,
424
+ contrast: {
425
+ measured: contrastMeasured,
426
+ unknown: contrastUnknown,
427
+ },
428
+ library: rules.library
429
+ ? {
430
+ instances: copies,
431
+ knownNodes: knownComponents,
432
+ unknownNodes: unknownComponents,
433
+ shareOfKnown: knownComponents ? copies / knownComponents : null,
434
+ largestPrimitiveGroups: largest.slice(0, 10),
435
+ }
436
+ : null,
437
+ assumptions: [
438
+ "Complete captured subtree only; hidden ancestors and overlapping/composited paints need independent review.",
439
+ "Interactive IDs are explicit declarations, not inferred clickability or WCAG compliance.",
440
+ "Unavailable resolved colors and mixed styles remain unknown; rules never mutate or block by default.",
441
+ ],
442
+ };
443
+ }
@@ -0,0 +1,4 @@
1
+ import type { ProjectConfig } from "./instance/config.js";
2
+ import type { DoctorCheck } from "./instance/connection.js";
3
+ /** An optional advisory record, not proof that an archive can be restored. Disabled unless configured. */
4
+ export declare function backupNoteCheck(root: string, config: ProjectConfig, now?: Date): Promise<DoctorCheck | undefined>;
@@ -0,0 +1,49 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ /** An optional advisory record, not proof that an archive can be restored. Disabled unless configured. */
4
+ export async function backupNoteCheck(root, config, now = new Date()) {
5
+ if (!config.backup) {
6
+ return undefined;
7
+ }
8
+ const warn = (detail) => ({
9
+ check: "backup note",
10
+ ok: true,
11
+ warning: true,
12
+ detail,
13
+ remedy: "Export a current archive, restore it into scratch, then update the backup note; see docs/backup.md.",
14
+ });
15
+ let note;
16
+ try {
17
+ note = JSON.parse(await readFile(path.join(root, config.backup.noteFile), "utf8"));
18
+ }
19
+ catch {
20
+ return warn("No readable backup note; archive/restore status is unknown");
21
+ }
22
+ if (!note ||
23
+ note.schemaVersion !== 1 ||
24
+ typeof note.fileId !== "string" ||
25
+ typeof note.recordedAt !== "string" ||
26
+ typeof note.archiveSha256 !== "string" ||
27
+ !/^[a-f0-9]{64}$/i.test(note.archiveSha256)) {
28
+ return warn("Backup note is incomplete or invalid; archive/restore status is unknown");
29
+ }
30
+ if (!config.file || note.fileId !== config.file.id) {
31
+ return warn("Backup note does not match a pinned project file");
32
+ }
33
+ const timestamp = Date.parse(note.recordedAt);
34
+ const age = now.getTime() - timestamp;
35
+ if (!Number.isFinite(timestamp) || age < 0) {
36
+ return warn("Backup note has an invalid or future date");
37
+ }
38
+ if (age >= config.backup.remindAfterDays * 86_400_000) {
39
+ return warn(`Backup note is ${Math.floor(age / 86_400_000)} days old; refresh the archive and restore evidence`);
40
+ }
41
+ if (note.restoreVerified !== true) {
42
+ return warn("Archive recorded recently, but its restore is not recorded as verified");
43
+ }
44
+ return {
45
+ check: "backup note",
46
+ ok: true,
47
+ detail: "Recent owner-maintained backup/restore note matches the pinned file (a note is not independent restore proof)",
48
+ };
49
+ }
@@ -0,0 +1,25 @@
1
+ import { type BaselineCase, type BaselinesConfig, type CoverageRequirement } from "./baselines-config.js";
2
+ import type { BaselineResult } from "./baselines.js";
3
+ export interface CoverageRow {
4
+ requirement: CoverageRequirement;
5
+ status: "missing-case" | "missing-capture" | "unapproved-metadata" | "changed-unapproved" | "approved-design-intent" | "unsupported";
6
+ frameIds: string[];
7
+ reason?: string;
8
+ }
9
+ export interface BaselineCoverage {
10
+ schemaVersion: 1;
11
+ kind: "baseline-coverage";
12
+ configured: boolean;
13
+ designCasesComplete: boolean;
14
+ rows: CoverageRow[];
15
+ unknown: {
16
+ id: string;
17
+ fields: string[];
18
+ }[];
19
+ browserVerification: "not-run";
20
+ accessibility: "not-assessed";
21
+ }
22
+ export declare const designCaseKey: (route: string, value: BaselineCase) => string;
23
+ export declare function baselineCoverage(config: BaselinesConfig, results?: BaselineResult[]): BaselineCoverage;
24
+ /** Offline coverage validates the approved image bytes, never only a manifest's claims. */
25
+ export declare function readBaselineCoverage(root: string, config: BaselinesConfig, expectedFileId?: string): Promise<BaselineCoverage>;