@penpair/core 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters.js +58 -11
- package/dist/approvals.js +143 -29
- package/dist/atomic-output.js +9 -4
- package/dist/audit-rules.js +345 -79
- package/dist/backup-note.js +29 -8
- package/dist/baseline-coverage.js +169 -35
- package/dist/baselines-config.js +119 -29
- package/dist/baselines.js +280 -57
- package/dist/cli-arguments.js +18 -6
- package/dist/cli-main.js +1645 -392
- package/dist/client.d.ts +5 -2
- package/dist/client.js +127 -28
- package/dist/command-result.js +341 -52
- package/dist/coordination.js +91 -29
- package/dist/credentials.js +57 -13
- package/dist/deep-check.js +56 -12
- package/dist/design-spec.js +189 -49
- package/dist/endpoint.js +102 -34
- package/dist/export.js +86 -18
- package/dist/file-archive.js +33 -7
- package/dist/file-guard.js +29 -11
- package/dist/formats.js +215 -14
- package/dist/index.d.ts +8 -7
- package/dist/index.js +8 -7
- package/dist/inspection.d.ts +1 -1
- package/dist/inspection.js +378 -65
- package/dist/instance/config-schema.js +400 -41
- package/dist/instance/config.js +101 -30
- package/dist/instance/connect.js +151 -44
- package/dist/instance/connection.d.ts +26 -5
- package/dist/instance/connection.js +194 -21
- package/dist/instance/gitignore.js +5 -2
- package/dist/instance/init.js +32 -7
- package/dist/instance/runner.js +32 -10
- package/dist/instance/setup.js +46 -13
- package/dist/instance/tty.js +26 -8
- package/dist/journal.d.ts +10 -0
- package/dist/journal.js +212 -49
- package/dist/kit-smoke.js +82 -20
- package/dist/kit-source.js +10 -40
- package/dist/kit.js +102 -34
- package/dist/layout-policy.d.ts +2 -0
- package/dist/layout-policy.js +86 -0
- package/dist/layout.d.ts +7 -1
- package/dist/layout.js +27 -70
- package/dist/live-smoke.js +13 -4
- package/dist/local-profile.js +2 -1
- package/dist/mcp-server.js +360 -47
- package/dist/native-api.js +47 -11
- package/dist/onboard.js +33 -7
- package/dist/operation.d.ts +2 -2
- package/dist/operation.js +279 -69
- package/dist/page-session.d.ts +23 -0
- package/dist/page-session.js +142 -0
- package/dist/png.js +18 -3
- package/dist/portable-tokens.js +254 -57
- package/dist/probe.d.ts +1 -1
- package/dist/probe.js +48 -11
- package/dist/project-input.js +24 -10
- package/dist/project-snippets.js +18 -6
- package/dist/project-status.js +133 -24
- package/dist/registry.js +217 -60
- package/dist/run-file.js +129 -30
- package/dist/shots.js +108 -26
- package/dist/skill-check-entry.d.ts +17 -0
- package/dist/skill-check-entry.js +89 -0
- package/dist/skill-check-instructions.d.ts +7 -0
- package/dist/skill-check-instructions.js +39 -0
- package/dist/skill-check.d.ts +15 -0
- package/dist/skill-check.js +36 -0
- package/dist/skill-file.d.ts +2 -0
- package/dist/skill-file.js +31 -0
- package/dist/skill-instructions.d.ts +4 -0
- package/dist/skill-instructions.js +97 -22
- package/dist/skill-metadata.d.ts +8 -0
- package/dist/skill-metadata.js +52 -0
- package/dist/skill-routing.d.ts +2 -0
- package/dist/skill-routing.js +36 -0
- package/dist/skills.js +131 -41
- package/dist/snippet.js +27 -7
- package/dist/snippets/read.js +8 -2
- package/dist/structure-snapshot.d.ts +12 -0
- package/dist/structure-snapshot.js +439 -85
- package/dist/token-edit.js +219 -64
- package/dist/token-projection.d.ts +56 -0
- package/dist/token-projection.js +286 -0
- package/dist/tokens-diff.js +125 -32
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -2
- package/skills/design-judgment/SKILL.md +1 -1
- package/skills/design-judgment/agents/openai.yaml +6 -0
- package/skills/penpair-design/SKILL.md +14 -1
- package/skills/penpair-design/agents/openai.yaml +6 -0
- package/skills/penpair-design/references/penpot-plugin-gotchas.md +2 -2
- package/skills/penpair-design/references/project-facts.md +2 -0
- package/skills/penpair-handoff/SKILL.md +14 -1
- package/skills/penpair-handoff/agents/openai.yaml +6 -0
- package/skills/penpair-handoff/references/comparison-companion.md +16 -0
- package/skills/penpair-handoff/references/handoff-flow.md +2 -0
- package/skills/penpair-recipes/SKILL.md +13 -1
- package/skills/penpair-recipes/agents/openai.yaml +6 -0
- package/skills/penpair-recipes/assets/visual-checkpoint.md +48 -0
- package/skills/penpair-recipes/references/visual-checkpoint.md +37 -0
- package/skills/penpair-setup/SKILL.md +9 -3
- package/skills/penpair-setup/agents/openai.yaml +6 -0
- package/skills/penpair-verify/SKILL.md +49 -0
- package/skills/penpair-verify/agents/openai.yaml +6 -0
- package/skills/penpair-verify/references/browser-evidence.md +33 -0
- package/dist/cli.d.ts +0 -2
- package/dist/cli.js +0 -3
- package/dist/docker/lifecycle.d.ts +0 -96
- package/dist/docker/lifecycle.js +0 -273
- package/dist/docker/secrets.d.ts +0 -11
- package/dist/docker/secrets.js +0 -38
- package/dist/docker/template.d.ts +0 -8
- package/dist/docker/template.js +0 -158
- package/dist/docker-profile.d.ts +0 -114
- package/dist/docker-profile.js +0 -18
- package/dist/instance/lifecycle.d.ts +0 -129
- package/dist/instance/lifecycle.js +0 -336
- package/dist/instance/secrets.d.ts +0 -11
- package/dist/instance/secrets.js +0 -38
- package/dist/instance/template.d.ts +0 -8
- package/dist/instance/template.js +0 -158
package/dist/project-status.js
CHANGED
|
@@ -5,75 +5,184 @@ import { readBaselineCoverage } from "./baseline-coverage.js";
|
|
|
5
5
|
import { redactEndpoint } from "./endpoint.js";
|
|
6
6
|
export const projectFileUrl = (config) => {
|
|
7
7
|
const url = onboardingChecklist(config).fileUrl;
|
|
8
|
-
if (!url)
|
|
8
|
+
if (!url) {
|
|
9
9
|
throw new Error("Pin the owner-confirmed file before opening it");
|
|
10
|
+
}
|
|
10
11
|
return url;
|
|
11
12
|
};
|
|
12
|
-
export async function openProject(config, open) {
|
|
13
|
+
export async function openProject(config, open) {
|
|
14
|
+
const url = projectFileUrl(config);
|
|
15
|
+
await open(url);
|
|
16
|
+
return url;
|
|
17
|
+
}
|
|
13
18
|
export async function projectStatus(root, config, options = {}) {
|
|
14
|
-
const origin = onboardingChecklist(config).origin
|
|
19
|
+
const origin = onboardingChecklist(config).origin;
|
|
20
|
+
const lines = [];
|
|
15
21
|
const safe = (error) => (options.redact ?? redactEndpoint)(String(error.message));
|
|
16
22
|
let reachable = false;
|
|
17
23
|
try {
|
|
18
|
-
reachable = await (options.reachable ??
|
|
24
|
+
reachable = await (options.reachable ??
|
|
25
|
+
(async (origin) => (await fetch(origin, {
|
|
26
|
+
redirect: "manual",
|
|
27
|
+
signal: AbortSignal.timeout(5000),
|
|
28
|
+
})).status < 500))(origin);
|
|
19
29
|
}
|
|
20
30
|
catch { }
|
|
21
|
-
lines.push({
|
|
22
|
-
|
|
31
|
+
lines.push({
|
|
32
|
+
id: "deployment",
|
|
33
|
+
label: "Penpot",
|
|
34
|
+
state: reachable ? "ok" : "failed",
|
|
35
|
+
detail: reachable ? `${origin} answers` : `${origin} is unavailable`,
|
|
36
|
+
});
|
|
37
|
+
lines.push({
|
|
38
|
+
id: "file-pin",
|
|
39
|
+
label: "File pin",
|
|
40
|
+
state: config.file ? "ok" : "unknown",
|
|
41
|
+
detail: config.file ? (config.file.name ?? config.file.id) : "No owner-confirmed file pin",
|
|
42
|
+
});
|
|
23
43
|
let connection = null;
|
|
24
44
|
if (options.connection) {
|
|
25
45
|
let client;
|
|
26
46
|
try {
|
|
27
47
|
client = await options.connection();
|
|
28
48
|
const observation = await runSnippet(client, snippet("project-status", `return {ok:true,fileId:penpot.currentFile.id,file:penpot.currentFile.name,revision:penpot.currentFile.revn,pages:penpot.currentFile.pages.map(p=>({id:p.id,name:p.name})),apiVersion:typeof penpot.version==="string"?penpot.version:null,capabilities:{tokens:!!penpot.library?.local?.tokens,fonts:!!penpot.fonts,nativeLayout:typeof penpot.currentPage.root.waitForLayoutUpdate==="function"}};`, {}, "read"));
|
|
29
|
-
if (!config.file ||
|
|
49
|
+
if (!config.file ||
|
|
50
|
+
observation.fileId !== config.file.id ||
|
|
51
|
+
!Array.isArray(observation.pages) ||
|
|
52
|
+
!Number.isInteger(observation.revision)) {
|
|
30
53
|
throw new Error("Status did not observe the configured file identity/revision");
|
|
54
|
+
}
|
|
31
55
|
const { execution: _execution, ...data } = observation;
|
|
32
56
|
connection = data;
|
|
33
|
-
lines.push({
|
|
57
|
+
lines.push({
|
|
58
|
+
id: "connection",
|
|
59
|
+
label: "Editor and plugin",
|
|
60
|
+
state: "ok",
|
|
61
|
+
detail: `Pinned file connected; ${observation.pages.length} pages`,
|
|
62
|
+
});
|
|
34
63
|
}
|
|
35
64
|
catch (error) {
|
|
36
|
-
lines.push({
|
|
65
|
+
lines.push({
|
|
66
|
+
id: "connection",
|
|
67
|
+
label: "Editor and plugin",
|
|
68
|
+
state: "failed",
|
|
69
|
+
detail: safe(error),
|
|
70
|
+
});
|
|
37
71
|
}
|
|
38
72
|
finally {
|
|
39
73
|
await client?.close();
|
|
40
74
|
}
|
|
41
75
|
}
|
|
42
|
-
else
|
|
43
|
-
lines.push({
|
|
76
|
+
else {
|
|
77
|
+
lines.push({
|
|
78
|
+
id: "connection",
|
|
79
|
+
label: "Editor and plugin",
|
|
80
|
+
state: "unknown",
|
|
81
|
+
detail: "No resolved project connection",
|
|
82
|
+
});
|
|
83
|
+
}
|
|
44
84
|
try {
|
|
45
|
-
const skills = await installSkills({
|
|
46
|
-
|
|
85
|
+
const skills = await installSkills({
|
|
86
|
+
root,
|
|
87
|
+
dryRun: true,
|
|
88
|
+
});
|
|
89
|
+
lines.push({
|
|
90
|
+
id: "skills",
|
|
91
|
+
label: "Skills",
|
|
92
|
+
state: skills.wouldInstall?.length || skills.wouldLink?.length || skills.kept.length
|
|
93
|
+
? "warning"
|
|
94
|
+
: "ok",
|
|
95
|
+
detail: `${skills.wouldInstall?.length ?? 0} missing/update candidates; ${skills.kept.length} locally edited files preserved; discovery is not verified`,
|
|
96
|
+
});
|
|
47
97
|
}
|
|
48
98
|
catch (error) {
|
|
49
|
-
lines.push({
|
|
99
|
+
lines.push({
|
|
100
|
+
id: "skills",
|
|
101
|
+
label: "Skills",
|
|
102
|
+
state: "unknown",
|
|
103
|
+
detail: safe(error),
|
|
104
|
+
});
|
|
50
105
|
}
|
|
51
106
|
let coverage = null;
|
|
52
107
|
if (config.baselines) {
|
|
53
108
|
try {
|
|
54
109
|
const found = await readBaselineCoverage(root, config.baselines, config.file?.id);
|
|
55
110
|
coverage = found;
|
|
56
|
-
lines.push({
|
|
111
|
+
lines.push({
|
|
112
|
+
id: "baselines",
|
|
113
|
+
label: "Baselines",
|
|
114
|
+
state: found.designCasesComplete ? "ok" : "warning",
|
|
115
|
+
detail: found.configured
|
|
116
|
+
? "See explicit case coverage"
|
|
117
|
+
: "No complete rich coverage matrix declared",
|
|
118
|
+
});
|
|
57
119
|
}
|
|
58
120
|
catch (error) {
|
|
59
|
-
lines.push({
|
|
121
|
+
lines.push({
|
|
122
|
+
id: "baselines",
|
|
123
|
+
label: "Baselines",
|
|
124
|
+
state: "failed",
|
|
125
|
+
detail: safe(error),
|
|
126
|
+
});
|
|
60
127
|
}
|
|
61
128
|
}
|
|
62
|
-
else
|
|
63
|
-
lines.push({
|
|
129
|
+
else {
|
|
130
|
+
lines.push({
|
|
131
|
+
id: "baselines",
|
|
132
|
+
label: "Baselines",
|
|
133
|
+
state: "unknown",
|
|
134
|
+
detail: "No baseline configuration",
|
|
135
|
+
});
|
|
136
|
+
}
|
|
64
137
|
if (options.adapterStatus) {
|
|
65
138
|
try {
|
|
66
139
|
const adapterLines = await options.adapterStatus();
|
|
67
|
-
if (!Array.isArray(adapterLines) ||
|
|
140
|
+
if (!Array.isArray(adapterLines) ||
|
|
141
|
+
adapterLines.length > 100 ||
|
|
142
|
+
adapterLines.some((l) => !l ||
|
|
143
|
+
typeof l.id !== "string" ||
|
|
144
|
+
typeof l.label !== "string" ||
|
|
145
|
+
typeof l.detail !== "string" ||
|
|
146
|
+
![
|
|
147
|
+
"ok",
|
|
148
|
+
"warning",
|
|
149
|
+
"failed",
|
|
150
|
+
"unknown",
|
|
151
|
+
].includes(l.state))) {
|
|
68
152
|
throw new Error("Invalid adapter status lines");
|
|
153
|
+
}
|
|
69
154
|
lines.push(...adapterLines);
|
|
70
155
|
}
|
|
71
156
|
catch (error) {
|
|
72
|
-
lines.push({
|
|
157
|
+
lines.push({
|
|
158
|
+
id: "adapters",
|
|
159
|
+
label: "Adapters",
|
|
160
|
+
state: "failed",
|
|
161
|
+
detail: safe(error),
|
|
162
|
+
});
|
|
73
163
|
}
|
|
74
164
|
}
|
|
75
|
-
else if (config.adapters?.length)
|
|
76
|
-
lines.push({
|
|
77
|
-
|
|
78
|
-
|
|
165
|
+
else if (config.adapters?.length) {
|
|
166
|
+
lines.push({
|
|
167
|
+
id: "adapters",
|
|
168
|
+
label: "Adapters",
|
|
169
|
+
state: "unknown",
|
|
170
|
+
detail: "Not loaded; --adapters explicitly executes trusted status hooks",
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
const complete = lines.every((l) => l.state !== "unknown");
|
|
174
|
+
const ok = complete && lines.every((l) => l.state === "ok");
|
|
175
|
+
return {
|
|
176
|
+
schemaVersion: 1,
|
|
177
|
+
kind: "project-status",
|
|
178
|
+
project: config.instance,
|
|
179
|
+
origin,
|
|
180
|
+
fileUrl: config.file ? projectFileUrl(config) : null,
|
|
181
|
+
connection,
|
|
182
|
+
coverage,
|
|
183
|
+
lines,
|
|
184
|
+
complete,
|
|
185
|
+
ok,
|
|
186
|
+
visualApproval: false,
|
|
187
|
+
};
|
|
79
188
|
}
|
package/dist/registry.js
CHANGED
|
@@ -8,44 +8,77 @@ export const defineSnippet = (definition) => definition;
|
|
|
8
8
|
export class RegistryError extends Error {
|
|
9
9
|
}
|
|
10
10
|
const namePattern = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
|
11
|
-
const paramTypes = [
|
|
12
|
-
|
|
11
|
+
const paramTypes = [
|
|
12
|
+
"string",
|
|
13
|
+
"number",
|
|
14
|
+
"boolean",
|
|
15
|
+
"string[]",
|
|
16
|
+
"number[]",
|
|
17
|
+
];
|
|
18
|
+
const bodyOf = (body, params) => typeof body === "function" ? body(params) : body;
|
|
13
19
|
const pageOf = (page) => page === undefined ? undefined : typeof page === "function" ? page : () => page;
|
|
14
20
|
/** Whatever page the definition names is handed to the snippet as CFG.page, with the guard in front of the body. */
|
|
15
21
|
function pageAware(def, params) {
|
|
16
22
|
const page = pageOf(def.page)?.(params);
|
|
17
|
-
return page === undefined
|
|
23
|
+
return page === undefined
|
|
24
|
+
? {
|
|
25
|
+
cfg: params,
|
|
26
|
+
guard: "",
|
|
27
|
+
}
|
|
28
|
+
: {
|
|
29
|
+
cfg: {
|
|
30
|
+
...params,
|
|
31
|
+
page,
|
|
32
|
+
},
|
|
33
|
+
guard: `${requirePage}\n`,
|
|
34
|
+
};
|
|
18
35
|
}
|
|
19
36
|
/** Turns a definition into what the registry runs. Throws a RegistryError that says what is wrong with the definition. */
|
|
20
37
|
export function normalize(def, source) {
|
|
21
38
|
const fail = (message) => {
|
|
22
39
|
throw new RegistryError(`snippet ${JSON.stringify(def.name)}: ${message}`);
|
|
23
40
|
};
|
|
24
|
-
if (typeof def !== "object" || def === null)
|
|
41
|
+
if (typeof def !== "object" || def === null) {
|
|
25
42
|
return fail("a snippet must be an object");
|
|
26
|
-
|
|
43
|
+
}
|
|
44
|
+
if (typeof def.name !== "string" || !namePattern.test(def.name)) {
|
|
27
45
|
fail("name must be lowercase words joined by hyphens");
|
|
28
|
-
|
|
46
|
+
}
|
|
47
|
+
if (typeof def.description !== "string" || def.description.trim() === "") {
|
|
29
48
|
fail("a description is required");
|
|
49
|
+
}
|
|
30
50
|
const params = def.params ?? [];
|
|
31
51
|
const seen = new Set();
|
|
32
52
|
for (const spec of params) {
|
|
33
|
-
if (!/^[A-Za-z][A-Za-z0-9]*$/.test(spec.name))
|
|
53
|
+
if (!/^[A-Za-z][A-Za-z0-9]*$/.test(spec.name)) {
|
|
34
54
|
fail(`parameter name ${JSON.stringify(spec.name)} is not valid`);
|
|
35
|
-
|
|
55
|
+
}
|
|
56
|
+
if (!paramTypes.includes(spec.type)) {
|
|
36
57
|
fail(`parameter ${spec.name} has an unknown type ${JSON.stringify(spec.type)}`);
|
|
37
|
-
|
|
58
|
+
}
|
|
59
|
+
if (seen.has(spec.name)) {
|
|
38
60
|
fail(`parameter ${spec.name} is declared twice`);
|
|
61
|
+
}
|
|
39
62
|
seen.add(spec.name);
|
|
40
63
|
}
|
|
41
|
-
const common = {
|
|
64
|
+
const common = {
|
|
65
|
+
name: def.name,
|
|
66
|
+
description: def.description,
|
|
67
|
+
params,
|
|
68
|
+
source,
|
|
69
|
+
};
|
|
42
70
|
if (def.kind === "read") {
|
|
43
|
-
if (typeof def.body !== "string" && typeof def.body !== "function")
|
|
71
|
+
if (typeof def.body !== "string" && typeof def.body !== "function") {
|
|
44
72
|
fail("a read snippet needs a body");
|
|
73
|
+
}
|
|
45
74
|
return {
|
|
46
75
|
kind: "read",
|
|
47
76
|
...common,
|
|
48
|
-
...(def.page === undefined
|
|
77
|
+
...(def.page === undefined
|
|
78
|
+
? {}
|
|
79
|
+
: {
|
|
80
|
+
page: pageOf(def.page),
|
|
81
|
+
}),
|
|
49
82
|
build(p) {
|
|
50
83
|
const { cfg, guard } = pageAware(def, p);
|
|
51
84
|
return snippet(def.name, `${guard}${bodyOf(def.body, p)}`, cfg);
|
|
@@ -53,9 +86,15 @@ export function normalize(def, source) {
|
|
|
53
86
|
};
|
|
54
87
|
}
|
|
55
88
|
if (def.kind === "write") {
|
|
56
|
-
for (const phase of [
|
|
57
|
-
|
|
89
|
+
for (const phase of [
|
|
90
|
+
"inspect",
|
|
91
|
+
"apply",
|
|
92
|
+
"readback",
|
|
93
|
+
]) {
|
|
94
|
+
if (typeof def[phase] !== "string" && typeof def[phase] !== "function") {
|
|
58
95
|
fail(`a write snippet needs ${phase}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
59
98
|
const phaseSnippet = (phase) => (p) => {
|
|
60
99
|
const { cfg, guard } = pageAware(def, p);
|
|
61
100
|
return snippet(`${def.name}-${phase}`, `${guard}${bodyOf(def[phase], p)}`, cfg);
|
|
@@ -69,9 +108,12 @@ export function normalize(def, source) {
|
|
|
69
108
|
inspect: phaseSnippet("inspect"),
|
|
70
109
|
apply: phaseSnippet("apply"),
|
|
71
110
|
readback: phaseSnippet("readback"),
|
|
72
|
-
verify: def.verify ??
|
|
73
|
-
|
|
74
|
-
|
|
111
|
+
verify: def.verify ?? (({ readback }) => verifyEvidence(readback)),
|
|
112
|
+
...(def.reconcile
|
|
113
|
+
? {
|
|
114
|
+
reconcile: def.reconcile,
|
|
115
|
+
}
|
|
116
|
+
: {}),
|
|
75
117
|
},
|
|
76
118
|
};
|
|
77
119
|
}
|
|
@@ -80,27 +122,33 @@ export function normalize(def, source) {
|
|
|
80
122
|
/** Checks that every phase of a snippet is valid plugin code and small enough, using only the declared defaults. No connection needed. */
|
|
81
123
|
export function checkEntry(entry) {
|
|
82
124
|
const sample = {};
|
|
83
|
-
for (const spec of entry.params)
|
|
84
|
-
if (spec.default !== undefined)
|
|
125
|
+
for (const spec of entry.params) {
|
|
126
|
+
if (spec.default !== undefined) {
|
|
85
127
|
sample[spec.name] = spec.default;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
86
130
|
const problems = [];
|
|
87
131
|
const snippets = [];
|
|
88
132
|
try {
|
|
89
|
-
if (entry.kind === "read")
|
|
133
|
+
if (entry.kind === "read") {
|
|
90
134
|
snippets.push(entry.build(sample));
|
|
91
|
-
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
92
137
|
snippets.push(entry.operation.inspect(sample), entry.operation.apply(sample), entry.operation.readback(sample));
|
|
138
|
+
}
|
|
93
139
|
}
|
|
94
140
|
catch (error) {
|
|
95
141
|
// A builder may need a real parameter to build at all; then only the rest of the checks apply.
|
|
96
142
|
const message = String(error.message);
|
|
97
|
-
if (/bytes/.test(message))
|
|
143
|
+
if (/bytes/.test(message)) {
|
|
98
144
|
problems.push(message);
|
|
145
|
+
}
|
|
99
146
|
}
|
|
100
147
|
for (const s of snippets) {
|
|
101
148
|
const error = compiles(s.code);
|
|
102
|
-
if (error)
|
|
149
|
+
if (error) {
|
|
103
150
|
problems.push(`${s.name}: ${error}`);
|
|
151
|
+
}
|
|
104
152
|
}
|
|
105
153
|
return problems;
|
|
106
154
|
}
|
|
@@ -108,16 +156,22 @@ export function checkEntry(entry) {
|
|
|
108
156
|
export function coerceParams(specs, raw) {
|
|
109
157
|
const out = {};
|
|
110
158
|
const known = new Set(specs.map((s) => s.name));
|
|
111
|
-
for (const key of Object.keys(raw))
|
|
112
|
-
if (!known.has(key))
|
|
113
|
-
throw new RegistryError(`unknown parameter ${key}; known: ${[
|
|
159
|
+
for (const key of Object.keys(raw)) {
|
|
160
|
+
if (!known.has(key)) {
|
|
161
|
+
throw new RegistryError(`unknown parameter ${key}; known: ${[
|
|
162
|
+
...known,
|
|
163
|
+
].join(", ") || "none"}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
114
166
|
for (const spec of specs) {
|
|
115
167
|
const value = raw[spec.name];
|
|
116
168
|
if (value === undefined) {
|
|
117
|
-
if (spec.default !== undefined)
|
|
169
|
+
if (spec.default !== undefined) {
|
|
118
170
|
out[spec.name] = spec.default;
|
|
119
|
-
|
|
171
|
+
}
|
|
172
|
+
else if (spec.required) {
|
|
120
173
|
throw new RegistryError(`parameter ${spec.name} is required`);
|
|
174
|
+
}
|
|
121
175
|
continue;
|
|
122
176
|
}
|
|
123
177
|
const text = Array.isArray(value) ? value.join(",") : value;
|
|
@@ -127,23 +181,32 @@ export function coerceParams(specs, raw) {
|
|
|
127
181
|
break;
|
|
128
182
|
case "number": {
|
|
129
183
|
const n = Number(text);
|
|
130
|
-
if (!Number.isFinite(n))
|
|
184
|
+
if (!Number.isFinite(n)) {
|
|
131
185
|
throw new RegistryError(`parameter ${spec.name} must be a number`);
|
|
186
|
+
}
|
|
132
187
|
out[spec.name] = n;
|
|
133
188
|
break;
|
|
134
189
|
}
|
|
135
190
|
case "boolean":
|
|
136
|
-
if (![
|
|
191
|
+
if (![
|
|
192
|
+
"true",
|
|
193
|
+
"false",
|
|
194
|
+
].includes(text)) {
|
|
137
195
|
throw new RegistryError(`parameter ${spec.name} must be true or false`);
|
|
196
|
+
}
|
|
138
197
|
out[spec.name] = text === "true";
|
|
139
198
|
break;
|
|
140
199
|
case "string[]":
|
|
141
|
-
out[spec.name] = text
|
|
200
|
+
out[spec.name] = text
|
|
201
|
+
.split(",")
|
|
202
|
+
.map((v) => v.trim())
|
|
203
|
+
.filter(Boolean);
|
|
142
204
|
break;
|
|
143
205
|
case "number[]": {
|
|
144
206
|
const list = text.split(",").map((v) => Number(v.trim()));
|
|
145
|
-
if (list.some((n) => !Number.isFinite(n)))
|
|
207
|
+
if (list.some((n) => !Number.isFinite(n))) {
|
|
146
208
|
throw new RegistryError(`parameter ${spec.name} must be numbers separated by commas`);
|
|
209
|
+
}
|
|
147
210
|
out[spec.name] = list;
|
|
148
211
|
break;
|
|
149
212
|
}
|
|
@@ -153,40 +216,70 @@ export function coerceParams(specs, raw) {
|
|
|
153
216
|
}
|
|
154
217
|
/** An absent error list is not evidence that the intended state exists. */
|
|
155
218
|
function verifyEvidence(readback) {
|
|
156
|
-
if (readback.problems !== undefined &&
|
|
157
|
-
|
|
158
|
-
|
|
219
|
+
if (readback.problems !== undefined &&
|
|
220
|
+
(!Array.isArray(readback.problems) ||
|
|
221
|
+
readback.problems.some((problem) => typeof problem !== "string"))) {
|
|
222
|
+
return [
|
|
223
|
+
"Readback problems must be a list of strings",
|
|
224
|
+
];
|
|
225
|
+
}
|
|
226
|
+
if (Array.isArray(readback.problems) && readback.problems.length > 0) {
|
|
159
227
|
return readback.problems;
|
|
228
|
+
}
|
|
160
229
|
const evidence = readback.verification;
|
|
161
|
-
const observation = (value) => typeof value === "object" &&
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
230
|
+
const observation = (value) => typeof value === "object" &&
|
|
231
|
+
value !== null &&
|
|
232
|
+
!Array.isArray(value) &&
|
|
233
|
+
Object.keys(value).length > 0;
|
|
234
|
+
if (!evidence || !observation(evidence.expected) || !observation(evidence.actual)) {
|
|
235
|
+
return [
|
|
236
|
+
"Readback needs nonempty verification.expected and verification.actual observations, or an explicit verifier",
|
|
237
|
+
];
|
|
238
|
+
}
|
|
239
|
+
return isDeepStrictEqual(evidence.expected, evidence.actual)
|
|
240
|
+
? []
|
|
241
|
+
: [
|
|
242
|
+
"Readback observations do not match the expected state",
|
|
243
|
+
];
|
|
165
244
|
}
|
|
166
245
|
export class SnippetRegistry {
|
|
167
246
|
entries = new Map();
|
|
168
247
|
register(def, source = "project") {
|
|
169
|
-
const entry = "build" in def || "operation" in def
|
|
170
|
-
|
|
248
|
+
const entry = "build" in def || "operation" in def
|
|
249
|
+
? def
|
|
250
|
+
: normalize(def, source);
|
|
251
|
+
if (this.entries.has(entry.name)) {
|
|
171
252
|
throw new RegistryError(`snippet ${entry.name} is already registered (${this.entries.get(entry.name).source})`);
|
|
253
|
+
}
|
|
172
254
|
this.entries.set(entry.name, entry);
|
|
173
255
|
return entry;
|
|
174
256
|
}
|
|
175
257
|
get(name) {
|
|
176
258
|
const entry = this.entries.get(name);
|
|
177
|
-
if (!entry)
|
|
178
|
-
throw new RegistryError(`no snippet named ${name}. Known: ${[
|
|
259
|
+
if (!entry) {
|
|
260
|
+
throw new RegistryError(`no snippet named ${name}. Known: ${[
|
|
261
|
+
...this.entries.keys(),
|
|
262
|
+
]
|
|
263
|
+
.sort()
|
|
264
|
+
.join(", ")}`);
|
|
265
|
+
}
|
|
179
266
|
return entry;
|
|
180
267
|
}
|
|
181
268
|
list() {
|
|
182
|
-
return [
|
|
269
|
+
return [
|
|
270
|
+
...this.entries.values(),
|
|
271
|
+
].sort((a, b) => a.name.localeCompare(b.name));
|
|
183
272
|
}
|
|
184
273
|
/** Runs a snippet by name. A write snippet only looks unless `apply` is set. */
|
|
185
274
|
async run(client, name, params, options = {}) {
|
|
186
275
|
const entry = this.get(name);
|
|
187
|
-
if (entry.kind === "write")
|
|
276
|
+
if (entry.kind === "write") {
|
|
188
277
|
return runOperation(client, entry.operation, params, options);
|
|
189
|
-
|
|
278
|
+
}
|
|
279
|
+
const record = await runOnPage(client, entry.build(params), entry.page?.(params), {
|
|
280
|
+
...options,
|
|
281
|
+
effect: "read",
|
|
282
|
+
});
|
|
190
283
|
return record;
|
|
191
284
|
}
|
|
192
285
|
}
|
|
@@ -196,7 +289,11 @@ const readBuiltIn = (name, description, params, build) => ({
|
|
|
196
289
|
description,
|
|
197
290
|
params,
|
|
198
291
|
source: "built-in",
|
|
199
|
-
...(params.some((p) => p.name === "page")
|
|
292
|
+
...(params.some((p) => p.name === "page")
|
|
293
|
+
? {
|
|
294
|
+
page: (p) => p.page,
|
|
295
|
+
}
|
|
296
|
+
: {}),
|
|
200
297
|
build,
|
|
201
298
|
});
|
|
202
299
|
/** The registry with everything penpair ships. Project snippets are added to it. */
|
|
@@ -204,16 +301,52 @@ export function builtInRegistry() {
|
|
|
204
301
|
const registry = new SnippetRegistry();
|
|
205
302
|
registry.register(readBuiltIn("pages", "The file, its revision, its pages and how many top-level shapes each has.", [], () => pages()), "built-in");
|
|
206
303
|
registry.register(readBuiltIn("tree", "One line per shape: type, name, size, position, layout and sizing, and the text it carries.", [
|
|
207
|
-
{
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
304
|
+
{
|
|
305
|
+
name: "page",
|
|
306
|
+
type: "string",
|
|
307
|
+
required: true,
|
|
308
|
+
description: "Page to read",
|
|
309
|
+
},
|
|
310
|
+
{
|
|
311
|
+
name: "depth",
|
|
312
|
+
type: "number",
|
|
313
|
+
default: 3,
|
|
314
|
+
description: "How many levels down",
|
|
315
|
+
},
|
|
316
|
+
{
|
|
317
|
+
name: "namePrefix",
|
|
318
|
+
type: "string",
|
|
319
|
+
description: "Only top-level shapes whose name starts with this",
|
|
320
|
+
},
|
|
321
|
+
{
|
|
322
|
+
name: "limit",
|
|
323
|
+
type: "number",
|
|
324
|
+
default: 400,
|
|
325
|
+
description: "Stop after this many lines",
|
|
326
|
+
},
|
|
211
327
|
], (p) => tree(p)), "built-in");
|
|
212
328
|
registry.register(readBuiltIn("audit", "Blank text, colours and strokes not bound to a token, children sticking out of their parent, gaps off the allowed steps.", [
|
|
213
|
-
{
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
329
|
+
{
|
|
330
|
+
name: "page",
|
|
331
|
+
type: "string",
|
|
332
|
+
required: true,
|
|
333
|
+
},
|
|
334
|
+
{
|
|
335
|
+
name: "framePrefix",
|
|
336
|
+
type: "string",
|
|
337
|
+
required: true,
|
|
338
|
+
description: "Audit top-level frames whose names start with this",
|
|
339
|
+
},
|
|
340
|
+
{
|
|
341
|
+
name: "spacingSteps",
|
|
342
|
+
type: "number[]",
|
|
343
|
+
description: "Allowed gap and padding values; leave out to skip that check",
|
|
344
|
+
},
|
|
345
|
+
{
|
|
346
|
+
name: "ignoreUnbound",
|
|
347
|
+
type: "string[]",
|
|
348
|
+
description: "Names (starts) not reported as unbound",
|
|
349
|
+
},
|
|
217
350
|
], (p) => audit(p)), "built-in");
|
|
218
351
|
registry.register(readBuiltIn("tokens-dump", "Bounded typed token preview; tokens dump captures complete chunked evidence.", [], () => tokensDump()), "built-in");
|
|
219
352
|
registry.register({
|
|
@@ -221,11 +354,35 @@ export function builtInRegistry() {
|
|
|
221
354
|
name: "archive",
|
|
222
355
|
description: "Nest the frames that are not part of the live work into one archive board. Frames keep names, ids and component links.",
|
|
223
356
|
params: [
|
|
224
|
-
{
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
357
|
+
{
|
|
358
|
+
name: "page",
|
|
359
|
+
type: "string",
|
|
360
|
+
required: true,
|
|
361
|
+
},
|
|
362
|
+
{
|
|
363
|
+
name: "archiveName",
|
|
364
|
+
type: "string",
|
|
365
|
+
required: true,
|
|
366
|
+
description: "Name of the archive board",
|
|
367
|
+
},
|
|
368
|
+
{
|
|
369
|
+
name: "keepPrefixes",
|
|
370
|
+
type: "string[]",
|
|
371
|
+
default: [],
|
|
372
|
+
description: "Boards whose names start with one of these stay live",
|
|
373
|
+
},
|
|
374
|
+
{
|
|
375
|
+
name: "partPrefixes",
|
|
376
|
+
type: "string[]",
|
|
377
|
+
default: [],
|
|
378
|
+
description: "Library parts, never archived",
|
|
379
|
+
},
|
|
380
|
+
{
|
|
381
|
+
name: "x",
|
|
382
|
+
type: "number",
|
|
383
|
+
default: 9000,
|
|
384
|
+
description: "Where the archive board is placed",
|
|
385
|
+
},
|
|
229
386
|
],
|
|
230
387
|
source: "built-in",
|
|
231
388
|
operation: archiveOperation,
|