@penpair/core 0.3.3 → 0.3.5
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/baseline-coverage.js +2 -0
- package/dist/baselines-config.d.ts +4 -2
- package/dist/baselines-config.js +17 -2
- package/dist/baselines.d.ts +4 -2
- package/dist/baselines.js +31 -5
- package/dist/cli-main.js +10 -3
- package/dist/instance/config-schema.d.ts +10 -1
- package/dist/instance/config-schema.js +16 -1
- package/dist/layout-policy.d.ts +0 -1
- package/dist/layout-policy.js +4 -47
- package/dist/layout-settling.d.ts +10 -0
- package/dist/layout-settling.js +155 -0
- package/dist/layout.js +2 -3
- package/dist/mcp-server.js +73 -6
- package/dist/snippet.js +16 -2
- package/dist/snippets/read.d.ts +3 -1
- package/dist/snippets/read.js +27 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -1
|
@@ -63,6 +63,7 @@ export function baselineCoverage(config, results = []) {
|
|
|
63
63
|
return (evidence?.approval?.kind !== "forced-reference" &&
|
|
64
64
|
![
|
|
65
65
|
"missing",
|
|
66
|
+
"renamed",
|
|
66
67
|
"unstable",
|
|
67
68
|
"case-mismatch",
|
|
68
69
|
].includes(result.status) &&
|
|
@@ -199,6 +200,7 @@ export async function readBaselineCoverage(root, config, expectedFileId) {
|
|
|
199
200
|
"same",
|
|
200
201
|
"changed",
|
|
201
202
|
"missing",
|
|
203
|
+
"renamed",
|
|
202
204
|
"unstable",
|
|
203
205
|
"case-mismatch",
|
|
204
206
|
].includes(value.status) ||
|
|
@@ -22,8 +22,10 @@ export interface CoverageConfig {
|
|
|
22
22
|
}
|
|
23
23
|
/** One frame of a Penpot page that becomes an approved reference image. */
|
|
24
24
|
export interface BaselineFrame {
|
|
25
|
-
/**
|
|
26
|
-
frame
|
|
25
|
+
/** Exact top-level frame name, or an expected name when frameId is supplied. */
|
|
26
|
+
frame?: string;
|
|
27
|
+
/** Stable board UUID, resolved at any depth on the configured page. */
|
|
28
|
+
frameId?: string;
|
|
27
29
|
/** Names the image file (`<id>.png`) and the check that uses it. Lowercase letters, digits, hyphens and underscores. */
|
|
28
30
|
id: string;
|
|
29
31
|
/** The route in the implementation this frame is the design of. Frames without one are exported but not given a check. */
|
package/dist/baselines-config.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
+
const frameIdPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
2
3
|
const idPattern = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
3
4
|
export function validateBaselines(value, fail) {
|
|
4
5
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
@@ -18,13 +19,27 @@ export function validateBaselines(value, fail) {
|
|
|
18
19
|
fail("baselines.frames must list at least one frame, at most 1000");
|
|
19
20
|
}
|
|
20
21
|
const seen = new Set();
|
|
22
|
+
const seenFrameIds = new Set();
|
|
21
23
|
for (const entry of v.frames) {
|
|
22
24
|
const f = entry;
|
|
23
25
|
if (typeof f !== "object" || f === null) {
|
|
24
26
|
fail("every entry in baselines.frames must be an object");
|
|
25
27
|
}
|
|
26
|
-
if (
|
|
27
|
-
fail("every baselines.frames entry needs
|
|
28
|
+
if (f.frame === undefined && f.frameId === undefined) {
|
|
29
|
+
fail("every baselines.frames entry needs frame or frameId");
|
|
30
|
+
}
|
|
31
|
+
if (f.frame !== undefined && (typeof f.frame !== "string" || f.frame.length === 0)) {
|
|
32
|
+
fail("baselines.frames frame must be a non-empty name");
|
|
33
|
+
}
|
|
34
|
+
if (f.frameId !== undefined) {
|
|
35
|
+
if (typeof f.frameId !== "string" || !frameIdPattern.test(f.frameId)) {
|
|
36
|
+
fail("baselines.frames frameId must be a UUID");
|
|
37
|
+
}
|
|
38
|
+
const frameId = f.frameId.toLowerCase();
|
|
39
|
+
if (seenFrameIds.has(frameId)) {
|
|
40
|
+
fail(`baselines.frames frameId ${JSON.stringify(f.frameId)} is used twice`);
|
|
41
|
+
}
|
|
42
|
+
seenFrameIds.add(frameId);
|
|
28
43
|
}
|
|
29
44
|
if (typeof f.id !== "string" || !idPattern.test(f.id)) {
|
|
30
45
|
fail(`baselines.frames id ${JSON.stringify(f.id)} must be lowercase letters, digits, hyphens or underscores`);
|
package/dist/baselines.d.ts
CHANGED
|
@@ -14,8 +14,10 @@ export type BaselineStatus =
|
|
|
14
14
|
| "changed"
|
|
15
15
|
/** The export never settled (two identical renders in a row), so it is not trusted. */
|
|
16
16
|
| "unstable"
|
|
17
|
-
/**
|
|
18
|
-
| "missing"
|
|
17
|
+
/** No board matches the configured ID, or no top-level frame matches the name. */
|
|
18
|
+
| "missing"
|
|
19
|
+
/** An ID-resolved board no longer has its configured expected name. */
|
|
20
|
+
| "renamed" | "case-mismatch";
|
|
19
21
|
export interface ArtifactProvenance extends CaptureIdentity {
|
|
20
22
|
approval?: {
|
|
21
23
|
kind: "owner-words";
|
package/dist/baselines.js
CHANGED
|
@@ -45,6 +45,7 @@ export async function exportBaselines(client, projectRoot, config, options = {})
|
|
|
45
45
|
});
|
|
46
46
|
const listing = await runOnPage(client, frames({
|
|
47
47
|
page: config.page,
|
|
48
|
+
recursive: config.frames.some((entry) => entry.frameId !== undefined),
|
|
48
49
|
}), config.page, options);
|
|
49
50
|
if (listing.ok !== true ||
|
|
50
51
|
typeof listing.fileId !== "string" ||
|
|
@@ -55,6 +56,9 @@ export async function exportBaselines(client, projectRoot, config, options = {})
|
|
|
55
56
|
!Array.isArray(listing.frames)) {
|
|
56
57
|
throw new Error("Baseline listing lacks file/page/revision evidence");
|
|
57
58
|
}
|
|
59
|
+
if (listing.truncated === true) {
|
|
60
|
+
throw new Error("Baseline frame search truncated at the page shape budget; nothing was captured");
|
|
61
|
+
}
|
|
58
62
|
const found = listing.frames;
|
|
59
63
|
const results = [];
|
|
60
64
|
const wrote = [];
|
|
@@ -79,7 +83,9 @@ export async function exportBaselines(client, projectRoot, config, options = {})
|
|
|
79
83
|
if (path.isAbsolute(file) || file.split(/[\\/]/).includes("..")) {
|
|
80
84
|
throw new Error("Existing baseline path leaves its folder");
|
|
81
85
|
}
|
|
82
|
-
const matches = found.filter((
|
|
86
|
+
const matches = found.filter((frame) => wanted.frameId
|
|
87
|
+
? frame.id.toLowerCase() === wanted.frameId.toLowerCase()
|
|
88
|
+
: frame.topLevel !== false && frame.name === wanted.frame);
|
|
83
89
|
if (!matches.length) {
|
|
84
90
|
results.push({
|
|
85
91
|
...(wanted.case
|
|
@@ -88,7 +94,8 @@ export async function exportBaselines(client, projectRoot, config, options = {})
|
|
|
88
94
|
}
|
|
89
95
|
: {}),
|
|
90
96
|
id: wanted.id,
|
|
91
|
-
frame: wanted.frame,
|
|
97
|
+
frame: wanted.frame ?? wanted.frameId,
|
|
98
|
+
frameId: wanted.frameId,
|
|
92
99
|
file,
|
|
93
100
|
status: "missing",
|
|
94
101
|
written: false,
|
|
@@ -99,7 +106,22 @@ export async function exportBaselines(client, projectRoot, config, options = {})
|
|
|
99
106
|
throw new Error(`more than one top-level frame on ${config.page} is named ${JSON.stringify(wanted.frame)}; rename one so the design reference is unambiguous`);
|
|
100
107
|
}
|
|
101
108
|
const frame = matches[0];
|
|
102
|
-
|
|
109
|
+
if (wanted.frameId && wanted.frame !== undefined && wanted.frame !== frame.name) {
|
|
110
|
+
results.push({
|
|
111
|
+
id: wanted.id,
|
|
112
|
+
frame: wanted.frame,
|
|
113
|
+
frameId: frame.id,
|
|
114
|
+
case: wanted.case,
|
|
115
|
+
file,
|
|
116
|
+
status: "renamed",
|
|
117
|
+
problems: [
|
|
118
|
+
`Expected frame name ${JSON.stringify(wanted.frame)}; current name is ${JSON.stringify(frame.name)}`,
|
|
119
|
+
],
|
|
120
|
+
written: false,
|
|
121
|
+
});
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
options.onStep?.(`exporting ${frame.name}`);
|
|
103
125
|
const exported = await exportShapePng(client, frame.id, {
|
|
104
126
|
...options,
|
|
105
127
|
page: config.page,
|
|
@@ -112,7 +134,7 @@ export async function exportBaselines(client, projectRoot, config, options = {})
|
|
|
112
134
|
}
|
|
113
135
|
: {}),
|
|
114
136
|
id: wanted.id,
|
|
115
|
-
frame:
|
|
137
|
+
frame: frame.name,
|
|
116
138
|
frameId: frame.id,
|
|
117
139
|
width: frame.width,
|
|
118
140
|
height: frame.height,
|
|
@@ -253,6 +275,7 @@ export async function exportBaselines(client, projectRoot, config, options = {})
|
|
|
253
275
|
}
|
|
254
276
|
const after = await runOnPage(client, frames({
|
|
255
277
|
page: config.page,
|
|
278
|
+
recursive: config.frames.some((entry) => entry.frameId !== undefined),
|
|
256
279
|
}), config.page, options);
|
|
257
280
|
const source = (value) => ({
|
|
258
281
|
fileId: value.fileId,
|
|
@@ -260,11 +283,14 @@ export async function exportBaselines(client, projectRoot, config, options = {})
|
|
|
260
283
|
revision: value.revn,
|
|
261
284
|
frames: value.frames,
|
|
262
285
|
});
|
|
263
|
-
if (
|
|
286
|
+
if (after.ok !== true ||
|
|
287
|
+
after.truncated === true ||
|
|
288
|
+
observationHash(source(listing)) !== observationHash(source(after))) {
|
|
264
289
|
throw new Error("Baseline source changed before publication; previous artifacts were preserved");
|
|
265
290
|
}
|
|
266
291
|
const complete = results.length === config.frames.length &&
|
|
267
292
|
results.every((result) => result.status !== "missing" &&
|
|
293
|
+
result.status !== "renamed" &&
|
|
268
294
|
result.status !== "unstable" &&
|
|
269
295
|
result.status !== "case-mismatch");
|
|
270
296
|
if (options.apply && complete) {
|
package/dist/cli-main.js
CHANGED
|
@@ -1914,9 +1914,16 @@ async function main(argv, cli) {
|
|
|
1914
1914
|
if (!wanted?.case || baseline?.page !== values.page) {
|
|
1915
1915
|
throw new Error("Approval case must reference an explicitly configured frame on this page");
|
|
1916
1916
|
}
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1917
|
+
if (wanted.frameId) {
|
|
1918
|
+
if (wanted.frameId.toLowerCase() !== values.frame.toLowerCase()) {
|
|
1919
|
+
throw new Error("Approval frame ID does not match the configured case");
|
|
1920
|
+
}
|
|
1921
|
+
}
|
|
1922
|
+
else {
|
|
1923
|
+
const found = await findShapesByName(client, wanted.frame, values.page);
|
|
1924
|
+
if (found.matches.length !== 1 || found.matches[0].id !== values.frame) {
|
|
1925
|
+
throw new Error("Approval frame ID does not match the configured case");
|
|
1926
|
+
}
|
|
1920
1927
|
}
|
|
1921
1928
|
intent = wanted.case;
|
|
1922
1929
|
}
|
|
@@ -285,7 +285,12 @@ export declare const projectConfigSchema: {
|
|
|
285
285
|
readonly maxItems: 1000;
|
|
286
286
|
readonly items: {
|
|
287
287
|
readonly type: "object";
|
|
288
|
-
readonly required: readonly ["
|
|
288
|
+
readonly required: readonly ["id"];
|
|
289
|
+
readonly anyOf: readonly [{
|
|
290
|
+
readonly required: readonly ["frame"];
|
|
291
|
+
}, {
|
|
292
|
+
readonly required: readonly ["frameId"];
|
|
293
|
+
}];
|
|
289
294
|
readonly dependentRequired: {
|
|
290
295
|
readonly case: readonly ["route"];
|
|
291
296
|
};
|
|
@@ -294,6 +299,10 @@ export declare const projectConfigSchema: {
|
|
|
294
299
|
readonly type: "string";
|
|
295
300
|
readonly minLength: 1;
|
|
296
301
|
};
|
|
302
|
+
readonly frameId: {
|
|
303
|
+
readonly type: "string";
|
|
304
|
+
readonly pattern: "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$";
|
|
305
|
+
};
|
|
297
306
|
readonly id: {
|
|
298
307
|
readonly type: "string";
|
|
299
308
|
readonly pattern: "^[a-z0-9][a-z0-9_-]{0,63}$";
|
|
@@ -333,9 +333,20 @@ export const projectConfigSchema = {
|
|
|
333
333
|
items: {
|
|
334
334
|
type: "object",
|
|
335
335
|
required: [
|
|
336
|
-
"frame",
|
|
337
336
|
"id",
|
|
338
337
|
],
|
|
338
|
+
anyOf: [
|
|
339
|
+
{
|
|
340
|
+
required: [
|
|
341
|
+
"frame",
|
|
342
|
+
],
|
|
343
|
+
},
|
|
344
|
+
{
|
|
345
|
+
required: [
|
|
346
|
+
"frameId",
|
|
347
|
+
],
|
|
348
|
+
},
|
|
349
|
+
],
|
|
339
350
|
dependentRequired: {
|
|
340
351
|
case: [
|
|
341
352
|
"route",
|
|
@@ -346,6 +357,10 @@ export const projectConfigSchema = {
|
|
|
346
357
|
type: "string",
|
|
347
358
|
minLength: 1,
|
|
348
359
|
},
|
|
360
|
+
frameId: {
|
|
361
|
+
type: "string",
|
|
362
|
+
pattern: "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$",
|
|
363
|
+
},
|
|
349
364
|
id: {
|
|
350
365
|
type: "string",
|
|
351
366
|
pattern: "^[a-z0-9][a-z0-9_-]{0,63}$",
|
package/dist/layout-policy.d.ts
CHANGED
package/dist/layout-policy.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
import { settleLayout } from "./layout-settling.js";
|
|
2
|
+
/** Trusted, dependency-free observation source for the Penpot sandbox. */
|
|
3
|
+
const layoutObservationSource = String.raw `
|
|
3
4
|
const observeLayout = async (penpot, root, options) => {
|
|
4
5
|
if (!Number.isInteger(options.maxNodes === undefined ? 10000 : options.maxNodes) || (options.maxNodes !== undefined && (options.maxNodes < 1 || options.maxNodes > 10000))) throw new Error("Invalid settling node budget");
|
|
5
6
|
const fileId = penpot.currentFile.id, pageId = penpot.currentPage.id;
|
|
@@ -38,49 +39,5 @@ const observeLayout = async (penpot, root, options) => {
|
|
|
38
39
|
validTarget();
|
|
39
40
|
return { ok: true, fileId, pageId, native: nativeOutcome === "completed", nativeOutcome, nativeError, geometry, truncated: false };
|
|
40
41
|
};
|
|
41
|
-
const settleLayout = async (observe, options) => {
|
|
42
|
-
const { timeoutMs, intervalMs, strategy = "auto", sleep } = options;
|
|
43
|
-
if (!["auto", "native", "geometry"].includes(strategy) || !Number.isFinite(timeoutMs) || timeoutMs < 1 || timeoutMs > 30000 || !Number.isFinite(intervalMs) || intervalMs <= 0 || Math.ceil(timeoutMs / intervalMs) + 1 > 1000) throw new Error("Invalid layout strategy or settling budgets");
|
|
44
|
-
const started = Date.now(), deadline = started + timeoutMs;
|
|
45
|
-
let samples = 0, nativeOutcome = "skipped", fallbackReason, identity, previous, equal = 0;
|
|
46
|
-
const result = (stable, method, reason) => ({ stable, method, samples, strategy, nativeOutcome, ...(fallbackReason ? { fallbackReason } : {}), elapsedMs: Date.now() - started, ...(reason ? { reason } : {}) });
|
|
47
|
-
for (let count = 0; count < Math.ceil(timeoutMs / intervalMs) + 1; count++) {
|
|
48
|
-
const remaining = deadline - Date.now();
|
|
49
|
-
if (remaining <= 0) return result(false, strategy === "native" ? "native" : "geometry", "Layout observation exceeded its deadline");
|
|
50
|
-
const nativeMs = count === 0 && strategy !== "geometry" ? strategy === "native" ? remaining : Math.max(1, Math.min(500, Math.floor(remaining / 4))) : 0;
|
|
51
|
-
const state = await observe(nativeMs, remaining);
|
|
52
|
-
samples++;
|
|
53
|
-
if (!state || state.ok !== true || typeof state.fileId !== "string" || !state.fileId || typeof state.pageId !== "string" || !state.pageId) throw new Error("Layout returned no target identity evidence");
|
|
54
|
-
if (typeof state.native !== "boolean" || typeof state.truncated !== "boolean" || state.nativeOutcome !== undefined && !["skipped", "unsupported", "completed", "failed"].includes(state.nativeOutcome)) throw new Error("Malformed layout attempt evidence");
|
|
55
|
-
const nextIdentity = JSON.stringify([state.fileId, state.pageId, state.geometry?.[0]?.[0]]);
|
|
56
|
-
if (identity && identity !== nextIdentity) throw new Error("Layout target changed between samples");
|
|
57
|
-
identity = nextIdentity;
|
|
58
|
-
if (state.truncated || !Array.isArray(state.geometry) || !state.geometry.length || state.geometry.length > 10000) throw new Error("Incomplete layout geometry evidence");
|
|
59
|
-
const ids = new Set(), childIndices = new Map();
|
|
60
|
-
for (const row of state.geometry) {
|
|
61
|
-
if (!Array.isArray(row) || row.length !== 7 || typeof row[0] !== "string" || !row[0] || ids.has(row[0]) || !row.slice(1,5).every(value => typeof value === "number" && Number.isFinite(value)) || ((row[5] !== null && !ids.has(row[5])) || !Number.isInteger(row[6]) || row[6] < 0)) throw new Error("Layout API returned no geometry evidence or malformed hierarchy");
|
|
62
|
-
if (!ids.size ? row[5] !== null || row[6] !== 0 : row[5] === null || typeof row[5] !== "string") throw new Error("Malformed layout root hierarchy");
|
|
63
|
-
if (row[5] !== null) {
|
|
64
|
-
const expectedIndex = childIndices.get(row[5]) || 0;
|
|
65
|
-
if (row[6] !== expectedIndex) throw new Error("Malformed layout sibling order");
|
|
66
|
-
childIndices.set(row[5], expectedIndex + 1);
|
|
67
|
-
}
|
|
68
|
-
ids.add(row[0]);
|
|
69
|
-
}
|
|
70
|
-
if (count === 0) {
|
|
71
|
-
nativeOutcome = state.nativeOutcome || (state.native ? state.error ? "failed" : "completed" : strategy === "geometry" ? "skipped" : "unsupported");
|
|
72
|
-
if (Date.now() >= deadline) return result(false, strategy === "native" || state.native ? "native" : "geometry", "Layout observation exceeded its deadline");
|
|
73
|
-
if (nativeOutcome === "completed") return result(true, "native");
|
|
74
|
-
if (strategy === "native") return result(false, "native", state.nativeError || state.error || "Native layout API unavailable");
|
|
75
|
-
if (strategy === "auto") fallbackReason = state.nativeError || state.error || "Native layout API unavailable";
|
|
76
|
-
}
|
|
77
|
-
if (Date.now() >= deadline) return result(false, "geometry", "Layout observation exceeded its deadline");
|
|
78
|
-
const current = JSON.stringify(state.geometry);
|
|
79
|
-
equal = current === previous ? equal + 1 : 1;
|
|
80
|
-
if (equal >= 3) return result(true, "geometry");
|
|
81
|
-
previous = current;
|
|
82
|
-
await sleep(Math.min(intervalMs, Math.max(0, deadline - Date.now())));
|
|
83
|
-
}
|
|
84
|
-
return result(false, "geometry", "Layout did not settle within its bounded samples");
|
|
85
|
-
};
|
|
86
42
|
`;
|
|
43
|
+
export const layoutPolicySource = `${layoutObservationSource}\nconst settleLayout = ${settleLayout.toString()};`;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { LayoutResult, LayoutStrategy } from "./layout.js";
|
|
2
|
+
interface LayoutSettlingOptions {
|
|
3
|
+
timeoutMs: number;
|
|
4
|
+
intervalMs: number;
|
|
5
|
+
strategy: LayoutStrategy;
|
|
6
|
+
sleep: (ms: number) => Promise<void>;
|
|
7
|
+
}
|
|
8
|
+
/** Self-contained policy: its JavaScript source also runs inside the Penpot sandbox. */
|
|
9
|
+
export declare function settleLayout(observe: (nativeMs: number, remaining: number) => Promise<unknown>, options: LayoutSettlingOptions): Promise<LayoutResult>;
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/** Self-contained policy: its JavaScript source also runs inside the Penpot sandbox. */
|
|
2
|
+
export async function settleLayout(observe, options) {
|
|
3
|
+
const { timeoutMs, intervalMs, strategy = "auto", sleep } = options;
|
|
4
|
+
if (![
|
|
5
|
+
"auto",
|
|
6
|
+
"native",
|
|
7
|
+
"geometry",
|
|
8
|
+
].includes(strategy) ||
|
|
9
|
+
!Number.isFinite(timeoutMs) ||
|
|
10
|
+
timeoutMs < 1 ||
|
|
11
|
+
timeoutMs > 30000 ||
|
|
12
|
+
!Number.isFinite(intervalMs) ||
|
|
13
|
+
intervalMs <= 0 ||
|
|
14
|
+
Math.ceil(timeoutMs / intervalMs) + 1 > 1000) {
|
|
15
|
+
throw new Error("Invalid layout strategy or settling budgets");
|
|
16
|
+
}
|
|
17
|
+
const started = Date.now();
|
|
18
|
+
const deadline = started + timeoutMs;
|
|
19
|
+
let samples = 0;
|
|
20
|
+
let nativeOutcome = "skipped";
|
|
21
|
+
let fallbackReason;
|
|
22
|
+
let targetIdentity;
|
|
23
|
+
let previousGeometry;
|
|
24
|
+
let matchingSamples = 0;
|
|
25
|
+
const createResult = (stable, method, reason) => ({
|
|
26
|
+
stable,
|
|
27
|
+
method,
|
|
28
|
+
samples,
|
|
29
|
+
strategy,
|
|
30
|
+
nativeOutcome,
|
|
31
|
+
...(fallbackReason
|
|
32
|
+
? {
|
|
33
|
+
fallbackReason,
|
|
34
|
+
}
|
|
35
|
+
: {}),
|
|
36
|
+
elapsedMs: Date.now() - started,
|
|
37
|
+
...(reason
|
|
38
|
+
? {
|
|
39
|
+
reason,
|
|
40
|
+
}
|
|
41
|
+
: {}),
|
|
42
|
+
});
|
|
43
|
+
for (let sampleIndex = 0; sampleIndex < Math.ceil(timeoutMs / intervalMs) + 1; sampleIndex++) {
|
|
44
|
+
const remaining = deadline - Date.now();
|
|
45
|
+
if (remaining <= 0) {
|
|
46
|
+
return createResult(false, strategy === "native" ? "native" : "geometry", "Layout observation exceeded its deadline");
|
|
47
|
+
}
|
|
48
|
+
const nativeMs = sampleIndex === 0 && strategy !== "geometry"
|
|
49
|
+
? strategy === "native"
|
|
50
|
+
? remaining
|
|
51
|
+
: Math.max(1, Math.min(500, Math.floor(remaining / 4)))
|
|
52
|
+
: 0;
|
|
53
|
+
const observation = (await observe(nativeMs, remaining));
|
|
54
|
+
samples++;
|
|
55
|
+
if (!observation ||
|
|
56
|
+
observation.ok !== true ||
|
|
57
|
+
typeof observation.fileId !== "string" ||
|
|
58
|
+
!observation.fileId ||
|
|
59
|
+
typeof observation.pageId !== "string" ||
|
|
60
|
+
!observation.pageId) {
|
|
61
|
+
throw new Error("Layout returned no target identity evidence");
|
|
62
|
+
}
|
|
63
|
+
if (typeof observation.native !== "boolean" ||
|
|
64
|
+
typeof observation.truncated !== "boolean" ||
|
|
65
|
+
(observation.nativeOutcome !== undefined &&
|
|
66
|
+
![
|
|
67
|
+
"skipped",
|
|
68
|
+
"unsupported",
|
|
69
|
+
"completed",
|
|
70
|
+
"failed",
|
|
71
|
+
].includes(observation.nativeOutcome))) {
|
|
72
|
+
throw new Error("Malformed layout attempt evidence");
|
|
73
|
+
}
|
|
74
|
+
const nextTargetIdentity = JSON.stringify([
|
|
75
|
+
observation.fileId,
|
|
76
|
+
observation.pageId,
|
|
77
|
+
observation.geometry?.[0]?.[0],
|
|
78
|
+
]);
|
|
79
|
+
if (targetIdentity && targetIdentity !== nextTargetIdentity) {
|
|
80
|
+
throw new Error("Layout target changed between samples");
|
|
81
|
+
}
|
|
82
|
+
targetIdentity = nextTargetIdentity;
|
|
83
|
+
if (observation.truncated ||
|
|
84
|
+
!Array.isArray(observation.geometry) ||
|
|
85
|
+
!observation.geometry.length ||
|
|
86
|
+
observation.geometry.length > 10000) {
|
|
87
|
+
throw new Error("Incomplete layout geometry evidence");
|
|
88
|
+
}
|
|
89
|
+
const observedShapeIds = new Set();
|
|
90
|
+
const siblingCountByParent = new Map();
|
|
91
|
+
for (const geometryRow of observation.geometry) {
|
|
92
|
+
if (!Array.isArray(geometryRow) ||
|
|
93
|
+
geometryRow.length !== 7 ||
|
|
94
|
+
typeof geometryRow[0] !== "string" ||
|
|
95
|
+
!geometryRow[0] ||
|
|
96
|
+
observedShapeIds.has(geometryRow[0]) ||
|
|
97
|
+
!geometryRow
|
|
98
|
+
.slice(1, 5)
|
|
99
|
+
.every((value) => typeof value === "number" && Number.isFinite(value)) ||
|
|
100
|
+
(geometryRow[5] !== null && !observedShapeIds.has(geometryRow[5])) ||
|
|
101
|
+
!Number.isInteger(geometryRow[6]) ||
|
|
102
|
+
geometryRow[6] < 0) {
|
|
103
|
+
throw new Error("Layout API returned no geometry evidence or malformed hierarchy");
|
|
104
|
+
}
|
|
105
|
+
if (!observedShapeIds.size
|
|
106
|
+
? geometryRow[5] !== null || geometryRow[6] !== 0
|
|
107
|
+
: geometryRow[5] === null || typeof geometryRow[5] !== "string") {
|
|
108
|
+
throw new Error("Malformed layout root hierarchy");
|
|
109
|
+
}
|
|
110
|
+
if (geometryRow[5] !== null) {
|
|
111
|
+
const expectedIndex = siblingCountByParent.get(geometryRow[5]) || 0;
|
|
112
|
+
if (geometryRow[6] !== expectedIndex) {
|
|
113
|
+
throw new Error("Malformed layout sibling order");
|
|
114
|
+
}
|
|
115
|
+
siblingCountByParent.set(geometryRow[5], expectedIndex + 1);
|
|
116
|
+
}
|
|
117
|
+
observedShapeIds.add(geometryRow[0]);
|
|
118
|
+
}
|
|
119
|
+
if (sampleIndex === 0) {
|
|
120
|
+
nativeOutcome =
|
|
121
|
+
observation.nativeOutcome ||
|
|
122
|
+
(observation.native
|
|
123
|
+
? observation.error
|
|
124
|
+
? "failed"
|
|
125
|
+
: "completed"
|
|
126
|
+
: strategy === "geometry"
|
|
127
|
+
? "skipped"
|
|
128
|
+
: "unsupported");
|
|
129
|
+
if (Date.now() >= deadline) {
|
|
130
|
+
return createResult(false, strategy === "native" || observation.native ? "native" : "geometry", "Layout observation exceeded its deadline");
|
|
131
|
+
}
|
|
132
|
+
if (nativeOutcome === "completed") {
|
|
133
|
+
return createResult(true, "native");
|
|
134
|
+
}
|
|
135
|
+
if (strategy === "native") {
|
|
136
|
+
return createResult(false, "native", observation.nativeError || observation.error || "Native layout API unavailable");
|
|
137
|
+
}
|
|
138
|
+
if (strategy === "auto") {
|
|
139
|
+
fallbackReason =
|
|
140
|
+
observation.nativeError || observation.error || "Native layout API unavailable";
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (Date.now() >= deadline) {
|
|
144
|
+
return createResult(false, "geometry", "Layout observation exceeded its deadline");
|
|
145
|
+
}
|
|
146
|
+
const currentGeometry = JSON.stringify(observation.geometry);
|
|
147
|
+
matchingSamples = currentGeometry === previousGeometry ? matchingSamples + 1 : 1;
|
|
148
|
+
if (matchingSamples >= 3) {
|
|
149
|
+
return createResult(true, "geometry");
|
|
150
|
+
}
|
|
151
|
+
previousGeometry = currentGeometry;
|
|
152
|
+
await sleep(Math.min(intervalMs, Math.max(0, deadline - Date.now())));
|
|
153
|
+
}
|
|
154
|
+
return createResult(false, "geometry", "Layout did not settle within its bounded samples");
|
|
155
|
+
}
|
package/dist/layout.js
CHANGED
|
@@ -2,13 +2,12 @@ import { PenpairError } from "./errors.js";
|
|
|
2
2
|
import { commandSleep } from "./command-result.js";
|
|
3
3
|
import { runSnippet, snippet } from "./snippet.js";
|
|
4
4
|
import { layoutPolicySource } from "./layout-policy.js";
|
|
5
|
-
|
|
6
|
-
const settle = new Function(`${layoutPolicySource}\nreturn settleLayout;`)();
|
|
5
|
+
import { settleLayout } from "./layout-settling.js";
|
|
7
6
|
/** Layout evidence is independent of render equality and visual approval. Transport failures propagate without fallback. */
|
|
8
7
|
export async function waitForStableLayout(client, options = {}) {
|
|
9
8
|
const { timeoutMs = 30000, intervalMs = 200, strategy = "auto", sleep = commandSleep, shapeId, } = options;
|
|
10
9
|
try {
|
|
11
|
-
return await
|
|
10
|
+
return await settleLayout(async (nativeMs, remaining) => runSnippet(client, snippet("layout-state", `${layoutPolicySource}\nconst shape = CFG.shapeId ? penpot.currentPage.getShapeById(CFG.shapeId) : penpot.currentPage.root;\nreturn await observeLayout(penpot, shape, CFG);`, {
|
|
12
11
|
shapeId,
|
|
13
12
|
nativeMs,
|
|
14
13
|
}, "read"), {
|
package/dist/mcp-server.js
CHANGED
|
@@ -21,6 +21,12 @@ const integer = (max) => ({
|
|
|
21
21
|
const definitions = [
|
|
22
22
|
{
|
|
23
23
|
name: "pages",
|
|
24
|
+
annotations: {
|
|
25
|
+
readOnlyHint: true,
|
|
26
|
+
destructiveHint: false,
|
|
27
|
+
idempotentHint: true,
|
|
28
|
+
openWorldHint: true,
|
|
29
|
+
},
|
|
24
30
|
description: "List pages in the pinned project file",
|
|
25
31
|
properties: {},
|
|
26
32
|
required: [],
|
|
@@ -30,6 +36,12 @@ const definitions = [
|
|
|
30
36
|
},
|
|
31
37
|
{
|
|
32
38
|
name: "tree",
|
|
39
|
+
annotations: {
|
|
40
|
+
readOnlyHint: true,
|
|
41
|
+
destructiveHint: false,
|
|
42
|
+
idempotentHint: true,
|
|
43
|
+
openWorldHint: true,
|
|
44
|
+
},
|
|
33
45
|
description: "Bounded tree preview; omitted nodes are explicit",
|
|
34
46
|
properties: {
|
|
35
47
|
page: string,
|
|
@@ -48,6 +60,12 @@ const definitions = [
|
|
|
48
60
|
},
|
|
49
61
|
{
|
|
50
62
|
name: "audit",
|
|
63
|
+
annotations: {
|
|
64
|
+
readOnlyHint: true,
|
|
65
|
+
destructiveHint: false,
|
|
66
|
+
idempotentHint: true,
|
|
67
|
+
openWorldHint: true,
|
|
68
|
+
},
|
|
51
69
|
description: "Inspect layout/token findings with documented heuristic limits",
|
|
52
70
|
properties: {
|
|
53
71
|
page: string,
|
|
@@ -63,6 +81,12 @@ const definitions = [
|
|
|
63
81
|
},
|
|
64
82
|
{
|
|
65
83
|
name: "inspect",
|
|
84
|
+
annotations: {
|
|
85
|
+
readOnlyHint: true,
|
|
86
|
+
destructiveHint: false,
|
|
87
|
+
idempotentHint: true,
|
|
88
|
+
openWorldHint: true,
|
|
89
|
+
},
|
|
66
90
|
description: "One bounded structured inspection chunk; use returned cursor for continuation",
|
|
67
91
|
properties: {
|
|
68
92
|
page: string,
|
|
@@ -88,6 +112,12 @@ const definitions = [
|
|
|
88
112
|
},
|
|
89
113
|
{
|
|
90
114
|
name: "tokens_dump",
|
|
115
|
+
annotations: {
|
|
116
|
+
readOnlyHint: true,
|
|
117
|
+
destructiveHint: false,
|
|
118
|
+
idempotentHint: true,
|
|
119
|
+
openWorldHint: true,
|
|
120
|
+
},
|
|
91
121
|
description: "Bounded token capture; completeness is explicit",
|
|
92
122
|
properties: {},
|
|
93
123
|
required: [],
|
|
@@ -100,6 +130,12 @@ const definitions = [
|
|
|
100
130
|
},
|
|
101
131
|
{
|
|
102
132
|
name: "run_status",
|
|
133
|
+
annotations: {
|
|
134
|
+
readOnlyHint: true,
|
|
135
|
+
destructiveHint: false,
|
|
136
|
+
idempotentHint: true,
|
|
137
|
+
openWorldHint: false,
|
|
138
|
+
},
|
|
103
139
|
description: "Inspect an explicit local recovery journal",
|
|
104
140
|
properties: {
|
|
105
141
|
id: string,
|
|
@@ -114,6 +150,12 @@ const definitions = [
|
|
|
114
150
|
},
|
|
115
151
|
{
|
|
116
152
|
name: "baselines_status",
|
|
153
|
+
annotations: {
|
|
154
|
+
readOnlyHint: true,
|
|
155
|
+
destructiveHint: false,
|
|
156
|
+
idempotentHint: true,
|
|
157
|
+
openWorldHint: false,
|
|
158
|
+
},
|
|
117
159
|
description: "Offline coverage/evidence status; no capture or approval",
|
|
118
160
|
properties: {},
|
|
119
161
|
required: [],
|
|
@@ -124,6 +166,12 @@ const definitions = [
|
|
|
124
166
|
},
|
|
125
167
|
{
|
|
126
168
|
name: "project_status",
|
|
169
|
+
annotations: {
|
|
170
|
+
readOnlyHint: true,
|
|
171
|
+
destructiveHint: false,
|
|
172
|
+
idempotentHint: true,
|
|
173
|
+
openWorldHint: true,
|
|
174
|
+
},
|
|
127
175
|
description: "Project health with explicit unknowns; no adapter imports",
|
|
128
176
|
properties: {},
|
|
129
177
|
required: [],
|
|
@@ -133,6 +181,12 @@ const definitions = [
|
|
|
133
181
|
},
|
|
134
182
|
{
|
|
135
183
|
name: "doctor",
|
|
184
|
+
annotations: {
|
|
185
|
+
readOnlyHint: true,
|
|
186
|
+
destructiveHint: false,
|
|
187
|
+
idempotentHint: true,
|
|
188
|
+
openWorldHint: true,
|
|
189
|
+
},
|
|
136
190
|
description: "Project connection/file identity diagnostics",
|
|
137
191
|
properties: {},
|
|
138
192
|
required: [],
|
|
@@ -142,6 +196,12 @@ const definitions = [
|
|
|
142
196
|
},
|
|
143
197
|
{
|
|
144
198
|
name: "design_spec",
|
|
199
|
+
annotations: {
|
|
200
|
+
readOnlyHint: true,
|
|
201
|
+
destructiveHint: false,
|
|
202
|
+
idempotentHint: true,
|
|
203
|
+
openWorldHint: true,
|
|
204
|
+
},
|
|
145
205
|
description: "Derived handoff for an exact frame ID; unknown semantics remain explicit",
|
|
146
206
|
properties: {
|
|
147
207
|
page: string,
|
|
@@ -161,6 +221,12 @@ const definitions = [
|
|
|
161
221
|
},
|
|
162
222
|
{
|
|
163
223
|
name: "export",
|
|
224
|
+
annotations: {
|
|
225
|
+
readOnlyHint: false,
|
|
226
|
+
destructiveHint: true,
|
|
227
|
+
idempotentHint: false,
|
|
228
|
+
openWorldHint: true,
|
|
229
|
+
},
|
|
164
230
|
description: "Capture stable PNG to a relative path inside the project; creates a local file",
|
|
165
231
|
properties: {
|
|
166
232
|
page: string,
|
|
@@ -183,6 +249,12 @@ const definitions = [
|
|
|
183
249
|
},
|
|
184
250
|
{
|
|
185
251
|
name: "run_registered",
|
|
252
|
+
annotations: {
|
|
253
|
+
readOnlyHint: false,
|
|
254
|
+
destructiveHint: true,
|
|
255
|
+
idempotentHint: false,
|
|
256
|
+
openWorldHint: true,
|
|
257
|
+
},
|
|
186
258
|
description: "Execute a trusted registered operation: inspect by default, explicit apply and recovery for writes. Not a sandbox.",
|
|
187
259
|
properties: {
|
|
188
260
|
name: string,
|
|
@@ -324,12 +396,7 @@ export async function createMcpServer(options) {
|
|
|
324
396
|
required: d.required,
|
|
325
397
|
additionalProperties: false,
|
|
326
398
|
},
|
|
327
|
-
annotations:
|
|
328
|
-
readOnlyHint: !d.write && !d.localWrite,
|
|
329
|
-
destructiveHint: Boolean(d.write || d.localWrite),
|
|
330
|
-
idempotentHint: !d.write && !d.localWrite,
|
|
331
|
-
openWorldHint: true,
|
|
332
|
-
},
|
|
399
|
+
annotations: d.annotations,
|
|
333
400
|
}));
|
|
334
401
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
335
402
|
tools,
|
package/dist/snippet.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Buffer } from "node:buffer";
|
|
2
|
+
import { parse } from "acorn";
|
|
2
3
|
import { executeCode, maxSnippetBytes, UncertainExecutionError, } from "./client.js";
|
|
3
4
|
import { PenpairError } from "./errors.js";
|
|
4
5
|
/**
|
|
@@ -25,8 +26,21 @@ export function snippet(name, body, params = {}, effect = "unknown") {
|
|
|
25
26
|
/** Whether the plugin source is valid JavaScript for an async function. Catches typos without a Penpot connection. */
|
|
26
27
|
export function compiles(code) {
|
|
27
28
|
try {
|
|
28
|
-
const
|
|
29
|
-
|
|
29
|
+
const prefix = "(async function (penpot) {\n";
|
|
30
|
+
const source = `${prefix}${code}\n})`;
|
|
31
|
+
const parsed = parse(source, {
|
|
32
|
+
ecmaVersion: "latest",
|
|
33
|
+
sourceType: "script",
|
|
34
|
+
});
|
|
35
|
+
const statement = parsed.body[0];
|
|
36
|
+
// Keep the input inside the function body, even when it contains closing braces.
|
|
37
|
+
if (parsed.body.length !== 1 ||
|
|
38
|
+
statement?.type !== "ExpressionStatement" ||
|
|
39
|
+
statement.expression.type !== "FunctionExpression" ||
|
|
40
|
+
statement.expression.body.start !== prefix.indexOf("{") ||
|
|
41
|
+
statement.expression.body.end !== source.length - 1) {
|
|
42
|
+
return "Snippet must be a single async function body";
|
|
43
|
+
}
|
|
30
44
|
return undefined;
|
|
31
45
|
}
|
|
32
46
|
catch (error) {
|
package/dist/snippets/read.d.ts
CHANGED
|
@@ -4,8 +4,10 @@ export declare function pages(): Snippet;
|
|
|
4
4
|
export declare function switchPage(name: string): Snippet;
|
|
5
5
|
export interface FramesParams {
|
|
6
6
|
page: string;
|
|
7
|
+
/** Include nested boards with a bounded whole-page search. */
|
|
8
|
+
recursive?: boolean;
|
|
7
9
|
}
|
|
8
|
-
/**
|
|
10
|
+
/** List top-level frames by default; opt in to a bounded search of every board on the page. */
|
|
9
11
|
export declare function frames(params: FramesParams): Snippet;
|
|
10
12
|
export interface TreeParams {
|
|
11
13
|
page: string;
|
package/dist/snippets/read.js
CHANGED
|
@@ -14,11 +14,36 @@ return { ok: true, switched: true };`, {
|
|
|
14
14
|
name,
|
|
15
15
|
});
|
|
16
16
|
}
|
|
17
|
-
/**
|
|
17
|
+
/** List top-level frames by default; opt in to a bounded search of every board on the page. */
|
|
18
18
|
export function frames(params) {
|
|
19
19
|
return readSnippet("frames", `${requirePage}
|
|
20
|
+
if (!CFG.recursive) {
|
|
21
|
+
return { ok: true, readOnly: true, file: penpot.currentFile.name, fileId: penpot.currentFile.id, revn: penpot.currentFile.revn, page: penpot.currentPage.name, pageId: penpot.currentPage.id,
|
|
22
|
+
frames: penpot.currentPage.root.children.filter((shape) => shape.type === "board").map((shape) => ({ id: shape.id, name: shape.name, width: Math.round(shape.width), height: Math.round(shape.height) })) };
|
|
23
|
+
}
|
|
24
|
+
const boards = [];
|
|
25
|
+
const budget = 10000;
|
|
26
|
+
const stack = [];
|
|
27
|
+
let visited = 0;
|
|
28
|
+
let truncated = false;
|
|
29
|
+
const enqueue = (children, parentBoardId, topLevel) => {
|
|
30
|
+
const take = Math.min(children.length, Math.max(0, budget - visited - stack.length));
|
|
31
|
+
if (take < children.length) { truncated = true; }
|
|
32
|
+
for (let index = take - 1; index >= 0; index--) {
|
|
33
|
+
stack.push({ shape: children[index], parentBoardId, topLevel });
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
enqueue(penpot.currentPage.root.children, null, true);
|
|
37
|
+
while (stack.length) {
|
|
38
|
+
const { shape, parentBoardId, topLevel } = stack.pop();
|
|
39
|
+
visited++;
|
|
40
|
+
if (shape.type === "board") {
|
|
41
|
+
boards.push({ id: shape.id, name: shape.name, width: Math.round(shape.width), height: Math.round(shape.height), parentBoardId, topLevel });
|
|
42
|
+
}
|
|
43
|
+
enqueue(shape.children || [], shape.type === "board" ? shape.id : parentBoardId, false);
|
|
44
|
+
}
|
|
20
45
|
return { ok: true, readOnly: true, file: penpot.currentFile.name, fileId: penpot.currentFile.id, revn: penpot.currentFile.revn, page: penpot.currentPage.name, pageId: penpot.currentPage.id,
|
|
21
|
-
frames:
|
|
46
|
+
frames: boards, visited, truncated, complete: !truncated };`, params);
|
|
22
47
|
}
|
|
23
48
|
/** One line per shape: type, name, size, position on the page, layout and sizing, the text it carries. */
|
|
24
49
|
export function tree(params) {
|
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Kept in its own file so that the command line and the library entry can both read it without importing each other. Equals package.json (a test checks). */
|
|
2
|
-
export declare const version = "0.3.
|
|
2
|
+
export declare const version = "0.3.5";
|
package/dist/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Kept in its own file so that the command line and the library entry can both read it without importing each other. Equals package.json (a test checks). */
|
|
2
|
-
export const version = "0.3.
|
|
2
|
+
export const version = "0.3.5";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@penpair/core",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.5",
|
|
4
4
|
"description": "Design together. Build together. The library and engine behind penpair: connect AI agents and people to a Penpot design file, safely. Install @penpair/web or @penpair/docker for the command.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
46
|
"@modelcontextprotocol/sdk": "1.32.0",
|
|
47
|
+
"acorn": "8.19.0",
|
|
47
48
|
"yaml": "2.9.1"
|
|
48
49
|
},
|
|
49
50
|
"scripts": {
|