@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
package/dist/baselines.js CHANGED
@@ -1,24 +1,29 @@
1
- import { createHash } from "node:crypto";
2
- import { mkdir, readFile, writeFile } from "node:fs/promises";
1
+ import { matchingApproval } from "./approvals.js";
2
+ import { pngSize } from "./png.js";
3
+ export { pngSize } from "./png.js";
4
+ import { createHash, randomUUID } from "node:crypto";
5
+ import { mkdir, readFile, writeFile, rename, rm } from "node:fs/promises";
3
6
  import path from "node:path";
7
+ import { baselineCoverage } from "./baseline-coverage.js";
8
+ import { resolveProjectOutput } from "./project-input.js";
9
+ import { validateBaselines } from "./baselines-config.js";
10
+ import { version } from "./version.js";
11
+ import { atomicWriteFile } from "./atomic-output.js";
12
+ import { observationHash } from "./journal.js";
4
13
  import { exportShapePng } from "./export.js";
5
14
  import { runOnPage } from "./operation.js";
6
15
  import { frames } from "./snippets/read.js";
7
16
  export * from "./baselines-config.js";
8
17
  const sha = (buffer) => createHash("sha256").update(buffer).digest("hex");
9
- /** Pixel size from a PNG's header (the IHDR chunk), or nothing when the bytes are not a PNG. */
10
- export function pngSize(buffer) {
11
- const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
12
- if (buffer.length < 24 || !buffer.subarray(0, 8).equals(signature) || buffer.subarray(12, 16).toString("ascii") !== "IHDR")
13
- return undefined;
14
- return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
15
- }
16
18
  async function readIfExists(file) {
17
19
  try {
18
20
  return await readFile(file);
19
21
  }
20
- catch {
21
- return undefined;
22
+ catch (error) {
23
+ if (error.code === "ENOENT") {
24
+ return undefined;
25
+ }
26
+ throw error;
22
27
  }
23
28
  }
24
29
  /**
@@ -27,64 +32,331 @@ async function readIfExists(file) {
27
32
  * the design never silently becomes the new reference.
28
33
  */
29
34
  export async function exportBaselines(client, projectRoot, config, options = {}) {
30
- const listing = await runOnPage(client, frames({ page: config.page }), config.page, options);
31
- const found = listing.frames ?? [];
35
+ if (options.forceReason !== undefined &&
36
+ (!options.update ||
37
+ !options.apply ||
38
+ !options.forceReason.trim() ||
39
+ options.forceReason.length > 2000)) {
40
+ throw new Error("A nonempty force reason requires apply and update");
41
+ }
42
+ config = structuredClone(config);
43
+ validateBaselines(config, (message) => {
44
+ throw new Error(message);
45
+ });
46
+ const listing = await runOnPage(client, frames({
47
+ page: config.page,
48
+ }), config.page, options);
49
+ if (listing.ok !== true ||
50
+ typeof listing.fileId !== "string" ||
51
+ !listing.fileId ||
52
+ typeof listing.pageId !== "string" ||
53
+ !listing.pageId ||
54
+ !Number.isInteger(listing.revn) ||
55
+ !Array.isArray(listing.frames)) {
56
+ throw new Error("Baseline listing lacks file/page/revision evidence");
57
+ }
58
+ const found = listing.frames;
32
59
  const results = [];
33
60
  const wrote = [];
34
- const folder = path.join(projectRoot, config.root);
61
+ const folder = await resolveProjectOutput(projectRoot, config.root);
62
+ const manifestPath = await resolveProjectOutput(projectRoot, path.join(config.root, "penpair-baselines.json"));
63
+ const previousBytes = await readIfExists(manifestPath);
64
+ const previous = previousBytes
65
+ ? JSON.parse(previousBytes.toString("utf8"))
66
+ : undefined;
67
+ if (previous &&
68
+ ((previous.schemaVersion !== undefined && previous.schemaVersion !== 2) ||
69
+ !Array.isArray(previous.frames))) {
70
+ throw new Error("Existing baseline manifest is invalid; preserve and inspect it");
71
+ }
72
+ const runId = randomUUID();
73
+ const generation = `captures/${runId}`;
74
+ const captures = new Map();
35
75
  for (const wanted of config.frames) {
36
- const file = `${wanted.id}.png`;
76
+ options.signal?.throwIfAborted();
77
+ const old = previous?.frames?.find((entry) => entry.id === wanted.id);
78
+ const file = old?.file ?? `${wanted.id}.png`;
79
+ if (path.isAbsolute(file) || file.split(/[\\/]/).includes("..")) {
80
+ throw new Error("Existing baseline path leaves its folder");
81
+ }
37
82
  const matches = found.filter((f) => f.name === wanted.frame);
38
- if (matches.length === 0) {
39
- results.push({ id: wanted.id, frame: wanted.frame, file, status: "missing", written: false });
83
+ if (!matches.length) {
84
+ results.push({
85
+ ...(wanted.case
86
+ ? {
87
+ case: wanted.case,
88
+ }
89
+ : {}),
90
+ id: wanted.id,
91
+ frame: wanted.frame,
92
+ file,
93
+ status: "missing",
94
+ written: false,
95
+ });
40
96
  continue;
41
97
  }
42
- if (matches.length > 1)
98
+ if (matches.length > 1) {
43
99
  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
+ }
44
101
  const frame = matches[0];
45
102
  options.onStep?.(`exporting ${wanted.frame}`);
46
- const exported = await exportShapePng(client, frame.id, { ...options, page: config.page, stable: true });
47
- const base = { id: wanted.id, frame: wanted.frame, frameId: frame.id, width: frame.width, height: frame.height, file };
103
+ const exported = await exportShapePng(client, frame.id, {
104
+ ...options,
105
+ page: config.page,
106
+ stable: true,
107
+ });
108
+ const base = {
109
+ ...(wanted.case
110
+ ? {
111
+ case: wanted.case,
112
+ }
113
+ : {}),
114
+ id: wanted.id,
115
+ frame: wanted.frame,
116
+ frameId: frame.id,
117
+ width: frame.width,
118
+ height: frame.height,
119
+ file,
120
+ };
121
+ if (exported.capture.fileId !== listing.fileId ||
122
+ exported.capture.pageId !== listing.pageId ||
123
+ exported.capture.revision !== listing.revn) {
124
+ throw new Error("Baseline source changed during capture; nothing was published");
125
+ }
48
126
  if (!exported.stable) {
49
- results.push({ ...base, status: "unstable", written: false });
127
+ results.push({
128
+ ...base,
129
+ status: "unstable",
130
+ written: false,
131
+ });
132
+ continue;
133
+ }
134
+ if (!pngSize(exported.png)) {
135
+ throw new Error("Baseline export is not a PNG with dimensions");
136
+ }
137
+ const existing = await readIfExists(await resolveProjectOutput(projectRoot, path.join(config.root, file)));
138
+ if (old &&
139
+ (!existing ||
140
+ old.sha256 !== sha(existing) ||
141
+ (old.approved && old.approved.sha256 !== old.sha256))) {
142
+ throw new Error("Approved baseline bytes no longer match their manifest; inspect before replacing");
143
+ }
144
+ const captured = {
145
+ ...(wanted.case
146
+ ? {
147
+ case: wanted.case,
148
+ }
149
+ : {}),
150
+ layout: exported.layout,
151
+ ...exported.capture,
152
+ sha256: sha(exported.png),
153
+ capturedAt: new Date().toISOString(),
154
+ toolVersion: version,
155
+ file: `${generation}/${wanted.id}.png`,
156
+ };
157
+ const caseProblems = [];
158
+ if (wanted.case) {
159
+ const viewport = wanted.case.viewport;
160
+ const size = pngSize(exported.png);
161
+ if (!exported.capture.designSize ||
162
+ exported.capture.designSize.width !== viewport.width ||
163
+ exported.capture.designSize.height !== viewport.height) {
164
+ caseProblems.push("Observed frame dimensions do not match the declared viewport");
165
+ }
166
+ if (size.width !== Math.round(viewport.width * exported.capture.scale) ||
167
+ size.height !== Math.round(viewport.height * exported.capture.scale)) {
168
+ caseProblems.push("PNG dimensions do not match declared viewport and capture scale");
169
+ }
170
+ if (wanted.case.activeThemeIds &&
171
+ (!exported.capture.activeThemeIds ||
172
+ observationHash([
173
+ ...wanted.case.activeThemeIds,
174
+ ].sort()) !==
175
+ observationHash([
176
+ ...exported.capture.activeThemeIds,
177
+ ].sort()))) {
178
+ caseProblems.push("Active native theme IDs do not match the capture assertion");
179
+ }
180
+ }
181
+ if (caseProblems.length) {
182
+ results.push({
183
+ ...base,
184
+ status: "case-mismatch",
185
+ written: false,
186
+ captured,
187
+ approved: old?.approved,
188
+ sha256: old?.sha256,
189
+ problems: caseProblems,
190
+ });
50
191
  continue;
51
192
  }
52
- const target = path.join(folder, file);
53
- const existing = await readIfExists(target);
54
- const status = existing === undefined ? "new" : sha(existing) === sha(exported.png) ? "same" : "changed";
55
- let written = false;
56
- if (options.apply && (status === "new" || (status === "changed" && options.update))) {
57
- await mkdir(folder, { recursive: true });
58
- await writeFile(target, exported.png);
59
- written = true;
60
- wrote.push(path.join(config.root, file));
61
- }
62
- // A new image that was not written (a look, without --apply) is described by the export; a changed one that was not updated by what is on disk.
63
- const onDisk = written || status === "same" || status === "new" ? exported.png : existing;
64
- const size = pngSize(onDisk);
65
- results.push({ ...base, status, written, sha256: sha(onDisk), bytes: onDisk.length, imageWidth: size?.width, imageHeight: size?.height });
193
+ const oldCase = old?.approved && "case" in old.approved ? old.approved.case : undefined;
194
+ const status = !existing
195
+ ? "new"
196
+ : sha(existing) === captured.sha256 &&
197
+ observationHash(oldCase ?? null) === observationHash(wanted.case ?? null) &&
198
+ (!old?.approved ||
199
+ "legacy" in old.approved ||
200
+ (old.approved.fileId === captured.fileId &&
201
+ old.approved.pageId === captured.pageId &&
202
+ old.approved.frameId === captured.frameId))
203
+ ? "same"
204
+ : "changed";
205
+ const promoteWaiver = status === "same" &&
206
+ old?.approved &&
207
+ "approval" in old.approved &&
208
+ old.approved.approval?.kind === "forced-reference" &&
209
+ options.update === true;
210
+ if ((status === "changed" || promoteWaiver) && options.update && options.apply) {
211
+ const record = await matchingApproval(projectRoot, exported.capture, captured.sha256, wanted.case);
212
+ if (record) {
213
+ captured.approval = {
214
+ kind: "owner-words",
215
+ id: record.id,
216
+ recordHash: record.hash,
217
+ };
218
+ }
219
+ else if (options.forceReason) {
220
+ captured.approval = {
221
+ kind: "forced-reference",
222
+ reason: options.forceReason,
223
+ };
224
+ }
225
+ else {
226
+ throw new Error("Changed baseline requires matching exact-artifact owner words (penpair approve), or an explicit --force-reason waiver; previous artifacts preserved");
227
+ }
228
+ }
229
+ const approve = status === "new" ||
230
+ (status === "changed" && options.update === true) ||
231
+ Boolean(promoteWaiver);
232
+ const approved = approve
233
+ ? captured
234
+ : (old?.approved ?? {
235
+ legacy: true,
236
+ sha256: sha(existing),
237
+ });
238
+ const image = approve ? exported.png : existing;
239
+ const size = pngSize(image);
240
+ results.push({
241
+ ...base,
242
+ file: approve && options.apply ? captured.file : file,
243
+ status,
244
+ written: false,
245
+ captured,
246
+ approved: options.apply || !approve ? approved : undefined,
247
+ sha256: sha(image),
248
+ bytes: image.length,
249
+ imageWidth: size?.width,
250
+ imageHeight: size?.height,
251
+ });
252
+ captures.set(wanted.id, exported.png);
253
+ }
254
+ const after = await runOnPage(client, frames({
255
+ page: config.page,
256
+ }), config.page, options);
257
+ const source = (value) => ({
258
+ fileId: value.fileId,
259
+ pageId: value.pageId,
260
+ revision: value.revn,
261
+ frames: value.frames,
262
+ });
263
+ if (observationHash(source(listing)) !== observationHash(source(after))) {
264
+ throw new Error("Baseline source changed before publication; previous artifacts were preserved");
66
265
  }
67
- if (options.apply) {
68
- const onDisk = results.filter((r) => r.status !== "missing" && r.status !== "unstable" && (r.written || r.status === "same" || r.status === "changed"));
69
- if (onDisk.length > 0) {
70
- await mkdir(folder, { recursive: true });
71
- const manifest = path.join(folder, "penpair-baselines.json");
72
- await writeFile(manifest, `${JSON.stringify(manifestOf(config, listing, onDisk), null, 2)}\n`);
73
- wrote.push(path.join(config.root, "penpair-baselines.json"));
74
- const cases = path.join(folder, "reflection-cases.json");
75
- await writeFile(cases, `${JSON.stringify(reflectionCases(config, onDisk), null, 2)}\n`);
76
- wrote.push(path.join(config.root, "reflection-cases.json"));
266
+ const complete = results.length === config.frames.length &&
267
+ results.every((result) => result.status !== "missing" &&
268
+ result.status !== "unstable" &&
269
+ result.status !== "case-mismatch");
270
+ if (options.apply && complete) {
271
+ const evidence = JSON.stringify({
272
+ config,
273
+ results,
274
+ forceReason: options.forceReason,
275
+ });
276
+ if (options.redact && options.redact(evidence) !== evidence) {
277
+ throw new Error("Baseline evidence requires credential redaction; previous artifacts preserved");
278
+ }
279
+ options.signal?.throwIfAborted();
280
+ options.onStep?.("publishing complete capture");
281
+ options.signal?.throwIfAborted();
282
+ const captureFolder = await resolveProjectOutput(projectRoot, path.join(config.root, "captures"));
283
+ await mkdir(captureFolder, {
284
+ recursive: true,
285
+ });
286
+ const staged = path.join(folder, `.stage-${runId}`);
287
+ await mkdir(staged);
288
+ try {
289
+ for (const [id, bytes] of captures) {
290
+ options.signal?.throwIfAborted();
291
+ await atomicWriteFile(path.join(staged, `${id}.png`), bytes);
292
+ options.onStep?.(`staging ${id}`);
293
+ }
294
+ const cases = reflectionCases(config, results);
295
+ await writeFile(path.join(staged, "reflection-cases.json"), `${JSON.stringify({
296
+ ...cases,
297
+ captureId: runId,
298
+ }, null, 2)}\n`, {
299
+ flag: "wx",
300
+ });
301
+ options.signal?.throwIfAborted();
302
+ await rename(staged, path.join(captureFolder, runId));
303
+ // Immutable generation first. The canonical manifest is the commit point and is published last.
304
+ await atomicWriteFile(path.join(folder, "reflection-cases.json"), `${JSON.stringify({
305
+ ...cases,
306
+ captureId: runId,
307
+ }, null, 2)}\n`, {
308
+ replace: true,
309
+ });
310
+ options.signal?.throwIfAborted();
311
+ for (const result of results) {
312
+ result.written =
313
+ result.file === result.captured?.file &&
314
+ (result.status === "new" || options.update === true);
315
+ }
316
+ const manifest = {
317
+ schemaVersion: 2,
318
+ complete: true,
319
+ captureId: runId,
320
+ penpotFile: listing.file,
321
+ fileId: listing.fileId,
322
+ page: config.page,
323
+ pageId: listing.pageId,
324
+ coverage: baselineCoverage(config, results),
325
+ capturedRevision: listing.revn,
326
+ casesFile: `${generation}/reflection-cases.json`,
327
+ note: "Capture provenance and approved provenance are separate. Changed references require exact-artifact owner words or a recorded forced-reference waiver.",
328
+ frames: results,
329
+ };
330
+ await atomicWriteFile(path.join(folder, "penpair-baselines.json"), `${JSON.stringify(manifest, null, 2)}\n`, {
331
+ replace: true,
332
+ });
333
+ for (const result of results) {
334
+ result.written =
335
+ result.file === result.captured?.file &&
336
+ (result.status === "new" || options.update === true);
337
+ if (result.written) {
338
+ wrote.push(path.join(config.root, result.file));
339
+ }
340
+ }
341
+ wrote.push(path.join(config.root, "reflection-cases.json"), path.join(config.root, "penpair-baselines.json"));
342
+ }
343
+ finally {
344
+ await rm(staged, {
345
+ recursive: true,
346
+ force: true,
347
+ });
77
348
  }
78
349
  }
79
- return { page: config.page, penpotFile: String(listing.file ?? ""), revn: Number(listing.revn ?? 0), results, wrote };
80
- }
81
- function manifestOf(config, listing, results) {
82
350
  return {
83
- note: "Written by `penpair baselines`. The images are the approved design for each frame; change one only on purpose (`penpair baselines --apply --update`).",
84
- penpotFile: listing.file,
351
+ coverage: baselineCoverage(config, results),
352
+ complete,
353
+ fileId: listing.fileId,
354
+ pageId: listing.pageId,
85
355
  page: config.page,
86
- revn: listing.revn,
87
- frames: results.map((r) => ({ id: r.id, frame: r.frame, frameId: r.frameId, width: r.imageWidth, height: r.imageHeight, file: r.file, sha256: r.sha256, bytes: r.bytes })),
356
+ penpotFile: String(listing.file),
357
+ revn: Number(listing.revn),
358
+ results,
359
+ wrote,
88
360
  };
89
361
  }
90
362
  /**
@@ -93,23 +365,53 @@ function manifestOf(config, listing, results) {
93
365
  * Review-only (`strict: false`): a difference is shown to a person, not failed.
94
366
  */
95
367
  export function reflectionCases(config, results) {
96
- const routes = {};
368
+ const routes = Object.create(null);
97
369
  const visualSmoke = [];
370
+ const pendingCases = [];
98
371
  for (const wanted of config.frames) {
99
372
  const result = results.find((r) => r.id === wanted.id);
100
- if (!wanted.route || !result || result.imageWidth === undefined || result.imageHeight === undefined)
373
+ if (!wanted.route ||
374
+ !result ||
375
+ result.imageWidth === undefined ||
376
+ result.imageHeight === undefined) {
377
+ continue;
378
+ }
379
+ if (wanted.case) {
380
+ const approvedCase = result.approved && "case" in result.approved ? result.approved.case : undefined;
381
+ pendingCases.push({
382
+ id: wanted.id,
383
+ route: wanted.route,
384
+ case: approvedCase ?? wanted.case,
385
+ baseline: result.file,
386
+ ref: `penpair:${wanted.id}`,
387
+ approvedProvenance: result.approved ?? null,
388
+ captured: result.captured ?? null,
389
+ approvedMetadata: Boolean(approvedCase && observationHash(approvedCase) === observationHash(wanted.case)),
390
+ reason: "Explicit theme/state/fixture/font setup requires an implementation provider mapping",
391
+ browserVerification: "not-run",
392
+ });
101
393
  continue;
102
- const viewport = { name: `design-${result.imageWidth}x${result.imageHeight}`, width: result.imageWidth, height: result.imageHeight };
103
- const entry = (routes[wanted.route] ??= { viewports: [] });
104
- if (!entry.viewports.some((v) => v.name === viewport.name))
394
+ }
395
+ const viewport = {
396
+ name: `design-${result.imageWidth}x${result.imageHeight}`,
397
+ width: result.imageWidth,
398
+ height: result.imageHeight,
399
+ };
400
+ const entry = (routes[wanted.route] ??= {
401
+ viewports: [],
402
+ });
403
+ if (!entry.viewports.some((v) => v.name === viewport.name)) {
105
404
  entry.viewports.push(viewport);
405
+ }
106
406
  visualSmoke.push({
107
407
  id: wanted.id,
108
408
  route: wanted.route,
109
409
  viewport: viewport.name,
110
410
  baselineRoot: config.root,
111
- baseline: `${wanted.id}.png`,
112
- threshold: { maxDiffPixelRatio: config.threshold ?? 0.05 },
411
+ baseline: result.file,
412
+ threshold: {
413
+ maxDiffPixelRatio: config.threshold ?? 0.05,
414
+ },
113
415
  strict: false,
114
416
  ref: `penpair:${wanted.id}`,
115
417
  });
@@ -118,5 +420,6 @@ export function reflectionCases(config, results) {
118
420
  note: "Generated by `penpair baselines`. Add each route's viewports to that route in your Reflection config and spread `visualSmoke` into the browser contract; baselineRoot is relative to the Reflection config's folder.",
119
421
  routes,
120
422
  visualSmoke,
423
+ pendingCases,
121
424
  };
122
425
  }
@@ -0,0 +1,7 @@
1
+ import { parseArgs, type ParseArgsConfig } from "node:util";
2
+ export declare class CommandArgumentError extends Error {
3
+ readonly code = "invalid-arguments";
4
+ readonly remedy = "Use the command's supported flags and required values; see penpair --help.";
5
+ }
6
+ /** Suggestions are explanatory only; never reinterpret a supplied option. */
7
+ export declare function parseCommandArgs<T extends ParseArgsConfig>(config: T): ReturnType<typeof parseArgs<T>>;
@@ -0,0 +1,47 @@
1
+ import { parseArgs } from "node:util";
2
+ export class CommandArgumentError extends Error {
3
+ code = "invalid-arguments";
4
+ remedy = "Use the command's supported flags and required values; see penpair --help.";
5
+ }
6
+ function distance(a, b) {
7
+ let previous = Array.from({
8
+ length: b.length + 1,
9
+ }, (_, index) => index);
10
+ for (let i = 1; i <= a.length; i++) {
11
+ const next = [
12
+ i,
13
+ ];
14
+ for (let j = 1; j <= b.length; j++) {
15
+ next[j] = Math.min(next[j - 1] + 1, previous[j] + 1, previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
16
+ }
17
+ previous = next;
18
+ }
19
+ return previous[b.length];
20
+ }
21
+ /** Suggestions are explanatory only; never reinterpret a supplied option. */
22
+ export function parseCommandArgs(config) {
23
+ try {
24
+ return parseArgs(config);
25
+ }
26
+ catch (error) {
27
+ const value = error;
28
+ if (!value.code?.startsWith("ERR_PARSE_ARGS")) {
29
+ throw error;
30
+ }
31
+ const unknown = /Unknown option ['"]--([^'"]+)['"]/.exec(value.message ?? "")?.[1];
32
+ let hint = "";
33
+ if (unknown && unknown.length <= 100) {
34
+ const candidates = Object.keys(config.options ?? {})
35
+ .map((name) => ({
36
+ name,
37
+ distance: distance(unknown.toLowerCase(), name.toLowerCase()),
38
+ }))
39
+ .sort((a, b) => a.distance - b.distance || a.name.localeCompare(b.name));
40
+ const best = candidates[0];
41
+ if (best && best.distance <= Math.max(2, Math.ceil(unknown.length / 2))) {
42
+ hint = ` Did you mean --${best.name}?`;
43
+ }
44
+ }
45
+ throw new CommandArgumentError((value.message ?? "Invalid command arguments") + hint);
46
+ }
47
+ }
@@ -1,10 +1,22 @@
1
+ import { type CommandResult } from "./command-result.js";
2
+ import { type ProjectConfig } from "./instance/config.js";
1
3
  import { type Adapter } from "./adapters.js";
2
4
  import { type LocalProfile } from "./local-profile.js";
3
5
  export interface CliOptions {
6
+ /** Explicit request-scoped project root; never changes the process working directory. */
7
+ root?: string;
8
+ /** Refuse unrelated global client endpoint fallback (used by the stdio server). */
9
+ projectEndpointOnly?: boolean;
10
+ /** Immutable server target: every loaded project configuration must still match. */
11
+ pinnedConfig?: ProjectConfig;
12
+ strictFileGuard?: boolean;
4
13
  /** Adapters already loaded (the command loads the project's own from penpair.config.json). */
5
14
  adapters?: Adapter[];
15
+ configuredAdapters?: string[];
6
16
  /** A way to run Penpot on this computer, from a package that provides one (`@penpair/docker`). Without it those commands explain where they come from. */
7
17
  local?: LocalProfile;
8
18
  }
9
- /** Runs the command line and returns the exit code. The `penpair` command calls it with nothing extra; a package that adds a way to run Penpot calls it with its profile. */
19
+ export declare function runCommand(argv: string[], options?: CliOptions & {
20
+ signal?: AbortSignal;
21
+ }): Promise<CommandResult>;
10
22
  export declare function runCli(argv: string[], options?: CliOptions): Promise<number>;