@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,328 @@
|
|
|
1
|
+
import { observationHash } from "../journal.js";
|
|
2
|
+
import { inspectImageEvidence } from "../image-evidence.js";
|
|
3
|
+
import { hashBytes } from "./artifact-paths.js";
|
|
4
|
+
const requiredFields = [
|
|
5
|
+
"geometry",
|
|
6
|
+
"text",
|
|
7
|
+
"layout",
|
|
8
|
+
"component",
|
|
9
|
+
"tokens",
|
|
10
|
+
"style",
|
|
11
|
+
"metadata",
|
|
12
|
+
];
|
|
13
|
+
function object(value, message) {
|
|
14
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
15
|
+
throw new Error(message);
|
|
16
|
+
}
|
|
17
|
+
return value;
|
|
18
|
+
}
|
|
19
|
+
export function nativeTree(inspection) {
|
|
20
|
+
if (inspection.kind !== "shape-inspection" ||
|
|
21
|
+
!inspection.complete ||
|
|
22
|
+
inspection.reasons.length ||
|
|
23
|
+
!Array.isArray(inspection.nodes) ||
|
|
24
|
+
!inspection.nodes.length ||
|
|
25
|
+
inspection.nodes.length > 20000) {
|
|
26
|
+
throw new Error("Native fidelity requires a complete bounded shape inspection");
|
|
27
|
+
}
|
|
28
|
+
const nodes = new Map();
|
|
29
|
+
for (const node of inspection.nodes) {
|
|
30
|
+
if (typeof node.id !== "string" ||
|
|
31
|
+
!node.id ||
|
|
32
|
+
nodes.has(node.id) ||
|
|
33
|
+
!Array.isArray(node.childIds) ||
|
|
34
|
+
!Array.isArray(node.unknowns) ||
|
|
35
|
+
node.unknowns.length) {
|
|
36
|
+
throw new Error("Unknown fields or duplicate identities block native fidelity");
|
|
37
|
+
}
|
|
38
|
+
for (const key of requiredFields) {
|
|
39
|
+
if (!Object.hasOwn(node, key)) {
|
|
40
|
+
throw new Error(`Native inspection is missing required field ${key}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const geometry = object(node.geometry, "Native geometry is unavailable");
|
|
44
|
+
for (const axis of [
|
|
45
|
+
"x",
|
|
46
|
+
"y",
|
|
47
|
+
"width",
|
|
48
|
+
"height",
|
|
49
|
+
"rotation",
|
|
50
|
+
]) {
|
|
51
|
+
if (typeof geometry[axis] !== "number" || !Number.isFinite(geometry[axis])) {
|
|
52
|
+
throw new Error("Native geometry contains an unobservable or nonfinite field");
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (Number(geometry.width) < 0 ||
|
|
56
|
+
Number(geometry.height) < 0 ||
|
|
57
|
+
typeof geometry.hidden !== "boolean" ||
|
|
58
|
+
typeof geometry.blocked !== "boolean" ||
|
|
59
|
+
[
|
|
60
|
+
"flipX",
|
|
61
|
+
"flipY",
|
|
62
|
+
"proportionLock",
|
|
63
|
+
].some((key) => typeof geometry[key] !== "boolean")) {
|
|
64
|
+
throw new Error("Native geometry flags/dimensions are unobservable");
|
|
65
|
+
}
|
|
66
|
+
if (![
|
|
67
|
+
"board",
|
|
68
|
+
"rect",
|
|
69
|
+
"rectangle",
|
|
70
|
+
"ellipse",
|
|
71
|
+
"text",
|
|
72
|
+
"path",
|
|
73
|
+
"group",
|
|
74
|
+
].includes(String(node.type))) {
|
|
75
|
+
throw new Error("Native shape content is unsupported or unobservable");
|
|
76
|
+
}
|
|
77
|
+
if (node.type === "path" && (typeof node.content !== "string" || !node.content)) {
|
|
78
|
+
throw new Error("Native path content fidelity is unobservable in this inspection");
|
|
79
|
+
}
|
|
80
|
+
const layout = object(node.layout, "Native layout is unavailable");
|
|
81
|
+
if (!Object.hasOwn(layout, "showInViewMode") || !Object.hasOwn(layout, "boardSizing")) {
|
|
82
|
+
throw new Error("Native board behavior observations are missing");
|
|
83
|
+
}
|
|
84
|
+
if (node.type === "board") {
|
|
85
|
+
if (typeof layout.showInViewMode !== "boolean") {
|
|
86
|
+
throw new Error("Native board viewer visibility is unobservable");
|
|
87
|
+
}
|
|
88
|
+
const sizing = object(layout.boardSizing, "Native board sizing is unobservable");
|
|
89
|
+
for (const key of [
|
|
90
|
+
"horizontalSizing",
|
|
91
|
+
"verticalSizing",
|
|
92
|
+
]) {
|
|
93
|
+
if (!Object.hasOwn(sizing, key) ||
|
|
94
|
+
![
|
|
95
|
+
null,
|
|
96
|
+
"auto",
|
|
97
|
+
"fix",
|
|
98
|
+
].includes(sizing[key])) {
|
|
99
|
+
throw new Error("Native board sizing is unsupported or unobservable");
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const constraints = object(layout.constraints, "Native constraints observations are missing");
|
|
104
|
+
const horizontal = [
|
|
105
|
+
"center",
|
|
106
|
+
"left",
|
|
107
|
+
"right",
|
|
108
|
+
"leftright",
|
|
109
|
+
"scale",
|
|
110
|
+
...(node.type === "board"
|
|
111
|
+
? [
|
|
112
|
+
null,
|
|
113
|
+
]
|
|
114
|
+
: []),
|
|
115
|
+
];
|
|
116
|
+
const vertical = [
|
|
117
|
+
"center",
|
|
118
|
+
"top",
|
|
119
|
+
"bottom",
|
|
120
|
+
"scale",
|
|
121
|
+
"topbottom",
|
|
122
|
+
...(node.type === "board"
|
|
123
|
+
? [
|
|
124
|
+
null,
|
|
125
|
+
]
|
|
126
|
+
: []),
|
|
127
|
+
];
|
|
128
|
+
if (!Object.hasOwn(constraints, "constraintsHorizontal") ||
|
|
129
|
+
!Object.hasOwn(constraints, "constraintsVertical") ||
|
|
130
|
+
!horizontal.includes(constraints.constraintsHorizontal) ||
|
|
131
|
+
!vertical.includes(constraints.constraintsVertical) ||
|
|
132
|
+
typeof constraints.fixedWhenScrolling !== "boolean") {
|
|
133
|
+
throw new Error("Native constraints are unobservable or unsupported");
|
|
134
|
+
}
|
|
135
|
+
const style = object(node.style, "Native style is unavailable");
|
|
136
|
+
if (!Object.hasOwn(style, "blur") || !Object.hasOwn(style, "backgroundBlur")) {
|
|
137
|
+
throw new Error("Native blur observations are missing");
|
|
138
|
+
}
|
|
139
|
+
if (node.type === "text") {
|
|
140
|
+
const text = object(node.text, "Native text is unavailable");
|
|
141
|
+
const typography = object(text.typography, "Native typography is unavailable");
|
|
142
|
+
if (!Object.hasOwn(typography, "textTransform") ||
|
|
143
|
+
!Object.hasOwn(typography, "textDecoration")) {
|
|
144
|
+
throw new Error("Native text transform/decoration observations are missing");
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const component = object(node.component, "Component observations are unavailable");
|
|
148
|
+
if (component.isComponentInstance === true ||
|
|
149
|
+
component.isComponentCopyInstance === true ||
|
|
150
|
+
component.isComponentMainInstance === true ||
|
|
151
|
+
component.isComponentRoot === true) {
|
|
152
|
+
throw new Error("Component override fidelity is unobservable in this inspection; native release verification is blocked");
|
|
153
|
+
}
|
|
154
|
+
if (node.type === "group" && !Object.hasOwn(node, "maskedGroup")) {
|
|
155
|
+
throw new Error("Mask fidelity is unobservable for this group");
|
|
156
|
+
}
|
|
157
|
+
nodes.set(node.id, node);
|
|
158
|
+
}
|
|
159
|
+
const roots = inspection.nodes.filter((node) => node.parentId === null);
|
|
160
|
+
if (roots.length !== 1 || roots[0]?.type !== "board") {
|
|
161
|
+
throw new Error("A release screen must be one native board root");
|
|
162
|
+
}
|
|
163
|
+
const root = roots[0];
|
|
164
|
+
const ordered = [];
|
|
165
|
+
const paths = new Map();
|
|
166
|
+
const visit = (node, currentPath, parentId) => {
|
|
167
|
+
const id = String(node.id);
|
|
168
|
+
if (paths.has(id) || node.parentId !== parentId || paths.size >= 20000) {
|
|
169
|
+
throw new Error("Native hierarchy is cyclic, ambiguous or inconsistent");
|
|
170
|
+
}
|
|
171
|
+
paths.set(id, currentPath);
|
|
172
|
+
ordered.push({
|
|
173
|
+
path: currentPath,
|
|
174
|
+
node,
|
|
175
|
+
});
|
|
176
|
+
const children = node.childIds;
|
|
177
|
+
if (node.childrenTotal !== children.length) {
|
|
178
|
+
throw new Error("Native hierarchy children are incomplete");
|
|
179
|
+
}
|
|
180
|
+
children.forEach((childId, index) => {
|
|
181
|
+
if (typeof childId !== "string" || !nodes.has(childId)) {
|
|
182
|
+
throw new Error("Native hierarchy has an unknown child");
|
|
183
|
+
}
|
|
184
|
+
const child = nodes.get(childId);
|
|
185
|
+
if (child.index !== index) {
|
|
186
|
+
throw new Error("Native child order is inconsistent");
|
|
187
|
+
}
|
|
188
|
+
visit(child, `${currentPath}/${index}`, id);
|
|
189
|
+
});
|
|
190
|
+
};
|
|
191
|
+
visit(root, "root", null);
|
|
192
|
+
if (paths.size !== nodes.size) {
|
|
193
|
+
throw new Error("Native inspection includes detached shapes");
|
|
194
|
+
}
|
|
195
|
+
return {
|
|
196
|
+
root,
|
|
197
|
+
ordered,
|
|
198
|
+
paths,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
function referenceValue(value, paths, remap) {
|
|
202
|
+
if (typeof value === "string") {
|
|
203
|
+
const original = remap[value] ?? value;
|
|
204
|
+
return paths.get(original) ?? original;
|
|
205
|
+
}
|
|
206
|
+
if (Array.isArray(value)) {
|
|
207
|
+
return value.map((entry) => referenceValue(entry, paths, remap));
|
|
208
|
+
}
|
|
209
|
+
if (value && typeof value === "object") {
|
|
210
|
+
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [
|
|
211
|
+
key,
|
|
212
|
+
referenceValue(entry, paths, remap),
|
|
213
|
+
]));
|
|
214
|
+
}
|
|
215
|
+
return value;
|
|
216
|
+
}
|
|
217
|
+
export function semanticInspection(inspection, remap = {}) {
|
|
218
|
+
const tree = nativeTree(inspection);
|
|
219
|
+
const rootGeometry = object(tree.root.geometry, "Root geometry is unavailable");
|
|
220
|
+
const paths = new Map([
|
|
221
|
+
...tree.paths.entries(),
|
|
222
|
+
].map(([id, value]) => [
|
|
223
|
+
remap[id] ?? id,
|
|
224
|
+
value,
|
|
225
|
+
]));
|
|
226
|
+
return tree.ordered.map(({ path, node }) => {
|
|
227
|
+
const geometry = object(node.geometry, "Geometry is unavailable");
|
|
228
|
+
const relativeGeometry = {
|
|
229
|
+
...geometry,
|
|
230
|
+
};
|
|
231
|
+
for (const axis of [
|
|
232
|
+
"x",
|
|
233
|
+
"y",
|
|
234
|
+
]) {
|
|
235
|
+
if (typeof geometry[axis] !== "number" || typeof rootGeometry[axis] !== "number") {
|
|
236
|
+
throw new Error("Native geometry must be finite and observable");
|
|
237
|
+
}
|
|
238
|
+
relativeGeometry[axis] =
|
|
239
|
+
Math.round((geometry[axis] - rootGeometry[axis]) * 1e6) / 1e6;
|
|
240
|
+
}
|
|
241
|
+
const metadata = object(node.metadata, "Interaction observations unavailable");
|
|
242
|
+
if (!Object.hasOwn(metadata, "interactions")) {
|
|
243
|
+
throw new Error("Native interactions are unobservable");
|
|
244
|
+
}
|
|
245
|
+
return {
|
|
246
|
+
path,
|
|
247
|
+
type: node.type,
|
|
248
|
+
children: node.childIds.length,
|
|
249
|
+
geometry: relativeGeometry,
|
|
250
|
+
text: node.text,
|
|
251
|
+
layout: node.layout,
|
|
252
|
+
tokens: node.tokens,
|
|
253
|
+
style: node.style,
|
|
254
|
+
maskedGroup: node.maskedGroup ?? null,
|
|
255
|
+
pathContent: node.type === "path" ? node.content : null,
|
|
256
|
+
component: referenceValue(node.component, paths, remap),
|
|
257
|
+
interactions: referenceValue(metadata.interactions, paths, remap),
|
|
258
|
+
};
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
export function inspectionContentHash(inspection) {
|
|
262
|
+
return observationHash(semanticInspection(inspection));
|
|
263
|
+
}
|
|
264
|
+
export function deriveNodeMap(source, target) {
|
|
265
|
+
const before = nativeTree(source);
|
|
266
|
+
const after = nativeTree(target);
|
|
267
|
+
if (before.ordered.length !== after.ordered.length) {
|
|
268
|
+
throw new Error("Native copy changed hierarchy node count");
|
|
269
|
+
}
|
|
270
|
+
const entries = before.ordered.map((entry, index) => {
|
|
271
|
+
const copied = after.ordered[index];
|
|
272
|
+
if (entry.path !== copied.path ||
|
|
273
|
+
entry.node.type !== copied.node.type ||
|
|
274
|
+
entry.node.id === copied.node.id) {
|
|
275
|
+
throw new Error("Native copy topology/type or independent shape identity differs");
|
|
276
|
+
}
|
|
277
|
+
return [
|
|
278
|
+
String(entry.node.id),
|
|
279
|
+
String(copied.node.id),
|
|
280
|
+
];
|
|
281
|
+
});
|
|
282
|
+
return Object.fromEntries(entries);
|
|
283
|
+
}
|
|
284
|
+
export function compareNativeCopy(source, target, fullNodeMap) {
|
|
285
|
+
deriveNodeMap(source, target);
|
|
286
|
+
const inverse = Object.fromEntries(Object.entries(fullNodeMap).map(([from, to]) => [
|
|
287
|
+
to,
|
|
288
|
+
from,
|
|
289
|
+
]));
|
|
290
|
+
const sourceHash = inspectionContentHash(source);
|
|
291
|
+
const targetHash = observationHash(semanticInspection(target, inverse));
|
|
292
|
+
if (sourceHash !== targetHash) {
|
|
293
|
+
throw new Error("Native copy changed text, hierarchy, layout, geometry, tokens, images, components or interaction references");
|
|
294
|
+
}
|
|
295
|
+
return {
|
|
296
|
+
sourceHash,
|
|
297
|
+
targetHash,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
export async function decodedPixels(png) {
|
|
301
|
+
if (png.length > 64 * 1024 * 1024) {
|
|
302
|
+
throw new Error("Release render exceeds 64-MiB input budget");
|
|
303
|
+
}
|
|
304
|
+
const header = inspectImageEvidence(png);
|
|
305
|
+
if (header.format !== "png" || header.width * header.height > 64_000_000) {
|
|
306
|
+
throw new Error("Release fidelity requires bounded PNG evidence");
|
|
307
|
+
}
|
|
308
|
+
const { default: sharp } = await import("sharp");
|
|
309
|
+
const image = sharp(png, {
|
|
310
|
+
failOn: "error",
|
|
311
|
+
limitInputPixels: 64_000_000,
|
|
312
|
+
});
|
|
313
|
+
const metadata = await image.metadata();
|
|
314
|
+
if (metadata.depth !== "uchar" || metadata.bitsPerSample === 16 || (metadata.pages ?? 1) !== 1) {
|
|
315
|
+
throw new Error("Release fidelity requires a static 8-bit render");
|
|
316
|
+
}
|
|
317
|
+
const { data, info } = await image.ensureAlpha().raw().toBuffer({
|
|
318
|
+
resolveWithObject: true,
|
|
319
|
+
});
|
|
320
|
+
if (info.width !== header.width || info.height !== header.height || info.channels !== 4) {
|
|
321
|
+
throw new Error("Decoded render dimensions changed");
|
|
322
|
+
}
|
|
323
|
+
return {
|
|
324
|
+
hash: hashBytes(data),
|
|
325
|
+
width: info.width,
|
|
326
|
+
height: info.height,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { PenpotToolClient } from "../client.js";
|
|
2
|
+
import type { ArtifactReview } from "../maps/types.js";
|
|
3
|
+
import type { DesignRelease, ReleaseOptions } from "./types.js";
|
|
4
|
+
export declare function finalizeRelease(client: PenpotToolClient, root: string, raw: DesignRelease, suppliedReview: ArtifactReview | undefined, options?: ReleaseOptions): Promise<DesignRelease>;
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { lstat } from "node:fs/promises";
|
|
2
|
+
import { observationHash, readJournalTarget, runJournaledSteps } from "../journal.js";
|
|
3
|
+
import { validateArtifactReview, findArtifactReview } from "../maps/review.js";
|
|
4
|
+
import { guardRevision } from "../coordination.js";
|
|
5
|
+
import { withPageSession } from "../page-session.js";
|
|
6
|
+
import { runSnippet } from "../snippet.js";
|
|
7
|
+
import { artifactPath, readArtifact, saveArtifact } from "./artifact-paths.js";
|
|
8
|
+
import { checkRelease, releaseReferences } from "./check.js";
|
|
9
|
+
import { observeRelease, releaseClient } from "./live.js";
|
|
10
|
+
import { readReleaseContract, writeDesignRelease, readDesignRelease } from "./store.js";
|
|
11
|
+
import { finalizeReleaseSnippet } from "./snippets.js";
|
|
12
|
+
import { validateDesignRelease, hashDesignRelease } from "./validation.js";
|
|
13
|
+
export async function finalizeRelease(client, root, raw, suppliedReview, options = {}) {
|
|
14
|
+
const release = validateDesignRelease(raw);
|
|
15
|
+
if (release.status !== "verified" && release.status !== "final") {
|
|
16
|
+
throw new Error("Only fully verified design evidence can be finalized");
|
|
17
|
+
}
|
|
18
|
+
client = releaseClient(client, release);
|
|
19
|
+
const checkStart = await readJournalTarget(client);
|
|
20
|
+
const checked = await checkRelease(client, root, release, options);
|
|
21
|
+
const checkedTarget = await readJournalTarget(client);
|
|
22
|
+
if (checkStart.fileId !== checkedTarget.fileId ||
|
|
23
|
+
checkStart.revision !== checkedTarget.revision) {
|
|
24
|
+
throw new Error("Release changed during the independent final check");
|
|
25
|
+
}
|
|
26
|
+
if (!checked.valid) {
|
|
27
|
+
throw new Error(`Release finalization is blocked: ${checked.reasons.join("; ")}`);
|
|
28
|
+
}
|
|
29
|
+
if (!options.apply || release.status === "final") {
|
|
30
|
+
return release;
|
|
31
|
+
}
|
|
32
|
+
let review = validateArtifactReview(suppliedReview);
|
|
33
|
+
const designHash = hashDesignRelease(release);
|
|
34
|
+
if (!findArtifactReview([
|
|
35
|
+
review,
|
|
36
|
+
], "design-release", release.id, designHash)) {
|
|
37
|
+
throw new Error("Owner review must bind the exact verified release design hash");
|
|
38
|
+
}
|
|
39
|
+
client = releaseClient(client, release);
|
|
40
|
+
return withPageSession(client, async () => {
|
|
41
|
+
const pointer = options.pointerPath ?? `design/releases/${release.productVersion}/release.json`;
|
|
42
|
+
const pointerFile = await artifactPath(root, pointer, true);
|
|
43
|
+
try {
|
|
44
|
+
await lstat(pointerFile);
|
|
45
|
+
const existing = await readDesignRelease(root, pointer);
|
|
46
|
+
if (existing.status === "final") {
|
|
47
|
+
throw new Error("This product version is already finalized; ordinary writes cannot replace it");
|
|
48
|
+
}
|
|
49
|
+
if (existing.id !== release.id || existing.productVersion !== release.productVersion) {
|
|
50
|
+
throw new Error("Final pointer belongs to another release identity");
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
if (error.code !== "ENOENT") {
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const bundlePath = `design/releases/${release.productVersion}/approved/bundle.json`;
|
|
59
|
+
const bundleFile = await artifactPath(root, bundlePath, true);
|
|
60
|
+
let storedBundle;
|
|
61
|
+
try {
|
|
62
|
+
await lstat(bundleFile);
|
|
63
|
+
const { readProjectBytes } = await import("../bounded-file.js");
|
|
64
|
+
storedBundle = JSON.parse((await readProjectBytes(root, bundlePath, 16 * 1024 * 1024)).toString("utf8"));
|
|
65
|
+
const originalReview = validateArtifactReview(storedBundle.review);
|
|
66
|
+
if (storedBundle.schemaVersion !== 1 ||
|
|
67
|
+
storedBundle.kind !== "approved-design-release" ||
|
|
68
|
+
storedBundle.designHash !== designHash ||
|
|
69
|
+
hashDesignRelease(storedBundle.release) !== designHash ||
|
|
70
|
+
originalReview.by !== review.by ||
|
|
71
|
+
originalReview.note !== review.note ||
|
|
72
|
+
originalReview.artifactHash !== review.artifactHash ||
|
|
73
|
+
observationHash(originalReview.artifacts ?? []) !== observationHash(review.artifacts ?? [])) {
|
|
74
|
+
throw new Error("Product version already has a different immutable design/review bundle");
|
|
75
|
+
}
|
|
76
|
+
review = originalReview;
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
if (error.code !== "ENOENT") {
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const references = releaseReferences(release);
|
|
84
|
+
for (const contract of release.contracts) {
|
|
85
|
+
const map = await readReleaseContract(root, contract);
|
|
86
|
+
for (const source of map.sources) {
|
|
87
|
+
if (source.path) {
|
|
88
|
+
references.push({
|
|
89
|
+
path: source.path,
|
|
90
|
+
sha256: source.sha256,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
for (const artifact of review.artifacts ?? []) {
|
|
96
|
+
references.push(artifact);
|
|
97
|
+
}
|
|
98
|
+
const snapshots = [];
|
|
99
|
+
for (const original of new Map(references.map((reference) => [
|
|
100
|
+
`${reference.path}:${reference.sha256}`,
|
|
101
|
+
reference,
|
|
102
|
+
])).values()) {
|
|
103
|
+
const bytes = await readArtifact(root, original, 64 * 1024 * 1024);
|
|
104
|
+
const snapshot = await saveArtifact(root, `design/releases/${release.productVersion}/approved/artifacts/${original.sha256}.bin`, bytes, options.redact);
|
|
105
|
+
snapshots.push({
|
|
106
|
+
original,
|
|
107
|
+
snapshot,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
const bundle = storedBundle ?? {
|
|
111
|
+
schemaVersion: 1,
|
|
112
|
+
kind: "approved-design-release",
|
|
113
|
+
designHash,
|
|
114
|
+
release,
|
|
115
|
+
review,
|
|
116
|
+
artifacts: snapshots,
|
|
117
|
+
};
|
|
118
|
+
const serialized = `${JSON.stringify(bundle, null, 2)}\n`;
|
|
119
|
+
if (Buffer.byteLength(serialized) > 16 * 1024 * 1024) {
|
|
120
|
+
throw new Error("Immutable release bundle exceeds the 16-MiB budget");
|
|
121
|
+
}
|
|
122
|
+
const bundleReference = await saveArtifact(root, bundlePath, serialized, options.redact);
|
|
123
|
+
for (const item of bundle.artifacts) {
|
|
124
|
+
await readArtifact(root, item.snapshot, 64 * 1024 * 1024);
|
|
125
|
+
}
|
|
126
|
+
const final = validateDesignRelease({
|
|
127
|
+
...release,
|
|
128
|
+
status: "final",
|
|
129
|
+
reviews: [
|
|
130
|
+
...release.reviews,
|
|
131
|
+
review,
|
|
132
|
+
],
|
|
133
|
+
finalized: {
|
|
134
|
+
bundle: bundleReference,
|
|
135
|
+
markerHash: bundleReference.sha256,
|
|
136
|
+
reviewHash: review.hash,
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
const offline = await checkRelease(undefined, root, final, options);
|
|
140
|
+
if (!offline.valid) {
|
|
141
|
+
throw new Error(`Immutable bundle is invalid; remote marker not written: ${offline.reasons.join("; ")}`);
|
|
142
|
+
}
|
|
143
|
+
const before = await observeRelease(client, release);
|
|
144
|
+
const beforeMarker = await readJournalTarget(client);
|
|
145
|
+
if (beforeMarker.fileId !== checkedTarget.fileId ||
|
|
146
|
+
beforeMarker.revision !== checkedTarget.revision) {
|
|
147
|
+
throw new Error("Release changed while preparing its immutable bundle; marker not written");
|
|
148
|
+
}
|
|
149
|
+
const complete = (observation) => Boolean(observation.marker?.status === "released" &&
|
|
150
|
+
observation.marker.bundleHash === bundleReference.sha256 &&
|
|
151
|
+
observation.marker.reviewHash === review.hash);
|
|
152
|
+
if (before.marker?.status === "released" && !complete(before)) {
|
|
153
|
+
throw new Error("Remote release marker belongs to different immutable evidence");
|
|
154
|
+
}
|
|
155
|
+
if (!complete(before)) {
|
|
156
|
+
await runJournaledSteps([
|
|
157
|
+
{
|
|
158
|
+
key: "released-marker",
|
|
159
|
+
label: "Mark independently verified design release",
|
|
160
|
+
signature: finalizeReleaseSnippet(release, bundleReference.sha256, review.hash).code,
|
|
161
|
+
initial: before,
|
|
162
|
+
inspect: () => observeRelease(client, release),
|
|
163
|
+
apply: () => runSnippet(guardRevision(client, checkedTarget), finalizeReleaseSnippet(release, bundleReference.sha256, review.hash), {
|
|
164
|
+
retries: 0,
|
|
165
|
+
effect: "write",
|
|
166
|
+
}),
|
|
167
|
+
verify: complete,
|
|
168
|
+
reconcile: (_before, current) => complete(current)
|
|
169
|
+
? "complete"
|
|
170
|
+
: current.marker?.status === "draft"
|
|
171
|
+
? "pending"
|
|
172
|
+
: "conflict",
|
|
173
|
+
},
|
|
174
|
+
], {
|
|
175
|
+
root,
|
|
176
|
+
operation: "design-release-finalize",
|
|
177
|
+
input: {
|
|
178
|
+
designHash,
|
|
179
|
+
bundleHash: bundleReference.sha256,
|
|
180
|
+
reviewHash: review.hash,
|
|
181
|
+
},
|
|
182
|
+
runId: options.runId ?? `finalize-${release.target.ownerKey.slice(0, 32)}`,
|
|
183
|
+
resume: options.resume,
|
|
184
|
+
target: () => readJournalTarget(client),
|
|
185
|
+
redact: options.redact,
|
|
186
|
+
onStep: options.onStep,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
const observed = await observeRelease(client, release);
|
|
190
|
+
if (!complete(observed)) {
|
|
191
|
+
throw new Error("Independent finalized marker readback failed; local final pointer not written");
|
|
192
|
+
}
|
|
193
|
+
const rechecked = await checkRelease(client, root, final, options);
|
|
194
|
+
if (!rechecked.valid) {
|
|
195
|
+
throw new Error(`Release changed after its remote marker; local final pointer not written: ${rechecked.reasons.join("; ")}`);
|
|
196
|
+
}
|
|
197
|
+
// The immutable bundle and remote readback exist before the authoritative local pointer changes.
|
|
198
|
+
await writeDesignRelease(root, pointer, final, options);
|
|
199
|
+
return final;
|
|
200
|
+
});
|
|
201
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export type * from "./types.js";
|
|
2
|
+
export { planRelease, readDesignRelease, writeDesignRelease } from "./store.js";
|
|
3
|
+
export { assembleRelease } from "./assemble.js";
|
|
4
|
+
export { verifyRelease } from "./verify.js";
|
|
5
|
+
export { finalizeRelease } from "./finalize.js";
|
|
6
|
+
export { checkRelease } from "./check.js";
|
|
7
|
+
export { releaseContext } from "./context.js";
|
|
8
|
+
export { validateDesignRelease, validateReleasePlan, hashDesignRelease, hashDesignRelease as releaseDesignHash, } from "./validation.js";
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { planRelease, readDesignRelease, writeDesignRelease } from "./store.js";
|
|
2
|
+
export { assembleRelease } from "./assemble.js";
|
|
3
|
+
export { verifyRelease } from "./verify.js";
|
|
4
|
+
export { finalizeRelease } from "./finalize.js";
|
|
5
|
+
export { checkRelease } from "./check.js";
|
|
6
|
+
export { releaseContext } from "./context.js";
|
|
7
|
+
export { validateDesignRelease, validateReleasePlan, hashDesignRelease, hashDesignRelease as releaseDesignHash, } from "./validation.js";
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { PenpotToolClient } from "../client.js";
|
|
2
|
+
import { type InspectionCapture } from "../inspection.js";
|
|
3
|
+
import type { DesignRelease, ReleaseSource } from "./types.js";
|
|
4
|
+
export interface ReleaseObservation {
|
|
5
|
+
pageId: string | null;
|
|
6
|
+
overviewFrameId: string | null;
|
|
7
|
+
marker: null | {
|
|
8
|
+
schemaVersion: 1;
|
|
9
|
+
owner: string;
|
|
10
|
+
releaseId: string;
|
|
11
|
+
productVersion: string;
|
|
12
|
+
ownerKey: string;
|
|
13
|
+
status: string;
|
|
14
|
+
bundleHash?: string;
|
|
15
|
+
reviewHash?: string;
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
export declare function releaseClient(client: PenpotToolClient, release: DesignRelease): PenpotToolClient;
|
|
19
|
+
export declare function observeRelease(client: PenpotToolClient, release: DesignRelease): Promise<ReleaseObservation>;
|
|
20
|
+
export declare function captureReleaseScreen(client: PenpotToolClient, fileId: string, pageId: string, frameId: string): Promise<InspectionCapture>;
|
|
21
|
+
export declare function dependencyFingerprint(raw: unknown): string;
|
|
22
|
+
export declare function assertSourceUnchanged(client: PenpotToolClient, root: string, release: DesignRelease, source: ReleaseSource): Promise<InspectionCapture>;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { captureInspection, inspectionFields } from "../inspection.js";
|
|
2
|
+
import { selectPage } from "../page-session.js";
|
|
3
|
+
import { observationHash } from "../journal.js";
|
|
4
|
+
import { runSnippet, snippet } from "../snippet.js";
|
|
5
|
+
import { guardFile } from "../file-guard.js";
|
|
6
|
+
import { readArtifact } from "./artifact-paths.js";
|
|
7
|
+
import { inspectionContentHash } from "./fidelity.js";
|
|
8
|
+
import { inspectReleaseSnippet } from "./snippets.js";
|
|
9
|
+
export function releaseClient(client, release) {
|
|
10
|
+
return guardFile(client, {
|
|
11
|
+
id: release.fileId,
|
|
12
|
+
}, {
|
|
13
|
+
mode: "strict",
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
export async function observeRelease(client, release) {
|
|
17
|
+
const result = await runSnippet(client, inspectReleaseSnippet(release));
|
|
18
|
+
if (!(result.pageId === null || typeof result.pageId === "string") ||
|
|
19
|
+
!(result.overviewFrameId === null || typeof result.overviewFrameId === "string") ||
|
|
20
|
+
!(result.marker === null || typeof result.marker === "object")) {
|
|
21
|
+
throw new Error("Release ownership observation is incomplete");
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
pageId: result.pageId,
|
|
25
|
+
overviewFrameId: result.overviewFrameId,
|
|
26
|
+
marker: result.marker,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
export async function captureReleaseScreen(client, fileId, pageId, frameId) {
|
|
30
|
+
await selectPage(client, {
|
|
31
|
+
id: pageId,
|
|
32
|
+
});
|
|
33
|
+
const page = await runSnippet(client, snippet("release-page-name", "return {ok:true,pageName:penpot.currentPage.name};", {}, "read"));
|
|
34
|
+
if (typeof page.pageName !== "string" || !page.pageName) {
|
|
35
|
+
throw new Error("Release page name unavailable");
|
|
36
|
+
}
|
|
37
|
+
const capture = await captureInspection(client, {
|
|
38
|
+
page: page.pageName,
|
|
39
|
+
shapeId: frameId,
|
|
40
|
+
fields: [
|
|
41
|
+
...inspectionFields,
|
|
42
|
+
],
|
|
43
|
+
depth: 256,
|
|
44
|
+
maxNodes: 20000,
|
|
45
|
+
documentBytes: 16 * 1024 * 1024,
|
|
46
|
+
});
|
|
47
|
+
if (!capture.complete ||
|
|
48
|
+
capture.kind !== "shape-inspection" ||
|
|
49
|
+
capture.fileId !== fileId ||
|
|
50
|
+
capture.pageId !== pageId ||
|
|
51
|
+
capture.nodes?.[0]?.id !== frameId) {
|
|
52
|
+
throw new Error("Incomplete or mismatched release screen inspection");
|
|
53
|
+
}
|
|
54
|
+
return capture;
|
|
55
|
+
}
|
|
56
|
+
export function dependencyFingerprint(raw) {
|
|
57
|
+
const value = raw;
|
|
58
|
+
if (value.schemaVersion !== 1 ||
|
|
59
|
+
value.kind !== "token-inspection" ||
|
|
60
|
+
value.complete !== true ||
|
|
61
|
+
!Array.isArray(value.reasons) ||
|
|
62
|
+
value.reasons.length ||
|
|
63
|
+
!Array.isArray(value.sets) ||
|
|
64
|
+
!Array.isArray(value.themes) ||
|
|
65
|
+
(value.counts?.unknownFields ?? 0) !== 0) {
|
|
66
|
+
throw new Error("Token dependency evidence is incomplete or unknown");
|
|
67
|
+
}
|
|
68
|
+
return observationHash({
|
|
69
|
+
sets: value.sets,
|
|
70
|
+
themes: value.themes,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
export async function assertSourceUnchanged(client, root, release, source) {
|
|
74
|
+
const capture = await captureReleaseScreen(client, release.fileId, source.pageId, source.frameId);
|
|
75
|
+
if (inspectionContentHash(capture) !== source.contentHash) {
|
|
76
|
+
throw new Error(`Pinned release source changed: ${source.id}`);
|
|
77
|
+
}
|
|
78
|
+
const usesTokens = capture.nodes?.some((node) => node.tokens && typeof node.tokens === "object" && Object.keys(node.tokens).length > 0);
|
|
79
|
+
if (usesTokens && !source.dependencies) {
|
|
80
|
+
throw new Error("Token-bound source requires a pinned dependency inspection");
|
|
81
|
+
}
|
|
82
|
+
if (source.dependencies) {
|
|
83
|
+
const before = JSON.parse((await readArtifact(root, source.dependencies)).toString("utf8"));
|
|
84
|
+
if (before.fileId !== release.fileId) {
|
|
85
|
+
throw new Error("Dependency snapshot belongs to another file");
|
|
86
|
+
}
|
|
87
|
+
const current = await captureInspection(client, {
|
|
88
|
+
kind: "tokens",
|
|
89
|
+
maxNodes: 20000,
|
|
90
|
+
documentBytes: 16 * 1024 * 1024,
|
|
91
|
+
});
|
|
92
|
+
if (dependencyFingerprint(current) !== dependencyFingerprint(before)) {
|
|
93
|
+
throw new Error("Source token dependencies changed since the pinned snapshot");
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return capture;
|
|
97
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type Snippet } from "../snippet.js";
|
|
2
|
+
import type { DesignRelease } from "./types.js";
|
|
3
|
+
export declare function inspectReleaseSnippet(release: DesignRelease): Snippet;
|
|
4
|
+
export declare function assembleReleaseSnippet(release: DesignRelease): Snippet;
|
|
5
|
+
export declare function finalizeReleaseSnippet(release: DesignRelease, bundleHash: string, reviewHash: string): Snippet;
|