@loomwork/blueprint-runtime 0.0.0-stage → 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 +202 -0
- package/NOTICE +8 -0
- package/README.md +83 -2
- package/dist/Blueprint.d.ts +14 -0
- package/dist/Blueprint.d.ts.map +1 -0
- package/dist/Blueprint.js +18 -0
- package/dist/Blueprint.js.map +1 -0
- package/dist/RendererRegistry.d.ts +9 -0
- package/dist/RendererRegistry.d.ts.map +1 -0
- package/dist/RendererRegistry.js +39 -0
- package/dist/RendererRegistry.js.map +1 -0
- package/dist/catalog.d.ts +129 -0
- package/dist/catalog.d.ts.map +1 -0
- package/dist/catalog.js +435 -0
- package/dist/catalog.js.map +1 -0
- package/dist/coverage.d.ts +18 -0
- package/dist/coverage.d.ts.map +1 -0
- package/dist/coverage.js +22 -0
- package/dist/coverage.js.map +1 -0
- package/dist/customWidget.d.ts +10 -0
- package/dist/customWidget.d.ts.map +1 -0
- package/dist/customWidget.js +34 -0
- package/dist/customWidget.js.map +1 -0
- package/dist/esm/Blueprint.js +15 -0
- package/dist/esm/Blueprint.js.map +1 -0
- package/dist/esm/RendererRegistry.js +35 -0
- package/dist/esm/RendererRegistry.js.map +1 -0
- package/dist/esm/catalog.js +430 -0
- package/dist/esm/catalog.js.map +1 -0
- package/dist/esm/coverage.js +19 -0
- package/dist/esm/coverage.js.map +1 -0
- package/dist/esm/customWidget.js +31 -0
- package/dist/esm/customWidget.js.map +1 -0
- package/dist/esm/identity.js +116 -0
- package/dist/esm/identity.js.map +1 -0
- package/dist/esm/index.js +15 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/irVersion.js +150 -0
- package/dist/esm/irVersion.js.map +1 -0
- package/dist/esm/layout.js +63 -0
- package/dist/esm/layout.js.map +1 -0
- package/dist/esm/normalize.js +115 -0
- package/dist/esm/normalize.js.map +1 -0
- package/dist/esm/pins.js +82 -0
- package/dist/esm/pins.js.map +1 -0
- package/dist/esm/traverseBlueprint.js +38 -0
- package/dist/esm/traverseBlueprint.js.map +1 -0
- package/dist/esm/traverseBlueprintAsync.js +48 -0
- package/dist/esm/traverseBlueprintAsync.js.map +1 -0
- package/dist/esm/types.js +2 -0
- package/dist/esm/types.js.map +1 -0
- package/dist/esm/widgetStatus.js +137 -0
- package/dist/esm/widgetStatus.js.map +1 -0
- package/dist/esm/widgets.js +105 -0
- package/dist/esm/widgets.js.map +1 -0
- package/dist/identity.d.ts +58 -0
- package/dist/identity.d.ts.map +1 -0
- package/dist/identity.js +122 -0
- package/dist/identity.js.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +61 -0
- package/dist/index.js.map +1 -0
- package/dist/irVersion.d.ts +23 -0
- package/dist/irVersion.d.ts.map +1 -0
- package/dist/irVersion.js +153 -0
- package/dist/irVersion.js.map +1 -0
- package/dist/layout.d.ts +57 -0
- package/dist/layout.d.ts.map +1 -0
- package/dist/layout.js +71 -0
- package/dist/layout.js.map +1 -0
- package/dist/normalize.d.ts +29 -0
- package/dist/normalize.d.ts.map +1 -0
- package/dist/normalize.js +126 -0
- package/dist/normalize.js.map +1 -0
- package/dist/pins.d.ts +27 -0
- package/dist/pins.d.ts.map +1 -0
- package/dist/pins.js +86 -0
- package/dist/pins.js.map +1 -0
- package/dist/traverseBlueprint.d.ts +27 -0
- package/dist/traverseBlueprint.d.ts.map +1 -0
- package/dist/traverseBlueprint.js +40 -0
- package/dist/traverseBlueprint.js.map +1 -0
- package/dist/traverseBlueprintAsync.d.ts +18 -0
- package/dist/traverseBlueprintAsync.d.ts.map +1 -0
- package/dist/traverseBlueprintAsync.js +51 -0
- package/dist/traverseBlueprintAsync.js.map +1 -0
- package/dist/types.d.ts +176 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/widgetStatus.d.ts +66 -0
- package/dist/widgetStatus.d.ts.map +1 -0
- package/dist/widgetStatus.js +141 -0
- package/dist/widgetStatus.js.map +1 -0
- package/dist/widgets.d.ts +101 -0
- package/dist/widgets.d.ts.map +1 -0
- package/dist/widgets.js +115 -0
- package/dist/widgets.js.map +1 -0
- package/package.json +60 -3
- package/src/Blueprint.ts +28 -0
- package/src/RendererRegistry.ts +38 -0
- package/src/catalog.ts +638 -0
- package/src/coverage.ts +52 -0
- package/src/customWidget.ts +42 -0
- package/src/identity.ts +178 -0
- package/src/index.ts +84 -0
- package/src/irVersion.ts +195 -0
- package/src/layout.ts +105 -0
- package/src/normalize.ts +130 -0
- package/src/pins.ts +84 -0
- package/src/traverseBlueprint.bench.ts +47 -0
- package/src/traverseBlueprint.ts +94 -0
- package/src/traverseBlueprintAsync.ts +92 -0
- package/src/types.ts +195 -0
- package/src/widgetStatus.ts +231 -0
- package/src/widgets.ts +213 -0
package/src/catalog.ts
ADDED
|
@@ -0,0 +1,638 @@
|
|
|
1
|
+
import { hasLayoutId, isLayoutRole } from "./layout";
|
|
2
|
+
import { IR_VERSIONS_READ_AS_IS, IR_VERSION_LEGACY, asCell, isWidget } from "./normalize";
|
|
3
|
+
import type { Blueprint, GridCell, Layout, Widget } from "./types";
|
|
4
|
+
|
|
5
|
+
// ── The component catalogue, and the check that it is honoured ────────────────
|
|
6
|
+
//
|
|
7
|
+
// A blueprint that a *person* wrote is checked by the type system: the widget
|
|
8
|
+
// builders are functions, and a typo is a compile error. A blueprint that a
|
|
9
|
+
// *model* composed at runtime has no such check — it is data, assembled from a
|
|
10
|
+
// catalogue, and the only thing standing between an off-catalogue component and
|
|
11
|
+
// a viewer is whatever validates it on the way out.
|
|
12
|
+
//
|
|
13
|
+
// Nothing did. `Widget.kind` is widened to `string` so custom renderers can
|
|
14
|
+
// register any kind, and an unregistered kind reaches the renderer, which prints
|
|
15
|
+
// a placeholder — per viewer, at render time, once the surface is already open.
|
|
16
|
+
//
|
|
17
|
+
// This module is the missing check. Two properties, and they are different:
|
|
18
|
+
//
|
|
19
|
+
// 1. every `kind` is one the catalogue defines — the *closure* property. A
|
|
20
|
+
// composer may only assemble from an audited set, so accessibility, i18n
|
|
21
|
+
// and design conformance are verified once per component and inherited by
|
|
22
|
+
// every composition;
|
|
23
|
+
// 2. every widget carries a `DataBinding` and no unbound payload in `props` —
|
|
24
|
+
// the *exfiltration* property. A blueprint declares what to fetch, never
|
|
25
|
+
// what was fetched, which is what lets one blueprint be shared and then
|
|
26
|
+
// hydrated separately against each viewer's own permissions. A composer
|
|
27
|
+
// that inlines a fetched row into `props` silently turns a shareable
|
|
28
|
+
// artifact into a copy of one viewer's data.
|
|
29
|
+
//
|
|
30
|
+
// `validateBlueprint` never throws. A missing component is a *finding* — the
|
|
31
|
+
// caller decides whether to degrade to a plainer widget, refuse, or record a
|
|
32
|
+
// catalogue gap — and a rate can be computed over findings, which is the point:
|
|
33
|
+
// "how often does a composer get this right on the first attempt" is not a
|
|
34
|
+
// question an exception can answer.
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* One component a composer may name.
|
|
38
|
+
*
|
|
39
|
+
* Both fields are required. An optional allow-list is the failure mode this
|
|
40
|
+
* module exists to prevent: an entry that declined to say which props it accepts
|
|
41
|
+
* would pass the payload check vacuously, and a check that cannot fail reads as
|
|
42
|
+
* coverage while verifying nothing. `props: []` is a perfectly good answer.
|
|
43
|
+
*/
|
|
44
|
+
export interface CatalogEntry {
|
|
45
|
+
readonly kind: string;
|
|
46
|
+
/**
|
|
47
|
+
* Prop keys this component accepts. `binding` is reserved and always allowed —
|
|
48
|
+
* the built-in builders duplicate the declared binding into `props` — so it
|
|
49
|
+
* need not be listed.
|
|
50
|
+
*/
|
|
51
|
+
readonly props: readonly string[];
|
|
52
|
+
/**
|
|
53
|
+
* Whether this component renders bound data.
|
|
54
|
+
* - `required` — a widget without a `DataBinding` renders nothing, so its
|
|
55
|
+
* absence is an error rather than an empty state;
|
|
56
|
+
* - `optional` — either is legitimate;
|
|
57
|
+
* - `none` — decorative or structural; a binding on it is a composer mistake.
|
|
58
|
+
*/
|
|
59
|
+
readonly binding: "required" | "optional" | "none";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface WidgetCatalog {
|
|
63
|
+
readonly id: string;
|
|
64
|
+
readonly entries: readonly CatalogEntry[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Identity helper — declare a catalogue with inferred types. */
|
|
68
|
+
export function defineWidgetCatalog(catalog: WidgetCatalog): WidgetCatalog {
|
|
69
|
+
return catalog;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const BUILTIN_CATALOG_ID = "loom.builtin.v1";
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The eight widgets `@loomwork/blueprint-runtime` ships, as a catalogue.
|
|
76
|
+
*
|
|
77
|
+
* `props` mirrors each builder's props interface minus `title` (hoisted onto
|
|
78
|
+
* `Widget.title`) and minus `binding` (reserved). Every built-in requires a
|
|
79
|
+
* binding — none of them render anything without one.
|
|
80
|
+
*/
|
|
81
|
+
export const builtinCatalog: WidgetCatalog = {
|
|
82
|
+
id: BUILTIN_CATALOG_ID,
|
|
83
|
+
entries: [
|
|
84
|
+
{ kind: "MetricCard", props: ["unit", "format"], binding: "required" },
|
|
85
|
+
{ kind: "Timeline", props: ["maxItems"], binding: "required" },
|
|
86
|
+
{ kind: "ApprovalCard", props: ["approveLabel", "rejectLabel"], binding: "required" },
|
|
87
|
+
{ kind: "StatusBadge", props: [], binding: "required" },
|
|
88
|
+
{ kind: "ProgressBar", props: ["max"], binding: "required" },
|
|
89
|
+
{ kind: "DataTable", props: ["columns"], binding: "required" },
|
|
90
|
+
{ kind: "AlertCard", props: ["severity"], binding: "required" },
|
|
91
|
+
{ kind: "StreamingText", props: ["placeholder"], binding: "required" },
|
|
92
|
+
],
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// ── Findings ──────────────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
export type BlueprintFindingCode =
|
|
98
|
+
/** A `kind` the catalogue does not define. */
|
|
99
|
+
| "unknown-kind"
|
|
100
|
+
/** A `binding: "required"` component with no `DataBinding`. */
|
|
101
|
+
| "missing-binding"
|
|
102
|
+
/** A `binding: "none"` component carrying one anyway. */
|
|
103
|
+
| "unexpected-binding"
|
|
104
|
+
/** A prop key outside the catalogue entry's allow-list — the exfiltration check. */
|
|
105
|
+
| "undeclared-prop"
|
|
106
|
+
/** A binding to a projection the blueprint never declares — renders blank, silently. */
|
|
107
|
+
| "undeclared-projection"
|
|
108
|
+
/**
|
|
109
|
+
* Two nodes sharing an id — node identity, and the renderer's key, collide. Widget ids and
|
|
110
|
+
* layout ids (IR 2.4.0) are ONE namespace: widget↔widget, widget↔layout and layout↔layout all
|
|
111
|
+
* collide. Reported on the second occurrence in pre-order.
|
|
112
|
+
*/
|
|
113
|
+
| "duplicate-node-id"
|
|
114
|
+
/** A grid cell's `span` is not an integer in `1..columns`. */
|
|
115
|
+
| "span-out-of-range"
|
|
116
|
+
/**
|
|
117
|
+
* A grid cell's placement (IR 2.1.0) cannot be laid out: `row`, `rowSpan` or `column` is not a
|
|
118
|
+
* positive integer, `column` exceeds `columns`, or `column + span - 1` overflows the grid.
|
|
119
|
+
*/
|
|
120
|
+
| "placement-out-of-range"
|
|
121
|
+
/**
|
|
122
|
+
* A layout's `role` (IR 2.2.0) is present but not a well-formed role (`isLayoutRole`): renderers
|
|
123
|
+
* draw the plain container, so nothing is lost but the custom rendering — a `warn`. An unknown
|
|
124
|
+
* but well-formed role is NOT a finding: whether a renderer has it registered is not loom's to say.
|
|
125
|
+
*/
|
|
126
|
+
| "role-invalid"
|
|
127
|
+
/**
|
|
128
|
+
* A layout's `id` (IR 2.4.0, ADR 0101) is present but not a non-empty string: renderers treat it
|
|
129
|
+
* as absent and key the container positionally, so nothing is lost but state preservation
|
|
130
|
+
* across a recompose — a `warn`.
|
|
131
|
+
*/
|
|
132
|
+
| "layout-id-invalid"
|
|
133
|
+
/** A grid's `columns` is not a positive integer — there is no column count to lay cells into. */
|
|
134
|
+
| "columns-invalid"
|
|
135
|
+
/** A node that is neither a `Widget` nor a `Layout` (a stray `null`, a string, a stranded `GridCell`). */
|
|
136
|
+
| "malformed-node"
|
|
137
|
+
/** `binding.status.path` is empty or not a dotted path. */
|
|
138
|
+
| "status-path-invalid"
|
|
139
|
+
/** `binding.sensitivity` is not a `@loomwork/classify` `DataClass`. */
|
|
140
|
+
| "sensitivity-unknown"
|
|
141
|
+
/** `widget.source` (or a `degradedFrom` entry) is missing `type` or `version`. */
|
|
142
|
+
| "source-invalid"
|
|
143
|
+
/**
|
|
144
|
+
* `source.redacted` (IR 2.5.0, ADR 0097 amendment) is present but not `true`, or is `true` on a
|
|
145
|
+
* widget that still carries a `binding` or a `degradedFrom` chain — both would leave the
|
|
146
|
+
* withheld content (a projection id, a path, the original type) on the artifact.
|
|
147
|
+
*/
|
|
148
|
+
| "redacted-invalid"
|
|
149
|
+
/** `blueprint.irVersion` is set but not a version this module recognizes. */
|
|
150
|
+
| "ir-version-unknown";
|
|
151
|
+
|
|
152
|
+
export interface BlueprintFinding {
|
|
153
|
+
readonly code: BlueprintFindingCode;
|
|
154
|
+
readonly severity: "error" | "warn";
|
|
155
|
+
readonly message: string;
|
|
156
|
+
/**
|
|
157
|
+
* The node the finding applies to. For a widget-level finding it is the widget's id. For a
|
|
158
|
+
* cell-level finding (`span-out-of-range`, `placement-out-of-range`) it is the id of the cell's
|
|
159
|
+
* OWN node — a widget's id, or a nested layout's well-formed id (IR 2.4.0) — else `"<layout>"`.
|
|
160
|
+
* For a layout-level finding (`columns-invalid`, `role-invalid`, `duplicate-node-id` on a
|
|
161
|
+
* layout) it is the layout's well-formed id when it has one, else `"<layout>"`. For
|
|
162
|
+
* `ir-version-unknown`, `malformed-node` and `layout-id-invalid` it is always `"<layout>"`.
|
|
163
|
+
*/
|
|
164
|
+
readonly nodeId: string;
|
|
165
|
+
/** The widget's kind, or `"<layout>"` for a layout-level finding. */
|
|
166
|
+
readonly kind: string;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export interface BlueprintValidation {
|
|
170
|
+
/** True iff there are no `error`-severity findings. */
|
|
171
|
+
readonly ok: boolean;
|
|
172
|
+
readonly findings: readonly BlueprintFinding[];
|
|
173
|
+
/**
|
|
174
|
+
* Widgets visited. The denominator for a first-attempt validation rate — a
|
|
175
|
+
* report with zero widgets is not a pass, and this is how a caller tells the
|
|
176
|
+
* difference.
|
|
177
|
+
*/
|
|
178
|
+
readonly widgets: number;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Duplicated into `props` by every built-in builder; never a payload. */
|
|
182
|
+
const RESERVED_PROPS = new Set(["binding"]);
|
|
183
|
+
|
|
184
|
+
const KNOWN_IR_VERSIONS = new Set([IR_VERSION_LEGACY, ...IR_VERSIONS_READ_AS_IS]);
|
|
185
|
+
const DATA_CLASSES = new Set(["secret", "sensitive", "internal"]);
|
|
186
|
+
const DOTTED = /^[^.\s]+(\.[^.\s]+)*$/;
|
|
187
|
+
|
|
188
|
+
interface Visit {
|
|
189
|
+
widget?: Widget;
|
|
190
|
+
cell?: {
|
|
191
|
+
span: number;
|
|
192
|
+
columns: number;
|
|
193
|
+
cellNodeId: string | undefined;
|
|
194
|
+
/** Read as `unknown`: a model-composed artifact can put anything here. */
|
|
195
|
+
row: unknown;
|
|
196
|
+
rowSpan: unknown;
|
|
197
|
+
column: unknown;
|
|
198
|
+
};
|
|
199
|
+
/** A layout's well-formed id (IR 2.4.0) — it joins the widget-id namespace. */
|
|
200
|
+
layoutId?: { id: string };
|
|
201
|
+
/** A layout whose `id` is present but not a non-empty string — read as `unknown`. */
|
|
202
|
+
layoutIdInvalid?: { id: unknown };
|
|
203
|
+
/** A grid whose `columns` is not a positive integer — reported once, and its cells are not checked. */
|
|
204
|
+
columnsInvalid?: { columns: unknown; nodeId: string };
|
|
205
|
+
/** A layout whose `role` is present but not `isLayoutRole` — read as `unknown`. */
|
|
206
|
+
roleInvalid?: { role: unknown; nodeId: string };
|
|
207
|
+
/** A tree node that is neither a `Widget` nor a `Layout`; the subtree below it is not walked. */
|
|
208
|
+
malformed?: { node: unknown };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* A blueprint is DATA a model may have composed, so the walk cannot assume its own types hold.
|
|
213
|
+
* A `Layout` here is structural: an object with a `type` and an array of `children`. Anything
|
|
214
|
+
* else reaching the stack — `null`, a string, a `GridCell` stranded in a flex/stack — is a
|
|
215
|
+
* `malformed-node` finding, never a property access on a non-object.
|
|
216
|
+
*/
|
|
217
|
+
const isLayoutNode = (n: unknown): n is Layout =>
|
|
218
|
+
typeof n === "object" &&
|
|
219
|
+
n !== null &&
|
|
220
|
+
"type" in n &&
|
|
221
|
+
Array.isArray((n as { children?: unknown }).children);
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Iterative walk — an explicit stack, so a deep tree cannot blow the call stack.
|
|
225
|
+
* Visits widgets (pre-order) and grid cells. A grid's cells are visited in
|
|
226
|
+
* forward sibling order (so findings on them read in document order) before
|
|
227
|
+
* their nodes are pushed onto the stack in reverse, which keeps the overall
|
|
228
|
+
* widget walk pre-order.
|
|
229
|
+
*
|
|
230
|
+
* The loop is length-driven rather than `pop() !== undefined`: a literal `undefined` child is
|
|
231
|
+
* one of the malformed shapes this walk must REPORT, and a sentinel loop would silently end the
|
|
232
|
+
* walk on it instead.
|
|
233
|
+
*/
|
|
234
|
+
function eachNode(root: Layout | Widget, visit: (v: Visit) => void): void {
|
|
235
|
+
const stack: unknown[] = [root];
|
|
236
|
+
while (stack.length > 0) {
|
|
237
|
+
const node = stack.pop();
|
|
238
|
+
if (isWidget(node)) {
|
|
239
|
+
visit({ widget: node });
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
if (!isLayoutNode(node)) {
|
|
243
|
+
visit({ malformed: { node } });
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
// `undefined` is absent (it does not survive serialisation, and every builder omits it).
|
|
247
|
+
// A malformed id is ABSENT for every other purpose: it names nothing and collides with nothing.
|
|
248
|
+
const id = (node as { id?: unknown }).id;
|
|
249
|
+
const nodeId = hasLayoutId(node) ? node.id : "<layout>";
|
|
250
|
+
if (hasLayoutId(node)) visit({ layoutId: { id: node.id } });
|
|
251
|
+
else if (id !== undefined) visit({ layoutIdInvalid: { id } });
|
|
252
|
+
const role = (node as { role?: unknown }).role;
|
|
253
|
+
if (role !== undefined && !isLayoutRole(role)) visit({ roleInvalid: { role, nodeId } });
|
|
254
|
+
if (node.type === "grid") {
|
|
255
|
+
const children = node.children as Array<GridCell | Widget | Layout>;
|
|
256
|
+
const columns = (node as { columns?: unknown }).columns;
|
|
257
|
+
// A grid with no usable column count cannot place ANY cell, so every span would be
|
|
258
|
+
// "out of range" against it. Report the cause once and skip the cascade.
|
|
259
|
+
const columnsOk = Number.isInteger(columns) && (columns as number) >= 1;
|
|
260
|
+
if (!columnsOk) {
|
|
261
|
+
visit({ columnsInvalid: { columns, nodeId } });
|
|
262
|
+
} else {
|
|
263
|
+
for (let i = 0; i < children.length; i++) {
|
|
264
|
+
const c = asCell(children[i] as GridCell | Widget | Layout);
|
|
265
|
+
visit({
|
|
266
|
+
cell: {
|
|
267
|
+
span: c.span ?? 1,
|
|
268
|
+
columns: columns as number,
|
|
269
|
+
cellNodeId: isWidget(c.node)
|
|
270
|
+
? c.node.id
|
|
271
|
+
: hasLayoutId(c.node)
|
|
272
|
+
? c.node.id
|
|
273
|
+
: undefined,
|
|
274
|
+
row: c.row,
|
|
275
|
+
rowSpan: c.rowSpan,
|
|
276
|
+
column: c.column,
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
for (let i = children.length - 1; i >= 0; i--) {
|
|
282
|
+
stack.push(asCell(children[i] as GridCell | Widget | Layout).node);
|
|
283
|
+
}
|
|
284
|
+
} else {
|
|
285
|
+
const children = node.children as unknown[];
|
|
286
|
+
for (let i = children.length - 1; i >= 0; i--) {
|
|
287
|
+
stack.push(children[i]);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** A bounded description of a malformed role or id: a model-composed value can be any size. */
|
|
294
|
+
function describeValue(value: unknown): string {
|
|
295
|
+
if (typeof value !== "string") {
|
|
296
|
+
if (value === null) return "null";
|
|
297
|
+
return Array.isArray(value) ? "(an array)" : `(a ${typeof value})`;
|
|
298
|
+
}
|
|
299
|
+
return value.length > 64
|
|
300
|
+
? `${JSON.stringify(value.slice(0, 64))}… (${value.length} chars)`
|
|
301
|
+
: JSON.stringify(value);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** A short, safe description of a malformed node for a finding message. */
|
|
305
|
+
function describeNode(node: unknown): string {
|
|
306
|
+
if (node === null) return "null";
|
|
307
|
+
if (Array.isArray(node)) return "an array";
|
|
308
|
+
const t = typeof node;
|
|
309
|
+
if (t !== "object") return `a ${t}`;
|
|
310
|
+
if ("node" in (node as object)) return "a GridCell outside a grid";
|
|
311
|
+
return "an object with no layout `type` or `children`";
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const isPositiveInt = (v: unknown): v is number => Number.isInteger(v) && (v as number) >= 1;
|
|
315
|
+
const show = (v: unknown): string => JSON.stringify(v) ?? String(v);
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Why a cell's placement cannot be laid out — empty when it can. `spanOk` gates the overflow
|
|
319
|
+
* test so an invalid span is reported once (as `span-out-of-range`), not again as an overflow.
|
|
320
|
+
*/
|
|
321
|
+
function placementProblems(c: {
|
|
322
|
+
row: unknown;
|
|
323
|
+
rowSpan: unknown;
|
|
324
|
+
column: unknown;
|
|
325
|
+
span: number;
|
|
326
|
+
spanOk: boolean;
|
|
327
|
+
columns: number;
|
|
328
|
+
}): string[] {
|
|
329
|
+
const out: string[] = [];
|
|
330
|
+
if (c.row !== undefined && !isPositiveInt(c.row))
|
|
331
|
+
out.push(`row ${show(c.row)} is not a positive integer`);
|
|
332
|
+
if (c.rowSpan !== undefined && !isPositiveInt(c.rowSpan))
|
|
333
|
+
out.push(`rowSpan ${show(c.rowSpan)} is not a positive integer`);
|
|
334
|
+
if (c.column !== undefined) {
|
|
335
|
+
if (!isPositiveInt(c.column) || c.column > c.columns)
|
|
336
|
+
out.push(`column ${show(c.column)} is outside 1..${c.columns}`);
|
|
337
|
+
else if (c.spanOk && c.column + c.span - 1 > c.columns)
|
|
338
|
+
out.push(`column ${c.column} + span ${c.span} overflows the ${c.columns}-column grid`);
|
|
339
|
+
}
|
|
340
|
+
return out;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Check a blueprint against a catalogue. Never throws; returns every finding it
|
|
345
|
+
* can see in one pass, so a caller degrades or records a gap on the whole report
|
|
346
|
+
* rather than on the first failure.
|
|
347
|
+
*/
|
|
348
|
+
export function validateBlueprint(
|
|
349
|
+
blueprint: Blueprint,
|
|
350
|
+
catalog: WidgetCatalog
|
|
351
|
+
): BlueprintValidation {
|
|
352
|
+
const entries = new Map<string, CatalogEntry>();
|
|
353
|
+
for (const entry of catalog.entries) entries.set(entry.kind, entry);
|
|
354
|
+
const projections = new Set(blueprint.projections);
|
|
355
|
+
|
|
356
|
+
const findings: BlueprintFinding[] = [];
|
|
357
|
+
// ONE namespace: widget ids and layout ids (IR 2.4.0) are both node identity.
|
|
358
|
+
// The value records which kind of node claimed the id first, so a widget↔widget collision keeps
|
|
359
|
+
// its message and a collision involving a layout says so.
|
|
360
|
+
const seenIds = new Map<string, "widget" | "layout">();
|
|
361
|
+
let widgets = 0;
|
|
362
|
+
|
|
363
|
+
if (blueprint.irVersion !== undefined && !KNOWN_IR_VERSIONS.has(blueprint.irVersion)) {
|
|
364
|
+
findings.push({
|
|
365
|
+
code: "ir-version-unknown",
|
|
366
|
+
severity: "error",
|
|
367
|
+
nodeId: "<layout>",
|
|
368
|
+
kind: "<layout>",
|
|
369
|
+
message: `irVersion "${blueprint.irVersion}" is not a version this module recognizes — a reader may not know how to interpret the artifact`,
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
eachNode(blueprint.layout, (v) => {
|
|
374
|
+
if (v.layoutId) {
|
|
375
|
+
const { id } = v.layoutId;
|
|
376
|
+
if (seenIds.has(id)) {
|
|
377
|
+
findings.push({
|
|
378
|
+
code: "duplicate-node-id",
|
|
379
|
+
severity: "error",
|
|
380
|
+
nodeId: id,
|
|
381
|
+
kind: "<layout>",
|
|
382
|
+
message: `a layout shares the id "${id}" with an earlier node — widget and layout ids are one namespace, so node identity and the renderer's key collide`,
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
if (!seenIds.has(id)) seenIds.set(id, "layout");
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (v.layoutIdInvalid) {
|
|
390
|
+
findings.push({
|
|
391
|
+
code: "layout-id-invalid",
|
|
392
|
+
severity: "warn",
|
|
393
|
+
nodeId: "<layout>",
|
|
394
|
+
kind: "<layout>",
|
|
395
|
+
message: `layout id ${describeValue(v.layoutIdInvalid.id)} is not a non-empty string — renderers treat it as absent and key this container by position, so its state is not kept across a recompose`,
|
|
396
|
+
});
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (v.columnsInvalid) {
|
|
401
|
+
findings.push({
|
|
402
|
+
code: "columns-invalid",
|
|
403
|
+
severity: "error",
|
|
404
|
+
nodeId: v.columnsInvalid.nodeId,
|
|
405
|
+
kind: "<layout>",
|
|
406
|
+
message: `grid columns ${JSON.stringify(v.columnsInvalid.columns) ?? "undefined"} is not a positive integer — there is no column count to place cells into, so this grid's spans are not checked`,
|
|
407
|
+
});
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (v.roleInvalid) {
|
|
412
|
+
findings.push({
|
|
413
|
+
code: "role-invalid",
|
|
414
|
+
severity: "warn",
|
|
415
|
+
nodeId: v.roleInvalid.nodeId,
|
|
416
|
+
kind: "<layout>",
|
|
417
|
+
message: `layout role ${describeValue(v.roleInvalid.role)} is not a well-formed role (a lowercase letter, then lowercase letters, digits or hyphens, at most 64 characters) — renderers draw the plain container`,
|
|
418
|
+
});
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if (v.malformed) {
|
|
423
|
+
findings.push({
|
|
424
|
+
code: "malformed-node",
|
|
425
|
+
severity: "error",
|
|
426
|
+
nodeId: "<layout>",
|
|
427
|
+
kind: "<layout>",
|
|
428
|
+
message: `a layout child is neither a Widget nor a Layout (${describeNode(v.malformed.node)}) — it renders nothing, and the subtree below it is not validated`,
|
|
429
|
+
});
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (v.cell) {
|
|
434
|
+
const { span, columns, cellNodeId, row, rowSpan, column } = v.cell;
|
|
435
|
+
const spanOk = Number.isInteger(span) && span >= 1 && span <= columns;
|
|
436
|
+
if (!spanOk) {
|
|
437
|
+
findings.push({
|
|
438
|
+
code: "span-out-of-range",
|
|
439
|
+
severity: "error",
|
|
440
|
+
nodeId: cellNodeId ?? "<layout>",
|
|
441
|
+
kind: "<layout>",
|
|
442
|
+
message: `cell span ${span} is outside 1..${columns} — a renderer cannot lay this cell out`,
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
const problems = placementProblems({ row, rowSpan, column, span, spanOk, columns });
|
|
446
|
+
if (problems.length > 0) {
|
|
447
|
+
findings.push({
|
|
448
|
+
code: "placement-out-of-range",
|
|
449
|
+
severity: "error",
|
|
450
|
+
nodeId: cellNodeId ?? "<layout>",
|
|
451
|
+
kind: "<layout>",
|
|
452
|
+
message: `cell placement cannot be laid out — ${problems.join("; ")}`,
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const widget = v.widget;
|
|
459
|
+
if (!widget) return;
|
|
460
|
+
widgets++;
|
|
461
|
+
const { id, kind } = widget;
|
|
462
|
+
|
|
463
|
+
const earlier = seenIds.get(id);
|
|
464
|
+
if (earlier !== undefined) {
|
|
465
|
+
findings.push({
|
|
466
|
+
code: "duplicate-node-id",
|
|
467
|
+
severity: "error",
|
|
468
|
+
nodeId: id,
|
|
469
|
+
kind,
|
|
470
|
+
message:
|
|
471
|
+
earlier === "widget"
|
|
472
|
+
? `two widgets share the id "${id}" — node identity and the renderer's key collide, so one silently replaces the other`
|
|
473
|
+
: `a widget shares the id "${id}" with an earlier layout — widget and layout ids are one namespace, so node identity and the renderer's key collide`,
|
|
474
|
+
});
|
|
475
|
+
} else {
|
|
476
|
+
seenIds.set(id, "widget");
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// IR 2.5.0: `source.redacted` is `true` or absent (an explicit `undefined` is absent), and a
|
|
480
|
+
// redacted widget carries nothing it withheld: no binding, no title, no props, no degradedFrom,
|
|
481
|
+
// and the constant source type/version. Runs BEFORE the unknown-kind return, so a redacted
|
|
482
|
+
// widget of an unknown kind that still carries data is caught too.
|
|
483
|
+
{
|
|
484
|
+
const rs = widget.source as { [k: string]: unknown } | null | undefined;
|
|
485
|
+
if (rs !== null && typeof rs === "object" && rs.redacted !== undefined) {
|
|
486
|
+
const bad = (message: string) =>
|
|
487
|
+
findings.push({ code: "redacted-invalid", severity: "error", nodeId: id, kind, message });
|
|
488
|
+
if (rs.redacted !== true) {
|
|
489
|
+
bad(`widget.source.redacted must be true or absent, got ${JSON.stringify(rs.redacted)}`);
|
|
490
|
+
} else {
|
|
491
|
+
const leaks: string[] = [];
|
|
492
|
+
if (widget.binding !== undefined) leaks.push("binding");
|
|
493
|
+
if (widget.title !== undefined) leaks.push("title");
|
|
494
|
+
if (widget.props && Object.keys(widget.props).length > 0) leaks.push("props");
|
|
495
|
+
if (rs.degradedFrom !== undefined) leaks.push("source.degradedFrom");
|
|
496
|
+
if (rs.type !== "redacted" || rs.version !== "1")
|
|
497
|
+
leaks.push(`original source type/version (must be "redacted"/"1")`);
|
|
498
|
+
if (leaks.length > 0)
|
|
499
|
+
bad(
|
|
500
|
+
`a redacted widget must carry no ${leaks.join(" and no ")} — it would leave the withheld content on the artifact`
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const entry = entries.get(kind);
|
|
507
|
+
if (!entry) {
|
|
508
|
+
findings.push({
|
|
509
|
+
code: "unknown-kind",
|
|
510
|
+
severity: "error",
|
|
511
|
+
nodeId: id,
|
|
512
|
+
kind,
|
|
513
|
+
message: `"${kind}" is not in catalogue "${catalog.id}" — a composer may only assemble from the audited set`,
|
|
514
|
+
});
|
|
515
|
+
// Nothing further is checkable without an entry: the allow-list and the
|
|
516
|
+
// binding requirement both come from it. Reporting invented follow-on
|
|
517
|
+
// findings here would inflate the count for one real defect.
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
if (entry.binding === "required" && !widget.binding) {
|
|
522
|
+
findings.push({
|
|
523
|
+
code: "missing-binding",
|
|
524
|
+
severity: "error",
|
|
525
|
+
nodeId: id,
|
|
526
|
+
kind,
|
|
527
|
+
message: `"${kind}" renders bound data but carries no binding — it would render blank with no error`,
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
if (entry.binding === "none" && widget.binding) {
|
|
531
|
+
findings.push({
|
|
532
|
+
code: "unexpected-binding",
|
|
533
|
+
severity: "warn",
|
|
534
|
+
nodeId: id,
|
|
535
|
+
kind,
|
|
536
|
+
message: `"${kind}" is declared as taking no binding, but this widget carries one`,
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
if (widget.binding && !projections.has(widget.binding.projectionId)) {
|
|
541
|
+
findings.push({
|
|
542
|
+
code: "undeclared-projection",
|
|
543
|
+
severity: "error",
|
|
544
|
+
nodeId: id,
|
|
545
|
+
kind,
|
|
546
|
+
message: `binds to projection "${widget.binding.projectionId}", which the blueprint does not declare — renderers hydrate only declared projections, so this widget resolves to undefined and renders blank`,
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const allowed = new Set(entry.props);
|
|
551
|
+
for (const prop of Object.keys(widget.props)) {
|
|
552
|
+
if (RESERVED_PROPS.has(prop) || allowed.has(prop)) continue;
|
|
553
|
+
findings.push({
|
|
554
|
+
code: "undeclared-prop",
|
|
555
|
+
severity: "error",
|
|
556
|
+
nodeId: id,
|
|
557
|
+
kind,
|
|
558
|
+
message: `prop "${prop}" is not declared by "${kind}" in catalogue "${catalog.id}" — a blueprint carries bindings, never fetched data, and an undeclared prop is how data gets inlined into a shared artifact`,
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// `binding` and `source` are typed, but a blueprint is data a model can compose —
|
|
563
|
+
// a malformed value (null, a string, ...) must be a finding, never a property
|
|
564
|
+
// access on a non-object, so every read below goes through a typeof guard first.
|
|
565
|
+
const status = widget.binding?.status;
|
|
566
|
+
if (status !== undefined) {
|
|
567
|
+
const path =
|
|
568
|
+
status !== null && typeof status === "object"
|
|
569
|
+
? (status as { path?: unknown }).path
|
|
570
|
+
: undefined;
|
|
571
|
+
if (typeof path !== "string" || path === "" || !DOTTED.test(path)) {
|
|
572
|
+
findings.push({
|
|
573
|
+
code: "status-path-invalid",
|
|
574
|
+
severity: "error",
|
|
575
|
+
nodeId: id,
|
|
576
|
+
kind,
|
|
577
|
+
message: `binding.status.path ${JSON.stringify(path)} is not a non-empty dotted path`,
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const sensitivity = widget.binding?.sensitivity;
|
|
583
|
+
if (sensitivity !== undefined && !DATA_CLASSES.has(sensitivity as string)) {
|
|
584
|
+
findings.push({
|
|
585
|
+
code: "sensitivity-unknown",
|
|
586
|
+
severity: "error",
|
|
587
|
+
nodeId: id,
|
|
588
|
+
kind,
|
|
589
|
+
message: `binding.sensitivity ${JSON.stringify(sensitivity)} is not a @loomwork/classify DataClass`,
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const source = widget.source;
|
|
594
|
+
if (source !== undefined) {
|
|
595
|
+
const src =
|
|
596
|
+
source !== null && typeof source === "object"
|
|
597
|
+
? (source as {
|
|
598
|
+
type?: unknown;
|
|
599
|
+
version?: unknown;
|
|
600
|
+
degradedFrom?: unknown;
|
|
601
|
+
redacted?: unknown;
|
|
602
|
+
})
|
|
603
|
+
: undefined;
|
|
604
|
+
const degradedFrom = Array.isArray(src?.degradedFrom) ? src.degradedFrom : undefined;
|
|
605
|
+
// Typed as `string`, so a NON-string is exactly what a hand-assembled artifact gets wrong.
|
|
606
|
+
// A truthiness test would accept `type: 123` and `version: {}` — both render as garbage.
|
|
607
|
+
const notAName = (v: unknown) => typeof v !== "string" || v === "";
|
|
608
|
+
const invalid =
|
|
609
|
+
src === undefined ||
|
|
610
|
+
notAName(src.type) ||
|
|
611
|
+
notAName(src.version) ||
|
|
612
|
+
(src.degradedFrom !== undefined && degradedFrom === undefined) ||
|
|
613
|
+
(degradedFrom?.some(
|
|
614
|
+
(d: unknown) =>
|
|
615
|
+
typeof d !== "object" ||
|
|
616
|
+
d === null ||
|
|
617
|
+
notAName((d as { type?: unknown }).type) ||
|
|
618
|
+
notAName((d as { version?: unknown }).version)
|
|
619
|
+
) ??
|
|
620
|
+
false);
|
|
621
|
+
if (invalid) {
|
|
622
|
+
findings.push({
|
|
623
|
+
code: "source-invalid",
|
|
624
|
+
severity: "error",
|
|
625
|
+
nodeId: id,
|
|
626
|
+
kind,
|
|
627
|
+
message: `widget.source is missing a non-empty string "type" or "version" — either on the source itself or a degradedFrom entry (which must be an array)`,
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
return {
|
|
634
|
+
ok: findings.every((f) => f.severity !== "error"),
|
|
635
|
+
findings,
|
|
636
|
+
widgets,
|
|
637
|
+
};
|
|
638
|
+
}
|
package/src/coverage.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { RendererRegistry } from "./RendererRegistry";
|
|
2
|
+
import type { WidgetCatalog } from "./catalog";
|
|
3
|
+
|
|
4
|
+
// ── Catalogue ↔ renderer coverage ─────────────────────────────────────────────
|
|
5
|
+
//
|
|
6
|
+
// `validateBlueprint` checks one direction: the composer only names components
|
|
7
|
+
// the catalogue defines. That is worth nothing on its own if the *renderer* does
|
|
8
|
+
// not implement everything the catalogue defines — the composition passes
|
|
9
|
+
// validation and the viewer gets a placeholder anyway.
|
|
10
|
+
//
|
|
11
|
+
// The two checks are therefore a pair, and the second one is the easier to
|
|
12
|
+
// forget, because it fails on shipped code with no product involved: a kind is
|
|
13
|
+
// added to the vocabulary, a renderer is not written for it, and the only
|
|
14
|
+
// evidence is a string in somebody's browser.
|
|
15
|
+
//
|
|
16
|
+
// `missing` is the half that makes a report `!ok`. `extra` — a renderer for a
|
|
17
|
+
// kind the catalogue does not define — is reported but does not fail: it is
|
|
18
|
+
// drift, not a defect, and it is exactly what a host that merges a BYO registry
|
|
19
|
+
// into a smaller catalogue looks like.
|
|
20
|
+
|
|
21
|
+
export interface CoverageReport {
|
|
22
|
+
/** True iff every catalogue kind has a renderer. */
|
|
23
|
+
readonly ok: boolean;
|
|
24
|
+
/** Catalogue kinds with no registered renderer — these render as a placeholder. */
|
|
25
|
+
readonly missing: readonly string[];
|
|
26
|
+
/** Registered kinds absent from the catalogue — reported, not failed. */
|
|
27
|
+
readonly extra: readonly string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Which of a catalogue's components this registry can actually render.
|
|
32
|
+
*
|
|
33
|
+
* Use it as a test in any package that ships a renderer, so adding a kind to the
|
|
34
|
+
* vocabulary without a renderer fails the build rather than the viewer.
|
|
35
|
+
*/
|
|
36
|
+
export function catalogCoverage(
|
|
37
|
+
catalog: WidgetCatalog,
|
|
38
|
+
registry: RendererRegistry<unknown>
|
|
39
|
+
): CoverageReport {
|
|
40
|
+
const registered = new Set(registry.kinds());
|
|
41
|
+
const missing: string[] = [];
|
|
42
|
+
const declared = new Set<string>();
|
|
43
|
+
|
|
44
|
+
for (const entry of catalog.entries) {
|
|
45
|
+
declared.add(entry.kind);
|
|
46
|
+
if (!registered.has(entry.kind)) missing.push(entry.kind);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const extra = [...registered].filter((kind) => !declared.has(kind));
|
|
50
|
+
|
|
51
|
+
return { ok: missing.length === 0, missing, extra };
|
|
52
|
+
}
|