@penpair/core 0.3.6 → 0.4.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.
- package/dist/adapters.d.ts +1 -1
- package/dist/adapters.js +3 -0
- package/dist/approvals.d.ts +4 -1
- package/dist/approvals.js +7 -4
- package/dist/baseline-coverage.js +3 -3
- package/dist/baselines-config.d.ts +3 -0
- package/dist/baselines-config.js +3 -0
- package/dist/baselines.d.ts +7 -0
- package/dist/baselines.js +90 -21
- package/dist/bounded-file.d.ts +2 -0
- package/dist/bounded-file.js +38 -0
- package/dist/cli-main.js +64 -3
- package/dist/design-commands/context.d.ts +9 -0
- package/dist/design-commands/context.js +1 -0
- package/dist/design-commands/index.d.ts +3 -0
- package/dist/design-commands/index.js +7 -0
- package/dist/design-commands/map-command.d.ts +4 -0
- package/dist/design-commands/map-command.js +685 -0
- package/dist/design-commands/release-command.d.ts +3 -0
- package/dist/design-commands/release-command.js +239 -0
- package/dist/design-releases/artifact-paths.d.ts +6 -0
- package/dist/design-releases/artifact-paths.js +82 -0
- package/dist/design-releases/assemble.d.ts +3 -0
- package/dist/design-releases/assemble.js +176 -0
- package/dist/design-releases/check.d.ts +16 -0
- package/dist/design-releases/check.js +323 -0
- package/dist/design-releases/context.d.ts +2 -0
- package/dist/design-releases/context.js +119 -0
- package/dist/design-releases/contract-graph.d.ts +13 -0
- package/dist/design-releases/contract-graph.js +75 -0
- package/dist/design-releases/fidelity.d.ts +23 -0
- package/dist/design-releases/fidelity.js +328 -0
- package/dist/design-releases/finalize.d.ts +4 -0
- package/dist/design-releases/finalize.js +201 -0
- package/dist/design-releases/index.d.ts +8 -0
- package/dist/design-releases/index.js +7 -0
- package/dist/design-releases/live.d.ts +22 -0
- package/dist/design-releases/live.js +97 -0
- package/dist/design-releases/snippets.d.ts +5 -0
- package/dist/design-releases/snippets.js +89 -0
- package/dist/design-releases/store.d.ts +10 -0
- package/dist/design-releases/store.js +205 -0
- package/dist/design-releases/types.d.ts +161 -0
- package/dist/design-releases/types.js +1 -0
- package/dist/design-releases/validation.d.ts +6 -0
- package/dist/design-releases/validation.js +384 -0
- package/dist/design-releases/verify.d.ts +3 -0
- package/dist/design-releases/verify.js +148 -0
- package/dist/image-codec.d.ts +3 -0
- package/dist/image-codec.js +78 -0
- package/dist/image-evidence.d.ts +15 -0
- package/dist/image-evidence.js +121 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/inspection.js +13 -5
- package/dist/instance/config-schema.d.ts +4 -0
- package/dist/instance/config-schema.js +7 -0
- package/dist/map-penpot/content-validation.d.ts +3 -0
- package/dist/map-penpot/content-validation.js +57 -0
- package/dist/map-penpot/content.d.ts +22 -0
- package/dist/map-penpot/content.js +173 -0
- package/dist/map-penpot/details.d.ts +4 -0
- package/dist/map-penpot/details.js +84 -0
- package/dist/map-penpot/evidence-error.d.ts +5 -0
- package/dist/map-penpot/evidence-error.js +9 -0
- package/dist/map-penpot/icons.d.ts +5 -0
- package/dist/map-penpot/icons.js +79 -0
- package/dist/map-penpot/import-content.d.ts +2 -0
- package/dist/map-penpot/import-content.js +150 -0
- package/dist/map-penpot/import.d.ts +5 -0
- package/dist/map-penpot/import.js +255 -0
- package/dist/map-penpot/index.d.ts +11 -0
- package/dist/map-penpot/index.js +10 -0
- package/dist/map-penpot/path-geometry.d.ts +5 -0
- package/dist/map-penpot/path-geometry.js +51 -0
- package/dist/map-penpot/presentation.d.ts +20 -0
- package/dist/map-penpot/presentation.js +205 -0
- package/dist/map-penpot/projection.d.ts +8 -0
- package/dist/map-penpot/projection.js +176 -0
- package/dist/map-penpot/publish.d.ts +4 -0
- package/dist/map-penpot/publish.js +301 -0
- package/dist/map-penpot/release-maps.d.ts +21 -0
- package/dist/map-penpot/release-maps.js +104 -0
- package/dist/map-penpot/snippets.d.ts +4 -0
- package/dist/map-penpot/snippets.js +164 -0
- package/dist/map-penpot/status-labels.d.ts +7 -0
- package/dist/map-penpot/status-labels.js +7 -0
- package/dist/map-penpot/types.d.ts +161 -0
- package/dist/map-penpot/types.js +1 -0
- package/dist/maps/annotation-layout.d.ts +24 -0
- package/dist/maps/annotation-layout.js +85 -0
- package/dist/maps/context.d.ts +13 -0
- package/dist/maps/context.js +207 -0
- package/dist/maps/index.d.ts +9 -0
- package/dist/maps/index.js +9 -0
- package/dist/maps/layout.d.ts +35 -0
- package/dist/maps/layout.js +364 -0
- package/dist/maps/proposal.d.ts +5 -0
- package/dist/maps/proposal.js +67 -0
- package/dist/maps/render.d.ts +4 -0
- package/dist/maps/render.js +331 -0
- package/dist/maps/review.d.ts +16 -0
- package/dist/maps/review.js +90 -0
- package/dist/maps/store.d.ts +21 -0
- package/dist/maps/store.js +222 -0
- package/dist/maps/types.d.ts +206 -0
- package/dist/maps/types.js +1 -0
- package/dist/maps/validation.d.ts +10 -0
- package/dist/maps/validation.js +375 -0
- package/dist/skill-routing.js +4 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -1
- package/skills/penpair-design-release/SKILL.md +55 -0
- package/skills/penpair-design-release/agents/openai.yaml +6 -0
- package/skills/penpair-design-release/references/workflow.md +55 -0
- package/skills/penpair-diagrams/SKILL.md +52 -0
- package/skills/penpair-diagrams/agents/openai.yaml +6 -0
- package/skills/penpair-diagrams/references/workflow.md +84 -0
- package/skills/penpair-handoff/SKILL.md +7 -0
- package/skills/penpair-handoff/references/handoff-flow.md +7 -1
- package/skills/penpair-integration-map/SKILL.md +44 -0
- package/skills/penpair-integration-map/agents/openai.yaml +6 -0
- package/skills/penpair-integration-map/references/workflow.md +33 -0
- package/skills/penpair-journey-map/SKILL.md +42 -0
- package/skills/penpair-journey-map/agents/openai.yaml +6 -0
- package/skills/penpair-journey-map/references/workflow.md +35 -0
- package/skills/penpair-verify/SKILL.md +7 -0
- package/skills/penpair-verify/references/browser-evidence.md +11 -1
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
import { observationHash } from "../journal.js";
|
|
2
|
+
import { validateArtifactReview } from "../maps/review.js";
|
|
3
|
+
import { mapObject, mapText, mapId, mapHash, mapPath } from "../maps/validation.js";
|
|
4
|
+
export function releaseReference(raw) {
|
|
5
|
+
const value = mapObject(raw, "Release artifact reference", [
|
|
6
|
+
"path",
|
|
7
|
+
"sha256",
|
|
8
|
+
]);
|
|
9
|
+
return {
|
|
10
|
+
path: mapPath(value.path, "Release artifact path"),
|
|
11
|
+
sha256: mapHash(value.sha256, "Release artifact hash"),
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
function screen(raw, planned = true) {
|
|
15
|
+
const value = mapObject(raw, "Release screen", [
|
|
16
|
+
"id",
|
|
17
|
+
"label",
|
|
18
|
+
"pageId",
|
|
19
|
+
"frameId",
|
|
20
|
+
"inspection",
|
|
21
|
+
"render",
|
|
22
|
+
"dependencies",
|
|
23
|
+
...(planned
|
|
24
|
+
? []
|
|
25
|
+
: [
|
|
26
|
+
"contentHash",
|
|
27
|
+
]),
|
|
28
|
+
]);
|
|
29
|
+
return {
|
|
30
|
+
id: mapId(value.id, "Screen ID"),
|
|
31
|
+
label: mapText(value.label, "Screen label", 2000),
|
|
32
|
+
pageId: mapId(value.pageId, "Source page ID"),
|
|
33
|
+
frameId: mapId(value.frameId, "Source frame ID"),
|
|
34
|
+
inspection: releaseReference(value.inspection),
|
|
35
|
+
...(value.render === undefined
|
|
36
|
+
? {}
|
|
37
|
+
: {
|
|
38
|
+
render: releaseReference(value.render),
|
|
39
|
+
}),
|
|
40
|
+
...(value.dependencies === undefined
|
|
41
|
+
? {}
|
|
42
|
+
: {
|
|
43
|
+
dependencies: releaseReference(value.dependencies),
|
|
44
|
+
}),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
function contract(raw, planned = true) {
|
|
48
|
+
const value = mapObject(raw, "Release contract", [
|
|
49
|
+
"path",
|
|
50
|
+
"sha256",
|
|
51
|
+
"review",
|
|
52
|
+
...(planned
|
|
53
|
+
? []
|
|
54
|
+
: [
|
|
55
|
+
"kind",
|
|
56
|
+
"id",
|
|
57
|
+
]),
|
|
58
|
+
]);
|
|
59
|
+
return {
|
|
60
|
+
...releaseReference({
|
|
61
|
+
path: value.path,
|
|
62
|
+
sha256: value.sha256,
|
|
63
|
+
}),
|
|
64
|
+
...(value.review === undefined
|
|
65
|
+
? {}
|
|
66
|
+
: {
|
|
67
|
+
review: validateArtifactReview(value.review),
|
|
68
|
+
}),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
function releaseHeader(value) {
|
|
72
|
+
const productVersion = mapText(value.productVersion, "Product SemVer", 100);
|
|
73
|
+
if (!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.test(productVersion)) {
|
|
74
|
+
throw new Error("Explicit product SemVer is required independently of the Penpair version");
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
id: mapId(value.id, "Release ID"),
|
|
78
|
+
title: mapText(value.title, "Release title", 2000),
|
|
79
|
+
productVersion,
|
|
80
|
+
fileId: mapId(value.fileId, "Pinned file ID"),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
function boundedList(value, label, maximum) {
|
|
84
|
+
if (!Array.isArray(value) || !value.length || value.length > maximum) {
|
|
85
|
+
throw new Error(`${label} must contain 1–${maximum} entries`);
|
|
86
|
+
}
|
|
87
|
+
return value;
|
|
88
|
+
}
|
|
89
|
+
function unique(values, label) {
|
|
90
|
+
if (new Set(values).size !== values.length) {
|
|
91
|
+
throw new Error(`Duplicate ${label}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
export function validateReleasePlan(raw) {
|
|
95
|
+
const value = mapObject(raw, "Design release plan", [
|
|
96
|
+
"schemaVersion",
|
|
97
|
+
"kind",
|
|
98
|
+
"id",
|
|
99
|
+
"title",
|
|
100
|
+
"productVersion",
|
|
101
|
+
"fileId",
|
|
102
|
+
"screens",
|
|
103
|
+
"contracts",
|
|
104
|
+
]);
|
|
105
|
+
if (value.schemaVersion !== 1 || value.kind !== "design-release-plan") {
|
|
106
|
+
throw new Error("Unsupported design release plan schema");
|
|
107
|
+
}
|
|
108
|
+
const screens = boundedList(value.screens, "Release screens", 100).map((entry) => screen(entry));
|
|
109
|
+
const contracts = boundedList(value.contracts, "Release contracts", 100).map((entry) => contract(entry));
|
|
110
|
+
unique(screens.map((entry) => entry.id), "screen IDs");
|
|
111
|
+
unique(screens.map((entry) => entry.frameId), "source frame IDs");
|
|
112
|
+
unique(contracts.map((entry) => entry.path), "contract paths");
|
|
113
|
+
return {
|
|
114
|
+
schemaVersion: 1,
|
|
115
|
+
kind: "design-release-plan",
|
|
116
|
+
...releaseHeader(value),
|
|
117
|
+
screens,
|
|
118
|
+
contracts,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
function receipt(raw) {
|
|
122
|
+
const value = mapObject(raw, "Release receipt", [
|
|
123
|
+
"sourceId",
|
|
124
|
+
"targetFrameId",
|
|
125
|
+
"sourceFrameId",
|
|
126
|
+
"targetPageId",
|
|
127
|
+
"nodeMap",
|
|
128
|
+
"sourceContentHash",
|
|
129
|
+
"targetContentHash",
|
|
130
|
+
"sourceInspection",
|
|
131
|
+
"targetInspection",
|
|
132
|
+
"originalRender",
|
|
133
|
+
"targetRender",
|
|
134
|
+
"decodedPixelsHash",
|
|
135
|
+
"width",
|
|
136
|
+
"height",
|
|
137
|
+
]);
|
|
138
|
+
const nodeMap = mapObject(value.nodeMap, "Node map", Object.keys(value.nodeMap && typeof value.nodeMap === "object" ? value.nodeMap : {}));
|
|
139
|
+
if (!Object.keys(nodeMap).length || Object.keys(nodeMap).length > 20000) {
|
|
140
|
+
throw new Error("Release node map is empty or unbounded");
|
|
141
|
+
}
|
|
142
|
+
const mapped = Object.fromEntries(Object.entries(nodeMap).map(([from, to]) => [
|
|
143
|
+
mapId(from, "Source node ID"),
|
|
144
|
+
mapId(to, "Target node ID"),
|
|
145
|
+
]));
|
|
146
|
+
unique(Object.values(mapped), "target descendant IDs");
|
|
147
|
+
if (!Number.isInteger(value.width) ||
|
|
148
|
+
Number(value.width) < 1 ||
|
|
149
|
+
!Number.isInteger(value.height) ||
|
|
150
|
+
Number(value.height) < 1) {
|
|
151
|
+
throw new Error("Receipt requires exact image dimensions");
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
sourceId: mapId(value.sourceId, "Receipt source ID"),
|
|
155
|
+
targetFrameId: mapId(value.targetFrameId, "Receipt target frame ID"),
|
|
156
|
+
sourceFrameId: mapId(value.sourceFrameId, "Receipt source frame ID"),
|
|
157
|
+
targetPageId: mapId(value.targetPageId, "Receipt target page ID"),
|
|
158
|
+
nodeMap: mapped,
|
|
159
|
+
sourceContentHash: mapHash(value.sourceContentHash, "Source content hash"),
|
|
160
|
+
targetContentHash: mapHash(value.targetContentHash, "Target content hash"),
|
|
161
|
+
sourceInspection: releaseReference(value.sourceInspection),
|
|
162
|
+
targetInspection: releaseReference(value.targetInspection),
|
|
163
|
+
originalRender: releaseReference(value.originalRender),
|
|
164
|
+
targetRender: releaseReference(value.targetRender),
|
|
165
|
+
decodedPixelsHash: mapHash(value.decodedPixelsHash, "Decoded pixels hash"),
|
|
166
|
+
width: Number(value.width),
|
|
167
|
+
height: Number(value.height),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
export function validateDesignRelease(raw) {
|
|
171
|
+
const value = mapObject(raw, "Design release", [
|
|
172
|
+
"schemaVersion",
|
|
173
|
+
"kind",
|
|
174
|
+
"id",
|
|
175
|
+
"title",
|
|
176
|
+
"productVersion",
|
|
177
|
+
"fileId",
|
|
178
|
+
"status",
|
|
179
|
+
"sources",
|
|
180
|
+
"contracts",
|
|
181
|
+
"target",
|
|
182
|
+
"receipts",
|
|
183
|
+
"maps",
|
|
184
|
+
"overview",
|
|
185
|
+
"fidelity",
|
|
186
|
+
"reviews",
|
|
187
|
+
"implementation",
|
|
188
|
+
"createdAt",
|
|
189
|
+
"finalized",
|
|
190
|
+
]);
|
|
191
|
+
if (value.schemaVersion !== 1 ||
|
|
192
|
+
value.kind !== "design-release" ||
|
|
193
|
+
![
|
|
194
|
+
"planned",
|
|
195
|
+
"awaiting-copy",
|
|
196
|
+
"verified",
|
|
197
|
+
"final",
|
|
198
|
+
].includes(String(value.status))) {
|
|
199
|
+
throw new Error("Unsupported design release schema or state");
|
|
200
|
+
}
|
|
201
|
+
const sources = boundedList(value.sources, "Release sources", 100).map((entry) => {
|
|
202
|
+
const source = screen(entry, false);
|
|
203
|
+
return {
|
|
204
|
+
...source,
|
|
205
|
+
contentHash: mapHash(entry.contentHash, "Pinned source content hash"),
|
|
206
|
+
};
|
|
207
|
+
});
|
|
208
|
+
const contracts = boundedList(value.contracts, "Release contracts", 100).map((entry) => {
|
|
209
|
+
const item = entry;
|
|
210
|
+
if (item.kind !== "journey-map" && item.kind !== "integration-map") {
|
|
211
|
+
throw new Error("Release contract kind unsupported");
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
...contract(item, false),
|
|
215
|
+
kind: item.kind,
|
|
216
|
+
id: mapId(item.id, "Release contract ID"),
|
|
217
|
+
};
|
|
218
|
+
});
|
|
219
|
+
const target = mapObject(value.target, "Release target", [
|
|
220
|
+
"name",
|
|
221
|
+
"ownerKey",
|
|
222
|
+
"pageId",
|
|
223
|
+
"overviewFrameId",
|
|
224
|
+
]);
|
|
225
|
+
const implementation = mapObject(value.implementation, "Independent implementation status", [
|
|
226
|
+
"status",
|
|
227
|
+
"evidence",
|
|
228
|
+
]);
|
|
229
|
+
if (![
|
|
230
|
+
"not-assessed",
|
|
231
|
+
"in-progress",
|
|
232
|
+
"verified",
|
|
233
|
+
].includes(String(implementation.status)) ||
|
|
234
|
+
!Array.isArray(implementation.evidence) ||
|
|
235
|
+
implementation.evidence.length > 1000) {
|
|
236
|
+
throw new Error("Invalid implementation evidence status");
|
|
237
|
+
}
|
|
238
|
+
if (!Array.isArray(value.maps) || value.maps.length > 100) {
|
|
239
|
+
throw new Error("Invalid release map evidence list");
|
|
240
|
+
}
|
|
241
|
+
if (!Array.isArray(value.receipts) ||
|
|
242
|
+
value.receipts.length > 100 ||
|
|
243
|
+
!Array.isArray(value.reviews) ||
|
|
244
|
+
value.reviews.length > 100) {
|
|
245
|
+
throw new Error("Invalid release receipt/review list");
|
|
246
|
+
}
|
|
247
|
+
const result = {
|
|
248
|
+
schemaVersion: 1,
|
|
249
|
+
kind: "design-release",
|
|
250
|
+
...releaseHeader(value),
|
|
251
|
+
status: value.status,
|
|
252
|
+
sources,
|
|
253
|
+
contracts,
|
|
254
|
+
target: {
|
|
255
|
+
name: mapText(target.name, "Release page name", 200),
|
|
256
|
+
ownerKey: mapHash(target.ownerKey, "Release ownership key"),
|
|
257
|
+
...(target.pageId === undefined
|
|
258
|
+
? {}
|
|
259
|
+
: {
|
|
260
|
+
pageId: mapId(target.pageId, "Release page ID"),
|
|
261
|
+
}),
|
|
262
|
+
...(target.overviewFrameId === undefined
|
|
263
|
+
? {}
|
|
264
|
+
: {
|
|
265
|
+
overviewFrameId: mapId(target.overviewFrameId, "Overview frame ID"),
|
|
266
|
+
}),
|
|
267
|
+
},
|
|
268
|
+
maps: value.maps.map((raw) => {
|
|
269
|
+
const item = mapObject(raw, "Release map evidence", [
|
|
270
|
+
"kind",
|
|
271
|
+
"id",
|
|
272
|
+
"boardId",
|
|
273
|
+
"contractHash",
|
|
274
|
+
"projection",
|
|
275
|
+
"render",
|
|
276
|
+
]);
|
|
277
|
+
if (item.kind !== "journey-map" && item.kind !== "integration-map") {
|
|
278
|
+
throw new Error("Unsupported release map kind");
|
|
279
|
+
}
|
|
280
|
+
return {
|
|
281
|
+
kind: item.kind,
|
|
282
|
+
id: mapId(item.id, "Map ID"),
|
|
283
|
+
boardId: mapId(item.boardId, "Map board ID"),
|
|
284
|
+
contractHash: mapHash(item.contractHash, "Map contract hash"),
|
|
285
|
+
projection: releaseReference(item.projection),
|
|
286
|
+
render: releaseReference(item.render),
|
|
287
|
+
};
|
|
288
|
+
}),
|
|
289
|
+
receipts: value.receipts.map(receipt),
|
|
290
|
+
reviews: value.reviews.map(validateArtifactReview),
|
|
291
|
+
implementation: {
|
|
292
|
+
status: implementation.status,
|
|
293
|
+
evidence: implementation.evidence.map(releaseReference),
|
|
294
|
+
},
|
|
295
|
+
createdAt: mapText(value.createdAt, "Release creation time", 100),
|
|
296
|
+
};
|
|
297
|
+
if (result.target.name !== `release/${result.productVersion}` ||
|
|
298
|
+
result.target.ownerKey !==
|
|
299
|
+
observationHash({
|
|
300
|
+
id: result.id,
|
|
301
|
+
fileId: result.fileId,
|
|
302
|
+
productVersion: result.productVersion,
|
|
303
|
+
})) {
|
|
304
|
+
throw new Error("Release target name or ownership key changed");
|
|
305
|
+
}
|
|
306
|
+
unique(sources.map((entry) => entry.id), "source screen IDs");
|
|
307
|
+
unique(sources.map((entry) => entry.frameId), "source frame IDs");
|
|
308
|
+
unique(result.receipts.map((entry) => entry.sourceId), "receipt sources");
|
|
309
|
+
unique(result.receipts.map((entry) => entry.targetFrameId), "receipt targets");
|
|
310
|
+
for (const item of result.receipts) {
|
|
311
|
+
const source = sources.find((entry) => entry.id === item.sourceId);
|
|
312
|
+
if (!source ||
|
|
313
|
+
source.frameId !== item.sourceFrameId ||
|
|
314
|
+
item.targetPageId !== result.target.pageId ||
|
|
315
|
+
sources.some((entry) => entry.frameId === item.targetFrameId) ||
|
|
316
|
+
item.nodeMap[item.sourceFrameId] !== item.targetFrameId) {
|
|
317
|
+
throw new Error("Receipt does not bind the pinned source to an independent release target");
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
unique(result.maps.map((item) => `${item.kind}:${item.id}`), "release map identities");
|
|
321
|
+
if (value.overview !== undefined) {
|
|
322
|
+
const overview = mapObject(value.overview, "Overview evidence", [
|
|
323
|
+
"contentHash",
|
|
324
|
+
"render",
|
|
325
|
+
"decodedPixelsHash",
|
|
326
|
+
]);
|
|
327
|
+
result.overview = {
|
|
328
|
+
contentHash: mapHash(overview.contentHash, "Overview content hash"),
|
|
329
|
+
render: releaseReference(overview.render),
|
|
330
|
+
decodedPixelsHash: mapHash(overview.decodedPixelsHash, "Overview decoded pixels hash"),
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
if (value.fidelity !== undefined) {
|
|
334
|
+
const evidence = mapObject(value.fidelity, "Release fidelity", [
|
|
335
|
+
"schemaVersion",
|
|
336
|
+
"kind",
|
|
337
|
+
"verifiedAt",
|
|
338
|
+
"receiptsHash",
|
|
339
|
+
"exactDecodedPixels",
|
|
340
|
+
"nativeStructure",
|
|
341
|
+
]);
|
|
342
|
+
if (evidence.schemaVersion !== 1 ||
|
|
343
|
+
evidence.kind !== "design-release-fidelity" ||
|
|
344
|
+
evidence.exactDecodedPixels !== true ||
|
|
345
|
+
evidence.nativeStructure !== true ||
|
|
346
|
+
mapHash(evidence.receiptsHash, "Receipts hash") !== observationHash(result.receipts) ||
|
|
347
|
+
result.receipts.length !== sources.length) {
|
|
348
|
+
throw new Error("Fidelity evidence does not bind all screen receipts");
|
|
349
|
+
}
|
|
350
|
+
result.fidelity = {
|
|
351
|
+
schemaVersion: 1,
|
|
352
|
+
kind: "design-release-fidelity",
|
|
353
|
+
verifiedAt: mapText(evidence.verifiedAt, "Verification time", 100),
|
|
354
|
+
receiptsHash: String(evidence.receiptsHash),
|
|
355
|
+
exactDecodedPixels: true,
|
|
356
|
+
nativeStructure: true,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
if ((result.status === "verified" || result.status === "final") && !result.fidelity) {
|
|
360
|
+
throw new Error("Verified releases require complete fidelity evidence");
|
|
361
|
+
}
|
|
362
|
+
if (value.finalized !== undefined) {
|
|
363
|
+
const final = mapObject(value.finalized, "Final release pointer", [
|
|
364
|
+
"bundle",
|
|
365
|
+
"markerHash",
|
|
366
|
+
"reviewHash",
|
|
367
|
+
]);
|
|
368
|
+
result.finalized = {
|
|
369
|
+
bundle: releaseReference(final.bundle),
|
|
370
|
+
markerHash: mapHash(final.markerHash, "Final marker hash"),
|
|
371
|
+
reviewHash: mapHash(final.reviewHash, "Final review hash"),
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
if ((result.status === "final") !== Boolean(result.finalized)) {
|
|
375
|
+
throw new Error("Final release status requires a finalized immutable bundle pointer");
|
|
376
|
+
}
|
|
377
|
+
return result;
|
|
378
|
+
}
|
|
379
|
+
/** Owner review scopes immutable design evidence; implementation acceptance is independent. */
|
|
380
|
+
export function hashDesignRelease(raw) {
|
|
381
|
+
const release = validateDesignRelease(raw);
|
|
382
|
+
const { status: _status, reviews: _reviews, createdAt: _createdAt, finalized: _finalized, implementation: _implementation, ...candidate } = release;
|
|
383
|
+
return observationHash(candidate);
|
|
384
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { PenpotToolClient } from "../client.js";
|
|
2
|
+
import type { DesignRelease, ReceiptInput, ReleaseOptions } from "./types.js";
|
|
3
|
+
export declare function verifyRelease(client: PenpotToolClient, root: string, raw: DesignRelease, inputs: ReceiptInput[], options?: ReleaseOptions): Promise<DesignRelease>;
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { exportShapePng } from "../export.js";
|
|
2
|
+
import { observationHash } from "../journal.js";
|
|
3
|
+
import { withPageSession, selectPage } from "../page-session.js";
|
|
4
|
+
import { readArtifact, saveArtifact } from "./artifact-paths.js";
|
|
5
|
+
import { compareNativeCopy, decodedPixels, deriveNodeMap, inspectionContentHash, } from "./fidelity.js";
|
|
6
|
+
import { assertSourceUnchanged, captureReleaseScreen, observeRelease, releaseClient, } from "./live.js";
|
|
7
|
+
import { assertReleaseContracts } from "./store.js";
|
|
8
|
+
import { validateDesignRelease } from "./validation.js";
|
|
9
|
+
export async function verifyRelease(client, root, raw, inputs, options = {}) {
|
|
10
|
+
const release = validateDesignRelease(raw);
|
|
11
|
+
if (release.status === "final") {
|
|
12
|
+
throw new Error("Finalized releases cannot replace fidelity evidence");
|
|
13
|
+
}
|
|
14
|
+
if (!release.target.pageId ||
|
|
15
|
+
!release.target.overviewFrameId ||
|
|
16
|
+
!release.overview ||
|
|
17
|
+
release.maps.length !== release.contracts.length) {
|
|
18
|
+
throw new Error("Release page, overview and native maps must be assembled first");
|
|
19
|
+
}
|
|
20
|
+
if (!Array.isArray(inputs) ||
|
|
21
|
+
inputs.length !== release.sources.length ||
|
|
22
|
+
new Set(inputs.map((item) => item.sourceId)).size !== inputs.length ||
|
|
23
|
+
new Set(inputs.map((item) => item.targetFrameId)).size !== inputs.length) {
|
|
24
|
+
throw new Error("Explicit complete source-to-target receipts are required; duplicate receipts conflict");
|
|
25
|
+
}
|
|
26
|
+
if (inputs.some((item) => !release.sources.some((source) => source.id === item.sourceId) ||
|
|
27
|
+
release.sources.some((source) => source.frameId === item.targetFrameId))) {
|
|
28
|
+
throw new Error("Receipt uses an unknown source or an original source as target");
|
|
29
|
+
}
|
|
30
|
+
await assertReleaseContracts(root, release.contracts, false, undefined, release.fileId);
|
|
31
|
+
client = releaseClient(client, release);
|
|
32
|
+
return withPageSession(client, async () => {
|
|
33
|
+
const owned = await observeRelease(client, release);
|
|
34
|
+
if (owned.marker?.status !== "draft") {
|
|
35
|
+
throw new Error("Release is not an owned mutable draft");
|
|
36
|
+
}
|
|
37
|
+
const observations = [];
|
|
38
|
+
const fullMap = {};
|
|
39
|
+
for (const source of release.sources) {
|
|
40
|
+
const input = inputs.find((item) => item.sourceId === source.id);
|
|
41
|
+
if (!source.render) {
|
|
42
|
+
throw new Error("Pinned source render is required before native fidelity verification");
|
|
43
|
+
}
|
|
44
|
+
const original = await assertSourceUnchanged(client, root, release, source);
|
|
45
|
+
const copied = await captureReleaseScreen(client, release.fileId, release.target.pageId, input.targetFrameId);
|
|
46
|
+
const mapping = deriveNodeMap(original, copied);
|
|
47
|
+
for (const [from, to] of Object.entries(mapping)) {
|
|
48
|
+
if (Object.hasOwn(fullMap, from) || Object.values(fullMap).includes(to)) {
|
|
49
|
+
throw new Error("Duplicate or overlapping source/target descendants conflict");
|
|
50
|
+
}
|
|
51
|
+
fullMap[from] = to;
|
|
52
|
+
}
|
|
53
|
+
observations.push({
|
|
54
|
+
source,
|
|
55
|
+
input,
|
|
56
|
+
original,
|
|
57
|
+
copied,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
const receipts = [];
|
|
61
|
+
for (const { source, input, original, copied } of observations) {
|
|
62
|
+
const content = compareNativeCopy(original, copied, fullMap);
|
|
63
|
+
const frozen = await decodedPixels(await readArtifact(root, source.render, 64 * 1024 * 1024));
|
|
64
|
+
await selectPage(client, {
|
|
65
|
+
id: source.pageId,
|
|
66
|
+
});
|
|
67
|
+
const sourceImage = await exportShapePng(client, source.frameId, {
|
|
68
|
+
...options.exportOptions,
|
|
69
|
+
stable: true,
|
|
70
|
+
});
|
|
71
|
+
await selectPage(client, {
|
|
72
|
+
id: release.target.pageId,
|
|
73
|
+
});
|
|
74
|
+
const targetImage = await exportShapePng(client, input.targetFrameId, {
|
|
75
|
+
...options.exportOptions,
|
|
76
|
+
stable: true,
|
|
77
|
+
});
|
|
78
|
+
if (!sourceImage.stable ||
|
|
79
|
+
!targetImage.stable ||
|
|
80
|
+
sourceImage.capture.fileId !== release.fileId ||
|
|
81
|
+
sourceImage.capture.pageId !== source.pageId ||
|
|
82
|
+
targetImage.capture.fileId !== release.fileId ||
|
|
83
|
+
targetImage.capture.pageId !== release.target.pageId) {
|
|
84
|
+
throw new Error("Stable source and target render provenance is required");
|
|
85
|
+
}
|
|
86
|
+
const environment = (image) => ({
|
|
87
|
+
scale: image.capture.scale,
|
|
88
|
+
api: image.capture.penpotApiVersion,
|
|
89
|
+
render: image.capture.renderEnvironment,
|
|
90
|
+
themes: image.capture.activeThemeIds,
|
|
91
|
+
activeThemes: image.capture.activeThemes,
|
|
92
|
+
});
|
|
93
|
+
if (observationHash(environment(sourceImage)) !== observationHash(environment(targetImage))) {
|
|
94
|
+
throw new Error("Source and target render environments or themes differ");
|
|
95
|
+
}
|
|
96
|
+
const before = await decodedPixels(sourceImage.png);
|
|
97
|
+
const after = await decodedPixels(targetImage.png);
|
|
98
|
+
if (before.width !== after.width ||
|
|
99
|
+
before.height !== after.height ||
|
|
100
|
+
before.width !== frozen.width ||
|
|
101
|
+
before.height !== frozen.height ||
|
|
102
|
+
before.hash !== after.hash ||
|
|
103
|
+
before.hash !== frozen.hash) {
|
|
104
|
+
throw new Error("Native copy or pinned source changed exact decoded pixels or image dimensions");
|
|
105
|
+
}
|
|
106
|
+
const sourceAfter = await assertSourceUnchanged(client, root, release, source);
|
|
107
|
+
const targetAfter = await captureReleaseScreen(client, release.fileId, release.target.pageId, input.targetFrameId);
|
|
108
|
+
compareNativeCopy(sourceAfter, targetAfter, fullMap);
|
|
109
|
+
if (observationHash(deriveNodeMap(sourceAfter, targetAfter)) !==
|
|
110
|
+
observationHash(deriveNodeMap(original, copied))) {
|
|
111
|
+
throw new Error("Native descendant identities changed during fidelity capture");
|
|
112
|
+
}
|
|
113
|
+
if (inspectionContentHash(sourceAfter) !== source.contentHash) {
|
|
114
|
+
throw new Error("Source changed during fidelity capture");
|
|
115
|
+
}
|
|
116
|
+
const prefix = `design/releases/${release.productVersion}/evidence/screens/${source.id}`;
|
|
117
|
+
receipts.push({
|
|
118
|
+
...input,
|
|
119
|
+
sourceFrameId: source.frameId,
|
|
120
|
+
targetPageId: release.target.pageId,
|
|
121
|
+
nodeMap: deriveNodeMap(original, copied),
|
|
122
|
+
sourceContentHash: content.sourceHash,
|
|
123
|
+
targetContentHash: content.targetHash,
|
|
124
|
+
sourceInspection: await saveArtifact(root, `${prefix}-source-${observationHash(sourceAfter)}.json`, `${JSON.stringify(sourceAfter, null, 2)}\n`, options.redact),
|
|
125
|
+
targetInspection: await saveArtifact(root, `${prefix}-target-${observationHash(targetAfter)}.json`, `${JSON.stringify(targetAfter, null, 2)}\n`, options.redact),
|
|
126
|
+
originalRender: await saveArtifact(root, `${prefix}-source-${before.hash}.png`, sourceImage.png, options.redact),
|
|
127
|
+
targetRender: await saveArtifact(root, `${prefix}-target-${after.hash}.png`, targetImage.png, options.redact),
|
|
128
|
+
decodedPixelsHash: before.hash,
|
|
129
|
+
width: before.width,
|
|
130
|
+
height: before.height,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
return validateDesignRelease({
|
|
134
|
+
...release,
|
|
135
|
+
status: "verified",
|
|
136
|
+
receipts,
|
|
137
|
+
fidelity: {
|
|
138
|
+
schemaVersion: 1,
|
|
139
|
+
kind: "design-release-fidelity",
|
|
140
|
+
verifiedAt: new Date().toISOString(),
|
|
141
|
+
receiptsHash: observationHash(receipts),
|
|
142
|
+
exactDecodedPixels: true,
|
|
143
|
+
nativeStructure: true,
|
|
144
|
+
},
|
|
145
|
+
reviews: [],
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { inspectImageEvidence } from "./image-evidence.js";
|
|
2
|
+
const maximumDecodedPixels = 64_000_000;
|
|
3
|
+
function assertStaticPng(bytes) {
|
|
4
|
+
let offset = 8;
|
|
5
|
+
while (offset < bytes.length) {
|
|
6
|
+
if (offset + 12 > bytes.length) {
|
|
7
|
+
throw new Error("Truncated PNG cannot be encoded as lossless WebP");
|
|
8
|
+
}
|
|
9
|
+
const length = bytes.readUInt32BE(offset);
|
|
10
|
+
const end = offset + length + 12;
|
|
11
|
+
const chunk = bytes.subarray(offset + 4, offset + 8).toString("latin1");
|
|
12
|
+
if (end > bytes.length) {
|
|
13
|
+
throw new Error("Truncated PNG cannot be encoded as lossless WebP");
|
|
14
|
+
}
|
|
15
|
+
if (chunk === "acTL" || chunk === "fcTL" || chunk === "fdAT") {
|
|
16
|
+
throw new Error("Lossless WebP encoding requires a static 8-bit PNG");
|
|
17
|
+
}
|
|
18
|
+
offset = end;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** Keep PNG byte identity, or deterministically encode the same pixels as lossless WebP. */
|
|
22
|
+
export async function encodeBaselineImage(png, format) {
|
|
23
|
+
const source = inspectImageEvidence(png);
|
|
24
|
+
if (source.format !== "png" || (format !== "png" && format !== "webp")) {
|
|
25
|
+
throw new Error("Baseline encoding requires PNG input and png or webp output");
|
|
26
|
+
}
|
|
27
|
+
if (source.width * source.height > maximumDecodedPixels) {
|
|
28
|
+
throw new Error("Baseline image exceeds the 64-megapixel decode budget");
|
|
29
|
+
}
|
|
30
|
+
if (format === "png") {
|
|
31
|
+
return Buffer.from(png);
|
|
32
|
+
}
|
|
33
|
+
assertStaticPng(png);
|
|
34
|
+
const { default: sharp } = await import("sharp");
|
|
35
|
+
const decoder = sharp(png, {
|
|
36
|
+
failOn: "error",
|
|
37
|
+
limitInputPixels: maximumDecodedPixels,
|
|
38
|
+
});
|
|
39
|
+
const metadata = await decoder.metadata();
|
|
40
|
+
if (metadata.depth !== "uchar" || (metadata.pages ?? 1) !== 1 || metadata.bitsPerSample === 16) {
|
|
41
|
+
throw new Error("Lossless WebP encoding requires a static 8-bit PNG");
|
|
42
|
+
}
|
|
43
|
+
const encoded = await decoder
|
|
44
|
+
.keepIccProfile()
|
|
45
|
+
.webp({
|
|
46
|
+
lossless: true,
|
|
47
|
+
exact: true,
|
|
48
|
+
effort: 6,
|
|
49
|
+
})
|
|
50
|
+
.toBuffer();
|
|
51
|
+
const output = inspectImageEvidence(encoded);
|
|
52
|
+
if (output.format !== "webp" ||
|
|
53
|
+
output.width !== source.width ||
|
|
54
|
+
output.height !== source.height) {
|
|
55
|
+
throw new Error("Lossless WebP encoding changed image dimensions");
|
|
56
|
+
}
|
|
57
|
+
const verified = sharp(encoded, {
|
|
58
|
+
failOn: "error",
|
|
59
|
+
limitInputPixels: maximumDecodedPixels,
|
|
60
|
+
});
|
|
61
|
+
const encodedMetadata = await verified.metadata();
|
|
62
|
+
if (Boolean(metadata.icc) !== Boolean(encodedMetadata.icc) ||
|
|
63
|
+
(metadata.icc && !metadata.icc.equals(encodedMetadata.icc))) {
|
|
64
|
+
throw new Error("Lossless WebP encoding changed the ICC profile");
|
|
65
|
+
}
|
|
66
|
+
const before = await sharp(png, {
|
|
67
|
+
failOn: "error",
|
|
68
|
+
limitInputPixels: maximumDecodedPixels,
|
|
69
|
+
})
|
|
70
|
+
.ensureAlpha()
|
|
71
|
+
.raw()
|
|
72
|
+
.toBuffer();
|
|
73
|
+
const after = await verified.ensureAlpha().raw().toBuffer();
|
|
74
|
+
if (!before.equals(after)) {
|
|
75
|
+
throw new Error("Lossless WebP encoding changed RGBA pixels");
|
|
76
|
+
}
|
|
77
|
+
return encoded;
|
|
78
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type BaselineImageFormat = "png" | "webp";
|
|
2
|
+
export declare const maximumImageBytes: number;
|
|
3
|
+
interface ImageSize {
|
|
4
|
+
width: number;
|
|
5
|
+
height: number;
|
|
6
|
+
}
|
|
7
|
+
export interface ImageMetadata extends ImageSize {
|
|
8
|
+
format: BaselineImageFormat;
|
|
9
|
+
}
|
|
10
|
+
/** Header/container assurance only; this does not decode compressed pixels. */
|
|
11
|
+
export declare function inspectImageEvidence(bytes: Buffer): ImageMetadata;
|
|
12
|
+
export declare function readImageEvidence(root: string, file: string): Promise<{
|
|
13
|
+
bytes: Buffer;
|
|
14
|
+
} & ImageMetadata>;
|
|
15
|
+
export {};
|