@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
@@ -0,0 +1,331 @@
1
+ import { designDataBlock } from "../project-input.js";
2
+ import { mapAnnotationLayout } from "./annotation-layout.js";
3
+ import { mapDrawingGeometryProblems, mapEdgeLabelWidth, mapLabelHeight, mapNodeLayers, routeMapEdges, } from "./layout.js";
4
+ import { hashMapContract, validateMapContract, validateMapDetail } from "./validation.js";
5
+ function mermaidLabel(value) {
6
+ return Array.from(value)
7
+ .map((character) => /^[a-zA-Z0-9 .,_-]$/.test(character) ? character : `#${character.codePointAt(0)};`)
8
+ .join("");
9
+ }
10
+ function tableText(value) {
11
+ return value
12
+ .replaceAll("&", "&")
13
+ .replaceAll("<", "&lt;")
14
+ .replaceAll(">", "&gt;")
15
+ .replaceAll("|", "&#124;")
16
+ .replace(/[\\`*_[\]#!~]/g, (character) => `\\${character}`)
17
+ .replace(/[\r\n]+/g, " / ")
18
+ .replace(/[\u2028\u2029\u202a-\u202e\u2066-\u2069]/g, (character) => `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`);
19
+ }
20
+ function reviewTables(contract) {
21
+ const nodes = [
22
+ "## Regions and responsibilities",
23
+ "",
24
+ "| ID | Kind | Origin | Title | Summary | Description | Icon | Requirements |",
25
+ "| --- | --- | --- | --- | --- | --- | --- | --- |",
26
+ ...contract.nodes.map((node) => `| ${tableText(node.id)} | ${tableText(node.kind)} | ${node.origin} | ${tableText(node.label)} | ${tableText(node.summary ?? "")} | ${tableText(node.description ?? "")} | ${node.icon ?? ""} | ${tableText((node.requirementIds ?? []).join(", "))} |`),
27
+ ];
28
+ const requirements = [
29
+ "## Requirements",
30
+ "",
31
+ "| ID | Origin | Declared requirement |",
32
+ "| --- | --- | --- |",
33
+ ...contract.requirements.map((requirement) => `| ${tableText(requirement.id)} | ${requirement.origin} | ${tableText(requirement.text)} |`),
34
+ ];
35
+ const findings = [
36
+ "## Findings and decisions",
37
+ "",
38
+ "| ID | Severity | Status | Finding | Resolution |",
39
+ "| --- | --- | --- | --- | --- |",
40
+ ...contract.findings.map((finding) => `| ${tableText(finding.id)} | ${finding.severity} | ${finding.status} | ${tableText(finding.message)} | ${tableText(finding.resolution ?? "Pending")} |`),
41
+ ];
42
+ const detailRows = contract.nodes
43
+ .filter((node) => node.kind === "detail-link")
44
+ .map((node) => {
45
+ const source = contract.sources.find((entry) => entry.id === node.sourceIds[0]);
46
+ const target = `./${source
47
+ .path.split("/")
48
+ .map((segment) => encodeURIComponent(segment).replace(/[()]/g, (character) => `%${character.charCodeAt(0).toString(16)}`))
49
+ .join("/")}`;
50
+ return `| ${tableText(node.id)} | [${tableText(source.path)}](<${target}>) | ${tableText(source.pageId)} / ${tableText(source.frameId)} | ${tableText(node.requirementIds.join(", "))} | Separate artifact review required |`;
51
+ });
52
+ const details = detailRows.length
53
+ ? [
54
+ "## Linked detail contracts",
55
+ "",
56
+ "Links are project-relative. Each detail is published and reviewed independently; its contents are not inlined here.",
57
+ "",
58
+ "| Region | Contract | Native page / board | Requirements | Review |",
59
+ "| --- | --- | --- | --- | --- |",
60
+ ...detailRows,
61
+ "",
62
+ "",
63
+ ].join("\n")
64
+ : "";
65
+ return `${nodes.join("\n")}\n\n${requirements.join("\n")}\n\n${findings.join("\n")}\n\n${details}`;
66
+ }
67
+ export function renderMapMermaid(raw) {
68
+ const contract = validateMapContract(raw);
69
+ const aliases = new Map(contract.nodes.map((node, index) => [
70
+ node.id,
71
+ `node${index}`,
72
+ ]));
73
+ const lines = [
74
+ "flowchart LR",
75
+ ` %% title: ${mermaidLabel(contract.title)}`,
76
+ ` %% goal: ${mermaidLabel(contract.goal)}`,
77
+ ];
78
+ if (contract.summary !== undefined) {
79
+ lines.push(` %% summary: ${mermaidLabel(contract.summary)}`);
80
+ }
81
+ if (contract.description !== undefined) {
82
+ lines.push(` %% description: ${mermaidLabel(contract.description)}`);
83
+ }
84
+ for (const node of contract.nodes) {
85
+ lines.push(` ${aliases.get(node.id)}["${mermaidLabel((node.kind === "detail-link" ? "Details: " : "") + node.label + (node.summary ? ` / ${node.summary}` : ""))}"]`);
86
+ if (node.description !== undefined) {
87
+ lines.push(` %% ${aliases.get(node.id)} description: ${mermaidLabel(node.description)}`);
88
+ }
89
+ if (node.icon !== undefined) {
90
+ lines.push(` %% ${aliases.get(node.id)} icon: ${node.icon}`);
91
+ }
92
+ }
93
+ for (const edge of contract.edges) {
94
+ lines.push(` ${aliases.get(edge.from)} -->|"${mermaidLabel(edge.label)}"| ${aliases.get(edge.to)}`);
95
+ }
96
+ return `${lines.join("\n")}\n`;
97
+ }
98
+ export function renderMapMarkdown(raw) {
99
+ const contract = validateMapContract(raw);
100
+ const hash = hashMapContract(contract);
101
+ return (`# ${contract.kind === "journey-map" ? "Journey map" : "Integration map"}\n\n` +
102
+ `Contract hash: ${hash}\n\n` +
103
+ `Title: ${tableText(contract.title)}\n\nGoal: ${tableText(contract.goal)}\n\n` +
104
+ (contract.summary === undefined ? "" : `Summary: ${tableText(contract.summary)}\n\n`) +
105
+ (contract.description === undefined
106
+ ? ""
107
+ : `Description: ${tableText(contract.description)}\n\n`) +
108
+ "Proposed and observed design data; review records are separate. Appearance does not establish behavior, accessibility or implementation verification.\n\n" +
109
+ `\`\`\`mermaid\n${renderMapMermaid(contract)}\`\`\`\n\n` +
110
+ reviewTables(contract) +
111
+ designDataBlock(contract));
112
+ }
113
+ export function buildMapDrawingPlan(raw, detailMaps = {}) {
114
+ const contract = validateMapContract(raw);
115
+ const detailReasons = [];
116
+ const layers = mapNodeLayers(contract);
117
+ const columns = Math.max(...layers.values()) + 1;
118
+ const sameColumn = contract.edges.some((edge) => layers.get(edge.from) === layers.get(edge.to));
119
+ const columnXs = [
120
+ sameColumn ? 400 : 96,
121
+ ];
122
+ for (let column = 0; column < columns - 1; column++) {
123
+ const crossing = contract.edges.filter((edge) => layers.get(edge.from) === column && layers.get(edge.to) === column + 1);
124
+ const gap = Math.max(160, ...crossing.map((edge) => mapEdgeLabelWidth(edge.label) + 64 + crossing.length * 8));
125
+ columnXs.push(columnXs[column] + 240 + gap);
126
+ }
127
+ const titleBox = {
128
+ x: 32,
129
+ y: 24,
130
+ width: 1120,
131
+ height: mapLabelHeight(contract.title, 700) + 8,
132
+ };
133
+ const headerSummary = contract.summary ?? (contract.goal.length <= 240 ? contract.goal : undefined);
134
+ const summaryBox = headerSummary === undefined
135
+ ? undefined
136
+ : {
137
+ x: 32,
138
+ y: titleBox.y + titleBox.height + 12,
139
+ width: 1120,
140
+ height: mapLabelHeight(headerSummary, 1120),
141
+ };
142
+ const legendY = (summaryBox ? summaryBox.y + summaryBox.height : titleBox.y + titleBox.height) + 16;
143
+ const header = {
144
+ titleBox,
145
+ ...(summaryBox
146
+ ? {
147
+ summaryBox,
148
+ }
149
+ : {}),
150
+ legendY,
151
+ height: legendY + 48,
152
+ };
153
+ const compact = (text, limit) => text.length <= limit ? text : `${text.slice(0, limit - 1)}…`;
154
+ const columnEnds = Array.from({
155
+ length: columns,
156
+ }, () => Math.max(180, header.height + 32));
157
+ const nodes = contract.nodes.map((node) => {
158
+ const column = layers.get(node.id);
159
+ const y = columnEnds[column];
160
+ const height = 144;
161
+ columnEnds[column] = y + height + 160;
162
+ let detail;
163
+ if (node.kind === "detail-link") {
164
+ const source = contract.sources.find((entry) => entry.id === node.sourceIds[0]);
165
+ const child = detailMaps[source.id];
166
+ if (!child) {
167
+ detailReasons.push(`Resolve the independently reviewed detail contract before native publication: ${source.id}`);
168
+ }
169
+ else {
170
+ const target = validateMapDetail(contract, node, child);
171
+ detail = {
172
+ sourceId: source.id,
173
+ path: source.path,
174
+ sha256: source.sha256,
175
+ fileId: source.fileId,
176
+ pageId: source.pageId,
177
+ frameId: source.frameId,
178
+ mapId: `${target.kind}:${target.id}`,
179
+ contractHash: hashMapContract(target),
180
+ };
181
+ }
182
+ }
183
+ return {
184
+ ...node,
185
+ cardTitle: compact(node.label, 24),
186
+ ...(node.summary === undefined
187
+ ? {}
188
+ : {
189
+ cardSummary: compact(node.summary, 42),
190
+ }),
191
+ ...(node.label.length > 24 ||
192
+ (node.summary?.length ?? 0) > 42 ||
193
+ node.description !== undefined
194
+ ? {
195
+ annotationId: `node:${node.id}`,
196
+ }
197
+ : {}),
198
+ ...(detail
199
+ ? {
200
+ detail,
201
+ }
202
+ : {}),
203
+ x: columnXs[column],
204
+ y,
205
+ width: 240,
206
+ height,
207
+ };
208
+ });
209
+ const edges = routeMapEdges(nodes, contract.edges);
210
+ const width = Math.max(titleBox.x + titleBox.width, ...nodes.map((node) => node.x + node.width), ...edges.map((edge) => edge.labelBox.x + edge.labelBox.width)) + 80;
211
+ const flowHeight = Math.max(...nodes.map((node) => node.y + node.height), ...edges.map((edge) => edge.labelBox.y + edge.labelBox.height + 48), ...edges.flatMap((edge) => edge.points.map((point) => point.y))) + 80;
212
+ const annotations = [];
213
+ let annotationY = flowHeight + 32;
214
+ const noteWidth = 1120;
215
+ const noteGap = 24;
216
+ const noteColumnWidth = (noteWidth - noteGap) / 2;
217
+ let columnY = [
218
+ annotationY,
219
+ annotationY,
220
+ ];
221
+ const annotate = (annotation) => {
222
+ const width = annotation.nodeId ? noteColumnWidth : noteWidth;
223
+ const height = mapAnnotationLayout({
224
+ ...annotation,
225
+ width,
226
+ }).height;
227
+ const column = columnY[0] <= columnY[1] ? 0 : 1;
228
+ const y = annotation.nodeId ? columnY[column] : annotationY;
229
+ annotations.push({
230
+ ...annotation,
231
+ x: 32 + (annotation.nodeId ? column * (noteColumnWidth + noteGap) : 0),
232
+ y,
233
+ width,
234
+ height,
235
+ });
236
+ if (annotation.nodeId) {
237
+ columnY[column] = y + height + noteGap;
238
+ }
239
+ else {
240
+ annotationY = y + height + noteGap;
241
+ columnY = [
242
+ annotationY,
243
+ annotationY,
244
+ ];
245
+ }
246
+ };
247
+ if (contract.summary !== undefined ||
248
+ contract.goal.length > 240 ||
249
+ contract.description !== undefined) {
250
+ annotate({
251
+ id: "map:details",
252
+ title: contract.title,
253
+ goal: contract.goal,
254
+ ...(contract.summary === undefined
255
+ ? {}
256
+ : {
257
+ summary: contract.summary,
258
+ }),
259
+ ...(contract.description === undefined
260
+ ? {}
261
+ : {
262
+ description: contract.description,
263
+ }),
264
+ });
265
+ }
266
+ for (const node of nodes) {
267
+ if (node.annotationId) {
268
+ annotate({
269
+ id: node.annotationId,
270
+ nodeId: node.id,
271
+ title: node.label,
272
+ ...(node.summary === undefined
273
+ ? {}
274
+ : {
275
+ summary: node.summary,
276
+ }),
277
+ ...(node.description === undefined
278
+ ? {}
279
+ : {
280
+ description: node.description,
281
+ }),
282
+ });
283
+ }
284
+ }
285
+ const height = annotations.length ? Math.max(...columnY) + 32 : flowHeight;
286
+ const reasons = [
287
+ ...detailReasons,
288
+ ...mapDrawingGeometryProblems(nodes, edges),
289
+ ];
290
+ if (width > 16384 || height > 16384) {
291
+ reasons.push("Layered diagram exceeds 16384 px per axis; use linked detail boards before native publication");
292
+ }
293
+ const plan = {
294
+ schemaVersion: 1,
295
+ kind: "map-drawing-plan",
296
+ mapKind: contract.kind,
297
+ mapId: contract.id,
298
+ mapHash: hashMapContract(contract),
299
+ title: contract.title,
300
+ goal: contract.goal,
301
+ ...(contract.summary === undefined
302
+ ? {}
303
+ : {
304
+ summary: contract.summary,
305
+ }),
306
+ ...(contract.description === undefined
307
+ ? {}
308
+ : {
309
+ description: contract.description,
310
+ }),
311
+ ...(headerSummary === undefined
312
+ ? {}
313
+ : {
314
+ headerSummary,
315
+ }),
316
+ header,
317
+ annotations,
318
+ fontFamily: contract.presentation?.fontFamily ?? "Inter",
319
+ width,
320
+ height,
321
+ nodes,
322
+ edges,
323
+ complete: reasons.length === 0,
324
+ reasons,
325
+ };
326
+ if (Buffer.byteLength(JSON.stringify(plan)) > 64 * 1024) {
327
+ plan.complete = false;
328
+ plan.reasons.push("Drawing plan exceeds 64 KiB; use linked detail boards to reserve the plugin snippet budget");
329
+ }
330
+ return plan;
331
+ }
@@ -0,0 +1,16 @@
1
+ import type { ArtifactReview } from "./types.js";
2
+ export interface ArtifactReviewInput {
3
+ artifactKind: ArtifactReview["artifactKind"];
4
+ artifactId: string;
5
+ artifactHash: string;
6
+ by: string;
7
+ note: string;
8
+ artifacts?: Array<{
9
+ path: string;
10
+ sha256: string;
11
+ }>;
12
+ }
13
+ export declare function validateArtifactReview(raw: unknown): ArtifactReview;
14
+ /** Records supplied owner words; content hashes cannot authenticate identity or consent. */
15
+ export declare function createArtifactReview(input: ArtifactReviewInput): ArtifactReview;
16
+ export declare function findArtifactReview(reviews: ArtifactReview[], artifactKind: ArtifactReview["artifactKind"], artifactId: string, artifactHash: string): ArtifactReview | undefined;
@@ -0,0 +1,90 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { observationHash } from "../journal.js";
3
+ import { mapHash, mapId, mapObject, mapPath, mapText } from "./validation.js";
4
+ export function validateArtifactReview(raw) {
5
+ const review = mapObject(raw, "Artifact review", [
6
+ "schemaVersion",
7
+ "kind",
8
+ "id",
9
+ "artifactKind",
10
+ "artifactId",
11
+ "artifactHash",
12
+ "by",
13
+ "note",
14
+ "recordedAt",
15
+ "artifacts",
16
+ "hash",
17
+ ]);
18
+ if (review.schemaVersion !== 1 ||
19
+ review.kind !== "artifact-review" ||
20
+ typeof review.artifactKind !== "string" ||
21
+ ![
22
+ "journey-map",
23
+ "integration-map",
24
+ "design-release",
25
+ "design-evidence",
26
+ ].includes(review.artifactKind)) {
27
+ throw new Error("Unsupported artifact review schema or kind");
28
+ }
29
+ const recordedAt = mapText(review.recordedAt, "Review time", 100);
30
+ if (!Number.isFinite(Date.parse(recordedAt))) {
31
+ throw new Error("Review requires a valid recording time");
32
+ }
33
+ const result = {
34
+ schemaVersion: 1,
35
+ kind: "artifact-review",
36
+ id: mapId(review.id, "Review ID"),
37
+ artifactKind: review.artifactKind,
38
+ artifactId: mapId(review.artifactId, "Reviewed artifact ID"),
39
+ artifactHash: mapHash(review.artifactHash, "Reviewed artifact hash"),
40
+ by: mapText(review.by, "Owner identity", 200),
41
+ note: mapText(review.note, "Owner words", 4000),
42
+ recordedAt,
43
+ hash: mapHash(review.hash, "Review record hash"),
44
+ };
45
+ if (review.artifacts !== undefined) {
46
+ if (!Array.isArray(review.artifacts) || review.artifacts.length > 1000) {
47
+ throw new Error("Review artifacts must contain at most 1000 explicit references");
48
+ }
49
+ result.artifacts = review.artifacts.map((rawArtifact) => {
50
+ const artifact = mapObject(rawArtifact, "Review artifact", [
51
+ "path",
52
+ "sha256",
53
+ ]);
54
+ return {
55
+ path: mapPath(artifact.path, "Review artifact path"),
56
+ sha256: mapHash(artifact.sha256, "Review artifact hash"),
57
+ };
58
+ });
59
+ if (new Set(result.artifacts.map((artifact) => artifact.path)).size !== result.artifacts.length) {
60
+ throw new Error("Duplicate review artifact path");
61
+ }
62
+ }
63
+ const { hash, ...body } = result;
64
+ if (hash !== observationHash(body)) {
65
+ throw new Error("Artifact review evidence hash mismatch");
66
+ }
67
+ return result;
68
+ }
69
+ /** Records supplied owner words; content hashes cannot authenticate identity or consent. */
70
+ export function createArtifactReview(input) {
71
+ const body = {
72
+ schemaVersion: 1,
73
+ kind: "artifact-review",
74
+ id: randomUUID(),
75
+ ...input,
76
+ recordedAt: new Date().toISOString(),
77
+ };
78
+ return validateArtifactReview({
79
+ ...body,
80
+ hash: observationHash(body),
81
+ });
82
+ }
83
+ export function findArtifactReview(reviews, artifactKind, artifactId, artifactHash) {
84
+ return reviews
85
+ .map(validateArtifactReview)
86
+ .reverse()
87
+ .find((review) => review.artifactKind === artifactKind &&
88
+ review.artifactId === artifactId &&
89
+ review.artifactHash === artifactHash);
90
+ }
@@ -0,0 +1,21 @@
1
+ import type { ArtifactReview, MapContract, MapProposal } from "./types.js";
2
+ export interface MapWriteOptions {
3
+ replace?: boolean;
4
+ expectedHash?: string;
5
+ redact?: (text: string) => string;
6
+ }
7
+ export declare function readMapContract(root: string, file: string): Promise<MapContract>;
8
+ export declare function writeMapContract(root: string, file: string, raw: MapContract, options?: MapWriteOptions): Promise<void>;
9
+ export declare function readMapProposal(root: string, file: string): Promise<MapProposal>;
10
+ export declare function writeMapProposal(root: string, file: string, proposal: MapProposal, options?: MapWriteOptions): Promise<void>;
11
+ export declare function readArtifactReviews(root: string, file: string): Promise<ArtifactReview[]>;
12
+ export declare function verifyArtifactReviewEvidence(root: string, raw: ArtifactReview): Promise<boolean>;
13
+ export declare function writeArtifactReview(root: string, file: string, raw: ArtifactReview, options?: MapWriteOptions): Promise<ArtifactReview>;
14
+ export interface MapSourceObservation {
15
+ sourceHashes: Record<string, string>;
16
+ journeyHash?: string;
17
+ journeyRequirementIds?: string[];
18
+ detailMaps: Record<string, MapContract>;
19
+ }
20
+ /** Only explicitly referenced project files are read; a hash does not prove live design freshness. */
21
+ export declare function observeMapSources(root: string, raw: MapContract): Promise<MapSourceObservation>;
@@ -0,0 +1,222 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdir, open, unlink } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { atomicWriteFile } from "../atomic-output.js";
5
+ import { readProjectBytes } from "../bounded-file.js";
6
+ import { resolveProjectOutput } from "../project-input.js";
7
+ import { validateMapProposal } from "./proposal.js";
8
+ import { validateArtifactReview } from "./review.js";
9
+ import { hashMapContract, mapObject, maxMapBytes, validateMapContract, validateMapDetail, } from "./validation.js";
10
+ async function readBoundedBytes(root, file) {
11
+ return readProjectBytes(root, file, maxMapBytes);
12
+ }
13
+ async function readMapJson(root, file) {
14
+ const bytes = await readBoundedBytes(root, file);
15
+ try {
16
+ return JSON.parse(bytes.toString("utf8"));
17
+ }
18
+ catch {
19
+ throw new Error("Map artifact is not valid JSON; inspect the explicitly referenced file privately");
20
+ }
21
+ }
22
+ function encodeMapArtifact(value, redact) {
23
+ const encoded = `${JSON.stringify(value, null, 2)}\n`;
24
+ if (Buffer.byteLength(encoded) > maxMapBytes) {
25
+ throw new Error("Map artifact exceeds the 16 MiB byte budget");
26
+ }
27
+ if (redact && redact(encoded) !== encoded) {
28
+ throw new Error("Map artifact requires credential redaction; previous evidence preserved");
29
+ }
30
+ return encoded;
31
+ }
32
+ async function withArtifactLock(root, file, work) {
33
+ const target = await resolveProjectOutput(root, file);
34
+ await mkdir(path.dirname(target), {
35
+ recursive: true,
36
+ });
37
+ const lock = await open(`${target}.lock`, "wx", 0o600);
38
+ try {
39
+ return await work(target);
40
+ }
41
+ finally {
42
+ await lock.close();
43
+ await unlink(`${target}.lock`);
44
+ }
45
+ }
46
+ export async function readMapContract(root, file) {
47
+ return validateMapContract(await readMapJson(root, file));
48
+ }
49
+ export async function writeMapContract(root, file, raw, options = {}) {
50
+ const contract = validateMapContract(raw);
51
+ const encoded = encodeMapArtifact(contract, options.redact);
52
+ await withArtifactLock(root, file, async (target) => {
53
+ if (options.expectedHash !== undefined &&
54
+ hashMapContract(await readMapContract(root, file)) !== options.expectedHash) {
55
+ throw new Error("Canonical map changed before publication; inspect the current revision");
56
+ }
57
+ await atomicWriteFile(target, encoded, {
58
+ replace: options.replace ?? false,
59
+ });
60
+ });
61
+ }
62
+ export async function readMapProposal(root, file) {
63
+ return validateMapProposal(await readMapJson(root, file));
64
+ }
65
+ export async function writeMapProposal(root, file, proposal, options = {}) {
66
+ const encoded = encodeMapArtifact(validateMapProposal(proposal), options.redact);
67
+ await withArtifactLock(root, file, async (target) => atomicWriteFile(target, encoded, {
68
+ replace: options.replace ?? false,
69
+ }));
70
+ }
71
+ export async function readArtifactReviews(root, file) {
72
+ let raw;
73
+ try {
74
+ raw = await readMapJson(root, file);
75
+ }
76
+ catch (error) {
77
+ if (error.code === "ENOENT") {
78
+ return [];
79
+ }
80
+ throw error;
81
+ }
82
+ const document = mapObject(raw, "Artifact reviews", [
83
+ "schemaVersion",
84
+ "kind",
85
+ "records",
86
+ ]);
87
+ if (document.schemaVersion !== 1 ||
88
+ document.kind !== "artifact-reviews" ||
89
+ !Array.isArray(document.records) ||
90
+ document.records.length > 10000) {
91
+ throw new Error("Unsupported artifact reviews document");
92
+ }
93
+ const reviews = document.records.map(validateArtifactReview);
94
+ if (new Set(reviews.map((review) => review.id)).size !== reviews.length) {
95
+ throw new Error("Duplicate artifact review ID");
96
+ }
97
+ return reviews;
98
+ }
99
+ export async function verifyArtifactReviewEvidence(root, raw) {
100
+ const review = validateArtifactReview(raw);
101
+ for (const artifact of review.artifacts ?? []) {
102
+ let bytes;
103
+ try {
104
+ bytes = await readProjectBytes(root, artifact.path, 64 * 1024 * 1024);
105
+ }
106
+ catch (error) {
107
+ if (error.code === "ENOENT") {
108
+ return false;
109
+ }
110
+ throw error;
111
+ }
112
+ if (createHash("sha256").update(bytes).digest("hex") !== artifact.sha256) {
113
+ return false;
114
+ }
115
+ }
116
+ return true;
117
+ }
118
+ export async function writeArtifactReview(root, file, raw, options = {}) {
119
+ const review = validateArtifactReview(raw);
120
+ if (!(await verifyArtifactReviewEvidence(root, review))) {
121
+ throw new Error("Reviewed artifact bytes changed; no review was recorded");
122
+ }
123
+ await withArtifactLock(root, file, async (target) => {
124
+ const previous = await readArtifactReviews(root, file);
125
+ const duplicate = previous.find((entry) => entry.id === review.id);
126
+ if (duplicate) {
127
+ if (duplicate.hash !== review.hash) {
128
+ throw new Error("A different review already uses this record ID");
129
+ }
130
+ return;
131
+ }
132
+ if (previous.length >= 10000) {
133
+ throw new Error("Artifact review record limit reached");
134
+ }
135
+ const encoded = encodeMapArtifact({
136
+ schemaVersion: 1,
137
+ kind: "artifact-reviews",
138
+ records: [
139
+ ...previous,
140
+ review,
141
+ ],
142
+ }, options.redact);
143
+ await atomicWriteFile(target, encoded, {
144
+ replace: true,
145
+ });
146
+ });
147
+ return review;
148
+ }
149
+ /** Only explicitly referenced project files are read; a hash does not prove live design freshness. */
150
+ export async function observeMapSources(root, raw) {
151
+ const contract = validateMapContract(raw);
152
+ const sourceHashes = Object.create(null);
153
+ const detailMaps = Object.create(null);
154
+ const detailSourceIds = new Set(contract.nodes
155
+ .filter((node) => node.kind === "detail-link")
156
+ .flatMap((node) => node.sourceIds ?? []));
157
+ let detailBytes = 0;
158
+ for (const source of contract.sources) {
159
+ if (!source.path) {
160
+ continue;
161
+ }
162
+ try {
163
+ const bytes = await readBoundedBytes(root, source.path);
164
+ sourceHashes[source.id] = createHash("sha256").update(bytes).digest("hex");
165
+ if (detailSourceIds.has(source.id) && sourceHashes[source.id] === source.sha256) {
166
+ detailBytes += bytes.length;
167
+ if (detailBytes > 128 * 1024 * 1024) {
168
+ throw new Error("Direct detail contracts exceed the 128 MiB total input budget");
169
+ }
170
+ let rawDetail;
171
+ try {
172
+ rawDetail = JSON.parse(bytes.toString("utf8"));
173
+ }
174
+ catch {
175
+ throw new Error("Linked detail contract is not valid JSON; no source text was included");
176
+ }
177
+ let detail;
178
+ for (const node of contract.nodes.filter((entry) => entry.kind === "detail-link" && entry.sourceIds?.[0] === source.id)) {
179
+ detail = validateMapDetail(contract, node, rawDetail);
180
+ }
181
+ detailMaps[source.id] = detail;
182
+ }
183
+ }
184
+ catch (error) {
185
+ if (error.code === "ENOENT") {
186
+ sourceHashes[source.id] = "missing";
187
+ }
188
+ else {
189
+ throw error;
190
+ }
191
+ }
192
+ }
193
+ if (!contract.journey) {
194
+ return {
195
+ sourceHashes,
196
+ detailMaps,
197
+ };
198
+ }
199
+ let journey;
200
+ try {
201
+ journey = await readMapContract(root, contract.journey.path);
202
+ }
203
+ catch (error) {
204
+ if (error.code === "ENOENT") {
205
+ return {
206
+ sourceHashes,
207
+ detailMaps,
208
+ journeyHash: "missing",
209
+ };
210
+ }
211
+ throw error;
212
+ }
213
+ if (journey.kind !== "journey-map") {
214
+ throw new Error("Integration journey reference must resolve to a journey map");
215
+ }
216
+ return {
217
+ sourceHashes,
218
+ detailMaps,
219
+ journeyHash: hashMapContract(journey),
220
+ journeyRequirementIds: journey.requirements.map((requirement) => requirement.id),
221
+ };
222
+ }