@coldtea/pr-lens-schema 0.1.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/LICENSE +21 -0
- package/README.md +150 -0
- package/dist/apply.d.ts +42 -0
- package/dist/apply.d.ts.map +1 -0
- package/dist/apply.js +315 -0
- package/dist/apply.js.map +1 -0
- package/dist/config.d.ts +58 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +61 -0
- package/dist/config.js.map +1 -0
- package/dist/errors.d.ts +24 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +12 -0
- package/dist/errors.js.map +1 -0
- package/dist/examples/baseline.d.ts +18 -0
- package/dist/examples/baseline.d.ts.map +1 -0
- package/dist/examples/baseline.js +433 -0
- package/dist/examples/baseline.js.map +1 -0
- package/dist/examples/index.d.ts +1194 -0
- package/dist/examples/index.d.ts.map +1 -0
- package/dist/examples/index.js +20 -0
- package/dist/examples/index.js.map +1 -0
- package/dist/examples/minimal.d.ts +4 -0
- package/dist/examples/minimal.d.ts.map +1 -0
- package/dist/examples/minimal.js +25 -0
- package/dist/examples/minimal.js.map +1 -0
- package/dist/examples/postmark-refactor.d.ts +16 -0
- package/dist/examples/postmark-refactor.d.ts.map +1 -0
- package/dist/examples/postmark-refactor.js +468 -0
- package/dist/examples/postmark-refactor.js.map +1 -0
- package/dist/graph.d.ts +568 -0
- package/dist/graph.d.ts.map +1 -0
- package/dist/graph.js +266 -0
- package/dist/graph.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/integrity.d.ts +20 -0
- package/dist/integrity.d.ts.map +1 -0
- package/dist/integrity.js +167 -0
- package/dist/integrity.js.map +1 -0
- package/dist/manifest.d.ts +65 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +60 -0
- package/dist/manifest.js.map +1 -0
- package/dist/patch.d.ts +824 -0
- package/dist/patch.d.ts.map +1 -0
- package/dist/patch.js +84 -0
- package/dist/patch.js.map +1 -0
- package/dist/primitives.d.ts +92 -0
- package/dist/primitives.d.ts.map +1 -0
- package/dist/primitives.js +120 -0
- package/dist/primitives.js.map +1 -0
- package/dist/utils.d.ts +6 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +8 -0
- package/dist/utils.js.map +1 -0
- package/dist/validate.d.ts +18 -0
- package/dist/validate.d.ts.map +1 -0
- package/dist/validate.js +95 -0
- package/dist/validate.js.map +1 -0
- package/dist/version.d.ts +21 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +24 -0
- package/dist/version.js.map +1 -0
- package/examples/broadcast-baseline.graph.json +289 -0
- package/examples/broadcast-baseline.patch.json +321 -0
- package/examples/minimal.graph.json +45 -0
- package/examples/postmark-refactor.graph.json +580 -0
- package/examples/postmark-refactor.render-manifest.json +67 -0
- package/examples/pr-lens.config.json +32 -0
- package/json-schema/config.schema.json +153 -0
- package/json-schema/graph-doc.schema.json +1032 -0
- package/json-schema/patch-doc.schema.json +1383 -0
- package/json-schema/render-manifest.schema.json +183 -0
- package/package.json +66 -0
- package/src/apply.ts +399 -0
- package/src/config.ts +69 -0
- package/src/errors.ts +34 -0
- package/src/examples/baseline.ts +437 -0
- package/src/examples/index.ts +25 -0
- package/src/examples/minimal.ts +26 -0
- package/src/examples/postmark-refactor.ts +480 -0
- package/src/graph.ts +331 -0
- package/src/index.ts +82 -0
- package/src/integrity.ts +216 -0
- package/src/manifest.ts +64 -0
- package/src/patch.ts +100 -0
- package/src/primitives.ts +146 -0
- package/src/utils.ts +7 -0
- package/src/validate.ts +132 -0
- package/src/version.ts +31 -0
package/src/graph.ts
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { Delta, FileRef, Id, Label, Lens, SchemaVersionField, Sha, Summary } from "./primitives.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Coarse on purpose: this drives the card icon and shape, never analysis.
|
|
6
|
+
* Anything that does not fit is `other`, which still renders.
|
|
7
|
+
*/
|
|
8
|
+
export const NodeKind = z.enum([
|
|
9
|
+
"service",
|
|
10
|
+
"app",
|
|
11
|
+
"module",
|
|
12
|
+
"function",
|
|
13
|
+
"route",
|
|
14
|
+
"job",
|
|
15
|
+
"queue",
|
|
16
|
+
"datastore",
|
|
17
|
+
"cache",
|
|
18
|
+
"external",
|
|
19
|
+
"ui",
|
|
20
|
+
"config",
|
|
21
|
+
"test",
|
|
22
|
+
"package",
|
|
23
|
+
"other",
|
|
24
|
+
]);
|
|
25
|
+
export type NodeKind = z.infer<typeof NodeKind>;
|
|
26
|
+
|
|
27
|
+
export const Lane = z
|
|
28
|
+
.strictObject({
|
|
29
|
+
id: Id,
|
|
30
|
+
label: Label,
|
|
31
|
+
subtitle: Label.optional().describe("Secondary line in the lane header, e.g. the platform."),
|
|
32
|
+
order: z
|
|
33
|
+
.int()
|
|
34
|
+
.min(0)
|
|
35
|
+
.max(64)
|
|
36
|
+
.optional()
|
|
37
|
+
.describe("Left-to-right placement. Ties fall back to array order."),
|
|
38
|
+
delta: Delta.optional().describe("Set only when the lane itself is new or gone."),
|
|
39
|
+
summary: Summary.optional(),
|
|
40
|
+
})
|
|
41
|
+
.describe("A grouping band of the diagram. Every node belongs to exactly one lane.");
|
|
42
|
+
export type Lane = z.infer<typeof Lane>;
|
|
43
|
+
|
|
44
|
+
export const GraphNode = z
|
|
45
|
+
.strictObject({
|
|
46
|
+
id: Id,
|
|
47
|
+
label: Label,
|
|
48
|
+
kind: NodeKind,
|
|
49
|
+
delta: Delta,
|
|
50
|
+
lane: Id.describe("Id of the lane this node sits in."),
|
|
51
|
+
group: Id.optional().describe("Optional sub-cluster within the lane, e.g. a package."),
|
|
52
|
+
subtitle: Label.optional().describe("Secondary line on the card, e.g. a symbol signature."),
|
|
53
|
+
summary: Summary.optional().describe("Body text for this node's drill-down section."),
|
|
54
|
+
files: z
|
|
55
|
+
.array(FileRef)
|
|
56
|
+
.max(64)
|
|
57
|
+
.default([])
|
|
58
|
+
.describe("Backing source locations, used to build diff permalinks."),
|
|
59
|
+
badges: z
|
|
60
|
+
.array(Label)
|
|
61
|
+
.max(6)
|
|
62
|
+
.default([])
|
|
63
|
+
.describe("Extra chips on the card, beyond the delta badge the renderer adds."),
|
|
64
|
+
})
|
|
65
|
+
.describe("A node in the architecture graph.");
|
|
66
|
+
export type GraphNode = z.infer<typeof GraphNode>;
|
|
67
|
+
|
|
68
|
+
export const EdgeKind = z.enum([
|
|
69
|
+
"call",
|
|
70
|
+
"http",
|
|
71
|
+
"rpc",
|
|
72
|
+
"event",
|
|
73
|
+
"queue",
|
|
74
|
+
"data",
|
|
75
|
+
"dependency",
|
|
76
|
+
"render",
|
|
77
|
+
"other",
|
|
78
|
+
]);
|
|
79
|
+
export type EdgeKind = z.infer<typeof EdgeKind>;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* `hero` is the one connection the change is really about. More than a couple
|
|
83
|
+
* per diagram and the emphasis stops meaning anything.
|
|
84
|
+
*/
|
|
85
|
+
export const EdgeEmphasis = z.enum(["normal", "hero", "muted"]);
|
|
86
|
+
export type EdgeEmphasis = z.infer<typeof EdgeEmphasis>;
|
|
87
|
+
|
|
88
|
+
export const GraphEdge = z
|
|
89
|
+
.strictObject({
|
|
90
|
+
id: Id,
|
|
91
|
+
from: Id.describe("Source node id."),
|
|
92
|
+
to: Id.describe("Target node id."),
|
|
93
|
+
kind: EdgeKind,
|
|
94
|
+
delta: Delta,
|
|
95
|
+
label: Label.optional().describe("Text on the edge, e.g. a payload size or protocol."),
|
|
96
|
+
emphasis: EdgeEmphasis.default("normal"),
|
|
97
|
+
animated: z
|
|
98
|
+
.boolean()
|
|
99
|
+
.default(false)
|
|
100
|
+
.describe("Render a travelling pulse along this edge in the architecture lens."),
|
|
101
|
+
summary: Summary.optional(),
|
|
102
|
+
files: z.array(FileRef).max(32).default([]),
|
|
103
|
+
})
|
|
104
|
+
.describe("A directed connection between two nodes.");
|
|
105
|
+
export type GraphEdge = z.infer<typeof GraphEdge>;
|
|
106
|
+
|
|
107
|
+
/** `async` fires and forgets, `return` carries a result back, `self` never leaves the participant. */
|
|
108
|
+
export const MessageKind = z.enum(["sync", "async", "return", "self"]);
|
|
109
|
+
export type MessageKind = z.infer<typeof MessageKind>;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Order is the array position. An explicit step number would let a producer
|
|
113
|
+
* emit a document whose animation order disagrees with its own message list.
|
|
114
|
+
*/
|
|
115
|
+
export const FlowMessage = z
|
|
116
|
+
.strictObject({
|
|
117
|
+
id: Id,
|
|
118
|
+
from: Id.describe("Participant node id the message originates from."),
|
|
119
|
+
to: Id.describe("Participant node id the message arrives at. Equals `from` when kind is self."),
|
|
120
|
+
label: Label,
|
|
121
|
+
kind: MessageKind.default("sync"),
|
|
122
|
+
delta: Delta,
|
|
123
|
+
animated: z.boolean().default(true).describe("Whether the data-flow lens pulses this step."),
|
|
124
|
+
repeat: z
|
|
125
|
+
.int()
|
|
126
|
+
.min(1)
|
|
127
|
+
.max(1_000_000)
|
|
128
|
+
.optional()
|
|
129
|
+
.describe("Times the step occurs per run, e.g. 4 batched requests."),
|
|
130
|
+
note: Summary.optional().describe("Aside rendered beside the step in the drill-down."),
|
|
131
|
+
files: z.array(FileRef).max(32).default([]),
|
|
132
|
+
})
|
|
133
|
+
.refine((message) => (message.kind === "self") === (message.from === message.to), {
|
|
134
|
+
message: "kind 'self' and from === to must agree",
|
|
135
|
+
path: ["kind"],
|
|
136
|
+
})
|
|
137
|
+
.describe("One ordered step in a flow.");
|
|
138
|
+
export type FlowMessage = z.infer<typeof FlowMessage>;
|
|
139
|
+
|
|
140
|
+
export const FlowParticipant = z
|
|
141
|
+
.strictObject({
|
|
142
|
+
node: Id.describe("Id of the graph node this column represents."),
|
|
143
|
+
label: Label.optional().describe("Shorter name for the column when the node label is long."),
|
|
144
|
+
})
|
|
145
|
+
.describe("A column in the sequence diagram, ordered by array position.");
|
|
146
|
+
export type FlowParticipant = z.infer<typeof FlowParticipant>;
|
|
147
|
+
|
|
148
|
+
export const Flow = z
|
|
149
|
+
.strictObject({
|
|
150
|
+
id: Id,
|
|
151
|
+
title: Label,
|
|
152
|
+
summary: Summary.optional(),
|
|
153
|
+
delta: Delta.default("modified"),
|
|
154
|
+
participants: z.array(FlowParticipant).min(2).max(12),
|
|
155
|
+
messages: z.array(FlowMessage).min(1).max(64).describe("Ordered by array position."),
|
|
156
|
+
})
|
|
157
|
+
.describe("An ordered message sequence for the data-flow lens.");
|
|
158
|
+
export type Flow = z.infer<typeof Flow>;
|
|
159
|
+
|
|
160
|
+
export const StatChip = z
|
|
161
|
+
.strictObject({
|
|
162
|
+
label: Label,
|
|
163
|
+
value: z.string().min(1).max(32),
|
|
164
|
+
tone: z.enum(["neutral", "added", "modified", "removed", "hero"]).default("neutral"),
|
|
165
|
+
})
|
|
166
|
+
.describe("A headline chip above the diagram.");
|
|
167
|
+
export type StatChip = z.infer<typeof StatChip>;
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Counts that cannot be derived from the document, plus free-form chips.
|
|
171
|
+
* Per-delta element counts are deliberately absent: they are derivable, and a
|
|
172
|
+
* stored copy can only ever go stale against the node and edge lists.
|
|
173
|
+
*/
|
|
174
|
+
export const Stats = z
|
|
175
|
+
.strictObject({
|
|
176
|
+
filesChanged: z.int().min(0).optional(),
|
|
177
|
+
additions: z.int().min(0).optional().describe("Lines added across the diff."),
|
|
178
|
+
deletions: z.int().min(0).optional().describe("Lines removed across the diff."),
|
|
179
|
+
chips: z.array(StatChip).max(8).default([]),
|
|
180
|
+
})
|
|
181
|
+
.describe("Headline numbers for the comment header.");
|
|
182
|
+
export type Stats = z.infer<typeof Stats>;
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* A view either shows the whole document or a named selection. The two are
|
|
186
|
+
* distinct states rather than "a selection that happens to be empty", so
|
|
187
|
+
* removing the last element a view pointed at can never silently turn it into
|
|
188
|
+
* a view of everything.
|
|
189
|
+
*/
|
|
190
|
+
export const ViewScope = z
|
|
191
|
+
.discriminatedUnion("kind", [
|
|
192
|
+
z.strictObject({ kind: z.literal("all") }),
|
|
193
|
+
z
|
|
194
|
+
.strictObject({
|
|
195
|
+
kind: z.literal("selection"),
|
|
196
|
+
lanes: z.array(Id).max(64).default([]),
|
|
197
|
+
nodes: z.array(Id).max(256).default([]),
|
|
198
|
+
edges: z.array(Id).max(512).default([]),
|
|
199
|
+
flows: z.array(Id).max(32).default([]),
|
|
200
|
+
})
|
|
201
|
+
.meta({
|
|
202
|
+
anyOf: [
|
|
203
|
+
{ properties: { lanes: { minItems: 1 } }, required: ["lanes"] },
|
|
204
|
+
{ properties: { nodes: { minItems: 1 } }, required: ["nodes"] },
|
|
205
|
+
{ properties: { edges: { minItems: 1 } }, required: ["edges"] },
|
|
206
|
+
{ properties: { flows: { minItems: 1 } }, required: ["flows"] },
|
|
207
|
+
],
|
|
208
|
+
})
|
|
209
|
+
.refine(
|
|
210
|
+
(scope) =>
|
|
211
|
+
scope.lanes.length + scope.nodes.length + scope.edges.length + scope.flows.length > 0,
|
|
212
|
+
{ message: "a selection must name at least one element" },
|
|
213
|
+
),
|
|
214
|
+
])
|
|
215
|
+
.describe("What a drill-down section shows.");
|
|
216
|
+
export type ViewScope = z.infer<typeof ViewScope>;
|
|
217
|
+
|
|
218
|
+
export type View = {
|
|
219
|
+
id: string;
|
|
220
|
+
title: string;
|
|
221
|
+
lens: Lens;
|
|
222
|
+
summary?: string;
|
|
223
|
+
scope: ViewScope;
|
|
224
|
+
defaultOpen: boolean;
|
|
225
|
+
children: View[];
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* One node of the nested `<details>` tree in the PR comment. The whole tree
|
|
230
|
+
* ships pre-rendered, so expanding a section costs no round trip.
|
|
231
|
+
*/
|
|
232
|
+
export type ViewInput = {
|
|
233
|
+
id: string;
|
|
234
|
+
title: string;
|
|
235
|
+
lens: Lens;
|
|
236
|
+
summary?: string;
|
|
237
|
+
scope?:
|
|
238
|
+
| { kind: "all" }
|
|
239
|
+
| { kind: "selection"; lanes?: string[]; nodes?: string[]; edges?: string[]; flows?: string[] };
|
|
240
|
+
defaultOpen?: boolean;
|
|
241
|
+
children?: ViewInput[];
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
export const View: z.ZodType<View, ViewInput> = z.lazy(() =>
|
|
245
|
+
z
|
|
246
|
+
.strictObject({
|
|
247
|
+
id: Id,
|
|
248
|
+
title: Label,
|
|
249
|
+
lens: Lens,
|
|
250
|
+
summary: Summary.optional(),
|
|
251
|
+
scope: ViewScope.default({ kind: "all" }),
|
|
252
|
+
defaultOpen: z.boolean().default(false),
|
|
253
|
+
children: z.array(View).max(32).default([]),
|
|
254
|
+
})
|
|
255
|
+
.describe("A drill-down section; children nest as further <details> blocks."),
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Hints, not instructions: the renderer owns final placement so that layout
|
|
260
|
+
* stays deterministic for a given document. A hint is a floor rather than an
|
|
261
|
+
* answer — it can push a node further down the page, never above something
|
|
262
|
+
* that feeds it — so a stale hint can leave a gap but can never invert an
|
|
263
|
+
* edge. Absolute coordinates are intentionally not expressible.
|
|
264
|
+
*/
|
|
265
|
+
export const LayoutHints = z
|
|
266
|
+
.strictObject({
|
|
267
|
+
direction: z.enum(["right", "down"]).default("right").describe("Primary flow direction."),
|
|
268
|
+
laneOrder: z.array(Id).max(64).default([]).describe("Explicit left-to-right lane order."),
|
|
269
|
+
rank: z.record(Id, z.int().min(0).max(256)).optional().describe("Preferred layer index per node id."),
|
|
270
|
+
})
|
|
271
|
+
.describe("Optional, non-binding placement hints.");
|
|
272
|
+
export type LayoutHints = z.infer<typeof LayoutHints>;
|
|
273
|
+
|
|
274
|
+
/** Everything needed to rebuild a permalink to any file this document points at. */
|
|
275
|
+
export const Provenance = z
|
|
276
|
+
.strictObject({
|
|
277
|
+
repo: z.strictObject({
|
|
278
|
+
owner: z.string().min(1).max(64),
|
|
279
|
+
name: z.string().min(1).max(128),
|
|
280
|
+
host: z.string().min(1).max(128).default("github.com"),
|
|
281
|
+
}),
|
|
282
|
+
base: z.strictObject({ sha: Sha, ref: z.string().min(1).max(255).optional() }),
|
|
283
|
+
head: z.strictObject({ sha: Sha, ref: z.string().min(1).max(255).optional() }),
|
|
284
|
+
pullRequest: z
|
|
285
|
+
.strictObject({
|
|
286
|
+
number: z.int().min(1),
|
|
287
|
+
title: z.string().min(1).max(512).optional(),
|
|
288
|
+
url: z.url().optional(),
|
|
289
|
+
})
|
|
290
|
+
.optional(),
|
|
291
|
+
generator: z
|
|
292
|
+
.strictObject({
|
|
293
|
+
name: z.string().min(1).max(64),
|
|
294
|
+
version: z.string().min(1).max(32).optional(),
|
|
295
|
+
model: z.string().min(1).max(128).optional().describe("Extraction model, when one was used."),
|
|
296
|
+
})
|
|
297
|
+
.optional(),
|
|
298
|
+
})
|
|
299
|
+
.describe("Where the document came from.");
|
|
300
|
+
export type Provenance = z.infer<typeof Provenance>;
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* The document every PR Lens component speaks: extraction emits it, the
|
|
304
|
+
* renderer consumes it, and a baseline map is one of these kept current by
|
|
305
|
+
* patch documents.
|
|
306
|
+
*/
|
|
307
|
+
export const GraphDoc = z
|
|
308
|
+
.strictObject({
|
|
309
|
+
schemaVersion: SchemaVersionField,
|
|
310
|
+
kind: z.literal("graph"),
|
|
311
|
+
id: Id.optional().describe("Stable id when the document is stored, e.g. a baseline map."),
|
|
312
|
+
generatedAt: z.iso.datetime().optional(),
|
|
313
|
+
title: Label,
|
|
314
|
+
summary: Summary.optional().describe("The one-paragraph answer to 'what does this change do?'"),
|
|
315
|
+
lenses: z
|
|
316
|
+
.array(Lens)
|
|
317
|
+
.min(1)
|
|
318
|
+
.max(8)
|
|
319
|
+
.describe("Lenses this document carries enough detail to render."),
|
|
320
|
+
provenance: Provenance,
|
|
321
|
+
lanes: z.array(Lane).min(1).max(16),
|
|
322
|
+
nodes: z.array(GraphNode).min(1).max(256),
|
|
323
|
+
edges: z.array(GraphEdge).max(512).default([]),
|
|
324
|
+
flows: z.array(Flow).max(16).default([]),
|
|
325
|
+
stats: Stats.optional(),
|
|
326
|
+
views: z.array(View).max(32).default([]),
|
|
327
|
+
layout: LayoutHints.optional(),
|
|
328
|
+
})
|
|
329
|
+
.describe("A PR Lens graph document.");
|
|
330
|
+
export type GraphDoc = z.infer<typeof GraphDoc>;
|
|
331
|
+
export type GraphDocInput = z.input<typeof GraphDoc>;
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
export { SCHEMA_VERSION, type SchemaVersion } from "./version.js";
|
|
2
|
+
|
|
3
|
+
export {
|
|
4
|
+
Delta,
|
|
5
|
+
DELTAS,
|
|
6
|
+
FileRef,
|
|
7
|
+
FullSha,
|
|
8
|
+
Id,
|
|
9
|
+
Label,
|
|
10
|
+
Lens,
|
|
11
|
+
LENSES,
|
|
12
|
+
MAX_RENDER_ASSETS,
|
|
13
|
+
MAX_VIEWS,
|
|
14
|
+
Theme,
|
|
15
|
+
THEMES,
|
|
16
|
+
SchemaVersionField,
|
|
17
|
+
Sha,
|
|
18
|
+
Summary,
|
|
19
|
+
} from "./primitives.js";
|
|
20
|
+
|
|
21
|
+
export {
|
|
22
|
+
EdgeEmphasis,
|
|
23
|
+
EdgeKind,
|
|
24
|
+
Flow,
|
|
25
|
+
FlowMessage,
|
|
26
|
+
FlowParticipant,
|
|
27
|
+
GraphDoc,
|
|
28
|
+
GraphEdge,
|
|
29
|
+
GraphNode,
|
|
30
|
+
Lane,
|
|
31
|
+
LayoutHints,
|
|
32
|
+
MessageKind,
|
|
33
|
+
NodeKind,
|
|
34
|
+
Provenance,
|
|
35
|
+
StatChip,
|
|
36
|
+
Stats,
|
|
37
|
+
View,
|
|
38
|
+
ViewScope,
|
|
39
|
+
type GraphDocInput,
|
|
40
|
+
type ViewInput,
|
|
41
|
+
} from "./graph.js";
|
|
42
|
+
|
|
43
|
+
export {
|
|
44
|
+
EdgePatch,
|
|
45
|
+
FlowPatch,
|
|
46
|
+
LanePatch,
|
|
47
|
+
NodePatch,
|
|
48
|
+
PatchDoc,
|
|
49
|
+
PatchOp,
|
|
50
|
+
PATCH_OPS,
|
|
51
|
+
targetDescribesATransition,
|
|
52
|
+
type PatchDocInput,
|
|
53
|
+
} from "./patch.js";
|
|
54
|
+
|
|
55
|
+
export { Config, MapCorrections, Selector, type ConfigInput } from "./config.js";
|
|
56
|
+
|
|
57
|
+
export { RenderAsset, RenderManifest, type RenderManifestInput } from "./manifest.js";
|
|
58
|
+
|
|
59
|
+
export {
|
|
60
|
+
formatIssues,
|
|
61
|
+
PrLensSchemaError,
|
|
62
|
+
type Parsed,
|
|
63
|
+
type SchemaErrorCode,
|
|
64
|
+
type SchemaIssue,
|
|
65
|
+
} from "./errors.js";
|
|
66
|
+
|
|
67
|
+
export { graphIntegrityIssues, graphSnapshotIssues } from "./integrity.js";
|
|
68
|
+
|
|
69
|
+
export {
|
|
70
|
+
parseConfig,
|
|
71
|
+
parseGraphDoc,
|
|
72
|
+
parsePatchDoc,
|
|
73
|
+
parseRenderManifest,
|
|
74
|
+
safeParseConfig,
|
|
75
|
+
safeParseGraphDoc,
|
|
76
|
+
safeParsePatchDoc,
|
|
77
|
+
safeParseRenderManifest,
|
|
78
|
+
} from "./validate.js";
|
|
79
|
+
|
|
80
|
+
export { applyPatch, applyPatchDoc } from "./apply.js";
|
|
81
|
+
|
|
82
|
+
export { assertNever } from "./utils.js";
|
package/src/integrity.ts
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import type { SchemaIssue } from "./errors.js";
|
|
2
|
+
import type { GraphDoc, View } from "./graph.js";
|
|
3
|
+
import { FullSha, MAX_VIEWS, THEMES, type Delta } from "./primitives.js";
|
|
4
|
+
import { assertNever } from "./utils.js";
|
|
5
|
+
|
|
6
|
+
const duplicates = (ids: readonly string[]): string[] => {
|
|
7
|
+
const seen = new Set<string>();
|
|
8
|
+
const repeated = new Set<string>();
|
|
9
|
+
for (const id of ids) {
|
|
10
|
+
if (seen.has(id)) repeated.add(id);
|
|
11
|
+
seen.add(id);
|
|
12
|
+
}
|
|
13
|
+
return [...repeated];
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const flattenViews = (views: readonly View[], prefix: string): { view: View; path: string }[] =>
|
|
17
|
+
views.flatMap((view, index) => {
|
|
18
|
+
const path = `${prefix}[${index}]`;
|
|
19
|
+
return [{ view, path }, ...flattenViews(view.children, `${path}.children`)];
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Structural validation says a field holds an id; these checks say the id
|
|
24
|
+
* points at something. Extraction models routinely emit an edge to a node
|
|
25
|
+
* they forgot to declare, and a renderer must never be handed that document.
|
|
26
|
+
*/
|
|
27
|
+
export const graphIntegrityIssues = (doc: GraphDoc): SchemaIssue[] => {
|
|
28
|
+
const issues: SchemaIssue[] = [];
|
|
29
|
+
const broken = (path: string, message: string) =>
|
|
30
|
+
issues.push({ code: "BROKEN_REFERENCE", path, message });
|
|
31
|
+
const duplicate = (path: string, message: string) =>
|
|
32
|
+
issues.push({ code: "DUPLICATE_ID", path, message });
|
|
33
|
+
|
|
34
|
+
const laneIds = new Set(doc.lanes.map((lane) => lane.id));
|
|
35
|
+
const nodeIds = new Set(doc.nodes.map((node) => node.id));
|
|
36
|
+
const flowIds = new Set(doc.flows.map((flow) => flow.id));
|
|
37
|
+
const edgeIds = new Set(doc.edges.map((edge) => edge.id));
|
|
38
|
+
const lenses = new Set(doc.lenses);
|
|
39
|
+
|
|
40
|
+
for (const id of duplicates(doc.lanes.map((lane) => lane.id)))
|
|
41
|
+
duplicate("lanes", `duplicate lane id '${id}'`);
|
|
42
|
+
for (const id of duplicates(doc.nodes.map((node) => node.id)))
|
|
43
|
+
duplicate("nodes", `duplicate node id '${id}'`);
|
|
44
|
+
for (const id of duplicates(doc.edges.map((edge) => edge.id)))
|
|
45
|
+
duplicate("edges", `duplicate edge id '${id}'`);
|
|
46
|
+
for (const id of duplicates(doc.flows.map((flow) => flow.id)))
|
|
47
|
+
duplicate("flows", `duplicate flow id '${id}'`);
|
|
48
|
+
|
|
49
|
+
doc.nodes.forEach((node, index) => {
|
|
50
|
+
if (!laneIds.has(node.lane))
|
|
51
|
+
broken(`nodes[${index}].lane`, `node '${node.id}' references unknown lane '${node.lane}'`);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
doc.edges.forEach((edge, index) => {
|
|
55
|
+
if (!nodeIds.has(edge.from))
|
|
56
|
+
broken(`edges[${index}].from`, `edge '${edge.id}' references unknown node '${edge.from}'`);
|
|
57
|
+
if (!nodeIds.has(edge.to))
|
|
58
|
+
broken(`edges[${index}].to`, `edge '${edge.id}' references unknown node '${edge.to}'`);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
doc.flows.forEach((flow, flowIndex) => {
|
|
62
|
+
const participantIds = new Set(flow.participants.map((participant) => participant.node));
|
|
63
|
+
|
|
64
|
+
for (const id of duplicates(flow.participants.map((participant) => participant.node)))
|
|
65
|
+
duplicate(`flows[${flowIndex}].participants`, `participant '${id}' listed twice`);
|
|
66
|
+
for (const id of duplicates(flow.messages.map((message) => message.id)))
|
|
67
|
+
duplicate(`flows[${flowIndex}].messages`, `duplicate message id '${id}'`);
|
|
68
|
+
|
|
69
|
+
flow.participants.forEach((participant, index) => {
|
|
70
|
+
if (!nodeIds.has(participant.node))
|
|
71
|
+
broken(
|
|
72
|
+
`flows[${flowIndex}].participants[${index}].node`,
|
|
73
|
+
`flow '${flow.id}' references unknown node '${participant.node}'`,
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
flow.messages.forEach((message, index) => {
|
|
78
|
+
if (!participantIds.has(message.from))
|
|
79
|
+
broken(
|
|
80
|
+
`flows[${flowIndex}].messages[${index}].from`,
|
|
81
|
+
`message '${message.id}' sends from '${message.from}', which is not a participant`,
|
|
82
|
+
);
|
|
83
|
+
if (!participantIds.has(message.to))
|
|
84
|
+
broken(
|
|
85
|
+
`flows[${flowIndex}].messages[${index}].to`,
|
|
86
|
+
`message '${message.id}' sends to '${message.to}', which is not a participant`,
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
if (doc.flows.length > 0 && !lenses.has("data-flow"))
|
|
92
|
+
broken("lenses", "document carries flows but does not declare the 'data-flow' lens");
|
|
93
|
+
|
|
94
|
+
const views = flattenViews(doc.views, "views");
|
|
95
|
+
for (const id of duplicates(views.map(({ view }) => view.id)))
|
|
96
|
+
duplicate("views", `duplicate view id '${id}'`);
|
|
97
|
+
|
|
98
|
+
// Each array in the tree is capped, but the tree's depth is not, so the
|
|
99
|
+
// total is only bounded here.
|
|
100
|
+
if (views.length > MAX_VIEWS)
|
|
101
|
+
issues.push({
|
|
102
|
+
code: "INVALID_DOCUMENT",
|
|
103
|
+
path: "views",
|
|
104
|
+
message: `drill-down tree carries ${views.length} views; a render is one asset per view per theme, and at ${THEMES.length} themes only ${MAX_VIEWS} fit a render manifest`,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
for (const { view, path } of views) {
|
|
108
|
+
if (!lenses.has(view.lens))
|
|
109
|
+
broken(`${path}.lens`, `view '${view.id}' uses lens '${view.lens}', which the document does not declare`);
|
|
110
|
+
|
|
111
|
+
switch (view.scope.kind) {
|
|
112
|
+
case "all":
|
|
113
|
+
break;
|
|
114
|
+
case "selection": {
|
|
115
|
+
const selection = view.scope;
|
|
116
|
+
const scoped: [keyof Omit<typeof selection, "kind">, string, ReadonlySet<string>][] = [
|
|
117
|
+
["lanes", "lane", laneIds],
|
|
118
|
+
["nodes", "node", nodeIds],
|
|
119
|
+
["edges", "edge", edgeIds],
|
|
120
|
+
["flows", "flow", flowIds],
|
|
121
|
+
];
|
|
122
|
+
for (const [collection, singular, known] of scoped) {
|
|
123
|
+
selection[collection].forEach((id, index) => {
|
|
124
|
+
if (!known.has(id))
|
|
125
|
+
broken(
|
|
126
|
+
`${path}.scope.${collection}[${index}]`,
|
|
127
|
+
`view '${view.id}' scopes unknown ${singular} '${id}'`,
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
default:
|
|
134
|
+
assertNever(view.scope, "Unhandled view scope");
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (doc.layout) {
|
|
139
|
+
doc.layout.laneOrder.forEach((id, index) => {
|
|
140
|
+
if (!laneIds.has(id)) broken(`layout.laneOrder[${index}]`, `unknown lane '${id}'`);
|
|
141
|
+
});
|
|
142
|
+
for (const id of Object.keys(doc.layout.rank ?? {})) {
|
|
143
|
+
if (!nodeIds.has(id)) broken(`layout.rank.${id}`, `unknown node '${id}'`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return issues;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Everything a document must satisfy to be stored as a map, and the check to
|
|
152
|
+
* run before storing one.
|
|
153
|
+
*
|
|
154
|
+
* A map describes a system, not a change to one: it is identified, it names
|
|
155
|
+
* the single commit it reflects in full, and nothing in it is annotated as a
|
|
156
|
+
* change. A map that fails this would hand the next pull request a baseline
|
|
157
|
+
* that already claims to be mid change, and every delta computed against it
|
|
158
|
+
* would inherit the mistake.
|
|
159
|
+
*/
|
|
160
|
+
export const graphSnapshotIssues = (doc: GraphDoc): SchemaIssue[] => {
|
|
161
|
+
const issues: SchemaIssue[] = [];
|
|
162
|
+
|
|
163
|
+
if (doc.id === undefined)
|
|
164
|
+
issues.push({
|
|
165
|
+
code: "NOT_A_SNAPSHOT",
|
|
166
|
+
path: "id",
|
|
167
|
+
message: "a stored map needs an id, so a patch can say which map it targets",
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
for (const side of ["base", "head"] as const) {
|
|
171
|
+
if (!FullSha.safeParse(doc.provenance[side].sha).success)
|
|
172
|
+
issues.push({
|
|
173
|
+
code: "NOT_A_SNAPSHOT",
|
|
174
|
+
path: `provenance.${side}.sha`,
|
|
175
|
+
message: `a stored map records the commit it reflects in full, but this one records '${doc.provenance[side].sha}'`,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const requireUnchanged = (delta: Delta | undefined, path: string, subject: string) => {
|
|
180
|
+
if (delta === undefined || delta === "unchanged") return;
|
|
181
|
+
issues.push({
|
|
182
|
+
code: "NOT_A_SNAPSHOT",
|
|
183
|
+
path,
|
|
184
|
+
message: `${subject} is marked '${delta}', but a stored map describes a system rather than a change`,
|
|
185
|
+
});
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
if (doc.provenance.base.sha !== doc.provenance.head.sha)
|
|
189
|
+
issues.push({
|
|
190
|
+
code: "NOT_A_SNAPSHOT",
|
|
191
|
+
path: "provenance",
|
|
192
|
+
message: `a stored map reflects one commit, but base is ${doc.provenance.base.sha} and head is ${doc.provenance.head.sha}`,
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
doc.lanes.forEach((lane, index) =>
|
|
196
|
+
requireUnchanged(lane.delta, `lanes[${index}].delta`, `lane '${lane.id}'`),
|
|
197
|
+
);
|
|
198
|
+
doc.nodes.forEach((node, index) =>
|
|
199
|
+
requireUnchanged(node.delta, `nodes[${index}].delta`, `node '${node.id}'`),
|
|
200
|
+
);
|
|
201
|
+
doc.edges.forEach((edge, index) =>
|
|
202
|
+
requireUnchanged(edge.delta, `edges[${index}].delta`, `edge '${edge.id}'`),
|
|
203
|
+
);
|
|
204
|
+
doc.flows.forEach((flow, flowIndex) => {
|
|
205
|
+
requireUnchanged(flow.delta, `flows[${flowIndex}].delta`, `flow '${flow.id}'`);
|
|
206
|
+
flow.messages.forEach((message, index) =>
|
|
207
|
+
requireUnchanged(
|
|
208
|
+
message.delta,
|
|
209
|
+
`flows[${flowIndex}].messages[${index}].delta`,
|
|
210
|
+
`step '${message.id}' of flow '${flow.id}'`,
|
|
211
|
+
),
|
|
212
|
+
);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
return issues;
|
|
216
|
+
};
|
package/src/manifest.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { Id, Lens, MAX_RENDER_ASSETS, SchemaVersionField, Sha, Theme } from "./primitives.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* GitHub serves comment images through a proxy that caches aggressively, so
|
|
6
|
+
* every asset is addressed by the hash of its own bytes and a new render is a
|
|
7
|
+
* new URL rather than an updated one.
|
|
8
|
+
*/
|
|
9
|
+
export const RenderAsset = z
|
|
10
|
+
.strictObject({
|
|
11
|
+
id: Id,
|
|
12
|
+
lens: Lens,
|
|
13
|
+
theme: Theme.describe("Half of a <picture> pair."),
|
|
14
|
+
view: Id.optional().describe("Drill-down view this asset renders, when it is not the root."),
|
|
15
|
+
mediaType: z.literal("image/svg+xml"),
|
|
16
|
+
contentHash: z
|
|
17
|
+
.string()
|
|
18
|
+
.regex(/^[0-9a-f]{16,64}$/, "must be lowercase hex")
|
|
19
|
+
.describe("Hash of the asset bytes; the identity of this render."),
|
|
20
|
+
bytes: z.int().min(1),
|
|
21
|
+
width: z.int().min(1),
|
|
22
|
+
height: z.int().min(1),
|
|
23
|
+
animated: z.boolean().default(false).describe("Contains SMIL animation."),
|
|
24
|
+
url: z.url().optional().describe("Where the asset is published, once it has been uploaded."),
|
|
25
|
+
path: z
|
|
26
|
+
.string()
|
|
27
|
+
.min(1)
|
|
28
|
+
.max(1024)
|
|
29
|
+
.optional()
|
|
30
|
+
.describe("Local path, for surfaces that write files instead of uploading."),
|
|
31
|
+
})
|
|
32
|
+
.refine((asset) => asset.url !== undefined || asset.path !== undefined, {
|
|
33
|
+
message: "an asset needs a url or a path",
|
|
34
|
+
path: ["url"],
|
|
35
|
+
})
|
|
36
|
+
.meta({ anyOf: [{ required: ["url"] }, { required: ["path"] }] })
|
|
37
|
+
.describe("One rendered SVG.");
|
|
38
|
+
export type RenderAsset = z.infer<typeof RenderAsset>;
|
|
39
|
+
|
|
40
|
+
/** What a render produced: the inventory the comment composer builds from. */
|
|
41
|
+
export const RenderManifest = z
|
|
42
|
+
.strictObject({
|
|
43
|
+
schemaVersion: SchemaVersionField,
|
|
44
|
+
kind: z.literal("render-manifest"),
|
|
45
|
+
generatedAt: z.iso.datetime().optional(),
|
|
46
|
+
graph: z
|
|
47
|
+
.strictObject({
|
|
48
|
+
id: Id.optional(),
|
|
49
|
+
headSha: Sha.optional(),
|
|
50
|
+
contentHash: z
|
|
51
|
+
.string()
|
|
52
|
+
.regex(/^[0-9a-f]{16,64}$/, "must be lowercase hex")
|
|
53
|
+
.describe("Hash of the graph document this render came from."),
|
|
54
|
+
})
|
|
55
|
+
.describe("The document that was rendered."),
|
|
56
|
+
renderer: z.strictObject({
|
|
57
|
+
name: z.string().min(1).max(64),
|
|
58
|
+
version: z.string().min(1).max(32),
|
|
59
|
+
}),
|
|
60
|
+
assets: z.array(RenderAsset).min(1).max(MAX_RENDER_ASSETS),
|
|
61
|
+
})
|
|
62
|
+
.describe("A PR Lens render manifest.");
|
|
63
|
+
export type RenderManifest = z.infer<typeof RenderManifest>;
|
|
64
|
+
export type RenderManifestInput = z.input<typeof RenderManifest>;
|