@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.
- package/dist/adapters.d.ts +1 -1
- package/dist/adapters.js +4 -0
- package/dist/approvals.d.ts +4 -1
- package/dist/approvals.js +7 -4
- package/dist/baseline-coverage.js +3 -3
- package/dist/baselines-config.d.ts +3 -0
- package/dist/baselines-config.js +3 -0
- package/dist/baselines.d.ts +7 -0
- package/dist/baselines.js +90 -21
- package/dist/bounded-file.d.ts +2 -0
- package/dist/bounded-file.js +38 -0
- package/dist/browser-wake.d.ts +22 -0
- package/dist/browser-wake.js +241 -0
- package/dist/cli-main.js +193 -18
- package/dist/design-commands/context.d.ts +9 -0
- package/dist/design-commands/context.js +1 -0
- package/dist/design-commands/index.d.ts +3 -0
- package/dist/design-commands/index.js +7 -0
- package/dist/design-commands/map-command.d.ts +4 -0
- package/dist/design-commands/map-command.js +685 -0
- package/dist/design-commands/release-command.d.ts +3 -0
- package/dist/design-commands/release-command.js +239 -0
- package/dist/design-releases/artifact-paths.d.ts +6 -0
- package/dist/design-releases/artifact-paths.js +82 -0
- package/dist/design-releases/assemble.d.ts +3 -0
- package/dist/design-releases/assemble.js +176 -0
- package/dist/design-releases/check.d.ts +16 -0
- package/dist/design-releases/check.js +323 -0
- package/dist/design-releases/context.d.ts +2 -0
- package/dist/design-releases/context.js +119 -0
- package/dist/design-releases/contract-graph.d.ts +13 -0
- package/dist/design-releases/contract-graph.js +75 -0
- package/dist/design-releases/fidelity.d.ts +23 -0
- package/dist/design-releases/fidelity.js +328 -0
- package/dist/design-releases/finalize.d.ts +4 -0
- package/dist/design-releases/finalize.js +201 -0
- package/dist/design-releases/index.d.ts +8 -0
- package/dist/design-releases/index.js +7 -0
- package/dist/design-releases/live.d.ts +22 -0
- package/dist/design-releases/live.js +97 -0
- package/dist/design-releases/snippets.d.ts +5 -0
- package/dist/design-releases/snippets.js +89 -0
- package/dist/design-releases/store.d.ts +10 -0
- package/dist/design-releases/store.js +205 -0
- package/dist/design-releases/types.d.ts +161 -0
- package/dist/design-releases/types.js +1 -0
- package/dist/design-releases/validation.d.ts +6 -0
- package/dist/design-releases/validation.js +384 -0
- package/dist/design-releases/verify.d.ts +3 -0
- package/dist/design-releases/verify.js +148 -0
- package/dist/errors.js +1 -1
- package/dist/image-codec.d.ts +3 -0
- package/dist/image-codec.js +78 -0
- package/dist/image-evidence.d.ts +15 -0
- package/dist/image-evidence.js +121 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/inspection.js +13 -5
- package/dist/instance/config-schema.d.ts +4 -0
- package/dist/instance/config-schema.js +7 -0
- package/dist/instance/connection.d.ts +2 -0
- package/dist/instance/connection.js +15 -3
- package/dist/kit-source.js +1 -1
- package/dist/layout-policy.js +21 -11
- package/dist/layout-settling.d.ts +1 -0
- package/dist/layout-settling.js +51 -17
- package/dist/layout.d.ts +7 -1
- package/dist/layout.js +19 -3
- package/dist/map-penpot/content-validation.d.ts +3 -0
- package/dist/map-penpot/content-validation.js +57 -0
- package/dist/map-penpot/content.d.ts +22 -0
- package/dist/map-penpot/content.js +173 -0
- package/dist/map-penpot/details.d.ts +4 -0
- package/dist/map-penpot/details.js +84 -0
- package/dist/map-penpot/evidence-error.d.ts +5 -0
- package/dist/map-penpot/evidence-error.js +9 -0
- package/dist/map-penpot/icons.d.ts +5 -0
- package/dist/map-penpot/icons.js +79 -0
- package/dist/map-penpot/import-content.d.ts +2 -0
- package/dist/map-penpot/import-content.js +150 -0
- package/dist/map-penpot/import.d.ts +5 -0
- package/dist/map-penpot/import.js +255 -0
- package/dist/map-penpot/index.d.ts +11 -0
- package/dist/map-penpot/index.js +10 -0
- package/dist/map-penpot/path-geometry.d.ts +5 -0
- package/dist/map-penpot/path-geometry.js +51 -0
- package/dist/map-penpot/presentation.d.ts +20 -0
- package/dist/map-penpot/presentation.js +205 -0
- package/dist/map-penpot/projection.d.ts +8 -0
- package/dist/map-penpot/projection.js +176 -0
- package/dist/map-penpot/publish.d.ts +4 -0
- package/dist/map-penpot/publish.js +301 -0
- package/dist/map-penpot/release-maps.d.ts +21 -0
- package/dist/map-penpot/release-maps.js +104 -0
- package/dist/map-penpot/snippets.d.ts +4 -0
- package/dist/map-penpot/snippets.js +164 -0
- package/dist/map-penpot/status-labels.d.ts +7 -0
- package/dist/map-penpot/status-labels.js +7 -0
- package/dist/map-penpot/types.d.ts +161 -0
- package/dist/map-penpot/types.js +1 -0
- package/dist/maps/annotation-layout.d.ts +24 -0
- package/dist/maps/annotation-layout.js +85 -0
- package/dist/maps/context.d.ts +13 -0
- package/dist/maps/context.js +207 -0
- package/dist/maps/index.d.ts +9 -0
- package/dist/maps/index.js +9 -0
- package/dist/maps/layout.d.ts +35 -0
- package/dist/maps/layout.js +364 -0
- package/dist/maps/proposal.d.ts +5 -0
- package/dist/maps/proposal.js +67 -0
- package/dist/maps/render.d.ts +4 -0
- package/dist/maps/render.js +331 -0
- package/dist/maps/review.d.ts +16 -0
- package/dist/maps/review.js +90 -0
- package/dist/maps/store.d.ts +21 -0
- package/dist/maps/store.js +222 -0
- package/dist/maps/types.d.ts +206 -0
- package/dist/maps/types.js +1 -0
- package/dist/maps/validation.d.ts +10 -0
- package/dist/maps/validation.js +375 -0
- package/dist/plugin-health.d.ts +13 -0
- package/dist/plugin-health.js +39 -0
- package/dist/skill-routing.js +4 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -1
- package/skills/penpair-design-release/SKILL.md +55 -0
- package/skills/penpair-design-release/agents/openai.yaml +6 -0
- package/skills/penpair-design-release/references/workflow.md +55 -0
- package/skills/penpair-diagrams/SKILL.md +52 -0
- package/skills/penpair-diagrams/agents/openai.yaml +6 -0
- package/skills/penpair-diagrams/references/workflow.md +84 -0
- package/skills/penpair-handoff/SKILL.md +7 -0
- package/skills/penpair-handoff/references/handoff-flow.md +7 -1
- package/skills/penpair-integration-map/SKILL.md +44 -0
- package/skills/penpair-integration-map/agents/openai.yaml +6 -0
- package/skills/penpair-integration-map/references/workflow.md +33 -0
- package/skills/penpair-journey-map/SKILL.md +42 -0
- package/skills/penpair-journey-map/agents/openai.yaml +6 -0
- package/skills/penpair-journey-map/references/workflow.md +35 -0
- package/skills/penpair-verify/SKILL.md +7 -0
- package/skills/penpair-verify/references/browser-evidence.md +10 -0
package/dist/adapters.d.ts
CHANGED
|
@@ -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",
|
package/dist/approvals.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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}
|
|
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,
|
|
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 {
|
|
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 =
|
|
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
|
|
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;
|
package/dist/baselines-config.js
CHANGED
|
@@ -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
|
}
|
package/dist/baselines.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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(
|
|
244
|
+
sha256: sha(encoded),
|
|
176
245
|
capturedAt: new Date().toISOString(),
|
|
177
246
|
toolVersion: version,
|
|
178
|
-
file: `${generation}/${wanted.id}
|
|
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 ?
|
|
260
|
-
const size =
|
|
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
|
|
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 [
|
|
383
|
+
for (const [file, bytes] of captures) {
|
|
315
384
|
options.signal?.throwIfAborted();
|
|
316
|
-
await atomicWriteFile(path.join(staged,
|
|
317
|
-
options.onStep?.(`staging ${
|
|
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,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
|
+
}
|