@penpair/core 0.3.7 → 0.4.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 (142) hide show
  1. package/dist/adapters.d.ts +1 -1
  2. package/dist/adapters.js +4 -0
  3. package/dist/approvals.d.ts +4 -1
  4. package/dist/approvals.js +7 -4
  5. package/dist/baseline-coverage.js +3 -3
  6. package/dist/baselines-config.d.ts +3 -0
  7. package/dist/baselines-config.js +3 -0
  8. package/dist/baselines.d.ts +7 -0
  9. package/dist/baselines.js +90 -21
  10. package/dist/bounded-file.d.ts +2 -0
  11. package/dist/bounded-file.js +38 -0
  12. package/dist/browser-wake.d.ts +22 -0
  13. package/dist/browser-wake.js +241 -0
  14. package/dist/cli-main.js +193 -18
  15. package/dist/design-commands/context.d.ts +9 -0
  16. package/dist/design-commands/context.js +1 -0
  17. package/dist/design-commands/index.d.ts +3 -0
  18. package/dist/design-commands/index.js +7 -0
  19. package/dist/design-commands/map-command.d.ts +4 -0
  20. package/dist/design-commands/map-command.js +685 -0
  21. package/dist/design-commands/release-command.d.ts +3 -0
  22. package/dist/design-commands/release-command.js +239 -0
  23. package/dist/design-releases/artifact-paths.d.ts +6 -0
  24. package/dist/design-releases/artifact-paths.js +82 -0
  25. package/dist/design-releases/assemble.d.ts +3 -0
  26. package/dist/design-releases/assemble.js +176 -0
  27. package/dist/design-releases/check.d.ts +16 -0
  28. package/dist/design-releases/check.js +323 -0
  29. package/dist/design-releases/context.d.ts +2 -0
  30. package/dist/design-releases/context.js +119 -0
  31. package/dist/design-releases/contract-graph.d.ts +13 -0
  32. package/dist/design-releases/contract-graph.js +75 -0
  33. package/dist/design-releases/fidelity.d.ts +23 -0
  34. package/dist/design-releases/fidelity.js +328 -0
  35. package/dist/design-releases/finalize.d.ts +4 -0
  36. package/dist/design-releases/finalize.js +201 -0
  37. package/dist/design-releases/index.d.ts +8 -0
  38. package/dist/design-releases/index.js +7 -0
  39. package/dist/design-releases/live.d.ts +22 -0
  40. package/dist/design-releases/live.js +97 -0
  41. package/dist/design-releases/snippets.d.ts +5 -0
  42. package/dist/design-releases/snippets.js +89 -0
  43. package/dist/design-releases/store.d.ts +10 -0
  44. package/dist/design-releases/store.js +205 -0
  45. package/dist/design-releases/types.d.ts +161 -0
  46. package/dist/design-releases/types.js +1 -0
  47. package/dist/design-releases/validation.d.ts +6 -0
  48. package/dist/design-releases/validation.js +384 -0
  49. package/dist/design-releases/verify.d.ts +3 -0
  50. package/dist/design-releases/verify.js +148 -0
  51. package/dist/errors.js +1 -1
  52. package/dist/image-codec.d.ts +3 -0
  53. package/dist/image-codec.js +78 -0
  54. package/dist/image-evidence.d.ts +15 -0
  55. package/dist/image-evidence.js +121 -0
  56. package/dist/index.d.ts +5 -0
  57. package/dist/index.js +5 -0
  58. package/dist/inspection.js +13 -5
  59. package/dist/instance/config-schema.d.ts +4 -0
  60. package/dist/instance/config-schema.js +7 -0
  61. package/dist/instance/connection.d.ts +2 -0
  62. package/dist/instance/connection.js +15 -3
  63. package/dist/kit-source.js +1 -1
  64. package/dist/layout-policy.js +21 -11
  65. package/dist/layout-settling.d.ts +1 -0
  66. package/dist/layout-settling.js +51 -17
  67. package/dist/layout.d.ts +7 -1
  68. package/dist/layout.js +19 -3
  69. package/dist/map-penpot/content-validation.d.ts +3 -0
  70. package/dist/map-penpot/content-validation.js +57 -0
  71. package/dist/map-penpot/content.d.ts +22 -0
  72. package/dist/map-penpot/content.js +173 -0
  73. package/dist/map-penpot/details.d.ts +4 -0
  74. package/dist/map-penpot/details.js +84 -0
  75. package/dist/map-penpot/evidence-error.d.ts +5 -0
  76. package/dist/map-penpot/evidence-error.js +9 -0
  77. package/dist/map-penpot/icons.d.ts +5 -0
  78. package/dist/map-penpot/icons.js +79 -0
  79. package/dist/map-penpot/import-content.d.ts +2 -0
  80. package/dist/map-penpot/import-content.js +150 -0
  81. package/dist/map-penpot/import.d.ts +5 -0
  82. package/dist/map-penpot/import.js +255 -0
  83. package/dist/map-penpot/index.d.ts +11 -0
  84. package/dist/map-penpot/index.js +10 -0
  85. package/dist/map-penpot/path-geometry.d.ts +5 -0
  86. package/dist/map-penpot/path-geometry.js +51 -0
  87. package/dist/map-penpot/presentation.d.ts +20 -0
  88. package/dist/map-penpot/presentation.js +205 -0
  89. package/dist/map-penpot/projection.d.ts +8 -0
  90. package/dist/map-penpot/projection.js +176 -0
  91. package/dist/map-penpot/publish.d.ts +4 -0
  92. package/dist/map-penpot/publish.js +301 -0
  93. package/dist/map-penpot/release-maps.d.ts +21 -0
  94. package/dist/map-penpot/release-maps.js +104 -0
  95. package/dist/map-penpot/snippets.d.ts +4 -0
  96. package/dist/map-penpot/snippets.js +164 -0
  97. package/dist/map-penpot/status-labels.d.ts +7 -0
  98. package/dist/map-penpot/status-labels.js +7 -0
  99. package/dist/map-penpot/types.d.ts +161 -0
  100. package/dist/map-penpot/types.js +1 -0
  101. package/dist/maps/annotation-layout.d.ts +24 -0
  102. package/dist/maps/annotation-layout.js +85 -0
  103. package/dist/maps/context.d.ts +13 -0
  104. package/dist/maps/context.js +207 -0
  105. package/dist/maps/index.d.ts +9 -0
  106. package/dist/maps/index.js +9 -0
  107. package/dist/maps/layout.d.ts +35 -0
  108. package/dist/maps/layout.js +364 -0
  109. package/dist/maps/proposal.d.ts +5 -0
  110. package/dist/maps/proposal.js +67 -0
  111. package/dist/maps/render.d.ts +4 -0
  112. package/dist/maps/render.js +331 -0
  113. package/dist/maps/review.d.ts +16 -0
  114. package/dist/maps/review.js +90 -0
  115. package/dist/maps/store.d.ts +21 -0
  116. package/dist/maps/store.js +222 -0
  117. package/dist/maps/types.d.ts +206 -0
  118. package/dist/maps/types.js +1 -0
  119. package/dist/maps/validation.d.ts +10 -0
  120. package/dist/maps/validation.js +375 -0
  121. package/dist/plugin-health.d.ts +13 -0
  122. package/dist/plugin-health.js +39 -0
  123. package/dist/skill-routing.js +4 -0
  124. package/dist/version.d.ts +1 -1
  125. package/dist/version.js +1 -1
  126. package/package.json +2 -1
  127. package/skills/penpair-design-release/SKILL.md +55 -0
  128. package/skills/penpair-design-release/agents/openai.yaml +6 -0
  129. package/skills/penpair-design-release/references/workflow.md +55 -0
  130. package/skills/penpair-diagrams/SKILL.md +52 -0
  131. package/skills/penpair-diagrams/agents/openai.yaml +6 -0
  132. package/skills/penpair-diagrams/references/workflow.md +84 -0
  133. package/skills/penpair-handoff/SKILL.md +7 -0
  134. package/skills/penpair-handoff/references/handoff-flow.md +7 -1
  135. package/skills/penpair-integration-map/SKILL.md +44 -0
  136. package/skills/penpair-integration-map/agents/openai.yaml +6 -0
  137. package/skills/penpair-integration-map/references/workflow.md +33 -0
  138. package/skills/penpair-journey-map/SKILL.md +42 -0
  139. package/skills/penpair-journey-map/agents/openai.yaml +6 -0
  140. package/skills/penpair-journey-map/references/workflow.md +35 -0
  141. package/skills/penpair-verify/SKILL.md +7 -0
  142. package/skills/penpair-verify/references/browser-evidence.md +10 -0
@@ -51,7 +51,7 @@ export interface Adapter {
51
51
  /** Declares an adapter. Returns the definition unchanged, so a module can `export default defineAdapter({...})` with types checked. */
52
52
  export declare const defineAdapter: <T extends Adapter>(adapter: T) => T;
53
53
  /** The commands of the core itself; an adapter cannot take one of these names. */
54
- export declare const reservedCommands: readonly ["formats", "snapshot", "approve", "mcp", "spec", "inspect", "probe", "pages", "tree", "audit", "tokens", "export", "shots", "archive", "snippets", "run", "run-file", "kit", "recipes", "skills", "init", "setup", "connect", "up", "down", "open", "status", "doctor", "backup", "restore", "baselines", "config", "runs", "onboard", "help"];
54
+ export declare const reservedCommands: readonly ["journey-map", "integration-map", "design-release", "formats", "snapshot", "approve", "mcp", "spec", "inspect", "probe", "pages", "tree", "audit", "tokens", "export", "shots", "archive", "snippets", "run", "run-file", "kit", "recipes", "skills", "init", "setup", "connect", "up", "down", "open", "status", "doctor", "wake", "backup", "restore", "baselines", "config", "runs", "onboard", "help"];
55
55
  export interface LoadedAdapters {
56
56
  adapters: Adapter[];
57
57
  problems: string[];
package/dist/adapters.js CHANGED
@@ -7,6 +7,9 @@ import { readProjectConfig } from "./instance/config.js";
7
7
  export const defineAdapter = (adapter) => adapter;
8
8
  /** The commands of the core itself; an adapter cannot take one of these names. */
9
9
  export const reservedCommands = [
10
+ "journey-map",
11
+ "integration-map",
12
+ "design-release",
10
13
  "formats",
11
14
  "snapshot",
12
15
  "approve",
@@ -35,6 +38,7 @@ export const reservedCommands = [
35
38
  "open",
36
39
  "status",
37
40
  "doctor",
41
+ "wake",
38
42
  "backup",
39
43
  "restore",
40
44
  "baselines",
@@ -1,5 +1,6 @@
1
1
  import type { CaptureIdentity, ExportResult } from "./export.js";
2
2
  import { type BaselineCase } from "./baselines-config.js";
3
+ import type { BaselineImageFormat } from "./image-evidence.js";
3
4
  export interface ApprovalRecord {
4
5
  schemaVersion: 1;
5
6
  id: string;
@@ -28,5 +29,7 @@ export declare function readApprovals(root: string): Promise<ApprovalFile>;
28
29
  export declare function recordApproval(root: string, exported: ExportResult, words: {
29
30
  note: string;
30
31
  by: string;
31
- }, intent?: BaselineCase, redact?: (text: string) => string): Promise<ApprovalRecord>;
32
+ }, intent?: BaselineCase, redact?: (text: string) => string, options?: {
33
+ format?: BaselineImageFormat;
34
+ }): Promise<ApprovalRecord>;
32
35
  export declare function matchingApproval(root: string, capture: CaptureIdentity, sha256: string, intent?: BaselineCase): Promise<ApprovalRecord | undefined>;
package/dist/approvals.js CHANGED
@@ -7,6 +7,7 @@ import { observationHash } from "./journal.js";
7
7
  import { resolveProjectInput, resolveProjectOutput } from "./project-input.js";
8
8
  import { validateBaselineCase } from "./baselines-config.js";
9
9
  import { pngSize } from "./png.js";
10
+ import { encodeBaselineImage } from "./image-codec.js";
10
11
  export const approvalFile = "design/approvals.json";
11
12
  export const artifactSha = (bytes) => createHash("sha256").update(bytes).digest("hex");
12
13
  export function approvalFingerprint(capture, sha256, intent) {
@@ -91,7 +92,7 @@ export async function readApprovals(root) {
91
92
  }
92
93
  }
93
94
  /** Records supplied owner words; hashes detect accidental changes, not identity or consent. */
94
- export async function recordApproval(root, exported, words, intent, redact) {
95
+ export async function recordApproval(root, exported, words, intent, redact, options = {}) {
95
96
  exported = {
96
97
  ...exported,
97
98
  capture: structuredClone(exported.capture),
@@ -113,7 +114,9 @@ export async function recordApproval(root, exported, words, intent, redact) {
113
114
  words.by.length > 200) {
114
115
  throw new Error("Supply the owner's exact nonempty words and identity; an agent cannot invent approval");
115
116
  }
116
- const sha256 = artifactSha(exported.png);
117
+ const format = options.format ?? "png";
118
+ const imageBytes = await encodeBaselineImage(exported.png, format);
119
+ const sha256 = artifactSha(imageBytes);
117
120
  const fingerprint = approvalFingerprint(exported.capture, sha256, intent);
118
121
  if (intent &&
119
122
  (!matchesDesignViewport(exported.capture.designSize, intent.viewport) ||
@@ -142,7 +145,7 @@ export async function recordApproval(root, exported, words, intent, redact) {
142
145
  }
143
146
  : {}),
144
147
  sha256,
145
- image: `design/approvals/${sha256}.png`,
148
+ image: `design/approvals/${sha256}.${format}`,
146
149
  fingerprint,
147
150
  };
148
151
  const record = {
@@ -168,7 +171,7 @@ export async function recordApproval(root, exported, words, intent, redact) {
168
171
  recursive: true,
169
172
  });
170
173
  try {
171
- await atomicWriteFile(image, exported.png);
174
+ await atomicWriteFile(image, imageBytes);
172
175
  }
173
176
  catch (error) {
174
177
  if (error.code !== "EEXIST" ||
@@ -2,7 +2,7 @@ import { open } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { createHash } from "node:crypto";
4
4
  import { resolveProjectOutput } from "./project-input.js";
5
- import { pngSize } from "./png.js";
5
+ import { inspectImageEvidence } from "./image-evidence.js";
6
6
  import { matchesDesignViewport } from "./design-viewport.js";
7
7
  import { validateBaselines, } from "./baselines-config.js";
8
8
  import { observationHash } from "./journal.js";
@@ -228,13 +228,13 @@ export async function readBaselineCoverage(root, config, expectedFileId) {
228
228
  }
229
229
  const target = await resolveProjectOutput(root, path.join(config.root, result.file));
230
230
  const bytes = await boundedRead(target, 64 * 1024 * 1024);
231
- const size = pngSize(bytes);
231
+ const size = inspectImageEvidence(bytes);
232
232
  if (!size ||
233
233
  size.width < 1 ||
234
234
  size.height < 1 ||
235
235
  createHash("sha256").update(bytes).digest("hex") !== result.approved.sha256 ||
236
236
  result.sha256 !== result.approved.sha256) {
237
- throw new Error("Approved PNG bytes do not match their manifest");
237
+ throw new Error("Approved image bytes do not match their manifest");
238
238
  }
239
239
  result.imageWidth = size.width;
240
240
  result.imageHeight = size.height;
@@ -1,3 +1,4 @@
1
+ import type { BaselineImageFormat } from "./image-evidence.js";
1
2
  /** Declared design intent. It does not switch themes or establish browser behavior. */
2
3
  export interface BaselineCase {
3
4
  viewport: {
@@ -34,6 +35,8 @@ export interface BaselineFrame {
34
35
  }
35
36
  /** What `penpair baselines` exports: the frames of one page, as images in a folder of the project. */
36
37
  export interface BaselinesConfig {
38
+ /** Encoding for new references; existing approved formats require explicit migration. */
39
+ format?: BaselineImageFormat;
37
40
  page: string;
38
41
  /** Folder for the images, relative to the project. */
39
42
  root: string;
@@ -6,6 +6,9 @@ export function validateBaselines(value, fail) {
6
6
  return fail("baselines must be an object");
7
7
  }
8
8
  const v = value;
9
+ if (v.format !== undefined && v.format !== "png" && v.format !== "webp") {
10
+ fail("baselines.format must be png or webp");
11
+ }
9
12
  if (typeof v.page !== "string" || v.page.length === 0) {
10
13
  fail("baselines.page must be the name of a page");
11
14
  }
@@ -1,3 +1,4 @@
1
+ import { type BaselineImageFormat } from "./image-evidence.js";
1
2
  export { pngSize } from "./png.js";
2
3
  import type { PenpotToolClient } from "./client.js";
3
4
  import { type BaselineCoverage } from "./baseline-coverage.js";
@@ -70,6 +71,10 @@ export interface BaselinesReport {
70
71
  wrote: string[];
71
72
  }
72
73
  export interface BaselinesOptions extends ExportOptions {
74
+ /** Encoding for new references, or the explicit migration target. */
75
+ format?: BaselineImageFormat;
76
+ /** Change an existing reference's encoding; apply also requires update and exact-artifact approval. */
77
+ migrateFormat?: boolean;
73
78
  signal?: AbortSignal;
74
79
  /** Write images that are new. Without it nothing is written, whatever is found. */
75
80
  apply?: boolean;
@@ -78,6 +83,8 @@ export interface BaselinesOptions extends ExportOptions {
78
83
  /** Explicit waiver for a changed baseline; retained as a forced reference, never an owner approval. */
79
84
  forceReason?: string;
80
85
  }
86
+ /** Resolve the exact encoding used by both approval and baseline capture. */
87
+ export declare function resolveBaselineImageFormat(projectRoot: string, config: BaselinesConfig, baselineId: string, options?: Pick<BaselinesOptions, "format" | "migrateFormat">): Promise<BaselineImageFormat>;
81
88
  /**
82
89
  * Exports the configured frames of a page as images into the project's folder: the approved design that an implementation is
83
90
  * checked against. Looks first; writes only with `apply`, and replaces an image that differs only with `update`, so a change of
package/dist/baselines.js CHANGED
@@ -1,9 +1,11 @@
1
1
  import { matchingApproval } from "./approvals.js";
2
2
  import { matchesDesignViewport } from "./design-viewport.js";
3
3
  import { pngSize } from "./png.js";
4
+ import { encodeBaselineImage } from "./image-codec.js";
5
+ import { inspectImageEvidence } from "./image-evidence.js";
4
6
  export { pngSize } from "./png.js";
5
7
  import { createHash, randomUUID } from "node:crypto";
6
- import { mkdir, readFile, writeFile, rename, rm } from "node:fs/promises";
8
+ import { mkdir, readFile, writeFile, rename, rm, stat } from "node:fs/promises";
7
9
  import path from "node:path";
8
10
  import { baselineCoverage } from "./baseline-coverage.js";
9
11
  import { resolveProjectOutput } from "./project-input.js";
@@ -16,9 +18,16 @@ import { runOnPage } from "./operation.js";
16
18
  import { frames } from "./snippets/read.js";
17
19
  export * from "./baselines-config.js";
18
20
  const sha = (buffer) => createHash("sha256").update(buffer).digest("hex");
19
- async function readIfExists(file) {
21
+ async function readIfExists(file, maximum = 64 * 1024 * 1024) {
20
22
  try {
21
- return await readFile(file);
23
+ if ((await stat(file)).size > maximum) {
24
+ throw new Error("Baseline artifact exceeds its read budget");
25
+ }
26
+ const bytes = await readFile(file);
27
+ if (bytes.length > maximum) {
28
+ throw new Error("Baseline artifact exceeds its read budget");
29
+ }
30
+ return bytes;
22
31
  }
23
32
  catch (error) {
24
33
  if (error.code === "ENOENT") {
@@ -27,12 +36,72 @@ async function readIfExists(file) {
27
36
  throw error;
28
37
  }
29
38
  }
39
+ async function existingBaseline(projectRoot, config, id, old) {
40
+ const candidates = old
41
+ ? [
42
+ old.file,
43
+ ]
44
+ : [
45
+ `${id}.png`,
46
+ `${id}.webp`,
47
+ ];
48
+ let found = {};
49
+ for (const file of candidates) {
50
+ if (typeof file !== "string" || path.isAbsolute(file) || file.split(/[\\/]/).includes("..")) {
51
+ throw new Error("Existing baseline path leaves its folder");
52
+ }
53
+ const bytes = await readIfExists(await resolveProjectOutput(projectRoot, path.join(config.root, file)));
54
+ if (!bytes) {
55
+ continue;
56
+ }
57
+ if (found.bytes) {
58
+ throw new Error("Existing baseline candidates are ambiguous; inspect before capture");
59
+ }
60
+ found = {
61
+ file,
62
+ bytes,
63
+ format: inspectImageEvidence(bytes).format,
64
+ };
65
+ }
66
+ if (old &&
67
+ (!found.bytes ||
68
+ old.sha256 !== sha(found.bytes) ||
69
+ (old.approved && old.approved.sha256 !== old.sha256))) {
70
+ throw new Error("Approved baseline bytes no longer match their manifest; inspect before replacing");
71
+ }
72
+ return found;
73
+ }
74
+ /** Resolve the exact encoding used by both approval and baseline capture. */
75
+ export async function resolveBaselineImageFormat(projectRoot, config, baselineId, options = {}) {
76
+ const target = options.format ?? config.format ?? "webp";
77
+ if (target !== "png" && target !== "webp") {
78
+ throw new Error("Baseline format must be png or webp");
79
+ }
80
+ const manifestPath = await resolveProjectOutput(projectRoot, path.join(config.root, "penpair-baselines.json"));
81
+ const bytes = await readIfExists(manifestPath, 16 * 1024 * 1024);
82
+ const previous = bytes ? JSON.parse(bytes.toString("utf8")) : undefined;
83
+ if (previous &&
84
+ ((previous.schemaVersion !== undefined && previous.schemaVersion !== 2) ||
85
+ !Array.isArray(previous.frames) ||
86
+ previous.frames.length > 1000)) {
87
+ throw new Error("Existing baseline manifest is invalid; preserve and inspect it");
88
+ }
89
+ const old = previous?.frames?.find((entry) => entry.id === baselineId);
90
+ const existing = await existingBaseline(projectRoot, config, baselineId, old);
91
+ return existing.format && !options.migrateFormat ? existing.format : target;
92
+ }
30
93
  /**
31
94
  * Exports the configured frames of a page as images into the project's folder: the approved design that an implementation is
32
95
  * checked against. Looks first; writes only with `apply`, and replaces an image that differs only with `update`, so a change of
33
96
  * the design never silently becomes the new reference.
34
97
  */
35
98
  export async function exportBaselines(client, projectRoot, config, options = {}) {
99
+ if (options.format !== undefined && options.format !== "png" && options.format !== "webp") {
100
+ throw new Error("Baseline format must be png or webp");
101
+ }
102
+ if (options.migrateFormat && options.apply && !options.update) {
103
+ throw new Error("Applying a baseline format migration requires update");
104
+ }
36
105
  if (options.forceReason !== undefined &&
37
106
  (!options.update ||
38
107
  !options.apply ||
@@ -65,7 +134,7 @@ export async function exportBaselines(client, projectRoot, config, options = {})
65
134
  const wrote = [];
66
135
  const folder = await resolveProjectOutput(projectRoot, config.root);
67
136
  const manifestPath = await resolveProjectOutput(projectRoot, path.join(config.root, "penpair-baselines.json"));
68
- const previousBytes = await readIfExists(manifestPath);
137
+ const previousBytes = await readIfExists(manifestPath, 16 * 1024 * 1024);
69
138
  const previous = previousBytes
70
139
  ? JSON.parse(previousBytes.toString("utf8"))
71
140
  : undefined;
@@ -80,7 +149,13 @@ export async function exportBaselines(client, projectRoot, config, options = {})
80
149
  for (const wanted of config.frames) {
81
150
  options.signal?.throwIfAborted();
82
151
  const old = previous?.frames?.find((entry) => entry.id === wanted.id);
83
- const file = old?.file ?? `${wanted.id}.png`;
152
+ const existingEvidence = await existingBaseline(projectRoot, config, wanted.id, old);
153
+ const format = existingEvidence.format && !options.migrateFormat
154
+ ? existingEvidence.format
155
+ : (options.format ?? config.format ?? "webp");
156
+ const migrating = Boolean(existingEvidence.format && existingEvidence.format !== format);
157
+ const existing = existingEvidence.bytes;
158
+ const file = existingEvidence.file ?? `${wanted.id}.${format}`;
84
159
  if (path.isAbsolute(file) || file.split(/[\\/]/).includes("..")) {
85
160
  throw new Error("Existing baseline path leaves its folder");
86
161
  }
@@ -157,13 +232,7 @@ export async function exportBaselines(client, projectRoot, config, options = {})
157
232
  if (!pngSize(exported.png)) {
158
233
  throw new Error("Baseline export is not a PNG with dimensions");
159
234
  }
160
- const existing = await readIfExists(await resolveProjectOutput(projectRoot, path.join(config.root, file)));
161
- if (old &&
162
- (!existing ||
163
- old.sha256 !== sha(existing) ||
164
- (old.approved && old.approved.sha256 !== old.sha256))) {
165
- throw new Error("Approved baseline bytes no longer match their manifest; inspect before replacing");
166
- }
235
+ const encoded = await encodeBaselineImage(exported.png, format);
167
236
  const captured = {
168
237
  ...(wanted.case
169
238
  ? {
@@ -172,10 +241,10 @@ export async function exportBaselines(client, projectRoot, config, options = {})
172
241
  : {}),
173
242
  layout: exported.layout,
174
243
  ...exported.capture,
175
- sha256: sha(exported.png),
244
+ sha256: sha(encoded),
176
245
  capturedAt: new Date().toISOString(),
177
246
  toolVersion: version,
178
- file: `${generation}/${wanted.id}.png`,
247
+ file: `${generation}/${wanted.id}.${format}`,
179
248
  };
180
249
  const caseProblems = [];
181
250
  if (wanted.case) {
@@ -237,7 +306,7 @@ export async function exportBaselines(client, projectRoot, config, options = {})
237
306
  recordHash: record.hash,
238
307
  };
239
308
  }
240
- else if (options.forceReason) {
309
+ else if (options.forceReason && !migrating) {
241
310
  captured.approval = {
242
311
  kind: "forced-reference",
243
312
  reason: options.forceReason,
@@ -256,8 +325,8 @@ export async function exportBaselines(client, projectRoot, config, options = {})
256
325
  legacy: true,
257
326
  sha256: sha(existing),
258
327
  });
259
- const image = approve ? exported.png : existing;
260
- const size = pngSize(image);
328
+ const image = approve ? encoded : existing;
329
+ const size = inspectImageEvidence(image);
261
330
  results.push({
262
331
  ...base,
263
332
  file: approve && options.apply ? captured.file : file,
@@ -270,7 +339,7 @@ export async function exportBaselines(client, projectRoot, config, options = {})
270
339
  imageWidth: size?.width,
271
340
  imageHeight: size?.height,
272
341
  });
273
- captures.set(wanted.id, exported.png);
342
+ captures.set(`${wanted.id}.${format}`, encoded);
274
343
  }
275
344
  const after = await runOnPage(client, frames({
276
345
  page: config.page,
@@ -311,10 +380,10 @@ export async function exportBaselines(client, projectRoot, config, options = {})
311
380
  const staged = path.join(folder, `.stage-${runId}`);
312
381
  await mkdir(staged);
313
382
  try {
314
- for (const [id, bytes] of captures) {
383
+ for (const [file, bytes] of captures) {
315
384
  options.signal?.throwIfAborted();
316
- await atomicWriteFile(path.join(staged, `${id}.png`), bytes);
317
- options.onStep?.(`staging ${id}`);
385
+ await atomicWriteFile(path.join(staged, file), bytes);
386
+ options.onStep?.(`staging ${path.parse(file).name}`);
318
387
  }
319
388
  const cases = reflectionCases(config, results);
320
389
  await writeFile(path.join(staged, "reflection-cases.json"), `${JSON.stringify({
@@ -0,0 +1,2 @@
1
+ /** Read a regular project file with an allocation bound even if another process grows it. */
2
+ export declare function readProjectBytes(root: string, file: string, maxBytes: number): Promise<Buffer>;
@@ -0,0 +1,38 @@
1
+ import { constants } from "node:fs";
2
+ import { lstat, open } from "node:fs/promises";
3
+ import { resolveProjectInput } from "./project-input.js";
4
+ /** Read a regular project file with an allocation bound even if another process grows it. */
5
+ export async function readProjectBytes(root, file, maxBytes) {
6
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) {
7
+ throw new Error("A positive file byte budget is required");
8
+ }
9
+ const input = await resolveProjectInput(root, file);
10
+ if (!(await lstat(input)).isFile()) {
11
+ throw new Error("Artifact input must be a regular file");
12
+ }
13
+ const handle = await open(input, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
14
+ try {
15
+ const info = await handle.stat();
16
+ if (!info.isFile() || info.size > maxBytes) {
17
+ throw new Error("Artifact input is not a regular file within its byte budget");
18
+ }
19
+ const chunks = [];
20
+ let total = 0;
21
+ while (total <= maxBytes) {
22
+ const chunk = Buffer.alloc(Math.min(64 * 1024, maxBytes + 1 - total));
23
+ const { bytesRead } = await handle.read(chunk, 0, chunk.length, total);
24
+ if (bytesRead === 0) {
25
+ return Buffer.concat(chunks, total);
26
+ }
27
+ total += bytesRead;
28
+ if (total > maxBytes) {
29
+ throw new Error("Artifact input exceeds its byte budget");
30
+ }
31
+ chunks.push(chunk.subarray(0, bytesRead));
32
+ }
33
+ throw new Error("Artifact input exceeds its byte budget");
34
+ }
35
+ finally {
36
+ await handle.close();
37
+ }
38
+ }
@@ -0,0 +1,22 @@
1
+ export interface BrowserTargetOptions {
2
+ origin: string;
3
+ fileId: string;
4
+ port: number;
5
+ timeoutMs?: number;
6
+ allowLoopbackAlias?: boolean;
7
+ signal?: AbortSignal;
8
+ fetch?: typeof fetch;
9
+ }
10
+ export interface BrowserWakeResult {
11
+ activated: true;
12
+ targetId: string;
13
+ keepAlive: "none";
14
+ }
15
+ export interface BrowserInspectionResult {
16
+ targetId: string;
17
+ keepAlive: "unknown";
18
+ }
19
+ /** Activate only the existing page for the pinned file; never reload or evaluate page code. */
20
+ export declare function wakeBrowserTarget(options: BrowserTargetOptions): Promise<BrowserWakeResult>;
21
+ /** Read debugging metadata only; a listed tab does not establish plugin readiness or keep-alive. */
22
+ export declare function inspectBrowserTarget(options: BrowserTargetOptions): Promise<BrowserInspectionResult>;
@@ -0,0 +1,241 @@
1
+ const maxTargetBytes = 1024 * 1024;
2
+ const maxTargetCount = 1000;
3
+ /** Activate only the existing page for the pinned file; never reload or evaluate page code. */
4
+ export async function wakeBrowserTarget(options) {
5
+ return withBrowserDeadline(options, async (request) => {
6
+ const targetId = await findBrowserTarget(request);
7
+ const response = await browserRequest(request, `/json/activate/${encodeURIComponent(targetId)}`);
8
+ void response.body?.cancel().catch(() => undefined);
9
+ request.signal.throwIfAborted();
10
+ return {
11
+ activated: true,
12
+ targetId,
13
+ keepAlive: "none",
14
+ };
15
+ });
16
+ }
17
+ /** Read debugging metadata only; a listed tab does not establish plugin readiness or keep-alive. */
18
+ export async function inspectBrowserTarget(options) {
19
+ return withBrowserDeadline(options, async (request) => ({
20
+ targetId: await findBrowserTarget(request),
21
+ keepAlive: "unknown",
22
+ }));
23
+ }
24
+ async function withBrowserDeadline(options, work) {
25
+ const origin = validateBrowserOptions(options);
26
+ if (options.signal?.aborted) {
27
+ throw new Error("Browser debugging cancelled");
28
+ }
29
+ const controller = new AbortController();
30
+ const timeoutMs = options.timeoutMs ?? 5000;
31
+ let timedOut = false;
32
+ const externalAbort = () => controller.abort();
33
+ const timer = setTimeout(() => {
34
+ timedOut = true;
35
+ controller.abort();
36
+ }, timeoutMs);
37
+ options.signal?.addEventListener("abort", externalAbort, {
38
+ once: true,
39
+ });
40
+ let abort = () => undefined;
41
+ const interrupted = new Promise((_resolve, reject) => {
42
+ abort = () => reject(new Error(timedOut ? "Browser debugging deadline exceeded" : "Browser debugging cancelled"));
43
+ controller.signal.addEventListener("abort", abort, {
44
+ once: true,
45
+ });
46
+ });
47
+ try {
48
+ if (options.signal?.aborted) {
49
+ controller.abort();
50
+ }
51
+ controller.signal.throwIfAborted();
52
+ return await Promise.race([
53
+ interrupted,
54
+ work({
55
+ origin,
56
+ fileId: options.fileId,
57
+ allowLoopbackAlias: options.allowLoopbackAlias === true,
58
+ endpoint: `http://127.0.0.1:${options.port}`,
59
+ fetch: options.fetch ?? fetch,
60
+ signal: controller.signal,
61
+ }),
62
+ ]);
63
+ }
64
+ catch (error) {
65
+ if (controller.signal.aborted) {
66
+ throw new Error(timedOut ? "Browser debugging deadline exceeded" : "Browser debugging cancelled");
67
+ }
68
+ throw error;
69
+ }
70
+ finally {
71
+ clearTimeout(timer);
72
+ options.signal?.removeEventListener("abort", externalAbort);
73
+ controller.signal.removeEventListener("abort", abort);
74
+ }
75
+ }
76
+ function validateBrowserOptions(options) {
77
+ if (!Number.isInteger(options.port) || options.port < 1 || options.port > 65535) {
78
+ throw new Error("Browser debugging port must be an integer from 1 to 65535");
79
+ }
80
+ const timeoutMs = options.timeoutMs ?? 5000;
81
+ if (!Number.isInteger(timeoutMs) || timeoutMs < 1000 || timeoutMs > 30000) {
82
+ throw new Error("Browser debugging timeout must be an integer from 1000 to 30000 ms");
83
+ }
84
+ if (typeof options.fileId !== "string" ||
85
+ !options.fileId.trim() ||
86
+ options.fileId.length > 256 ||
87
+ /[\u0000-\u0020\u007f]/.test(options.fileId)) {
88
+ throw new Error("Browser activation requires a pinned file ID");
89
+ }
90
+ let origin;
91
+ try {
92
+ origin = new URL(options.origin);
93
+ }
94
+ catch {
95
+ throw new Error("Browser activation requires a valid Penpot origin");
96
+ }
97
+ if (![
98
+ "http:",
99
+ "https:",
100
+ ].includes(origin.protocol) ||
101
+ origin.username ||
102
+ origin.password ||
103
+ origin.pathname !== "/" ||
104
+ origin.search ||
105
+ origin.hash) {
106
+ throw new Error("Browser activation requires a Penpot origin without credentials, path or query");
107
+ }
108
+ if (options.allowLoopbackAlias && !isHttpLoopback(origin)) {
109
+ throw new Error("Browser loopback aliases require an HTTP localhost or 127.0.0.1 origin");
110
+ }
111
+ return origin.origin;
112
+ }
113
+ async function browserRequest(request, path) {
114
+ request.signal.throwIfAborted();
115
+ let response;
116
+ try {
117
+ response = await request.fetch(`${request.endpoint}${path}`, {
118
+ method: "GET",
119
+ redirect: "error",
120
+ signal: request.signal,
121
+ });
122
+ }
123
+ catch {
124
+ throw new Error("Cannot reach the local browser debugging endpoint; start a dedicated browser with a loopback debugging port");
125
+ }
126
+ request.signal.throwIfAborted();
127
+ if (!response.ok || response.redirected) {
128
+ void response.body?.cancel().catch(() => undefined);
129
+ throw new Error("The local browser debugging endpoint refused the request");
130
+ }
131
+ return response;
132
+ }
133
+ async function findBrowserTarget(request) {
134
+ const response = await browserRequest(request, "/json/list");
135
+ const text = await readTargetList(response);
136
+ request.signal.throwIfAborted();
137
+ let targets;
138
+ try {
139
+ targets = JSON.parse(text);
140
+ }
141
+ catch {
142
+ throw new Error("Invalid browser debugging target list");
143
+ }
144
+ if (!Array.isArray(targets) ||
145
+ targets.length > maxTargetCount ||
146
+ !targets.every(isBrowserTarget)) {
147
+ throw new Error("Invalid browser debugging target list");
148
+ }
149
+ const matching = targets.filter((target) => target.type === "page" && matchesFile(target.url, request));
150
+ if (matching.length !== 1) {
151
+ throw new Error(matching.length === 0
152
+ ? "No browser page matches the project's Penpot origin and pinned file ID"
153
+ : "Multiple browser pages match the pinned file; close duplicate tabs before waking");
154
+ }
155
+ const targetId = matching[0].id;
156
+ if (!/^[A-Za-z0-9_-]{1,256}$/.test(targetId)) {
157
+ throw new Error("Invalid browser debugging target ID");
158
+ }
159
+ return targetId;
160
+ }
161
+ function isBrowserTarget(value) {
162
+ if (!value || typeof value !== "object") {
163
+ return false;
164
+ }
165
+ const target = value;
166
+ return (typeof target.id === "string" &&
167
+ typeof target.type === "string" &&
168
+ typeof target.url === "string");
169
+ }
170
+ function isHttpLoopback(url) {
171
+ return url.protocol === "http:" && (url.hostname === "localhost" || url.hostname === "127.0.0.1");
172
+ }
173
+ function matchesOrigin(target, request) {
174
+ if (target.origin === request.origin) {
175
+ return true;
176
+ }
177
+ if (!request.allowLoopbackAlias || !isHttpLoopback(target)) {
178
+ return false;
179
+ }
180
+ const expected = new URL(request.origin);
181
+ return isHttpLoopback(expected) && target.port === expected.port;
182
+ }
183
+ function matchesFile(targetUrl, request) {
184
+ let url;
185
+ try {
186
+ url = new URL(targetUrl);
187
+ }
188
+ catch {
189
+ return false;
190
+ }
191
+ if (!matchesOrigin(url, request) || url.username || url.password) {
192
+ return false;
193
+ }
194
+ const hashRoute = url.hash.slice(1);
195
+ const hashQuery = hashRoute.indexOf("?");
196
+ const parameters = hashQuery >= 0 && hashRoute.slice(0, hashQuery) === "/workspace"
197
+ ? new URLSearchParams(hashRoute.slice(hashQuery + 1))
198
+ : url.pathname === "/workspace" && !url.hash
199
+ ? url.searchParams
200
+ : undefined;
201
+ const fileIds = parameters?.getAll("file-id");
202
+ return fileIds?.length === 1 && fileIds[0] === request.fileId;
203
+ }
204
+ async function readTargetList(response) {
205
+ const declaredBytes = Number(response.headers.get("content-length"));
206
+ if (declaredBytes > maxTargetBytes) {
207
+ void response.body?.cancel().catch(() => undefined);
208
+ throw new Error("Browser debugging target list exceeds the size limit");
209
+ }
210
+ if (!response.body) {
211
+ throw new Error("Invalid browser debugging target list");
212
+ }
213
+ const reader = response.body.getReader();
214
+ const chunks = [];
215
+ let bytes = 0;
216
+ try {
217
+ while (true) {
218
+ const next = await reader.read();
219
+ if (next.done) {
220
+ break;
221
+ }
222
+ bytes += next.value.byteLength;
223
+ if (bytes > maxTargetBytes) {
224
+ throw new Error("Browser debugging target list exceeds the size limit");
225
+ }
226
+ chunks.push(next.value);
227
+ }
228
+ }
229
+ catch (error) {
230
+ void reader.cancel().catch(() => undefined);
231
+ if (error instanceof Error &&
232
+ error.message === "Browser debugging target list exceeds the size limit") {
233
+ throw error;
234
+ }
235
+ throw new Error("Cannot read browser debugging target list");
236
+ }
237
+ finally {
238
+ reader.releaseLock();
239
+ }
240
+ return Buffer.concat(chunks, bytes).toString("utf8");
241
+ }