@penpair/core 0.1.3 → 0.3.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.
Files changed (149) hide show
  1. package/dist/adapters.d.ts +16 -1
  2. package/dist/adapters.js +62 -19
  3. package/dist/approvals.d.ts +32 -0
  4. package/dist/approvals.js +213 -0
  5. package/dist/atomic-output.d.ts +5 -0
  6. package/dist/atomic-output.js +33 -0
  7. package/dist/audit-rules.d.ts +60 -0
  8. package/dist/audit-rules.js +443 -0
  9. package/dist/backup-note.d.ts +4 -0
  10. package/dist/backup-note.js +49 -0
  11. package/dist/baseline-coverage.d.ts +25 -0
  12. package/dist/baseline-coverage.js +248 -0
  13. package/dist/baselines-config.d.ts +25 -0
  14. package/dist/baselines-config.js +150 -11
  15. package/dist/baselines.d.ts +37 -8
  16. package/dist/baselines.js +362 -59
  17. package/dist/cli-arguments.d.ts +7 -0
  18. package/dist/cli-arguments.js +47 -0
  19. package/dist/cli-main.d.ts +13 -1
  20. package/dist/cli-main.js +2107 -247
  21. package/dist/client.d.ts +30 -6
  22. package/dist/client.js +160 -35
  23. package/dist/command-result.d.ts +159 -0
  24. package/dist/command-result.js +468 -0
  25. package/dist/coordination.d.ts +15 -0
  26. package/dist/coordination.js +184 -0
  27. package/dist/credentials.d.ts +21 -0
  28. package/dist/credentials.js +105 -0
  29. package/dist/deep-check.js +57 -9
  30. package/dist/design-spec.d.ts +55 -0
  31. package/dist/design-spec.js +275 -0
  32. package/dist/endpoint.d.ts +8 -1
  33. package/dist/endpoint.js +133 -21
  34. package/dist/errors.d.ts +2 -1
  35. package/dist/errors.js +8 -4
  36. package/dist/export.d.ts +18 -0
  37. package/dist/export.js +113 -10
  38. package/dist/file-archive.d.ts +16 -0
  39. package/dist/file-archive.js +58 -0
  40. package/dist/file-guard.d.ts +8 -0
  41. package/dist/file-guard.js +58 -0
  42. package/dist/formats.d.ts +4 -0
  43. package/dist/formats.js +229 -0
  44. package/dist/index.d.ts +34 -1
  45. package/dist/index.js +34 -1
  46. package/dist/inspection.d.ts +106 -0
  47. package/dist/inspection.js +657 -0
  48. package/dist/instance/config-schema.d.ts +422 -0
  49. package/dist/instance/config-schema.js +435 -0
  50. package/dist/instance/config.d.ts +29 -0
  51. package/dist/instance/config.js +191 -29
  52. package/dist/instance/connect.d.ts +10 -1
  53. package/dist/instance/connect.js +178 -30
  54. package/dist/instance/connection.d.ts +39 -4
  55. package/dist/instance/connection.js +198 -14
  56. package/dist/instance/gitignore.js +5 -2
  57. package/dist/instance/init.js +32 -7
  58. package/dist/instance/runner.js +32 -10
  59. package/dist/instance/setup.js +46 -13
  60. package/dist/instance/tty.js +28 -8
  61. package/dist/journal.d.ts +87 -0
  62. package/dist/journal.js +423 -0
  63. package/dist/kit-smoke.d.ts +31 -0
  64. package/dist/kit-smoke.js +145 -0
  65. package/dist/kit-source.d.ts +2 -0
  66. package/dist/kit-source.js +279 -0
  67. package/dist/kit.d.ts +25 -0
  68. package/dist/kit.js +158 -0
  69. package/dist/layout-policy.d.ts +2 -0
  70. package/dist/layout-policy.js +86 -0
  71. package/dist/layout.d.ts +21 -0
  72. package/dist/layout.js +36 -0
  73. package/dist/live-smoke.d.ts +8 -0
  74. package/dist/live-smoke.js +23 -0
  75. package/dist/local-profile.js +2 -1
  76. package/dist/mcp-server.d.ts +12 -0
  77. package/dist/mcp-server.js +415 -0
  78. package/dist/native-api.d.ts +15 -0
  79. package/dist/native-api.js +78 -0
  80. package/dist/onboard.d.ts +10 -0
  81. package/dist/onboard.js +41 -0
  82. package/dist/operation.d.ts +22 -3
  83. package/dist/operation.js +341 -31
  84. package/dist/page-session.d.ts +23 -0
  85. package/dist/page-session.js +142 -0
  86. package/dist/png.d.ts +5 -0
  87. package/dist/png.js +22 -0
  88. package/dist/portable-tokens.d.ts +22 -0
  89. package/dist/portable-tokens.js +331 -0
  90. package/dist/probe.d.ts +6 -2
  91. package/dist/probe.js +64 -11
  92. package/dist/project-input.d.ts +6 -0
  93. package/dist/project-input.js +65 -0
  94. package/dist/project-snippets.js +29 -12
  95. package/dist/project-status.d.ts +24 -0
  96. package/dist/project-status.js +188 -0
  97. package/dist/registry.d.ts +2 -1
  98. package/dist/registry.js +224 -53
  99. package/dist/run-file.d.ts +26 -0
  100. package/dist/run-file.js +199 -0
  101. package/dist/shots.d.ts +43 -0
  102. package/dist/shots.js +172 -0
  103. package/dist/skill-instructions.d.ts +13 -0
  104. package/dist/skill-instructions.js +144 -0
  105. package/dist/skills.d.ts +7 -0
  106. package/dist/skills.js +163 -46
  107. package/dist/snippet.d.ts +3 -2
  108. package/dist/snippet.js +33 -9
  109. package/dist/snippets/archive.d.ts +9 -0
  110. package/dist/snippets/archive.js +10 -4
  111. package/dist/snippets/read.d.ts +1 -1
  112. package/dist/snippets/read.js +78 -41
  113. package/dist/structure-snapshot.d.ts +85 -0
  114. package/dist/structure-snapshot.js +525 -0
  115. package/dist/token-edit.d.ts +53 -0
  116. package/dist/token-edit.js +352 -0
  117. package/dist/token-projection.d.ts +56 -0
  118. package/dist/token-projection.js +286 -0
  119. package/dist/tokens-diff.d.ts +19 -6
  120. package/dist/tokens-diff.js +182 -18
  121. package/dist/version.d.ts +1 -1
  122. package/dist/version.js +1 -1
  123. package/package.json +1 -1
  124. package/skills/penpair-design/SKILL.md +20 -45
  125. package/skills/penpair-design/references/penpot-plugin-gotchas.md +58 -2
  126. package/skills/penpair-design/references/project-facts.md +3 -1
  127. package/skills/penpair-design/references/write-protocol.md +15 -3
  128. package/skills/penpair-handoff/SKILL.md +16 -0
  129. package/skills/penpair-handoff/references/comparison-companion.md +13 -0
  130. package/skills/penpair-handoff/references/handoff-flow.md +13 -0
  131. package/skills/penpair-recipes/SKILL.md +21 -0
  132. package/skills/penpair-recipes/references/build-flow.md +11 -0
  133. package/skills/penpair-setup/SKILL.md +8 -2
  134. package/dist/cli.d.ts +0 -2
  135. package/dist/cli.js +0 -3
  136. package/dist/docker/lifecycle.d.ts +0 -96
  137. package/dist/docker/lifecycle.js +0 -273
  138. package/dist/docker/secrets.d.ts +0 -11
  139. package/dist/docker/secrets.js +0 -38
  140. package/dist/docker/template.d.ts +0 -8
  141. package/dist/docker/template.js +0 -158
  142. package/dist/docker-profile.d.ts +0 -114
  143. package/dist/docker-profile.js +0 -18
  144. package/dist/instance/lifecycle.d.ts +0 -129
  145. package/dist/instance/lifecycle.js +0 -336
  146. package/dist/instance/secrets.d.ts +0 -11
  147. package/dist/instance/secrets.js +0 -38
  148. package/dist/instance/template.d.ts +0 -8
  149. package/dist/instance/template.js +0 -158
@@ -0,0 +1,188 @@
1
+ import { onboardingChecklist } from "./onboard.js";
2
+ import { snippet, runSnippet } from "./snippet.js";
3
+ import { installSkills } from "./skills.js";
4
+ import { readBaselineCoverage } from "./baseline-coverage.js";
5
+ import { redactEndpoint } from "./endpoint.js";
6
+ export const projectFileUrl = (config) => {
7
+ const url = onboardingChecklist(config).fileUrl;
8
+ if (!url) {
9
+ throw new Error("Pin the owner-confirmed file before opening it");
10
+ }
11
+ return url;
12
+ };
13
+ export async function openProject(config, open) {
14
+ const url = projectFileUrl(config);
15
+ await open(url);
16
+ return url;
17
+ }
18
+ export async function projectStatus(root, config, options = {}) {
19
+ const origin = onboardingChecklist(config).origin;
20
+ const lines = [];
21
+ const safe = (error) => (options.redact ?? redactEndpoint)(String(error.message));
22
+ let reachable = false;
23
+ try {
24
+ reachable = await (options.reachable ??
25
+ (async (origin) => (await fetch(origin, {
26
+ redirect: "manual",
27
+ signal: AbortSignal.timeout(5000),
28
+ })).status < 500))(origin);
29
+ }
30
+ catch { }
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
+ });
43
+ let connection = null;
44
+ if (options.connection) {
45
+ let client;
46
+ try {
47
+ client = await options.connection();
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"));
49
+ if (!config.file ||
50
+ observation.fileId !== config.file.id ||
51
+ !Array.isArray(observation.pages) ||
52
+ !Number.isInteger(observation.revision)) {
53
+ throw new Error("Status did not observe the configured file identity/revision");
54
+ }
55
+ const { execution: _execution, ...data } = observation;
56
+ connection = data;
57
+ lines.push({
58
+ id: "connection",
59
+ label: "Editor and plugin",
60
+ state: "ok",
61
+ detail: `Pinned file connected; ${observation.pages.length} pages`,
62
+ });
63
+ }
64
+ catch (error) {
65
+ lines.push({
66
+ id: "connection",
67
+ label: "Editor and plugin",
68
+ state: "failed",
69
+ detail: safe(error),
70
+ });
71
+ }
72
+ finally {
73
+ await client?.close();
74
+ }
75
+ }
76
+ else {
77
+ lines.push({
78
+ id: "connection",
79
+ label: "Editor and plugin",
80
+ state: "unknown",
81
+ detail: "No resolved project connection",
82
+ });
83
+ }
84
+ try {
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
+ });
97
+ }
98
+ catch (error) {
99
+ lines.push({
100
+ id: "skills",
101
+ label: "Skills",
102
+ state: "unknown",
103
+ detail: safe(error),
104
+ });
105
+ }
106
+ let coverage = null;
107
+ if (config.baselines) {
108
+ try {
109
+ const found = await readBaselineCoverage(root, config.baselines, config.file?.id);
110
+ coverage = found;
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
+ });
119
+ }
120
+ catch (error) {
121
+ lines.push({
122
+ id: "baselines",
123
+ label: "Baselines",
124
+ state: "failed",
125
+ detail: safe(error),
126
+ });
127
+ }
128
+ }
129
+ else {
130
+ lines.push({
131
+ id: "baselines",
132
+ label: "Baselines",
133
+ state: "unknown",
134
+ detail: "No baseline configuration",
135
+ });
136
+ }
137
+ if (options.adapterStatus) {
138
+ try {
139
+ const adapterLines = await options.adapterStatus();
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))) {
152
+ throw new Error("Invalid adapter status lines");
153
+ }
154
+ lines.push(...adapterLines);
155
+ }
156
+ catch (error) {
157
+ lines.push({
158
+ id: "adapters",
159
+ label: "Adapters",
160
+ state: "failed",
161
+ detail: safe(error),
162
+ });
163
+ }
164
+ }
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
+ };
188
+ }
@@ -27,7 +27,7 @@ export interface ReadDefinition extends DefinitionBase {
27
27
  /**
28
28
  * A change done the safe way. `inspect` only looks and says what would change; `apply` changes once and must be safe to repeat;
29
29
  * `readback` looks again from scratch. `verify` compares them and returns the problems; by default the readback's
30
- * `problems` array is used (an empty or missing array means nothing is wrong).
30
+ * `verification.expected` and `verification.actual` must contain matching nonempty observation objects.
31
31
  */
32
32
  export interface WriteDefinition extends DefinitionBase {
33
33
  kind: "write";
@@ -35,6 +35,7 @@ export interface WriteDefinition extends DefinitionBase {
35
35
  apply: Body;
36
36
  readback: Body;
37
37
  verify?: Operation<Params>["verify"];
38
+ reconcile?: Operation<Params>["reconcile"];
38
39
  }
39
40
  export type SnippetDefinition = ReadDefinition | WriteDefinition;
40
41
  /** Declares a project snippet. Returns the definition unchanged, so a module can `export default defineSnippet({...})` with types checked. */
package/dist/registry.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { runOperation, runOnPage } from "./operation.js";
2
+ import { isDeepStrictEqual } from "node:util";
2
3
  import { compiles, requirePage, snippet } from "./snippet.js";
3
4
  import { archiveOperation } from "./operation.js";
4
5
  import { audit, pages, tokensDump, tree } from "./snippets/read.js";
@@ -7,44 +8,77 @@ export const defineSnippet = (definition) => definition;
7
8
  export class RegistryError extends Error {
8
9
  }
9
10
  const namePattern = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
10
- const paramTypes = ["string", "number", "boolean", "string[]", "number[]"];
11
- const bodyOf = (body, params) => (typeof body === "function" ? body(params) : body);
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;
12
19
  const pageOf = (page) => page === undefined ? undefined : typeof page === "function" ? page : () => page;
13
20
  /** Whatever page the definition names is handed to the snippet as CFG.page, with the guard in front of the body. */
14
21
  function pageAware(def, params) {
15
22
  const page = pageOf(def.page)?.(params);
16
- return page === undefined ? { cfg: params, guard: "" } : { cfg: { ...params, page }, guard: `${requirePage}\n` };
23
+ return page === undefined
24
+ ? {
25
+ cfg: params,
26
+ guard: "",
27
+ }
28
+ : {
29
+ cfg: {
30
+ ...params,
31
+ page,
32
+ },
33
+ guard: `${requirePage}\n`,
34
+ };
17
35
  }
18
36
  /** Turns a definition into what the registry runs. Throws a RegistryError that says what is wrong with the definition. */
19
37
  export function normalize(def, source) {
20
38
  const fail = (message) => {
21
39
  throw new RegistryError(`snippet ${JSON.stringify(def.name)}: ${message}`);
22
40
  };
23
- if (typeof def !== "object" || def === null)
41
+ if (typeof def !== "object" || def === null) {
24
42
  return fail("a snippet must be an object");
25
- if (typeof def.name !== "string" || !namePattern.test(def.name))
43
+ }
44
+ if (typeof def.name !== "string" || !namePattern.test(def.name)) {
26
45
  fail("name must be lowercase words joined by hyphens");
27
- if (typeof def.description !== "string" || def.description.trim() === "")
46
+ }
47
+ if (typeof def.description !== "string" || def.description.trim() === "") {
28
48
  fail("a description is required");
49
+ }
29
50
  const params = def.params ?? [];
30
51
  const seen = new Set();
31
52
  for (const spec of params) {
32
- if (!/^[A-Za-z][A-Za-z0-9]*$/.test(spec.name))
53
+ if (!/^[A-Za-z][A-Za-z0-9]*$/.test(spec.name)) {
33
54
  fail(`parameter name ${JSON.stringify(spec.name)} is not valid`);
34
- if (!paramTypes.includes(spec.type))
55
+ }
56
+ if (!paramTypes.includes(spec.type)) {
35
57
  fail(`parameter ${spec.name} has an unknown type ${JSON.stringify(spec.type)}`);
36
- if (seen.has(spec.name))
58
+ }
59
+ if (seen.has(spec.name)) {
37
60
  fail(`parameter ${spec.name} is declared twice`);
61
+ }
38
62
  seen.add(spec.name);
39
63
  }
40
- const common = { name: def.name, description: def.description, params, source };
64
+ const common = {
65
+ name: def.name,
66
+ description: def.description,
67
+ params,
68
+ source,
69
+ };
41
70
  if (def.kind === "read") {
42
- if (typeof def.body !== "string" && typeof def.body !== "function")
71
+ if (typeof def.body !== "string" && typeof def.body !== "function") {
43
72
  fail("a read snippet needs a body");
73
+ }
44
74
  return {
45
75
  kind: "read",
46
76
  ...common,
47
- ...(def.page === undefined ? {} : { page: pageOf(def.page) }),
77
+ ...(def.page === undefined
78
+ ? {}
79
+ : {
80
+ page: pageOf(def.page),
81
+ }),
48
82
  build(p) {
49
83
  const { cfg, guard } = pageAware(def, p);
50
84
  return snippet(def.name, `${guard}${bodyOf(def.body, p)}`, cfg);
@@ -52,9 +86,15 @@ export function normalize(def, source) {
52
86
  };
53
87
  }
54
88
  if (def.kind === "write") {
55
- for (const phase of ["inspect", "apply", "readback"])
56
- if (typeof def[phase] !== "string" && typeof def[phase] !== "function")
89
+ for (const phase of [
90
+ "inspect",
91
+ "apply",
92
+ "readback",
93
+ ]) {
94
+ if (typeof def[phase] !== "string" && typeof def[phase] !== "function") {
57
95
  fail(`a write snippet needs ${phase}`);
96
+ }
97
+ }
58
98
  const phaseSnippet = (phase) => (p) => {
59
99
  const { cfg, guard } = pageAware(def, p);
60
100
  return snippet(`${def.name}-${phase}`, `${guard}${bodyOf(def[phase], p)}`, cfg);
@@ -68,8 +108,12 @@ export function normalize(def, source) {
68
108
  inspect: phaseSnippet("inspect"),
69
109
  apply: phaseSnippet("apply"),
70
110
  readback: phaseSnippet("readback"),
71
- verify: def.verify ??
72
- (({ readback }) => (Array.isArray(readback.problems) ? readback.problems.map(String) : [])),
111
+ verify: def.verify ?? (({ readback }) => verifyEvidence(readback)),
112
+ ...(def.reconcile
113
+ ? {
114
+ reconcile: def.reconcile,
115
+ }
116
+ : {}),
73
117
  },
74
118
  };
75
119
  }
@@ -78,27 +122,33 @@ export function normalize(def, source) {
78
122
  /** Checks that every phase of a snippet is valid plugin code and small enough, using only the declared defaults. No connection needed. */
79
123
  export function checkEntry(entry) {
80
124
  const sample = {};
81
- for (const spec of entry.params)
82
- if (spec.default !== undefined)
125
+ for (const spec of entry.params) {
126
+ if (spec.default !== undefined) {
83
127
  sample[spec.name] = spec.default;
128
+ }
129
+ }
84
130
  const problems = [];
85
131
  const snippets = [];
86
132
  try {
87
- if (entry.kind === "read")
133
+ if (entry.kind === "read") {
88
134
  snippets.push(entry.build(sample));
89
- else
135
+ }
136
+ else {
90
137
  snippets.push(entry.operation.inspect(sample), entry.operation.apply(sample), entry.operation.readback(sample));
138
+ }
91
139
  }
92
140
  catch (error) {
93
141
  // A builder may need a real parameter to build at all; then only the rest of the checks apply.
94
142
  const message = String(error.message);
95
- if (/bytes/.test(message))
143
+ if (/bytes/.test(message)) {
96
144
  problems.push(message);
145
+ }
97
146
  }
98
147
  for (const s of snippets) {
99
148
  const error = compiles(s.code);
100
- if (error)
149
+ if (error) {
101
150
  problems.push(`${s.name}: ${error}`);
151
+ }
102
152
  }
103
153
  return problems;
104
154
  }
@@ -106,16 +156,22 @@ export function checkEntry(entry) {
106
156
  export function coerceParams(specs, raw) {
107
157
  const out = {};
108
158
  const known = new Set(specs.map((s) => s.name));
109
- for (const key of Object.keys(raw))
110
- if (!known.has(key))
111
- throw new RegistryError(`unknown parameter ${key}; known: ${[...known].join(", ") || "none"}`);
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
+ }
112
166
  for (const spec of specs) {
113
167
  const value = raw[spec.name];
114
168
  if (value === undefined) {
115
- if (spec.default !== undefined)
169
+ if (spec.default !== undefined) {
116
170
  out[spec.name] = spec.default;
117
- else if (spec.required)
171
+ }
172
+ else if (spec.required) {
118
173
  throw new RegistryError(`parameter ${spec.name} is required`);
174
+ }
119
175
  continue;
120
176
  }
121
177
  const text = Array.isArray(value) ? value.join(",") : value;
@@ -125,23 +181,32 @@ export function coerceParams(specs, raw) {
125
181
  break;
126
182
  case "number": {
127
183
  const n = Number(text);
128
- if (!Number.isFinite(n))
184
+ if (!Number.isFinite(n)) {
129
185
  throw new RegistryError(`parameter ${spec.name} must be a number`);
186
+ }
130
187
  out[spec.name] = n;
131
188
  break;
132
189
  }
133
190
  case "boolean":
134
- if (!["true", "false"].includes(text))
191
+ if (![
192
+ "true",
193
+ "false",
194
+ ].includes(text)) {
135
195
  throw new RegistryError(`parameter ${spec.name} must be true or false`);
196
+ }
136
197
  out[spec.name] = text === "true";
137
198
  break;
138
199
  case "string[]":
139
- out[spec.name] = text.split(",").map((v) => v.trim()).filter(Boolean);
200
+ out[spec.name] = text
201
+ .split(",")
202
+ .map((v) => v.trim())
203
+ .filter(Boolean);
140
204
  break;
141
205
  case "number[]": {
142
206
  const list = text.split(",").map((v) => Number(v.trim()));
143
- if (list.some((n) => !Number.isFinite(n)))
207
+ if (list.some((n) => !Number.isFinite(n))) {
144
208
  throw new RegistryError(`parameter ${spec.name} must be numbers separated by commas`);
209
+ }
145
210
  out[spec.name] = list;
146
211
  break;
147
212
  }
@@ -149,30 +214,72 @@ export function coerceParams(specs, raw) {
149
214
  }
150
215
  return out;
151
216
  }
217
+ /** An absent error list is not evidence that the intended state exists. */
218
+ function verifyEvidence(readback) {
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) {
227
+ return readback.problems;
228
+ }
229
+ const evidence = readback.verification;
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
+ ];
244
+ }
152
245
  export class SnippetRegistry {
153
246
  entries = new Map();
154
247
  register(def, source = "project") {
155
- const entry = "build" in def || "operation" in def ? def : normalize(def, source);
156
- if (this.entries.has(entry.name))
248
+ const entry = "build" in def || "operation" in def
249
+ ? def
250
+ : normalize(def, source);
251
+ if (this.entries.has(entry.name)) {
157
252
  throw new RegistryError(`snippet ${entry.name} is already registered (${this.entries.get(entry.name).source})`);
253
+ }
158
254
  this.entries.set(entry.name, entry);
159
255
  return entry;
160
256
  }
161
257
  get(name) {
162
258
  const entry = this.entries.get(name);
163
- if (!entry)
164
- throw new RegistryError(`no snippet named ${name}. Known: ${[...this.entries.keys()].sort().join(", ")}`);
259
+ if (!entry) {
260
+ throw new RegistryError(`no snippet named ${name}. Known: ${[
261
+ ...this.entries.keys(),
262
+ ]
263
+ .sort()
264
+ .join(", ")}`);
265
+ }
165
266
  return entry;
166
267
  }
167
268
  list() {
168
- return [...this.entries.values()].sort((a, b) => a.name.localeCompare(b.name));
269
+ return [
270
+ ...this.entries.values(),
271
+ ].sort((a, b) => a.name.localeCompare(b.name));
169
272
  }
170
273
  /** Runs a snippet by name. A write snippet only looks unless `apply` is set. */
171
274
  async run(client, name, params, options = {}) {
172
275
  const entry = this.get(name);
173
- if (entry.kind === "write")
276
+ if (entry.kind === "write") {
174
277
  return runOperation(client, entry.operation, params, options);
175
- const record = await runOnPage(client, entry.build(params), entry.page?.(params), options);
278
+ }
279
+ const record = await runOnPage(client, entry.build(params), entry.page?.(params), {
280
+ ...options,
281
+ effect: "read",
282
+ });
176
283
  return record;
177
284
  }
178
285
  }
@@ -182,7 +289,11 @@ const readBuiltIn = (name, description, params, build) => ({
182
289
  description,
183
290
  params,
184
291
  source: "built-in",
185
- ...(params.some((p) => p.name === "page") ? { page: (p) => p.page } : {}),
292
+ ...(params.some((p) => p.name === "page")
293
+ ? {
294
+ page: (p) => p.page,
295
+ }
296
+ : {}),
186
297
  build,
187
298
  });
188
299
  /** The registry with everything penpair ships. Project snippets are added to it. */
@@ -190,28 +301,88 @@ export function builtInRegistry() {
190
301
  const registry = new SnippetRegistry();
191
302
  registry.register(readBuiltIn("pages", "The file, its revision, its pages and how many top-level shapes each has.", [], () => pages()), "built-in");
192
303
  registry.register(readBuiltIn("tree", "One line per shape: type, name, size, position, layout and sizing, and the text it carries.", [
193
- { name: "page", type: "string", required: true, description: "Page to read" },
194
- { name: "depth", type: "number", default: 3, description: "How many levels down" },
195
- { name: "namePrefix", type: "string", description: "Only top-level shapes whose name starts with this" },
196
- { name: "limit", type: "number", default: 400, description: "Stop after this many lines" },
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
+ },
197
327
  ], (p) => tree(p)), "built-in");
198
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.", [
199
- { name: "page", type: "string", required: true },
200
- { name: "framePrefix", type: "string", required: true, description: "Audit top-level frames whose names start with this" },
201
- { name: "spacingSteps", type: "number[]", description: "Allowed gap and padding values; leave out to skip that check" },
202
- { name: "ignoreUnbound", type: "string[]", description: "Names (starts) not reported as unbound" },
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
+ },
203
350
  ], (p) => audit(p)), "built-in");
204
- registry.register(readBuiltIn("tokens-dump", "Token sets in order and themes, as JSON.", [], () => tokensDump()), "built-in");
351
+ registry.register(readBuiltIn("tokens-dump", "Bounded typed token preview; tokens dump captures complete chunked evidence.", [], () => tokensDump()), "built-in");
205
352
  registry.register({
206
353
  kind: "write",
207
354
  name: "archive",
208
355
  description: "Nest the frames that are not part of the live work into one archive board. Frames keep names, ids and component links.",
209
356
  params: [
210
- { name: "page", type: "string", required: true },
211
- { name: "archiveName", type: "string", required: true, description: "Name of the archive board" },
212
- { name: "keepPrefixes", type: "string[]", default: [], description: "Boards whose names start with one of these stay live" },
213
- { name: "partPrefixes", type: "string[]", default: [], description: "Library parts, never archived" },
214
- { name: "x", type: "number", default: 9000, description: "Where the archive board is placed" },
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
+ },
215
386
  ],
216
387
  source: "built-in",
217
388
  operation: archiveOperation,
@@ -0,0 +1,26 @@
1
+ import type { PenpotToolClient } from "./client.js";
2
+ import { type Snippet } from "./snippet.js";
3
+ import { type Params, type WriteEntry } from "./registry.js";
4
+ import { type OperationOptions } from "./operation.js";
5
+ export interface FileScriptOptions {
6
+ include?: string[];
7
+ page?: string;
8
+ params?: Params;
9
+ inspect?: string;
10
+ readback?: string;
11
+ virtualIncludes?: Record<string, string>;
12
+ }
13
+ export interface PreparedFileScript {
14
+ kind: "read" | "write";
15
+ source: {
16
+ file: string;
17
+ sha256: string;
18
+ }[];
19
+ params: Params;
20
+ page?: string;
21
+ read?: Snippet;
22
+ operation?: WriteEntry["operation"];
23
+ }
24
+ /** Reads and compiles explicitly selected files without importing project modules on the host. */
25
+ export declare function prepareFileScript(root: string, file: string, options?: FileScriptOptions): Promise<PreparedFileScript>;
26
+ export declare function runFileScript(client: PenpotToolClient, prepared: PreparedFileScript, options?: OperationOptions): Promise<Record<string, unknown>>;