@penpair/core 0.2.0 → 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 (101) hide show
  1. package/dist/adapters.js +58 -11
  2. package/dist/approvals.js +143 -29
  3. package/dist/atomic-output.js +9 -4
  4. package/dist/audit-rules.js +345 -79
  5. package/dist/backup-note.js +29 -8
  6. package/dist/baseline-coverage.js +169 -35
  7. package/dist/baselines-config.js +119 -29
  8. package/dist/baselines.js +280 -57
  9. package/dist/cli-arguments.js +18 -6
  10. package/dist/cli-main.js +1621 -392
  11. package/dist/client.d.ts +5 -2
  12. package/dist/client.js +127 -28
  13. package/dist/command-result.js +341 -52
  14. package/dist/coordination.js +91 -29
  15. package/dist/credentials.js +57 -13
  16. package/dist/deep-check.js +56 -12
  17. package/dist/design-spec.js +189 -49
  18. package/dist/endpoint.js +102 -34
  19. package/dist/export.js +86 -18
  20. package/dist/file-archive.js +33 -7
  21. package/dist/file-guard.js +29 -11
  22. package/dist/formats.js +215 -14
  23. package/dist/index.d.ts +8 -7
  24. package/dist/index.js +8 -7
  25. package/dist/inspection.d.ts +1 -1
  26. package/dist/inspection.js +378 -65
  27. package/dist/instance/config-schema.js +400 -41
  28. package/dist/instance/config.js +101 -30
  29. package/dist/instance/connect.js +151 -44
  30. package/dist/instance/connection.d.ts +26 -5
  31. package/dist/instance/connection.js +194 -21
  32. package/dist/instance/gitignore.js +5 -2
  33. package/dist/instance/init.js +32 -7
  34. package/dist/instance/runner.js +32 -10
  35. package/dist/instance/setup.js +46 -13
  36. package/dist/instance/tty.js +26 -8
  37. package/dist/journal.d.ts +10 -0
  38. package/dist/journal.js +212 -49
  39. package/dist/kit-smoke.js +82 -20
  40. package/dist/kit-source.js +10 -40
  41. package/dist/kit.js +102 -34
  42. package/dist/layout-policy.d.ts +2 -0
  43. package/dist/layout-policy.js +86 -0
  44. package/dist/layout.d.ts +7 -1
  45. package/dist/layout.js +27 -70
  46. package/dist/live-smoke.js +13 -4
  47. package/dist/local-profile.js +2 -1
  48. package/dist/mcp-server.js +360 -47
  49. package/dist/native-api.js +47 -11
  50. package/dist/onboard.js +33 -7
  51. package/dist/operation.d.ts +2 -2
  52. package/dist/operation.js +279 -69
  53. package/dist/page-session.d.ts +23 -0
  54. package/dist/page-session.js +142 -0
  55. package/dist/png.js +18 -3
  56. package/dist/portable-tokens.js +254 -57
  57. package/dist/probe.d.ts +1 -1
  58. package/dist/probe.js +48 -11
  59. package/dist/project-input.js +24 -10
  60. package/dist/project-snippets.js +18 -6
  61. package/dist/project-status.js +133 -24
  62. package/dist/registry.js +217 -60
  63. package/dist/run-file.js +129 -30
  64. package/dist/shots.js +108 -26
  65. package/dist/skill-instructions.js +91 -22
  66. package/dist/skills.js +129 -39
  67. package/dist/snippet.js +27 -7
  68. package/dist/snippets/read.js +8 -2
  69. package/dist/structure-snapshot.d.ts +12 -0
  70. package/dist/structure-snapshot.js +439 -85
  71. package/dist/token-edit.js +219 -64
  72. package/dist/token-projection.d.ts +56 -0
  73. package/dist/token-projection.js +286 -0
  74. package/dist/tokens-diff.js +125 -32
  75. package/dist/version.d.ts +1 -1
  76. package/dist/version.js +1 -1
  77. package/package.json +1 -1
  78. package/skills/penpair-design/SKILL.md +3 -0
  79. package/skills/penpair-design/references/penpot-plugin-gotchas.md +2 -2
  80. package/skills/penpair-design/references/project-facts.md +2 -0
  81. package/skills/penpair-handoff/SKILL.md +2 -0
  82. package/skills/penpair-handoff/references/comparison-companion.md +13 -0
  83. package/skills/penpair-handoff/references/handoff-flow.md +2 -0
  84. package/skills/penpair-recipes/SKILL.md +5 -0
  85. package/skills/penpair-setup/SKILL.md +3 -1
  86. package/dist/cli.d.ts +0 -2
  87. package/dist/cli.js +0 -3
  88. package/dist/docker/lifecycle.d.ts +0 -96
  89. package/dist/docker/lifecycle.js +0 -273
  90. package/dist/docker/secrets.d.ts +0 -11
  91. package/dist/docker/secrets.js +0 -38
  92. package/dist/docker/template.d.ts +0 -8
  93. package/dist/docker/template.js +0 -158
  94. package/dist/docker-profile.d.ts +0 -114
  95. package/dist/docker-profile.js +0 -18
  96. package/dist/instance/lifecycle.d.ts +0 -129
  97. package/dist/instance/lifecycle.js +0 -336
  98. package/dist/instance/secrets.d.ts +0 -11
  99. package/dist/instance/secrets.js +0 -38
  100. package/dist/instance/template.d.ts +0 -8
  101. package/dist/instance/template.js +0 -158
@@ -1,3 +1,5 @@
1
+ import { withCleanupDeadline } from "./command-result.js";
2
+ import { withPageSession, observePage, selectPage, rememberPage, pinPageSnippet, } from "./page-session.js";
1
3
  import { mkdir, open, readFile, stat, unlink } from "node:fs/promises";
2
4
  import path from "node:path";
3
5
  import { captureInspection } from "./inspection.js";
@@ -7,70 +9,271 @@ import { atomicWriteFile } from "./atomic-output.js";
7
9
  import { designDataBlock, resolveProjectInput, resolveProjectOutput } from "./project-input.js";
8
10
  import { runSnippet, snippet } from "./snippet.js";
9
11
  import { version } from "./version.js";
10
- function ordered(value) { if (Array.isArray(value))
11
- return value.map(ordered); if (!value || typeof value !== "object")
12
- return value; return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k, v]) => [k, ordered(v)])); }
12
+ function ordered(value) {
13
+ if (Array.isArray(value)) {
14
+ return value.map(ordered);
15
+ }
16
+ if (!value || typeof value !== "object") {
17
+ return value;
18
+ }
19
+ return Object.fromEntries(Object.entries(value)
20
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
21
+ .map(([k, v]) => [
22
+ k,
23
+ ordered(v),
24
+ ]));
25
+ }
13
26
  export const structuralJson = (value) => JSON.stringify(ordered(value), null, 2) + "\n";
14
27
  export function structuralPage(raw, name) {
15
28
  const capture = validateDesignInspection(raw);
16
- return { schemaVersion: 1, kind: "page-structure", fileId: capture.fileId, pageId: capture.pageId, revision: capture.revision, name, nodes: structuredClone(capture.nodes).sort((a, b) => String(a.id) < String(b.id) ? -1 : String(a.id) > String(b.id) ? 1 : 0) };
29
+ return {
30
+ schemaVersion: 1,
31
+ kind: "page-structure",
32
+ fileId: capture.fileId,
33
+ pageId: capture.pageId,
34
+ revision: capture.revision,
35
+ name,
36
+ nodes: structuredClone(capture.nodes).sort((a, b) => String(a.id) < String(b.id) ? -1 : String(a.id) > String(b.id) ? 1 : 0),
37
+ };
17
38
  }
18
39
  export function assembleStructuralSnapshot(pages, rawTokens) {
19
- if (rawTokens.schemaVersion !== 1 || rawTokens.kind !== "token-inspection" || rawTokens.complete !== true || rawTokens.redacted === true || rawTokens.reasons.length || !Array.isArray(rawTokens.sets) || !Array.isArray(rawTokens.themes))
40
+ if (rawTokens.schemaVersion !== 1 ||
41
+ rawTokens.kind !== "token-inspection" ||
42
+ rawTokens.complete !== true ||
43
+ rawTokens.redacted === true ||
44
+ rawTokens.reasons.length ||
45
+ !Array.isArray(rawTokens.sets) ||
46
+ !Array.isArray(rawTokens.themes)) {
20
47
  throw new Error("Complete unredacted token inspection required");
21
- if (!pages.length || pages.length > 100 || new Set(pages.map(p => p.pageId)).size !== pages.length || pages.some(p => p.fileId !== rawTokens.fileId || p.revision !== rawTokens.revision) || pages.reduce((n, p) => n + p.nodes.length, 0) > 50000)
48
+ }
49
+ if (!pages.length ||
50
+ pages.length > 100 ||
51
+ new Set(pages.map((p) => p.pageId)).size !== pages.length ||
52
+ pages.some((p) => p.fileId !== rawTokens.fileId || p.revision !== rawTokens.revision) ||
53
+ pages.reduce((n, p) => n + p.nodes.length, 0) > 50000) {
22
54
  throw new Error("Snapshot identity/revision/page/node budget mismatch");
55
+ }
23
56
  for (const page of pages) {
24
- validateDesignInspection({ schemaVersion: 1, kind: "shape-inspection", fileId: page.fileId, pageId: page.pageId, revision: page.revision, complete: true, counts: {}, reasons: [], producer: { name: "penpair", version }, nodes: page.nodes });
25
- if (Buffer.byteLength(structuralJson(page)) > 8 * 1024 * 1024)
57
+ validateDesignInspection({
58
+ schemaVersion: 1,
59
+ kind: "shape-inspection",
60
+ fileId: page.fileId,
61
+ pageId: page.pageId,
62
+ revision: page.revision,
63
+ complete: true,
64
+ counts: {},
65
+ reasons: [],
66
+ producer: {
67
+ name: "penpair",
68
+ version,
69
+ },
70
+ nodes: page.nodes,
71
+ });
72
+ if (Buffer.byteLength(structuralJson(page)) > 8 * 1024 * 1024) {
26
73
  throw new Error("Page snapshot exceeds 8 MiB; no partial snapshot was published");
74
+ }
27
75
  }
28
- const snapshot = { schemaVersion: 1, kind: "structural-snapshot", fileId: rawTokens.fileId, revision: rawTokens.revision, pages: structuredClone(pages).sort((a, b) => a.pageId < b.pageId ? -1 : a.pageId > b.pageId ? 1 : 0), tokens: { schemaVersion: 1, kind: "token-structure", fileId: rawTokens.fileId, revision: rawTokens.revision, sets: structuredClone(rawTokens.sets), themes: structuredClone(rawTokens.themes) }, fidelity: "review-only" };
29
- if (Buffer.byteLength(structuralJson(snapshot.tokens)) > 8 * 1024 * 1024 || Buffer.byteLength(structuralJson(snapshot)) > 128 * 1024 * 1024)
76
+ const snapshot = {
77
+ schemaVersion: 1,
78
+ kind: "structural-snapshot",
79
+ fileId: rawTokens.fileId,
80
+ revision: rawTokens.revision,
81
+ pages: structuredClone(pages).sort((a, b) => a.pageId < b.pageId ? -1 : a.pageId > b.pageId ? 1 : 0),
82
+ tokens: {
83
+ schemaVersion: 1,
84
+ kind: "token-structure",
85
+ fileId: rawTokens.fileId,
86
+ revision: rawTokens.revision,
87
+ sets: structuredClone(rawTokens.sets),
88
+ themes: structuredClone(rawTokens.themes),
89
+ },
90
+ fidelity: "review-only",
91
+ };
92
+ if (Buffer.byteLength(structuralJson(snapshot.tokens)) > 8 * 1024 * 1024 ||
93
+ Buffer.byteLength(structuralJson(snapshot)) > 128 * 1024 * 1024) {
30
94
  throw new Error("Snapshot token/total byte budget exceeded");
95
+ }
31
96
  return snapshot;
32
97
  }
33
98
  const listingSnippet = () => snippet("snapshot-pages", `
34
99
  if (penpot.currentFile.pages.length > 100) return {ok:false,message:"Snapshot supports at most 100 pages"};
35
100
  return {ok:true,fileId:penpot.currentFile.id,revision:penpot.currentFile.revn,pages:penpot.currentFile.pages.map(p=>({id:p.id,name:p.name}))};`, {}, "read");
101
+ export class SnapshotCaptureError extends Error {
102
+ progress;
103
+ restorationError;
104
+ constructor(message, progress, restorationError) {
105
+ super(message +
106
+ (restorationError ? `; visible-page restoration also failed: ${restorationError}` : ""));
107
+ this.progress = progress;
108
+ this.restorationError = restorationError;
109
+ }
110
+ }
36
111
  export async function captureStructuralSnapshot(client, options = {}) {
37
- const before = await runSnippet(client, listingSnippet(), options);
38
- if (!Array.isArray(before.pages) || !before.pages.length || typeof before.fileId !== "string" || !Number.isInteger(before.revision))
39
- throw new Error("Snapshot listing identity missing");
40
- const pages = before.pages;
41
- if (pages.some(p => typeof p.id !== "string" || typeof p.name !== "string") || new Set(pages.map(p => p.id)).size !== pages.length || new Set(pages.map(p => p.name)).size !== pages.length)
42
- throw new Error("Ambiguous page identity/name; page selection must be explicit and unique");
43
- const result = [];
44
- let nodes = 0;
45
- const started = Date.now();
46
- const budget = () => { const remaining = 300000 - (Date.now() - started); if (remaining < 1)
47
- throw new Error("Snapshot collection exceeded five-minute budget"); return { maxTotalBytes: 8 * 1024 * 1024, timeoutMs: Math.min(60000, remaining) }; };
48
- for (const page of pages) {
49
- options.signal?.throwIfAborted();
50
- const capture = await captureInspection(client, { kind: "shape", shapeId: "@page-root", page: page.name, depth: 10000, maxNodes: 50000, documentBytes: 8 * 1024 * 1024 }, budget());
51
- if (!capture.complete || capture.kind !== "shape-inspection" || capture.pageId !== page.id)
52
- throw new Error("Snapshot page inspection incomplete or selected identity changed");
53
- const data = structuralPage(capture, page.name);
54
- nodes += data.nodes.length;
55
- if (nodes > 50000)
56
- throw new Error("Snapshot file exceeds 50000 nodes");
57
- result.push(data);
58
- }
59
- const tokens = await captureInspection(client, { kind: "tokens", documentBytes: 8 * 1024 * 1024 }, budget());
60
- const after = await runSnippet(client, listingSnippet(), options);
61
- if (!tokens.complete || tokens.kind !== "token-inspection" || observationHash(before, true) !== observationHash(after, true))
62
- throw new Error("Snapshot source changed or token inspection incomplete");
63
- const snapshot = assembleStructuralSnapshot(result, tokens);
64
- if (snapshot.fileId !== before.fileId || snapshot.revision !== before.revision)
65
- throw new Error("Snapshot source changed during collection");
66
- return snapshot;
112
+ return withPageSession(client, async () => {
113
+ const started = Date.now();
114
+ const progress = [];
115
+ const budget = () => {
116
+ const remaining = Math.min(300000, options.timeoutMs ?? 300000) - (Date.now() - started);
117
+ if (remaining < 1) {
118
+ throw new Error("Snapshot collection exceeded time budget");
119
+ }
120
+ return {
121
+ maxTotalBytes: 8 * 1024 * 1024,
122
+ timeoutMs: Math.min(60000, remaining),
123
+ };
124
+ };
125
+ const original = await observePage(client, {
126
+ ...options,
127
+ timeoutMs: budget().timeoutMs,
128
+ });
129
+ rememberPage(client, original);
130
+ let result;
131
+ let failure;
132
+ let restorationError;
133
+ const notify = (pageId, stage) => {
134
+ const event = {
135
+ pageId,
136
+ stage,
137
+ elapsedMs: Date.now() - started,
138
+ };
139
+ progress.push(event);
140
+ options.onProgress?.(event);
141
+ options.onStep?.(`snapshot ${stage}: page ${pageId} (${event.elapsedMs} ms)`);
142
+ };
143
+ try {
144
+ const before = await runSnippet(client, pinPageSnippet(client, listingSnippet()), {
145
+ ...options,
146
+ timeoutMs: budget().timeoutMs,
147
+ });
148
+ if (!Array.isArray(before.pages) ||
149
+ !before.pages.length ||
150
+ typeof before.fileId !== "string" ||
151
+ !Number.isInteger(before.revision)) {
152
+ throw new Error("Snapshot listing identity missing");
153
+ }
154
+ const pages = before.pages;
155
+ if (pages.some((p) => typeof p.id !== "string" || !p.id || typeof p.name !== "string") ||
156
+ new Set(pages.map((p) => p.id)).size !== pages.length ||
157
+ new Set(pages.map((p) => p.name)).size !== pages.length) {
158
+ throw new Error("Ambiguous page identity/name; page selection must be explicit and unique");
159
+ }
160
+ const captures = [];
161
+ let nodes = 0;
162
+ for (const page of pages) {
163
+ options.signal?.throwIfAborted();
164
+ notify(page.id, "start");
165
+ await selectPage(client, {
166
+ id: page.id,
167
+ }, {
168
+ ...options,
169
+ timeoutMs: budget().timeoutMs,
170
+ });
171
+ const capture = await captureInspection(client, {
172
+ kind: "shape",
173
+ shapeId: "@page-root",
174
+ page: page.name,
175
+ depth: 10000,
176
+ maxNodes: 50000,
177
+ documentBytes: 8 * 1024 * 1024,
178
+ }, budget());
179
+ await observePage(client, {
180
+ ...options,
181
+ timeoutMs: budget().timeoutMs,
182
+ });
183
+ if (!capture.complete ||
184
+ capture.kind !== "shape-inspection" ||
185
+ capture.pageId !== page.id) {
186
+ throw new Error(`Snapshot page ${page.id} inspection incomplete or selected identity changed`);
187
+ }
188
+ const data = structuralPage(capture, page.name);
189
+ nodes += data.nodes.length;
190
+ if (nodes > 50000) {
191
+ throw new Error("Snapshot file exceeds 50000 nodes");
192
+ }
193
+ captures.push(data);
194
+ notify(page.id, "complete");
195
+ }
196
+ const tokens = await captureInspection(client, {
197
+ kind: "tokens",
198
+ documentBytes: 8 * 1024 * 1024,
199
+ }, budget());
200
+ const after = await runSnippet(client, pinPageSnippet(client, listingSnippet()), {
201
+ ...options,
202
+ timeoutMs: budget().timeoutMs,
203
+ });
204
+ if (!tokens.complete ||
205
+ tokens.kind !== "token-inspection" ||
206
+ observationHash(before, true) !== observationHash(after, true)) {
207
+ throw new Error("Snapshot source changed or token inspection incomplete");
208
+ }
209
+ result = assembleStructuralSnapshot(captures, tokens);
210
+ if (result.fileId !== before.fileId || result.revision !== before.revision) {
211
+ throw new Error("Snapshot source changed during collection");
212
+ }
213
+ }
214
+ catch (error) {
215
+ failure = error;
216
+ }
217
+ finally {
218
+ try {
219
+ await withCleanupDeadline(async () => {
220
+ const current = await runSnippet(client, snippet("restore-page-state", "return {ok:true,fileId:penpot.currentFile.id,pageId:penpot.currentPage.id};", {}, "read"), {
221
+ retries: 0,
222
+ timeoutMs: 5000,
223
+ });
224
+ if (current.fileId !== original.fileId || typeof current.pageId !== "string") {
225
+ throw new Error("Original file no longer open; cannot restore its visible page");
226
+ }
227
+ rememberPage(client, {
228
+ fileId: original.fileId,
229
+ pageId: current.pageId,
230
+ });
231
+ if (current.pageId !== original.pageId) {
232
+ await selectPage(client, {
233
+ id: original.pageId,
234
+ }, {
235
+ timeoutMs: 5000,
236
+ });
237
+ }
238
+ }, 10000);
239
+ }
240
+ catch (error) {
241
+ restorationError = error instanceof Error ? error.message : String(error);
242
+ }
243
+ }
244
+ if (failure || restorationError) {
245
+ throw new SnapshotCaptureError(failure instanceof Error
246
+ ? failure.message
247
+ : failure
248
+ ? String(failure)
249
+ : "Snapshot visible-page restoration failed", progress, restorationError);
250
+ }
251
+ return result;
252
+ });
253
+ }
254
+ async function readBounded(file, maximum) {
255
+ if ((await stat(file)).size > maximum) {
256
+ throw new Error("Structural artifact exceeds byte budget");
257
+ }
258
+ return JSON.parse(await readFile(file, "utf8"));
67
259
  }
68
- async function readBounded(file, maximum) { if ((await stat(file)).size > maximum)
69
- throw new Error("Structural artifact exceeds byte budget"); return JSON.parse(await readFile(file, "utf8")); }
70
260
  export async function writeStructuralSnapshot(root, out, snapshot, redact) {
71
- snapshot = assembleStructuralSnapshot(snapshot.pages, { ...snapshot.tokens, kind: "token-inspection", pageId: null, complete: true, reasons: [], counts: {}, producer: { name: "penpair", version } });
261
+ snapshot = assembleStructuralSnapshot(snapshot.pages, {
262
+ ...snapshot.tokens,
263
+ kind: "token-inspection",
264
+ pageId: null,
265
+ complete: true,
266
+ reasons: [],
267
+ counts: {},
268
+ producer: {
269
+ name: "penpair",
270
+ version,
271
+ },
272
+ });
72
273
  const pointer = await resolveProjectOutput(root, path.join(out, "snapshot.json"));
73
- await mkdir(path.dirname(pointer), { recursive: true });
274
+ await mkdir(path.dirname(pointer), {
275
+ recursive: true,
276
+ });
74
277
  const lock = await open(pointer + ".lock", "wx", 0o600);
75
278
  try {
76
279
  return await publishStructuralSnapshot(root, out, snapshot, redact);
@@ -82,90 +285,241 @@ export async function writeStructuralSnapshot(root, out, snapshot, redact) {
82
285
  }
83
286
  async function publishStructuralSnapshot(root, out, snapshot, redact) {
84
287
  const artifacts = new Map();
85
- const pages = snapshot.pages.map(page => { const file = `page-${observationHash(page.pageId).slice(0, 16)}.json`; artifacts.set(file, page); return { id: page.pageId, name: page.name, file, hash: observationHash(page) }; });
288
+ const pages = snapshot.pages.map((page) => {
289
+ const file = `page-${observationHash(page.pageId).slice(0, 16)}.json`;
290
+ artifacts.set(file, page);
291
+ return {
292
+ id: page.pageId,
293
+ name: page.name,
294
+ file,
295
+ hash: observationHash(page),
296
+ };
297
+ });
86
298
  artifacts.set("tokens.json", snapshot.tokens);
87
- const body = { schemaVersion: 1, kind: "structural-snapshot-manifest", fileId: snapshot.fileId, revision: snapshot.revision, producer: `penpair@${version}`, fidelity: "review-only", pages, tokens: { file: "tokens.json", hash: observationHash(snapshot.tokens) } };
88
- const manifest = { ...body, hash: observationHash(body) };
89
- const encoded = structuralJson({ manifest, artifacts: [...artifacts] });
90
- if (redact && redact(encoded) !== encoded)
299
+ const body = {
300
+ schemaVersion: 1,
301
+ kind: "structural-snapshot-manifest",
302
+ fileId: snapshot.fileId,
303
+ revision: snapshot.revision,
304
+ producer: `penpair@${version}`,
305
+ fidelity: "review-only",
306
+ pages,
307
+ tokens: {
308
+ file: "tokens.json",
309
+ hash: observationHash(snapshot.tokens),
310
+ },
311
+ };
312
+ const manifest = {
313
+ ...body,
314
+ hash: observationHash(body),
315
+ };
316
+ const encoded = structuralJson({
317
+ manifest,
318
+ artifacts: [
319
+ ...artifacts,
320
+ ],
321
+ });
322
+ if (redact && redact(encoded) !== encoded) {
91
323
  throw new Error("Snapshot evidence requires redaction; previous artifacts preserved");
324
+ }
92
325
  const pointer = await resolveProjectOutput(root, path.join(out, "snapshot.json"));
93
326
  try {
94
327
  const old = await readStructuralSnapshot(root, out);
95
- if (old.fileId !== snapshot.fileId || old.revision > snapshot.revision)
328
+ if (old.fileId !== snapshot.fileId || old.revision > snapshot.revision) {
96
329
  throw new Error("Refusing to replace another file's or newer structural snapshot");
330
+ }
97
331
  }
98
332
  catch (error) {
99
- if (error.code !== "ENOENT")
333
+ if (error.code !== "ENOENT") {
100
334
  throw error;
335
+ }
101
336
  }
102
337
  for (const [file, data] of artifacts) {
103
338
  const target = await resolveProjectOutput(root, path.join(out, "generations", manifest.hash, file));
104
- await mkdir(path.dirname(target), { recursive: true });
339
+ await mkdir(path.dirname(target), {
340
+ recursive: true,
341
+ });
105
342
  try {
106
343
  await atomicWriteFile(target, structuralJson(data));
107
344
  }
108
345
  catch (error) {
109
- if (error.code !== "EEXIST" || observationHash(await readBounded(target, 8 * 1024 * 1024)) !== observationHash(data))
346
+ if (error.code !== "EEXIST" ||
347
+ observationHash(await readBounded(target, 8 * 1024 * 1024)) !== observationHash(data)) {
110
348
  throw error;
349
+ }
111
350
  }
112
351
  }
113
- await atomicWriteFile(pointer, structuralJson(manifest), { replace: true });
352
+ await atomicWriteFile(pointer, structuralJson(manifest), {
353
+ replace: true,
354
+ });
114
355
  return manifest;
115
356
  }
116
357
  export async function readStructuralSnapshot(root, folder) {
117
- const manifest = await readBounded(await resolveProjectInput(root, path.join(folder, "snapshot.json")), 1024 * 1024);
358
+ const manifest = (await readBounded(await resolveProjectInput(root, path.join(folder, "snapshot.json")), 1024 * 1024));
118
359
  const { hash, ...body } = manifest;
119
- if (manifest.schemaVersion !== 1 || manifest.kind !== "structural-snapshot-manifest" || manifest.fidelity !== "review-only" || !/^[a-f0-9]{64}$/.test(hash) || hash !== observationHash(body) || !Array.isArray(manifest.pages) || !manifest.pages.length || manifest.pages.length > 100 || manifest.tokens.file !== "tokens.json")
360
+ if (manifest.schemaVersion !== 1 ||
361
+ manifest.kind !== "structural-snapshot-manifest" ||
362
+ manifest.fidelity !== "review-only" ||
363
+ !/^[a-f0-9]{64}$/.test(hash) ||
364
+ hash !== observationHash(body) ||
365
+ !Array.isArray(manifest.pages) ||
366
+ !manifest.pages.length ||
367
+ manifest.pages.length > 100 ||
368
+ manifest.tokens.file !== "tokens.json") {
120
369
  throw new Error("Unsupported or altered structural manifest");
121
- const read = async (file, expected) => { const data = await readBounded(await resolveProjectInput(root, path.join(folder, "generations", hash, file)), 8 * 1024 * 1024); if (observationHash(data) !== expected)
122
- throw new Error("Structural artifact hash mismatch"); return data; };
370
+ }
371
+ const read = async (file, expected) => {
372
+ const data = await readBounded(await resolveProjectInput(root, path.join(folder, "generations", hash, file)), 8 * 1024 * 1024);
373
+ if (observationHash(data) !== expected) {
374
+ throw new Error("Structural artifact hash mismatch");
375
+ }
376
+ return data;
377
+ };
123
378
  const pages = [];
124
379
  for (const entry of manifest.pages) {
125
- if (!/^page-[a-f0-9]{16}\.json$/.test(entry.file))
380
+ if (!/^page-[a-f0-9]{16}\.json$/.test(entry.file)) {
126
381
  throw new Error("Invalid structural page artifact path");
127
- const page = await read(entry.file, entry.hash);
128
- if (page.kind !== "page-structure" || page.pageId !== entry.id || page.name !== entry.name || page.fileId !== manifest.fileId || page.revision !== manifest.revision)
382
+ }
383
+ const page = (await read(entry.file, entry.hash));
384
+ if (page.kind !== "page-structure" ||
385
+ page.pageId !== entry.id ||
386
+ page.name !== entry.name ||
387
+ page.fileId !== manifest.fileId ||
388
+ page.revision !== manifest.revision) {
129
389
  throw new Error("Structural page identity mismatch");
390
+ }
130
391
  pages.push(page);
131
392
  }
132
- const tokens = await read("tokens.json", manifest.tokens.hash);
133
- if (tokens.kind !== "token-structure" || tokens.fileId !== manifest.fileId || tokens.revision !== manifest.revision)
393
+ const tokens = (await read("tokens.json", manifest.tokens.hash));
394
+ if (tokens.kind !== "token-structure" ||
395
+ tokens.fileId !== manifest.fileId ||
396
+ tokens.revision !== manifest.revision) {
134
397
  throw new Error("Structural token identity mismatch");
135
- return assembleStructuralSnapshot(pages, { ...tokens, kind: "token-inspection", pageId: null, complete: true, reasons: [], counts: {}, producer: { name: "penpair", version } });
398
+ }
399
+ return assembleStructuralSnapshot(pages, {
400
+ ...tokens,
401
+ kind: "token-inspection",
402
+ pageId: null,
403
+ complete: true,
404
+ reasons: [],
405
+ counts: {},
406
+ producer: {
407
+ name: "penpair",
408
+ version,
409
+ },
410
+ });
136
411
  }
137
412
  export function diffStructuralSnapshots(before, after, limit = 1000) {
138
- if (!Number.isInteger(limit) || limit < 1 || limit > 10000)
413
+ if (!Number.isInteger(limit) || limit < 1 || limit > 10000) {
139
414
  throw new Error("Structural diff limit must be 1–10000");
140
- if (before.fileId !== after.fileId)
415
+ }
416
+ if (before.fileId !== after.fileId) {
141
417
  throw new Error("Structural snapshots belong to different files; identities are not comparable");
418
+ }
142
419
  const changes = [];
143
420
  let totalChanges = 0;
144
- const add = (change) => { totalChanges++; if (changes.length < limit)
145
- changes.push(change); };
146
- const oldPages = new Map(before.pages.map(p => [p.pageId, p])), newPages = new Map(after.pages.map(p => [p.pageId, p]));
147
- for (const id of [...new Set([...oldPages.keys(), ...newPages.keys()])].sort()) {
148
- const a = oldPages.get(id), b = newPages.get(id);
421
+ const add = (change) => {
422
+ totalChanges++;
423
+ if (changes.length < limit) {
424
+ changes.push(change);
425
+ }
426
+ };
427
+ const oldPages = new Map(before.pages.map((p) => [
428
+ p.pageId,
429
+ p,
430
+ ]));
431
+ const newPages = new Map(after.pages.map((p) => [
432
+ p.pageId,
433
+ p,
434
+ ]));
435
+ for (const id of [
436
+ ...new Set([
437
+ ...oldPages.keys(),
438
+ ...newPages.keys(),
439
+ ]),
440
+ ].sort()) {
441
+ const a = oldPages.get(id);
442
+ const b = newPages.get(id);
149
443
  if (!a || !b) {
150
- add({ kind: a ? "page-removed" : "page-added", pageId: id, before: a ?? null, after: b ?? null });
444
+ add({
445
+ kind: a ? "page-removed" : "page-added",
446
+ pageId: id,
447
+ before: a ?? null,
448
+ after: b ?? null,
449
+ });
151
450
  continue;
152
451
  }
153
- if (a.name !== b.name)
154
- add({ kind: "page-name", pageId: id, before: a.name, after: b.name });
155
- const oldNodes = new Map(a.nodes.map(n => [n.id, n])), newNodes = new Map(b.nodes.map(n => [n.id, n]));
156
- for (const nodeId of [...new Set([...oldNodes.keys(), ...newNodes.keys()])].sort()) {
157
- const x = oldNodes.get(nodeId), y = newNodes.get(nodeId);
452
+ if (a.name !== b.name) {
453
+ add({
454
+ kind: "page-name",
455
+ pageId: id,
456
+ before: a.name,
457
+ after: b.name,
458
+ });
459
+ }
460
+ const oldNodes = new Map(a.nodes.map((n) => [
461
+ n.id,
462
+ n,
463
+ ]));
464
+ const newNodes = new Map(b.nodes.map((n) => [
465
+ n.id,
466
+ n,
467
+ ]));
468
+ for (const nodeId of [
469
+ ...new Set([
470
+ ...oldNodes.keys(),
471
+ ...newNodes.keys(),
472
+ ]),
473
+ ].sort()) {
474
+ const x = oldNodes.get(nodeId);
475
+ const y = newNodes.get(nodeId);
158
476
  if (!x || !y) {
159
- add({ kind: x ? "node-removed" : "node-added", pageId: id, nodeId: String(nodeId), before: x ?? null, after: y ?? null });
477
+ add({
478
+ kind: x ? "node-removed" : "node-added",
479
+ pageId: id,
480
+ nodeId: String(nodeId),
481
+ before: x ?? null,
482
+ after: y ?? null,
483
+ });
160
484
  continue;
161
485
  }
162
- for (const key of [...new Set([...Object.keys(x), ...Object.keys(y)])].sort())
163
- if (observationHash(x[key] ?? null) !== observationHash(y[key] ?? null))
164
- add({ kind: key, pageId: id, nodeId: String(nodeId), before: x[key] ?? null, after: y[key] ?? null });
486
+ for (const key of [
487
+ ...new Set([
488
+ ...Object.keys(x),
489
+ ...Object.keys(y),
490
+ ]),
491
+ ].sort()) {
492
+ if (observationHash(x[key] ?? null) !== observationHash(y[key] ?? null)) {
493
+ add({
494
+ kind: key,
495
+ pageId: id,
496
+ nodeId: String(nodeId),
497
+ before: x[key] ?? null,
498
+ after: y[key] ?? null,
499
+ });
500
+ }
501
+ }
165
502
  }
166
503
  }
167
- if (observationHash(before.tokens.sets) !== observationHash(after.tokens.sets) || observationHash(before.tokens.themes) !== observationHash(after.tokens.themes))
168
- add({ kind: "tokens", before: before.tokens, after: after.tokens });
169
- return { schemaVersion: 1, kind: "structural-diff", fileId: before.fileId, beforeRevision: before.revision, afterRevision: after.revision, totalChanges, complete: totalChanges <= limit, changes, fidelity: "review-only" };
504
+ if (observationHash(before.tokens.sets) !== observationHash(after.tokens.sets) ||
505
+ observationHash(before.tokens.themes) !== observationHash(after.tokens.themes)) {
506
+ add({
507
+ kind: "tokens",
508
+ before: before.tokens,
509
+ after: after.tokens,
510
+ });
511
+ }
512
+ return {
513
+ schemaVersion: 1,
514
+ kind: "structural-diff",
515
+ fileId: before.fileId,
516
+ beforeRevision: before.revision,
517
+ afterRevision: after.revision,
518
+ totalChanges,
519
+ complete: totalChanges <= limit,
520
+ changes,
521
+ fidelity: "review-only",
522
+ };
170
523
  }
171
- export const renderStructuralDiff = (diff) => "# Structural design diff\n\nReview evidence; not a full-fidelity backup or restore archive.\n\n" + designDataBlock(diff);
524
+ export const renderStructuralDiff = (diff) => "# Structural design diff\n\nReview evidence; not a full-fidelity backup or restore archive.\n\n" +
525
+ designDataBlock(diff);