@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.
Files changed (125) hide show
  1. package/dist/adapters.js +58 -11
  2. package/dist/approvals.js +143 -29
  3. package/dist/atomic-output.js +9 -4
  4. package/dist/audit-rules.js +345 -79
  5. package/dist/backup-note.js +29 -8
  6. package/dist/baseline-coverage.js +169 -35
  7. package/dist/baselines-config.js +119 -29
  8. package/dist/baselines.js +280 -57
  9. package/dist/cli-arguments.js +18 -6
  10. package/dist/cli-main.js +1645 -392
  11. package/dist/client.d.ts +5 -2
  12. package/dist/client.js +127 -28
  13. package/dist/command-result.js +341 -52
  14. package/dist/coordination.js +91 -29
  15. package/dist/credentials.js +57 -13
  16. package/dist/deep-check.js +56 -12
  17. package/dist/design-spec.js +189 -49
  18. package/dist/endpoint.js +102 -34
  19. package/dist/export.js +86 -18
  20. package/dist/file-archive.js +33 -7
  21. package/dist/file-guard.js +29 -11
  22. package/dist/formats.js +215 -14
  23. package/dist/index.d.ts +8 -7
  24. package/dist/index.js +8 -7
  25. package/dist/inspection.d.ts +1 -1
  26. package/dist/inspection.js +378 -65
  27. package/dist/instance/config-schema.js +400 -41
  28. package/dist/instance/config.js +101 -30
  29. package/dist/instance/connect.js +151 -44
  30. package/dist/instance/connection.d.ts +26 -5
  31. package/dist/instance/connection.js +194 -21
  32. package/dist/instance/gitignore.js +5 -2
  33. package/dist/instance/init.js +32 -7
  34. package/dist/instance/runner.js +32 -10
  35. package/dist/instance/setup.js +46 -13
  36. package/dist/instance/tty.js +26 -8
  37. package/dist/journal.d.ts +10 -0
  38. package/dist/journal.js +212 -49
  39. package/dist/kit-smoke.js +82 -20
  40. package/dist/kit-source.js +10 -40
  41. package/dist/kit.js +102 -34
  42. package/dist/layout-policy.d.ts +2 -0
  43. package/dist/layout-policy.js +86 -0
  44. package/dist/layout.d.ts +7 -1
  45. package/dist/layout.js +27 -70
  46. package/dist/live-smoke.js +13 -4
  47. package/dist/local-profile.js +2 -1
  48. package/dist/mcp-server.js +360 -47
  49. package/dist/native-api.js +47 -11
  50. package/dist/onboard.js +33 -7
  51. package/dist/operation.d.ts +2 -2
  52. package/dist/operation.js +279 -69
  53. package/dist/page-session.d.ts +23 -0
  54. package/dist/page-session.js +142 -0
  55. package/dist/png.js +18 -3
  56. package/dist/portable-tokens.js +254 -57
  57. package/dist/probe.d.ts +1 -1
  58. package/dist/probe.js +48 -11
  59. package/dist/project-input.js +24 -10
  60. package/dist/project-snippets.js +18 -6
  61. package/dist/project-status.js +133 -24
  62. package/dist/registry.js +217 -60
  63. package/dist/run-file.js +129 -30
  64. package/dist/shots.js +108 -26
  65. package/dist/skill-check-entry.d.ts +17 -0
  66. package/dist/skill-check-entry.js +89 -0
  67. package/dist/skill-check-instructions.d.ts +7 -0
  68. package/dist/skill-check-instructions.js +39 -0
  69. package/dist/skill-check.d.ts +15 -0
  70. package/dist/skill-check.js +36 -0
  71. package/dist/skill-file.d.ts +2 -0
  72. package/dist/skill-file.js +31 -0
  73. package/dist/skill-instructions.d.ts +4 -0
  74. package/dist/skill-instructions.js +97 -22
  75. package/dist/skill-metadata.d.ts +8 -0
  76. package/dist/skill-metadata.js +52 -0
  77. package/dist/skill-routing.d.ts +2 -0
  78. package/dist/skill-routing.js +36 -0
  79. package/dist/skills.js +131 -41
  80. package/dist/snippet.js +27 -7
  81. package/dist/snippets/read.js +8 -2
  82. package/dist/structure-snapshot.d.ts +12 -0
  83. package/dist/structure-snapshot.js +439 -85
  84. package/dist/token-edit.js +219 -64
  85. package/dist/token-projection.d.ts +56 -0
  86. package/dist/token-projection.js +286 -0
  87. package/dist/tokens-diff.js +125 -32
  88. package/dist/version.d.ts +1 -1
  89. package/dist/version.js +1 -1
  90. package/package.json +3 -2
  91. package/skills/design-judgment/SKILL.md +1 -1
  92. package/skills/design-judgment/agents/openai.yaml +6 -0
  93. package/skills/penpair-design/SKILL.md +14 -1
  94. package/skills/penpair-design/agents/openai.yaml +6 -0
  95. package/skills/penpair-design/references/penpot-plugin-gotchas.md +2 -2
  96. package/skills/penpair-design/references/project-facts.md +2 -0
  97. package/skills/penpair-handoff/SKILL.md +14 -1
  98. package/skills/penpair-handoff/agents/openai.yaml +6 -0
  99. package/skills/penpair-handoff/references/comparison-companion.md +16 -0
  100. package/skills/penpair-handoff/references/handoff-flow.md +2 -0
  101. package/skills/penpair-recipes/SKILL.md +13 -1
  102. package/skills/penpair-recipes/agents/openai.yaml +6 -0
  103. package/skills/penpair-recipes/assets/visual-checkpoint.md +48 -0
  104. package/skills/penpair-recipes/references/visual-checkpoint.md +37 -0
  105. package/skills/penpair-setup/SKILL.md +9 -3
  106. package/skills/penpair-setup/agents/openai.yaml +6 -0
  107. package/skills/penpair-verify/SKILL.md +49 -0
  108. package/skills/penpair-verify/agents/openai.yaml +6 -0
  109. package/skills/penpair-verify/references/browser-evidence.md +33 -0
  110. package/dist/cli.d.ts +0 -2
  111. package/dist/cli.js +0 -3
  112. package/dist/docker/lifecycle.d.ts +0 -96
  113. package/dist/docker/lifecycle.js +0 -273
  114. package/dist/docker/secrets.d.ts +0 -11
  115. package/dist/docker/secrets.js +0 -38
  116. package/dist/docker/template.d.ts +0 -8
  117. package/dist/docker/template.js +0 -158
  118. package/dist/docker-profile.d.ts +0 -114
  119. package/dist/docker-profile.js +0 -18
  120. package/dist/instance/lifecycle.d.ts +0 -129
  121. package/dist/instance/lifecycle.js +0 -336
  122. package/dist/instance/secrets.d.ts +0 -11
  123. package/dist/instance/secrets.js +0 -38
  124. package/dist/instance/template.d.ts +0 -8
  125. package/dist/instance/template.js +0 -158
package/dist/baselines.js CHANGED
@@ -20,8 +20,9 @@ async function readIfExists(file) {
20
20
  return await readFile(file);
21
21
  }
22
22
  catch (error) {
23
- if (error.code === "ENOENT")
23
+ if (error.code === "ENOENT") {
24
24
  return undefined;
25
+ }
25
26
  throw error;
26
27
  }
27
28
  }
@@ -31,22 +32,43 @@ async function readIfExists(file) {
31
32
  * the design never silently becomes the new reference.
32
33
  */
33
34
  export async function exportBaselines(client, projectRoot, config, options = {}) {
34
- if (options.forceReason !== undefined && (!options.update || !options.apply || !options.forceReason.trim() || options.forceReason.length > 2000))
35
+ if (options.forceReason !== undefined &&
36
+ (!options.update ||
37
+ !options.apply ||
38
+ !options.forceReason.trim() ||
39
+ options.forceReason.length > 2000)) {
35
40
  throw new Error("A nonempty force reason requires apply and update");
41
+ }
36
42
  config = structuredClone(config);
37
- validateBaselines(config, message => { throw new Error(message); });
38
- const listing = await runOnPage(client, frames({ page: config.page }), config.page, options);
39
- if (listing.ok !== true || typeof listing.fileId !== "string" || !listing.fileId || typeof listing.pageId !== "string" || !listing.pageId || !Number.isInteger(listing.revn) || !Array.isArray(listing.frames))
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)) {
40
56
  throw new Error("Baseline listing lacks file/page/revision evidence");
57
+ }
41
58
  const found = listing.frames;
42
59
  const results = [];
43
60
  const wrote = [];
44
61
  const folder = await resolveProjectOutput(projectRoot, config.root);
45
62
  const manifestPath = await resolveProjectOutput(projectRoot, path.join(config.root, "penpair-baselines.json"));
46
63
  const previousBytes = await readIfExists(manifestPath);
47
- const previous = previousBytes ? JSON.parse(previousBytes.toString("utf8")) : undefined;
48
- if (previous && (previous.schemaVersion !== undefined && previous.schemaVersion !== 2 || !Array.isArray(previous.frames)))
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))) {
49
70
  throw new Error("Existing baseline manifest is invalid; preserve and inspect it");
71
+ }
50
72
  const runId = randomUUID();
51
73
  const generation = `captures/${runId}`;
52
74
  const captures = new Map();
@@ -54,79 +76,213 @@ export async function exportBaselines(client, projectRoot, config, options = {})
54
76
  options.signal?.throwIfAborted();
55
77
  const old = previous?.frames?.find((entry) => entry.id === wanted.id);
56
78
  const file = old?.file ?? `${wanted.id}.png`;
57
- if (path.isAbsolute(file) || file.split(/[\\/]/).includes(".."))
79
+ if (path.isAbsolute(file) || file.split(/[\\/]/).includes("..")) {
58
80
  throw new Error("Existing baseline path leaves its folder");
81
+ }
59
82
  const matches = found.filter((f) => f.name === wanted.frame);
60
83
  if (!matches.length) {
61
- results.push({ ...(wanted.case ? { case: wanted.case } : {}), id: wanted.id, frame: wanted.frame, file, status: "missing", written: false });
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
+ });
62
96
  continue;
63
97
  }
64
- if (matches.length > 1)
98
+ if (matches.length > 1) {
65
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
+ }
66
101
  const frame = matches[0];
67
102
  options.onStep?.(`exporting ${wanted.frame}`);
68
- const exported = await exportShapePng(client, frame.id, { ...options, page: config.page, stable: true });
69
- const base = { ...(wanted.case ? { case: wanted.case } : {}), id: wanted.id, frame: wanted.frame, frameId: frame.id, width: frame.width, height: frame.height, file };
70
- if (exported.capture.fileId !== listing.fileId || exported.capture.pageId !== listing.pageId || exported.capture.revision !== listing.revn)
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) {
71
124
  throw new Error("Baseline source changed during capture; nothing was published");
125
+ }
72
126
  if (!exported.stable) {
73
- results.push({ ...base, status: "unstable", written: false });
127
+ results.push({
128
+ ...base,
129
+ status: "unstable",
130
+ written: false,
131
+ });
74
132
  continue;
75
133
  }
76
- if (!pngSize(exported.png))
134
+ if (!pngSize(exported.png)) {
77
135
  throw new Error("Baseline export is not a PNG with dimensions");
136
+ }
78
137
  const existing = await readIfExists(await resolveProjectOutput(projectRoot, path.join(config.root, file)));
79
- if (old && (!existing || old.sha256 !== sha(existing) || old.approved && old.approved.sha256 !== old.sha256))
138
+ if (old &&
139
+ (!existing ||
140
+ old.sha256 !== sha(existing) ||
141
+ (old.approved && old.approved.sha256 !== old.sha256))) {
80
142
  throw new Error("Approved baseline bytes no longer match their manifest; inspect before replacing");
81
- const captured = { ...(wanted.case ? { case: wanted.case } : {}), layout: exported.layout, ...exported.capture, sha256: sha(exported.png), capturedAt: new Date().toISOString(), toolVersion: version, file: `${generation}/${wanted.id}.png` };
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
+ };
82
157
  const caseProblems = [];
83
158
  if (wanted.case) {
84
- const viewport = wanted.case.viewport, size = pngSize(exported.png);
85
- if (!exported.capture.designSize || exported.capture.designSize.width !== viewport.width || exported.capture.designSize.height !== viewport.height)
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) {
86
164
  caseProblems.push("Observed frame dimensions do not match the declared viewport");
87
- if (size.width !== Math.round(viewport.width * exported.capture.scale) || size.height !== Math.round(viewport.height * exported.capture.scale))
165
+ }
166
+ if (size.width !== Math.round(viewport.width * exported.capture.scale) ||
167
+ size.height !== Math.round(viewport.height * exported.capture.scale)) {
88
168
  caseProblems.push("PNG dimensions do not match declared viewport and capture scale");
89
- if (wanted.case.activeThemeIds && (!exported.capture.activeThemeIds || observationHash([...wanted.case.activeThemeIds].sort()) !== observationHash([...exported.capture.activeThemeIds].sort())))
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()))) {
90
178
  caseProblems.push("Active native theme IDs do not match the capture assertion");
179
+ }
91
180
  }
92
181
  if (caseProblems.length) {
93
- results.push({ ...base, status: "case-mismatch", written: false, captured, approved: old?.approved, sha256: old?.sha256, problems: caseProblems });
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
+ });
94
191
  continue;
95
192
  }
96
193
  const oldCase = old?.approved && "case" in old.approved ? old.approved.case : undefined;
97
- const status = !existing ? "new" : sha(existing) === captured.sha256 && observationHash(oldCase ?? null) === observationHash(wanted.case ?? null) && (!old?.approved || "legacy" in old.approved || old.approved.fileId === captured.fileId && old.approved.pageId === captured.pageId && old.approved.frameId === captured.frameId) ? "same" : "changed";
98
- const promoteWaiver = status === "same" && old?.approved && "approval" in old.approved && old.approved.approval?.kind === "forced-reference" && options.update === true;
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;
99
210
  if ((status === "changed" || promoteWaiver) && options.update && options.apply) {
100
211
  const record = await matchingApproval(projectRoot, exported.capture, captured.sha256, wanted.case);
101
- if (record)
102
- captured.approval = { kind: "owner-words", id: record.id, recordHash: record.hash };
103
- else if (options.forceReason)
104
- captured.approval = { kind: "forced-reference", reason: options.forceReason };
105
- else
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 {
106
226
  throw new Error("Changed baseline requires matching exact-artifact owner words (penpair approve), or an explicit --force-reason waiver; previous artifacts preserved");
227
+ }
107
228
  }
108
- const approve = status === "new" || status === "changed" && options.update === true || Boolean(promoteWaiver);
109
- const approved = approve ? captured : old?.approved ?? { legacy: true, sha256: sha(existing) };
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
+ });
110
238
  const image = approve ? exported.png : existing;
111
239
  const size = pngSize(image);
112
- results.push({ ...base, file: approve && options.apply ? captured.file : file, status, written: false, captured,
113
- approved: options.apply || !approve ? approved : undefined, sha256: sha(image), bytes: image.length, imageWidth: size?.width, imageHeight: size?.height });
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
+ });
114
252
  captures.set(wanted.id, exported.png);
115
253
  }
116
- const after = await runOnPage(client, frames({ page: config.page }), config.page, options);
117
- const source = (value) => ({ fileId: value.fileId, pageId: value.pageId, revision: value.revn, frames: value.frames });
118
- if (observationHash(source(listing)) !== observationHash(source(after)))
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))) {
119
264
  throw new Error("Baseline source changed before publication; previous artifacts were preserved");
120
- const complete = results.length === config.frames.length && results.every((result) => result.status !== "missing" && result.status !== "unstable" && result.status !== "case-mismatch");
265
+ }
266
+ const complete = results.length === config.frames.length &&
267
+ results.every((result) => result.status !== "missing" &&
268
+ result.status !== "unstable" &&
269
+ result.status !== "case-mismatch");
121
270
  if (options.apply && complete) {
122
- const evidence = JSON.stringify({ config, results, forceReason: options.forceReason });
123
- if (options.redact && options.redact(evidence) !== evidence)
271
+ const evidence = JSON.stringify({
272
+ config,
273
+ results,
274
+ forceReason: options.forceReason,
275
+ });
276
+ if (options.redact && options.redact(evidence) !== evidence) {
124
277
  throw new Error("Baseline evidence requires credential redaction; previous artifacts preserved");
278
+ }
125
279
  options.signal?.throwIfAborted();
126
280
  options.onStep?.("publishing complete capture");
127
281
  options.signal?.throwIfAborted();
128
282
  const captureFolder = await resolveProjectOutput(projectRoot, path.join(config.root, "captures"));
129
- await mkdir(captureFolder, { recursive: true });
283
+ await mkdir(captureFolder, {
284
+ recursive: true,
285
+ });
130
286
  const staged = path.join(folder, `.stage-${runId}`);
131
287
  await mkdir(staged);
132
288
  try {
@@ -136,29 +292,72 @@ export async function exportBaselines(client, projectRoot, config, options = {})
136
292
  options.onStep?.(`staging ${id}`);
137
293
  }
138
294
  const cases = reflectionCases(config, results);
139
- await writeFile(path.join(staged, "reflection-cases.json"), `${JSON.stringify({ ...cases, captureId: runId }, null, 2)}\n`, { flag: "wx" });
295
+ await writeFile(path.join(staged, "reflection-cases.json"), `${JSON.stringify({
296
+ ...cases,
297
+ captureId: runId,
298
+ }, null, 2)}\n`, {
299
+ flag: "wx",
300
+ });
140
301
  options.signal?.throwIfAborted();
141
302
  await rename(staged, path.join(captureFolder, runId));
142
303
  // Immutable generation first. The canonical manifest is the commit point and is published last.
143
- await atomicWriteFile(path.join(folder, "reflection-cases.json"), `${JSON.stringify({ ...cases, captureId: runId }, null, 2)}\n`, { replace: true });
304
+ await atomicWriteFile(path.join(folder, "reflection-cases.json"), `${JSON.stringify({
305
+ ...cases,
306
+ captureId: runId,
307
+ }, null, 2)}\n`, {
308
+ replace: true,
309
+ });
144
310
  options.signal?.throwIfAborted();
145
- for (const result of results)
146
- result.written = result.file === result.captured?.file && (result.status === "new" || options.update === true);
147
- const manifest = { schemaVersion: 2, complete: true, captureId: runId, penpotFile: listing.file, fileId: listing.fileId, page: config.page, pageId: listing.pageId,
148
- coverage: baselineCoverage(config, results), capturedRevision: listing.revn, casesFile: `${generation}/reflection-cases.json`, note: "Capture provenance and approved provenance are separate. Changed references require exact-artifact owner words or a recorded forced-reference waiver.", frames: results };
149
- await atomicWriteFile(path.join(folder, "penpair-baselines.json"), `${JSON.stringify(manifest, null, 2)}\n`, { replace: true });
150
311
  for (const result of results) {
151
- result.written = result.file === result.captured?.file && (result.status === "new" || options.update === true);
152
- if (result.written)
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) {
153
338
  wrote.push(path.join(config.root, result.file));
339
+ }
154
340
  }
155
341
  wrote.push(path.join(config.root, "reflection-cases.json"), path.join(config.root, "penpair-baselines.json"));
156
342
  }
157
343
  finally {
158
- await rm(staged, { recursive: true, force: true });
344
+ await rm(staged, {
345
+ recursive: true,
346
+ force: true,
347
+ });
159
348
  }
160
349
  }
161
- return { coverage: baselineCoverage(config, results), complete, fileId: listing.fileId, pageId: listing.pageId, page: config.page, penpotFile: String(listing.file), revn: Number(listing.revn), results, wrote };
350
+ return {
351
+ coverage: baselineCoverage(config, results),
352
+ complete,
353
+ fileId: listing.fileId,
354
+ pageId: listing.pageId,
355
+ page: config.page,
356
+ penpotFile: String(listing.file),
357
+ revn: Number(listing.revn),
358
+ results,
359
+ wrote,
360
+ };
162
361
  }
163
362
  /**
164
363
  * Visual cases for Reflection, one per frame that names a route: the image's exact size is the viewport (so the capture and the
@@ -171,24 +370,48 @@ export function reflectionCases(config, results) {
171
370
  const pendingCases = [];
172
371
  for (const wanted of config.frames) {
173
372
  const result = results.find((r) => r.id === wanted.id);
174
- if (!wanted.route || !result || result.imageWidth === undefined || result.imageHeight === undefined)
373
+ if (!wanted.route ||
374
+ !result ||
375
+ result.imageWidth === undefined ||
376
+ result.imageHeight === undefined) {
175
377
  continue;
378
+ }
176
379
  if (wanted.case) {
177
380
  const approvedCase = result.approved && "case" in result.approved ? result.approved.case : undefined;
178
- pendingCases.push({ id: wanted.id, route: wanted.route, case: approvedCase ?? wanted.case, baseline: result.file, ref: `penpair:${wanted.id}`, approvedProvenance: result.approved ?? null, captured: result.captured ?? null, approvedMetadata: Boolean(approvedCase && observationHash(approvedCase) === observationHash(wanted.case)), reason: "Explicit theme/state/fixture/font setup requires an implementation provider mapping", browserVerification: "not-run" });
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
+ });
179
393
  continue;
180
394
  }
181
- const viewport = { name: `design-${result.imageWidth}x${result.imageHeight}`, width: result.imageWidth, height: result.imageHeight };
182
- const entry = (routes[wanted.route] ??= { viewports: [] });
183
- if (!entry.viewports.some((v) => v.name === viewport.name))
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)) {
184
404
  entry.viewports.push(viewport);
405
+ }
185
406
  visualSmoke.push({
186
407
  id: wanted.id,
187
408
  route: wanted.route,
188
409
  viewport: viewport.name,
189
410
  baselineRoot: config.root,
190
411
  baseline: result.file,
191
- threshold: { maxDiffPixelRatio: config.threshold ?? 0.05 },
412
+ threshold: {
413
+ maxDiffPixelRatio: config.threshold ?? 0.05,
414
+ },
192
415
  strict: false,
193
416
  ref: `penpair:${wanted.id}`,
194
417
  });
@@ -4,11 +4,16 @@ export class CommandArgumentError extends Error {
4
4
  remedy = "Use the command's supported flags and required values; see penpair --help.";
5
5
  }
6
6
  function distance(a, b) {
7
- let previous = Array.from({ length: b.length + 1 }, (_, index) => index);
7
+ let previous = Array.from({
8
+ length: b.length + 1,
9
+ }, (_, index) => index);
8
10
  for (let i = 1; i <= a.length; i++) {
9
- const next = [i];
10
- for (let j = 1; j <= b.length; j++)
11
+ const next = [
12
+ i,
13
+ ];
14
+ for (let j = 1; j <= b.length; j++) {
11
15
  next[j] = Math.min(next[j - 1] + 1, previous[j] + 1, previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
16
+ }
12
17
  previous = next;
13
18
  }
14
19
  return previous[b.length];
@@ -20,15 +25,22 @@ export function parseCommandArgs(config) {
20
25
  }
21
26
  catch (error) {
22
27
  const value = error;
23
- if (!value.code?.startsWith("ERR_PARSE_ARGS"))
28
+ if (!value.code?.startsWith("ERR_PARSE_ARGS")) {
24
29
  throw error;
30
+ }
25
31
  const unknown = /Unknown option ['"]--([^'"]+)['"]/.exec(value.message ?? "")?.[1];
26
32
  let hint = "";
27
33
  if (unknown && unknown.length <= 100) {
28
- const candidates = Object.keys(config.options ?? {}).map((name) => ({ name, distance: distance(unknown.toLowerCase(), name.toLowerCase()) })).sort((a, b) => a.distance - b.distance || a.name.localeCompare(b.name));
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));
29
40
  const best = candidates[0];
30
- if (best && best.distance <= Math.max(2, Math.ceil(unknown.length / 2)))
41
+ if (best && best.distance <= Math.max(2, Math.ceil(unknown.length / 2))) {
31
42
  hint = ` Did you mean --${best.name}?`;
43
+ }
32
44
  }
33
45
  throw new CommandArgumentError((value.message ?? "Invalid command arguments") + hint);
34
46
  }