@penpair/core 0.3.7 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters.d.ts +1 -1
- package/dist/adapters.js +3 -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/cli-main.js +64 -3
- 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/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/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/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
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { open } from "node:fs/promises";
|
|
2
|
+
import { resolveProjectInput } from "./project-input.js";
|
|
3
|
+
export const maximumImageBytes = 64 * 1024 * 1024;
|
|
4
|
+
function losslessWebpSize(bytes) {
|
|
5
|
+
const malformed = () => new Error("Malformed static lossless WebP evidence");
|
|
6
|
+
if (bytes.readUInt32LE(4) + 8 !== bytes.length) {
|
|
7
|
+
throw malformed();
|
|
8
|
+
}
|
|
9
|
+
let imageSize;
|
|
10
|
+
let canvasSize;
|
|
11
|
+
let offset = 12;
|
|
12
|
+
while (offset < bytes.length) {
|
|
13
|
+
if (offset + 8 > bytes.length) {
|
|
14
|
+
throw malformed();
|
|
15
|
+
}
|
|
16
|
+
const chunk = bytes.subarray(offset, offset + 4).toString("latin1");
|
|
17
|
+
const size = bytes.readUInt32LE(offset + 4);
|
|
18
|
+
const payload = offset + 8;
|
|
19
|
+
const end = payload + size;
|
|
20
|
+
const paddedEnd = end + (size % 2);
|
|
21
|
+
if (paddedEnd > bytes.length || (size % 2 !== 0 && bytes[end] !== 0)) {
|
|
22
|
+
throw malformed();
|
|
23
|
+
}
|
|
24
|
+
if (chunk === "VP8 " || chunk === "ANIM" || chunk === "ANMF" || chunk === "ALPH") {
|
|
25
|
+
throw new Error("Provider evidence must reference PNG or static lossless WebP bytes");
|
|
26
|
+
}
|
|
27
|
+
if (chunk === "VP8X") {
|
|
28
|
+
if (offset !== 12 ||
|
|
29
|
+
canvasSize ||
|
|
30
|
+
size !== 10 ||
|
|
31
|
+
(bytes[payload] & 0xc3) !== 0 ||
|
|
32
|
+
bytes.readUIntLE(payload + 1, 3) !== 0) {
|
|
33
|
+
throw malformed();
|
|
34
|
+
}
|
|
35
|
+
canvasSize = {
|
|
36
|
+
width: bytes.readUIntLE(payload + 4, 3) + 1,
|
|
37
|
+
height: bytes.readUIntLE(payload + 7, 3) + 1,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
else if (chunk === "VP8L") {
|
|
41
|
+
if (imageSize || size <= 5 || bytes[payload] !== 0x2f) {
|
|
42
|
+
throw malformed();
|
|
43
|
+
}
|
|
44
|
+
const dimensions = bytes.readUInt32LE(payload + 1);
|
|
45
|
+
if (dimensions >>> 29 !== 0) {
|
|
46
|
+
throw malformed();
|
|
47
|
+
}
|
|
48
|
+
imageSize = {
|
|
49
|
+
width: (dimensions & 0x3fff) + 1,
|
|
50
|
+
height: ((dimensions >>> 14) & 0x3fff) + 1,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
else if (offset === 12) {
|
|
54
|
+
throw malformed();
|
|
55
|
+
}
|
|
56
|
+
offset = paddedEnd;
|
|
57
|
+
}
|
|
58
|
+
if (!imageSize ||
|
|
59
|
+
(canvasSize && (canvasSize.width !== imageSize.width || canvasSize.height !== imageSize.height))) {
|
|
60
|
+
throw malformed();
|
|
61
|
+
}
|
|
62
|
+
return imageSize;
|
|
63
|
+
}
|
|
64
|
+
/** Header/container assurance only; this does not decode compressed pixels. */
|
|
65
|
+
export function inspectImageEvidence(bytes) {
|
|
66
|
+
if (bytes.length > maximumImageBytes) {
|
|
67
|
+
throw new Error("Image exceeds 64 MiB");
|
|
68
|
+
}
|
|
69
|
+
if (bytes.length >= 24 &&
|
|
70
|
+
bytes.subarray(0, 8).toString("hex") === "89504e470d0a1a0a" &&
|
|
71
|
+
bytes.subarray(12, 16).toString("latin1") === "IHDR") {
|
|
72
|
+
const width = bytes.readUInt32BE(16);
|
|
73
|
+
const height = bytes.readUInt32BE(20);
|
|
74
|
+
if (width > 0 && height > 0) {
|
|
75
|
+
return {
|
|
76
|
+
format: "png",
|
|
77
|
+
width,
|
|
78
|
+
height,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
else if (bytes.length >= 12 &&
|
|
83
|
+
bytes.subarray(0, 4).toString("latin1") === "RIFF" &&
|
|
84
|
+
bytes.subarray(8, 12).toString("latin1") === "WEBP") {
|
|
85
|
+
return {
|
|
86
|
+
format: "webp",
|
|
87
|
+
...losslessWebpSize(bytes),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
throw new Error("Provider evidence must reference PNG or static lossless WebP bytes");
|
|
91
|
+
}
|
|
92
|
+
export async function readImageEvidence(root, file) {
|
|
93
|
+
const input = await resolveProjectInput(root, file);
|
|
94
|
+
const handle = await open(input, "r");
|
|
95
|
+
try {
|
|
96
|
+
const info = await handle.stat();
|
|
97
|
+
if (!info.isFile() || info.size > maximumImageBytes) {
|
|
98
|
+
throw new Error("Image exceeds 64 MiB or is not a regular file");
|
|
99
|
+
}
|
|
100
|
+
const buffer = Buffer.alloc(info.size + 1);
|
|
101
|
+
let length = 0;
|
|
102
|
+
while (length < buffer.length) {
|
|
103
|
+
const { bytesRead } = await handle.read(buffer, length, buffer.length - length, null);
|
|
104
|
+
if (bytesRead === 0) {
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
length += bytesRead;
|
|
108
|
+
}
|
|
109
|
+
if (length > info.size) {
|
|
110
|
+
throw new Error("Image changed during bounded read");
|
|
111
|
+
}
|
|
112
|
+
const bytes = buffer.subarray(0, length);
|
|
113
|
+
return {
|
|
114
|
+
bytes,
|
|
115
|
+
...inspectImageEvidence(bytes),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
await handle.close();
|
|
120
|
+
}
|
|
121
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -56,3 +56,8 @@ export * from "./project-status.js";
|
|
|
56
56
|
export * from "./audit-rules.js";
|
|
57
57
|
export * from "./credentials.js";
|
|
58
58
|
export * from "./token-projection.js";
|
|
59
|
+
export * from "./image-evidence.js";
|
|
60
|
+
export * from "./image-codec.js";
|
|
61
|
+
export * from "./maps/index.js";
|
|
62
|
+
export * from "./map-penpot/index.js";
|
|
63
|
+
export * from "./design-releases/index.js";
|
package/dist/index.js
CHANGED
|
@@ -55,3 +55,8 @@ export * from "./project-status.js";
|
|
|
55
55
|
export * from "./audit-rules.js";
|
|
56
56
|
export * from "./credentials.js";
|
|
57
57
|
export * from "./token-projection.js";
|
|
58
|
+
export * from "./image-evidence.js";
|
|
59
|
+
export * from "./image-codec.js";
|
|
60
|
+
export * from "./maps/index.js";
|
|
61
|
+
export * from "./map-penpot/index.js";
|
|
62
|
+
export * from "./design-releases/index.js";
|
package/dist/inspection.js
CHANGED
|
@@ -79,6 +79,7 @@ const clone = (value, unknowns, key) => {
|
|
|
79
79
|
} catch { unknowns.push(key); return null; }
|
|
80
80
|
};
|
|
81
81
|
const read = (object,key,unknowns,label=key) => { try { return clone(object[key],unknowns,label); } catch { unknowns.push(label); return null; } };
|
|
82
|
+
const optional = (object,key,unknowns,label=key) => { try { const value=object[key]; return value===undefined?null:clone(value,unknowns,label); } catch {unknowns.push(label);return null;} };
|
|
82
83
|
const pick = (object,keys,unknowns,prefix) => Object.fromEntries(keys.map(key=>[key,read(object,key,unknowns,prefix+"."+key)]));
|
|
83
84
|
let estimatedBytes = 1024;
|
|
84
85
|
if (CFG.kind === "shape") {
|
|
@@ -97,11 +98,11 @@ if (CFG.kind === "shape") {
|
|
|
97
98
|
if (item.depth <= CFG.depth) {
|
|
98
99
|
const unknowns = [], row = {id:s.id,parentId:item.parentId,index:item.index,depth:item.depth,type:read(s,"type",unknowns),name:read(s,"name",unknowns),childIds:children.slice(0,CFG.maxNodes).map(c=>c.id),childrenTotal:children.length,unknowns};
|
|
99
100
|
if (children.length > CFG.maxNodes) reasons.add("node-budget");
|
|
100
|
-
if (CFG.fields.includes("geometry")) row.geometry = pick(s,["x","y","width","height","rotation","hidden","blocked"],unknowns,"geometry");
|
|
101
|
-
if (CFG.fields.includes("text")) row.text = s.type === "text" ? {characters:read(s,"characters",unknowns),typography:pick(s,["fontId","fontFamily","fontVariantId","fontSize","fontWeight","fontStyle","lineHeight","letterSpacing","growType","direction","align","verticalAlign"],unknowns,"typography")} : null;
|
|
101
|
+
if (CFG.fields.includes("geometry")) row.geometry = pick(s,["x","y","width","height","rotation","hidden","blocked","flipX","flipY","proportionLock"],unknowns,"geometry");
|
|
102
|
+
if (CFG.fields.includes("text")) row.text = s.type === "text" ? {characters:read(s,"characters",unknowns),typography:pick(s,["fontId","fontFamily","fontVariantId","fontSize","fontWeight","fontStyle","lineHeight","letterSpacing","growType","direction","align","verticalAlign","textTransform","textDecoration"],unknowns,"typography")} : null;
|
|
102
103
|
if (row.text && Object.values(row.text.typography).includes("mixed")) unknowns.push("text.mixedRangeStyles");
|
|
103
104
|
if (CFG.fields.includes("layout")) {
|
|
104
|
-
row.layout = {};
|
|
105
|
+
row.layout = {constraints:pick(s,["constraintsHorizontal","constraintsVertical","fixedWhenScrolling"],unknowns,"layout.constraints")};
|
|
105
106
|
for (const [key,keys] of [["flex",["dir","wrap","alignItems","alignContent","justifyContent","rowGap","columnGap","topPadding","rightPadding","bottomPadding","leftPadding","horizontalSizing","verticalSizing"]],["layoutChild",["absolute","zIndex","horizontalSizing","verticalSizing","alignSelf","topMargin","rightMargin","bottomMargin","leftMargin","minWidth","maxWidth","minHeight","maxHeight"]]]) {
|
|
106
107
|
try { row.layout[key] = s[key] ? pick(s[key],keys,unknowns,"layout."+key) : null; } catch { row.layout[key]=null; unknowns.push("layout."+key); }
|
|
107
108
|
}
|
|
@@ -111,10 +112,17 @@ if (CFG.kind === "shape") {
|
|
|
111
112
|
if(grid)for(const key of ["rows","columns"])row.layout.grid[key]=(grid[key]||[]).map((track,index)=>pick(track,["type","value"],unknowns,"layout.grid."+key+"."+index));
|
|
112
113
|
} catch {row.layout.grid=null;unknowns.push("layout.grid");}
|
|
113
114
|
try {row.layout.cell=s.layoutCell?pick(s.layoutCell,["row","rowSpan","column","columnSpan","areaName","position"],unknowns,"layout.cell"):null;} catch {row.layout.cell=null;unknowns.push("layout.cell");}
|
|
114
|
-
row.layout.clipContent = read(s,"clipContent",unknowns,"layout.clipContent");
|
|
115
|
+
row.layout.clipContent = s.type === "board" ? read(s,"clipContent",unknowns,"layout.clipContent") : null;
|
|
116
|
+
row.layout.showInViewMode = s.type === "board" ? read(s,"showInViewMode",unknowns,"layout.showInViewMode") : null;
|
|
117
|
+
row.layout.boardSizing = s.type === "board" ? Object.fromEntries(["horizontalSizing","verticalSizing"].map(key=>[key,optional(s,key,unknowns,"layout.boardSizing."+key)])) : null;
|
|
115
118
|
}
|
|
116
119
|
if (CFG.fields.includes("tokens")) row.tokens = read(s,"tokens",unknowns);
|
|
117
|
-
if (CFG.fields.includes("style"))
|
|
120
|
+
if (CFG.fields.includes("style")) {
|
|
121
|
+
row.style = pick(s,["fills","strokes","shadows","opacity","blendMode","borderRadiusTopLeft","borderRadiusTopRight","borderRadiusBottomRight","borderRadiusBottomLeft"],unknowns,"style");
|
|
122
|
+
for (const key of ["blur","backgroundBlur"]) {
|
|
123
|
+
row.style[key]=optional(s,key,unknowns,"style."+key);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
118
126
|
if (CFG.fields.includes("component")) {
|
|
119
127
|
row.component = {};
|
|
120
128
|
for (const key of ["isComponentInstance","isComponentMainInstance","isComponentCopyInstance","isComponentRoot"]) {
|
|
@@ -160,6 +160,10 @@ export declare const projectConfigSchema: {
|
|
|
160
160
|
readonly type: "object";
|
|
161
161
|
readonly required: readonly ["page", "root", "frames"];
|
|
162
162
|
readonly properties: {
|
|
163
|
+
readonly format: {
|
|
164
|
+
readonly enum: readonly ["png", "webp"];
|
|
165
|
+
readonly description: "Format for new references; existing approved formats are preserved unless explicitly migrated.";
|
|
166
|
+
};
|
|
163
167
|
readonly page: {
|
|
164
168
|
readonly type: "string";
|
|
165
169
|
readonly minLength: 1;
|
|
@@ -280,6 +280,13 @@ export const projectConfigSchema = {
|
|
|
280
280
|
"frames",
|
|
281
281
|
],
|
|
282
282
|
properties: {
|
|
283
|
+
format: {
|
|
284
|
+
enum: [
|
|
285
|
+
"png",
|
|
286
|
+
"webp",
|
|
287
|
+
],
|
|
288
|
+
description: "Format for new references; existing approved formats are preserved unless explicitly migrated.",
|
|
289
|
+
},
|
|
283
290
|
page: {
|
|
284
291
|
type: "string",
|
|
285
292
|
minLength: 1,
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
function text(value, maximum) {
|
|
2
|
+
return value === undefined || (typeof value === "string" && value.length <= maximum);
|
|
3
|
+
}
|
|
4
|
+
function box(value) {
|
|
5
|
+
if (!value || typeof value !== "object") {
|
|
6
|
+
return false;
|
|
7
|
+
}
|
|
8
|
+
const bounds = value;
|
|
9
|
+
return ([
|
|
10
|
+
"x",
|
|
11
|
+
"y",
|
|
12
|
+
"width",
|
|
13
|
+
"height",
|
|
14
|
+
].every((key) => Number.isFinite(bounds[key])) &&
|
|
15
|
+
Number(bounds.x) >= 0 &&
|
|
16
|
+
Number(bounds.y) >= 0 &&
|
|
17
|
+
Number(bounds.width) >= 64 &&
|
|
18
|
+
Number(bounds.height) > 0);
|
|
19
|
+
}
|
|
20
|
+
/** Reject malformed content geometry before a publication can contact Penpot. */
|
|
21
|
+
export function validateMapDrawingContent(drawing) {
|
|
22
|
+
if (!text(drawing.goal, 8000) ||
|
|
23
|
+
!text(drawing.summary, 240) ||
|
|
24
|
+
!text(drawing.description, 8000)) {
|
|
25
|
+
throw new Error("Map descriptions exceed their supported text budgets");
|
|
26
|
+
}
|
|
27
|
+
if (drawing.header &&
|
|
28
|
+
(!box(drawing.header.titleBox) ||
|
|
29
|
+
(drawing.header.summaryBox !== undefined && !box(drawing.header.summaryBox)) ||
|
|
30
|
+
!Number.isFinite(drawing.header.legendY) ||
|
|
31
|
+
drawing.header.legendY < 0 ||
|
|
32
|
+
!Number.isFinite(drawing.header.height) ||
|
|
33
|
+
drawing.header.height <= 0)) {
|
|
34
|
+
throw new Error("Map header requires finite positive geometry");
|
|
35
|
+
}
|
|
36
|
+
if (drawing.annotations === undefined) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const nodeIds = new Set(drawing.nodes.map((node) => node.id));
|
|
40
|
+
if (!Array.isArray(drawing.annotations) ||
|
|
41
|
+
drawing.annotations.length > 1001 ||
|
|
42
|
+
new Set(drawing.annotations.map((annotation) => annotation?.id)).size !==
|
|
43
|
+
drawing.annotations.length ||
|
|
44
|
+
drawing.annotations.some((annotation) => !annotation ||
|
|
45
|
+
typeof annotation.id !== "string" ||
|
|
46
|
+
!/^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,255}$/.test(annotation.id) ||
|
|
47
|
+
!box(annotation) ||
|
|
48
|
+
(annotation.nodeId !== undefined && !nodeIds.has(annotation.nodeId)) ||
|
|
49
|
+
typeof annotation.title !== "string" ||
|
|
50
|
+
!annotation.title.trim() ||
|
|
51
|
+
annotation.title.length > 4000 ||
|
|
52
|
+
!text(annotation.goal, 8000) ||
|
|
53
|
+
!text(annotation.summary, 240) ||
|
|
54
|
+
!text(annotation.description, annotation.nodeId ? 4000 : 8000))) {
|
|
55
|
+
throw new Error("Map annotations require unique identities, bounded text and finite geometry");
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { MapDrawing, ProjectionShape } from "./types.js";
|
|
2
|
+
export declare const nativeMapRoles: readonly ["node", "label", "edge", "edge-label", "title", "badge", "legend", "map-summary", "map-full-summary", "map-goal", "map-description", "summary", "full-label", "full-summary", "description", "annotation-panel", "annotation-id", "annotation-heading", "icon-well", "icon", "port-in", "port-out"];
|
|
3
|
+
export interface NativeMapTextEntry {
|
|
4
|
+
role: ProjectionShape["role"];
|
|
5
|
+
logicalId: string;
|
|
6
|
+
text: string;
|
|
7
|
+
x: number;
|
|
8
|
+
y: number;
|
|
9
|
+
width: number;
|
|
10
|
+
height: number;
|
|
11
|
+
fontSize: number;
|
|
12
|
+
color: string;
|
|
13
|
+
strong?: boolean;
|
|
14
|
+
semantic?: {
|
|
15
|
+
scope: "map" | "node";
|
|
16
|
+
field: "title" | "goal" | "label" | "summary" | "description";
|
|
17
|
+
nodeId?: string;
|
|
18
|
+
compact?: boolean;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export declare function nativeMapTextEntries(drawing: MapDrawing): NativeMapTextEntry[];
|
|
22
|
+
export declare function nativeMapShapeKeys(drawing: MapDrawing): Set<string>;
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { mapAnnotationLayout } from "../maps/annotation-layout.js";
|
|
2
|
+
import { mapLabelHeight } from "../maps/layout.js";
|
|
3
|
+
import { mapOriginLabels, mapOriginLegend } from "./status-labels.js";
|
|
4
|
+
export const nativeMapRoles = [
|
|
5
|
+
"node",
|
|
6
|
+
"label",
|
|
7
|
+
"edge",
|
|
8
|
+
"edge-label",
|
|
9
|
+
"title",
|
|
10
|
+
"badge",
|
|
11
|
+
"legend",
|
|
12
|
+
"map-summary",
|
|
13
|
+
"map-full-summary",
|
|
14
|
+
"map-goal",
|
|
15
|
+
"map-description",
|
|
16
|
+
"summary",
|
|
17
|
+
"full-label",
|
|
18
|
+
"full-summary",
|
|
19
|
+
"description",
|
|
20
|
+
"annotation-panel",
|
|
21
|
+
"annotation-id",
|
|
22
|
+
"annotation-heading",
|
|
23
|
+
"icon-well",
|
|
24
|
+
"icon",
|
|
25
|
+
"port-in",
|
|
26
|
+
"port-out",
|
|
27
|
+
];
|
|
28
|
+
export function nativeMapTextEntries(drawing) {
|
|
29
|
+
const result = [];
|
|
30
|
+
const add = (role, logicalId, text, x, y, width, height, fontSize = 14, color = "#101828", semantic, strong = false) => {
|
|
31
|
+
result.push({
|
|
32
|
+
role,
|
|
33
|
+
logicalId,
|
|
34
|
+
text,
|
|
35
|
+
x,
|
|
36
|
+
y,
|
|
37
|
+
width,
|
|
38
|
+
height,
|
|
39
|
+
fontSize,
|
|
40
|
+
color,
|
|
41
|
+
...(semantic
|
|
42
|
+
? {
|
|
43
|
+
semantic,
|
|
44
|
+
}
|
|
45
|
+
: {}),
|
|
46
|
+
...(strong
|
|
47
|
+
? {
|
|
48
|
+
strong,
|
|
49
|
+
}
|
|
50
|
+
: {}),
|
|
51
|
+
});
|
|
52
|
+
};
|
|
53
|
+
const titleBox = drawing.header?.titleBox ?? {
|
|
54
|
+
x: 32,
|
|
55
|
+
y: 24,
|
|
56
|
+
width: 900,
|
|
57
|
+
height: 40,
|
|
58
|
+
};
|
|
59
|
+
add("title", drawing.mapId, drawing.title, titleBox.x, titleBox.y, titleBox.width, titleBox.height, 24, "#101828", {
|
|
60
|
+
scope: "map",
|
|
61
|
+
field: "title",
|
|
62
|
+
}, true);
|
|
63
|
+
const headerText = drawing.headerSummary ??
|
|
64
|
+
drawing.summary ??
|
|
65
|
+
(drawing.goal && drawing.goal.length <= 240 ? drawing.goal : undefined);
|
|
66
|
+
if (headerText) {
|
|
67
|
+
const box = drawing.header?.summaryBox ?? {
|
|
68
|
+
x: 32,
|
|
69
|
+
y: 72,
|
|
70
|
+
width: 900,
|
|
71
|
+
height: mapLabelHeight(headerText, 900),
|
|
72
|
+
};
|
|
73
|
+
add("map-summary", drawing.mapId, headerText, box.x, box.y, box.width, box.height, 14, "#667085", {
|
|
74
|
+
scope: "map",
|
|
75
|
+
field: drawing.summary !== undefined ? "summary" : "goal",
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
if (drawing.nodes.some((node) => node.origin)) {
|
|
79
|
+
add("legend", drawing.mapId, mapOriginLegend, 32, drawing.header?.legendY ?? (headerText ? 132 : 72), 1000, 20, 10, "#667085");
|
|
80
|
+
}
|
|
81
|
+
for (const node of drawing.nodes) {
|
|
82
|
+
const modern = node.cardTitle !== undefined;
|
|
83
|
+
const title = modern
|
|
84
|
+
? node.cardTitle
|
|
85
|
+
: (node.kind === "decision" ? "Decision: " : node.kind === "detail-link" ? "Details: " : "") +
|
|
86
|
+
node.label;
|
|
87
|
+
add("label", node.id, title, node.x + 64, node.y + 18, node.width - 80, 32, 14, "#101828", {
|
|
88
|
+
scope: "node",
|
|
89
|
+
field: "label",
|
|
90
|
+
nodeId: node.id,
|
|
91
|
+
compact: modern && title !== node.label,
|
|
92
|
+
}, true);
|
|
93
|
+
const summary = node.cardSummary ?? node.summary;
|
|
94
|
+
if (summary) {
|
|
95
|
+
add("summary", node.id, summary, node.x + 64, node.y + 50, node.width - 80, 48, 12, "#667085", {
|
|
96
|
+
scope: "node",
|
|
97
|
+
field: "summary",
|
|
98
|
+
nodeId: node.id,
|
|
99
|
+
compact: summary !== node.summary,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
if (node.origin) {
|
|
103
|
+
add("badge", node.id, mapOriginLabels[node.origin], node.x + 16, node.y + node.height - 24, node.width - 32, 16, 10, "#667085");
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
for (const edge of drawing.edges) {
|
|
107
|
+
if (edge.label && edge.labelBox) {
|
|
108
|
+
add("edge-label", edge.id, edge.label, edge.labelBox.x, edge.labelBox.y, edge.labelBox.width, edge.labelBox.height, 12, "#667085");
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
for (const annotation of drawing.annotations ?? []) {
|
|
112
|
+
const node = annotation.nodeId
|
|
113
|
+
? drawing.nodes.find((candidate) => candidate.id === annotation.nodeId)
|
|
114
|
+
: undefined;
|
|
115
|
+
const id = node?.id ?? drawing.mapId;
|
|
116
|
+
const layout = mapAnnotationLayout(annotation);
|
|
117
|
+
const x = annotation.x + 20;
|
|
118
|
+
const width = annotation.width - 40;
|
|
119
|
+
add("annotation-id", annotation.id, node ? `Node · ${node.id}` : "Map context", x, annotation.y + layout.idBox.y, width, layout.idBox.height, 10, "#667085");
|
|
120
|
+
if (node && layout.titleBox) {
|
|
121
|
+
add("full-label", node.id, node.label, x, annotation.y + layout.titleBox.y, width, layout.titleBox.height, 16, "#101828", {
|
|
122
|
+
scope: "node",
|
|
123
|
+
field: "label",
|
|
124
|
+
nodeId: node.id,
|
|
125
|
+
}, true);
|
|
126
|
+
}
|
|
127
|
+
for (const fieldLayout of layout.fields) {
|
|
128
|
+
const field = fieldLayout.field;
|
|
129
|
+
const value = annotation[field];
|
|
130
|
+
add("annotation-heading", `${annotation.id}:${field}`, field[0].toUpperCase() + field.slice(1), x, annotation.y + fieldLayout.headingY, width, fieldLayout.headingHeight, 11, "#667085", undefined, true);
|
|
131
|
+
const role = node
|
|
132
|
+
? field === "summary"
|
|
133
|
+
? "full-summary"
|
|
134
|
+
: "description"
|
|
135
|
+
: field === "goal"
|
|
136
|
+
? "map-goal"
|
|
137
|
+
: field === "summary"
|
|
138
|
+
? "map-full-summary"
|
|
139
|
+
: "map-description";
|
|
140
|
+
add(role, id, value, x, annotation.y + fieldLayout.y, width, fieldLayout.height, 14, "#344054", {
|
|
141
|
+
scope: node ? "node" : "map",
|
|
142
|
+
field,
|
|
143
|
+
...(node
|
|
144
|
+
? {
|
|
145
|
+
nodeId: node.id,
|
|
146
|
+
}
|
|
147
|
+
: {}),
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return result;
|
|
152
|
+
}
|
|
153
|
+
export function nativeMapShapeKeys(drawing) {
|
|
154
|
+
return new Set([
|
|
155
|
+
...nativeMapTextEntries(drawing).map((entry) => `${entry.role}:${entry.logicalId}`),
|
|
156
|
+
...drawing.nodes.flatMap((node) => [
|
|
157
|
+
"node",
|
|
158
|
+
"icon-well",
|
|
159
|
+
"icon",
|
|
160
|
+
"port-in",
|
|
161
|
+
"port-out",
|
|
162
|
+
].map((role) => `${role}:${node.id}`)),
|
|
163
|
+
...drawing.edges.flatMap((edge) => [
|
|
164
|
+
`edge:${edge.id}`,
|
|
165
|
+
...(edge.label
|
|
166
|
+
? [
|
|
167
|
+
`edge-label:${edge.id}`,
|
|
168
|
+
]
|
|
169
|
+
: []),
|
|
170
|
+
]),
|
|
171
|
+
...(drawing.annotations ?? []).map((annotation) => `annotation-panel:${annotation.id}`),
|
|
172
|
+
]);
|
|
173
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { MapDrawing } from "./types.js";
|
|
2
|
+
/** Resolve only explicitly pinned direct contracts; never recurse into another map's sources. */
|
|
3
|
+
export declare function resolveMapDetails(root: string, drawing: MapDrawing): Promise<MapDrawing>;
|
|
4
|
+
export declare function requireResolvedMapDetails(drawing: MapDrawing): void;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readProjectBytes } from "../bounded-file.js";
|
|
3
|
+
import { hashMapContract, maxMapBytes, validateMapContract, validateMapDetail, } from "../maps/validation.js";
|
|
4
|
+
/** Resolve only explicitly pinned direct contracts; never recurse into another map's sources. */
|
|
5
|
+
export async function resolveMapDetails(root, drawing) {
|
|
6
|
+
const references = drawing.nodes.filter((node) => node.detail);
|
|
7
|
+
if (references.length > 64) {
|
|
8
|
+
throw new Error("Native map supports at most 64 direct detail references");
|
|
9
|
+
}
|
|
10
|
+
const identity = drawing.mapId.match(/^(journey-map|integration-map):(.+)$/);
|
|
11
|
+
if (references.length && !identity) {
|
|
12
|
+
throw new Error("Native detail maps require a namespaced canonical parent identity");
|
|
13
|
+
}
|
|
14
|
+
const parent = {
|
|
15
|
+
kind: identity?.[1],
|
|
16
|
+
id: identity?.[2] ?? "",
|
|
17
|
+
sources: references.map((node) => ({
|
|
18
|
+
...node.detail,
|
|
19
|
+
id: node.detail.sourceId,
|
|
20
|
+
kind: "contract",
|
|
21
|
+
})),
|
|
22
|
+
};
|
|
23
|
+
let totalBytes = 0;
|
|
24
|
+
const cache = new Map();
|
|
25
|
+
const resolved = new Map();
|
|
26
|
+
for (const node of references) {
|
|
27
|
+
const detail = node.detail;
|
|
28
|
+
const key = `${detail.path}:${detail.sha256}`;
|
|
29
|
+
let contract = cache.get(key);
|
|
30
|
+
if (!contract) {
|
|
31
|
+
const bytes = await readProjectBytes(root, detail.path, maxMapBytes);
|
|
32
|
+
totalBytes += bytes.length;
|
|
33
|
+
if (totalBytes > 128 * 1024 * 1024) {
|
|
34
|
+
throw new Error("Direct detail contracts exceed the 128 MiB aggregate budget");
|
|
35
|
+
}
|
|
36
|
+
if (createHash("sha256").update(bytes).digest("hex") !== detail.sha256) {
|
|
37
|
+
throw new Error(`Pinned detail contract changed: ${detail.sourceId}`);
|
|
38
|
+
}
|
|
39
|
+
let raw;
|
|
40
|
+
try {
|
|
41
|
+
raw = JSON.parse(bytes.toString("utf8"));
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
throw new Error(`Invalid detail contract JSON: ${detail.sourceId}`);
|
|
45
|
+
}
|
|
46
|
+
contract = validateMapContract(raw);
|
|
47
|
+
cache.set(key, contract);
|
|
48
|
+
}
|
|
49
|
+
validateMapDetail(parent, {
|
|
50
|
+
id: node.id,
|
|
51
|
+
sourceIds: [
|
|
52
|
+
detail.sourceId,
|
|
53
|
+
],
|
|
54
|
+
requirementIds: node.requirementIds,
|
|
55
|
+
}, contract);
|
|
56
|
+
const identity = {
|
|
57
|
+
mapId: `${contract.kind}:${contract.id}`,
|
|
58
|
+
contractHash: hashMapContract(contract),
|
|
59
|
+
};
|
|
60
|
+
if ((detail.mapId !== undefined && detail.mapId !== identity.mapId) ||
|
|
61
|
+
(detail.contractHash !== undefined && detail.contractHash !== identity.contractHash)) {
|
|
62
|
+
throw new Error(`Resolved detail contract identity differs: ${detail.sourceId}`);
|
|
63
|
+
}
|
|
64
|
+
resolved.set(key, identity);
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
...drawing,
|
|
68
|
+
nodes: drawing.nodes.map((node) => node.detail
|
|
69
|
+
? {
|
|
70
|
+
...node,
|
|
71
|
+
detail: {
|
|
72
|
+
...node.detail,
|
|
73
|
+
...resolved.get(`${node.detail.path}:${node.detail.sha256}`),
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
: node),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
export function requireResolvedMapDetails(drawing) {
|
|
80
|
+
if (drawing.nodes.some((node) => node.detail &&
|
|
81
|
+
(!node.detail.mapId || !/^[a-f0-9]{64}$/.test(node.detail.contractHash ?? "")))) {
|
|
82
|
+
throw new Error("Resolve pinned detail contract identities before using native map evidence");
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { PenpairError } from "../errors.js";
|
|
2
|
+
export class MapExportEvidenceError extends PenpairError {
|
|
3
|
+
runId;
|
|
4
|
+
constructor(runId, detail) {
|
|
5
|
+
super("incomplete-output", `Native map write is verified, but export evidence failed for run ${runId}: ${detail}. Inspect the run and rerun the original publish command with --apply --resume ${runId}.`);
|
|
6
|
+
this.runId = runId;
|
|
7
|
+
this.name = "MapExportEvidenceError";
|
|
8
|
+
}
|
|
9
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { MapIcon } from "../maps/types.js";
|
|
2
|
+
import type { ProjectionShape } from "./types.js";
|
|
3
|
+
export declare const nativeMapIcons: Record<MapIcon, string>;
|
|
4
|
+
export declare function defaultMapIcon(kind: string): MapIcon;
|
|
5
|
+
export declare function identifyMapIcon(shape: Pick<ProjectionShape, "content">): MapIcon | undefined;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
export const nativeMapIcons = {
|
|
2
|
+
screen: "M0 0 L24 0 L24 18 L0 18 L0 0 M6 24 L18 24 M12 18 L12 24",
|
|
3
|
+
action: "M14 0 L2 14 L11 14 L10 24 L22 9 L13 9 L14 0",
|
|
4
|
+
decision: "M12 0 L24 12 L12 24 L0 12 L12 0",
|
|
5
|
+
data: "M0 0 L24 0 L24 24 L0 24 L0 0 M0 8 L24 8 M0 16 L24 16",
|
|
6
|
+
service: "M0 0 L24 0 L24 10 L0 10 L0 0 M0 14 L24 14 L24 24 L0 24 L0 14 M5 5 L8 5 M5 19 L8 19",
|
|
7
|
+
code: "M8 0 L0 12 L8 24 M16 0 L24 12 L16 24",
|
|
8
|
+
check: "M0 12 L8 22 L24 0",
|
|
9
|
+
link: "M0 8 L0 0 L16 0 L16 16 L8 16 M8 8 L24 8 L24 24 L8 24 L8 8",
|
|
10
|
+
};
|
|
11
|
+
export function defaultMapIcon(kind) {
|
|
12
|
+
if (kind === "decision") {
|
|
13
|
+
return "decision";
|
|
14
|
+
}
|
|
15
|
+
if (kind === "detail-link") {
|
|
16
|
+
return "link";
|
|
17
|
+
}
|
|
18
|
+
if (/screen|view|page|route/.test(kind)) {
|
|
19
|
+
return "screen";
|
|
20
|
+
}
|
|
21
|
+
if (/data|store|state/.test(kind)) {
|
|
22
|
+
return "data";
|
|
23
|
+
}
|
|
24
|
+
if (/service|api|request/.test(kind)) {
|
|
25
|
+
return "service";
|
|
26
|
+
}
|
|
27
|
+
if (/component|hook|code/.test(kind)) {
|
|
28
|
+
return "code";
|
|
29
|
+
}
|
|
30
|
+
if (/success|outcome|complete/.test(kind)) {
|
|
31
|
+
return "check";
|
|
32
|
+
}
|
|
33
|
+
return "action";
|
|
34
|
+
}
|
|
35
|
+
function normalizedIcon(content) {
|
|
36
|
+
if (typeof content !== "string" || content.length > 8192) {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
const tokens = content.match(/[ML]|[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?/g);
|
|
40
|
+
if (!tokens || tokens.join("") !== content.replace(/[\s,]/g, "")) {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
const points = [];
|
|
44
|
+
for (let index = 0; index < tokens.length; index += 3) {
|
|
45
|
+
const command = tokens[index];
|
|
46
|
+
const x = Number(tokens[index + 1]);
|
|
47
|
+
const y = Number(tokens[index + 2]);
|
|
48
|
+
if (![
|
|
49
|
+
"M",
|
|
50
|
+
"L",
|
|
51
|
+
].includes(command ?? "") ||
|
|
52
|
+
!Number.isFinite(x) ||
|
|
53
|
+
!Number.isFinite(y)) {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
points.push({
|
|
57
|
+
command: command,
|
|
58
|
+
x,
|
|
59
|
+
y,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
const minX = Math.min(...points.map((point) => point.x));
|
|
63
|
+
const minY = Math.min(...points.map((point) => point.y));
|
|
64
|
+
const width = Math.max(...points.map((point) => point.x)) - minX;
|
|
65
|
+
const height = Math.max(...points.map((point) => point.y)) - minY;
|
|
66
|
+
if (width <= 0 || height <= 0) {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
return points
|
|
70
|
+
.map((point) => `${point.command}${Math.round(((point.x - minX) / width) * 10000)},${Math.round(((point.y - minY) / height) * 10000)}`)
|
|
71
|
+
.join(" ");
|
|
72
|
+
}
|
|
73
|
+
export function identifyMapIcon(shape) {
|
|
74
|
+
const actual = normalizedIcon(shape.content);
|
|
75
|
+
if (!actual) {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
return Object.keys(nativeMapIcons).find((icon) => normalizedIcon(nativeMapIcons[icon]) === actual);
|
|
79
|
+
}
|