@penpair/core 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters.js +58 -11
- package/dist/approvals.js +143 -29
- package/dist/atomic-output.js +9 -4
- package/dist/audit-rules.js +345 -79
- package/dist/backup-note.js +29 -8
- package/dist/baseline-coverage.js +169 -35
- package/dist/baselines-config.js +119 -29
- package/dist/baselines.js +280 -57
- package/dist/cli-arguments.js +18 -6
- package/dist/cli-main.js +1645 -392
- package/dist/client.d.ts +5 -2
- package/dist/client.js +127 -28
- package/dist/command-result.js +341 -52
- package/dist/coordination.js +91 -29
- package/dist/credentials.js +57 -13
- package/dist/deep-check.js +56 -12
- package/dist/design-spec.js +189 -49
- package/dist/endpoint.js +102 -34
- package/dist/export.js +86 -18
- package/dist/file-archive.js +33 -7
- package/dist/file-guard.js +29 -11
- package/dist/formats.js +215 -14
- package/dist/index.d.ts +8 -7
- package/dist/index.js +8 -7
- package/dist/inspection.d.ts +1 -1
- package/dist/inspection.js +378 -65
- package/dist/instance/config-schema.js +400 -41
- package/dist/instance/config.js +101 -30
- package/dist/instance/connect.js +151 -44
- package/dist/instance/connection.d.ts +26 -5
- package/dist/instance/connection.js +194 -21
- package/dist/instance/gitignore.js +5 -2
- package/dist/instance/init.js +32 -7
- package/dist/instance/runner.js +32 -10
- package/dist/instance/setup.js +46 -13
- package/dist/instance/tty.js +26 -8
- package/dist/journal.d.ts +10 -0
- package/dist/journal.js +212 -49
- package/dist/kit-smoke.js +82 -20
- package/dist/kit-source.js +10 -40
- package/dist/kit.js +102 -34
- package/dist/layout-policy.d.ts +2 -0
- package/dist/layout-policy.js +86 -0
- package/dist/layout.d.ts +7 -1
- package/dist/layout.js +27 -70
- package/dist/live-smoke.js +13 -4
- package/dist/local-profile.js +2 -1
- package/dist/mcp-server.js +360 -47
- package/dist/native-api.js +47 -11
- package/dist/onboard.js +33 -7
- package/dist/operation.d.ts +2 -2
- package/dist/operation.js +279 -69
- package/dist/page-session.d.ts +23 -0
- package/dist/page-session.js +142 -0
- package/dist/png.js +18 -3
- package/dist/portable-tokens.js +254 -57
- package/dist/probe.d.ts +1 -1
- package/dist/probe.js +48 -11
- package/dist/project-input.js +24 -10
- package/dist/project-snippets.js +18 -6
- package/dist/project-status.js +133 -24
- package/dist/registry.js +217 -60
- package/dist/run-file.js +129 -30
- package/dist/shots.js +108 -26
- package/dist/skill-check-entry.d.ts +17 -0
- package/dist/skill-check-entry.js +89 -0
- package/dist/skill-check-instructions.d.ts +7 -0
- package/dist/skill-check-instructions.js +39 -0
- package/dist/skill-check.d.ts +15 -0
- package/dist/skill-check.js +36 -0
- package/dist/skill-file.d.ts +2 -0
- package/dist/skill-file.js +31 -0
- package/dist/skill-instructions.d.ts +4 -0
- package/dist/skill-instructions.js +97 -22
- package/dist/skill-metadata.d.ts +8 -0
- package/dist/skill-metadata.js +52 -0
- package/dist/skill-routing.d.ts +2 -0
- package/dist/skill-routing.js +36 -0
- package/dist/skills.js +131 -41
- package/dist/snippet.js +27 -7
- package/dist/snippets/read.js +8 -2
- package/dist/structure-snapshot.d.ts +12 -0
- package/dist/structure-snapshot.js +439 -85
- package/dist/token-edit.js +219 -64
- package/dist/token-projection.d.ts +56 -0
- package/dist/token-projection.js +286 -0
- package/dist/tokens-diff.js +125 -32
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -2
- package/skills/design-judgment/SKILL.md +1 -1
- package/skills/design-judgment/agents/openai.yaml +6 -0
- package/skills/penpair-design/SKILL.md +14 -1
- package/skills/penpair-design/agents/openai.yaml +6 -0
- package/skills/penpair-design/references/penpot-plugin-gotchas.md +2 -2
- package/skills/penpair-design/references/project-facts.md +2 -0
- package/skills/penpair-handoff/SKILL.md +14 -1
- package/skills/penpair-handoff/agents/openai.yaml +6 -0
- package/skills/penpair-handoff/references/comparison-companion.md +16 -0
- package/skills/penpair-handoff/references/handoff-flow.md +2 -0
- package/skills/penpair-recipes/SKILL.md +13 -1
- package/skills/penpair-recipes/agents/openai.yaml +6 -0
- package/skills/penpair-recipes/assets/visual-checkpoint.md +48 -0
- package/skills/penpair-recipes/references/visual-checkpoint.md +37 -0
- package/skills/penpair-setup/SKILL.md +9 -3
- package/skills/penpair-setup/agents/openai.yaml +6 -0
- package/skills/penpair-verify/SKILL.md +49 -0
- package/skills/penpair-verify/agents/openai.yaml +6 -0
- package/skills/penpair-verify/references/browser-evidence.md +33 -0
- package/dist/cli.d.ts +0 -2
- package/dist/cli.js +0 -3
- package/dist/docker/lifecycle.d.ts +0 -96
- package/dist/docker/lifecycle.js +0 -273
- package/dist/docker/secrets.d.ts +0 -11
- package/dist/docker/secrets.js +0 -38
- package/dist/docker/template.d.ts +0 -8
- package/dist/docker/template.js +0 -158
- package/dist/docker-profile.d.ts +0 -114
- package/dist/docker-profile.js +0 -18
- package/dist/instance/lifecycle.d.ts +0 -129
- package/dist/instance/lifecycle.js +0 -336
- package/dist/instance/secrets.d.ts +0 -11
- package/dist/instance/secrets.js +0 -38
- package/dist/instance/template.d.ts +0 -8
- 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) {
|
|
11
|
-
|
|
12
|
-
|
|
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 {
|
|
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 ||
|
|
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
|
-
|
|
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({
|
|
25
|
-
|
|
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 = {
|
|
29
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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, {
|
|
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), {
|
|
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(
|
|
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 = {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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), {
|
|
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" ||
|
|
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), {
|
|
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 ||
|
|
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
|
-
|
|
122
|
-
|
|
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
|
-
|
|
128
|
-
|
|
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" ||
|
|
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
|
-
|
|
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
|
-
|
|
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) => {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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({
|
|
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({
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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({
|
|
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 [
|
|
163
|
-
|
|
164
|
-
|
|
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) ||
|
|
168
|
-
|
|
169
|
-
|
|
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" +
|
|
524
|
+
export const renderStructuralDiff = (diff) => "# Structural design diff\n\nReview evidence; not a full-fidelity backup or restore archive.\n\n" +
|
|
525
|
+
designDataBlock(diff);
|