@texuli/spec 0.3.0 → 0.5.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.
Files changed (48) hide show
  1. package/README.md +82 -14
  2. package/dist/build.d.ts +5 -0
  3. package/dist/build.js +1 -0
  4. package/dist/canonical.js +20 -13
  5. package/dist/catalog.d.ts +9 -3
  6. package/dist/catalog.js +7 -1
  7. package/dist/codegen/index.d.ts +2 -2
  8. package/dist/codegen/index.js +2 -2
  9. package/dist/codegen/kotlin.d.ts +6 -0
  10. package/dist/codegen/kotlin.js +80 -8
  11. package/dist/conformance.d.ts +11 -2
  12. package/dist/conformance.js +93 -2
  13. package/dist/contract.d.ts +112 -0
  14. package/dist/contract.js +196 -0
  15. package/dist/dialect.d.ts +10 -1
  16. package/dist/dialect.js +6 -1
  17. package/dist/generated/typescript/blueprint.d.ts +103 -5
  18. package/dist/generated/typescript/blueprint.js +1 -1
  19. package/dist/generated/typescript/catalog-schema.d.ts +35 -0
  20. package/dist/generated/typescript/catalog-schema.js +1 -1
  21. package/dist/generated/typescript/diagnostics.d.ts +10 -1
  22. package/dist/generated/typescript/diagnostics.js +1 -1
  23. package/dist/generated/typescript/manifest.js +1 -1
  24. package/dist/generated/typescript/vocabulary.d.ts +194 -4
  25. package/dist/generated/typescript/vocabulary.js +31 -2
  26. package/dist/generated/typescript/workflow.d.ts +84 -0
  27. package/dist/generated/typescript/workflow.js +3 -0
  28. package/dist/index.d.ts +13 -5
  29. package/dist/index.js +9 -3
  30. package/dist/migrations/0.2.0-0.3.0.d.ts +7 -0
  31. package/dist/migrations/0.2.0-0.3.0.js +10 -0
  32. package/dist/migrations/0.3.0-0.4.0.d.ts +9 -0
  33. package/dist/migrations/0.3.0-0.4.0.js +12 -0
  34. package/dist/migrations/0.4.0-0.5.0.d.ts +8 -0
  35. package/dist/migrations/0.4.0-0.5.0.js +11 -0
  36. package/dist/migrations/index.d.ts +15 -0
  37. package/dist/migrations/index.js +55 -0
  38. package/dist/migrations/types.d.ts +14 -0
  39. package/dist/migrations/types.js +1 -0
  40. package/dist/surface.d.ts +60 -0
  41. package/dist/surface.js +162 -0
  42. package/dist/validate.d.ts +89 -14
  43. package/dist/validate.js +725 -64
  44. package/dist/version.d.ts +1 -1
  45. package/dist/version.js +1 -1
  46. package/dist/workflow.d.ts +15 -0
  47. package/dist/workflow.js +256 -0
  48. package/package.json +13 -1
package/dist/validate.js CHANGED
@@ -1,15 +1,23 @@
1
+ import { ACTION_FIELDS, contractRoot, DATA_BINDING_KEY, SPEC_ACTION_OPS, SPEC_LIMITS, SPEC_REGEX, SPEC_STATE_TYPES, STATE_BINDING_KEY, STATE_FIELDS, toDialectSchema, } from "./contract.js";
1
2
  import { isRecord, validateInstance } from "./dialect.js";
3
+ import { checkStateSurface, surfaceInputs } from "./surface.js";
2
4
  const MAX_TREE_DEPTH = 64;
5
+ const ACTION_KEYS = new Set(Object.values(ACTION_FIELDS).flatMap((f) => f.allowed));
6
+ const STATE_KEYS = new Set(Object.values(STATE_FIELDS).flat());
7
+ const FORM_ID = 'action-form';
8
+ const BINDING_SHAPE = 'A binding is exactly {"$state": name} or {"$data": name}.';
3
9
  /**
4
10
  * Validate a blueprint envelope against the spec alone: the envelope schema,
5
- * then every node against the catalog vocabulary (closed ids, closed prop
6
- * names, prop kinds and enum values, required props, no platform-extension
7
- * props from data, a `slot` that names a slot prop of the parent) and the
8
- * declared profile. A `when` condition is checked for shape only; the
9
- * runtime formula parser checks its operands. With `catalogHash`, an envelope
10
- * bound to another catalog is refused. This is the shared, target-neutral
11
- * contract; URL policy, limits, grammar and rendering stay in the runtime
12
- * validators (`@texuli/catalog/validate`).
11
+ * the declared state and flow, then every node against the catalog
12
+ * vocabulary (closed ids, closed prop names, prop kinds and enum values,
13
+ * property schemas, required props, no platform-extension props from data,
14
+ * a `slot` that names a slot prop of the parent, bindings, two-way `bind`,
15
+ * `on` events and their closed actions) and the declared profile. A `when`
16
+ * condition is checked for shape only; the runtime formula parser checks
17
+ * its operands. With `catalogHash`, an envelope bound to another catalog is
18
+ * refused. Deprecated components and props add `warnings` (never failing).
19
+ * This is the shared, target-neutral contract; URL policy, limits, grammar
20
+ * and rendering stay in the runtime validators (`@texuli/catalog/validate`).
13
21
  */
14
22
  export function validateBlueprint(input, documents, options = {}) {
15
23
  const diagnostics = validateInstance(documents.blueprintSchema, input).map((issue) => ({ code: 'schema', path: issue.path, message: `${issue.code}: ${issue.message}` }));
@@ -22,49 +30,312 @@ export function validateBlueprint(input, documents, options = {}) {
22
30
  message: 'The blueprint is bound to another catalog.',
23
31
  });
24
32
  }
33
+ const { workflowSchema: schema, validateWorkflowGraph: validate } = documents;
34
+ const workflow = schema && validate ? { schema, validate } : undefined;
35
+ const state = checkStateDeclarations(input.state, diagnostics, workflow);
36
+ const flow = checkFlow(input.flow, diagnostics);
37
+ const warnings = [];
25
38
  const portable = input.profile === 'portable';
26
39
  if (isRecord(input.tree)) {
27
- diagnostics.push(...validateTree(input.tree, documents.catalog, { portable, path: '$.tree' }));
40
+ // The state surface's node rules, as the player refuses them at paint time.
41
+ for (const issue of checkStateSurface(input.tree, surfaceInputs(input))) {
42
+ diagnostics.push({
43
+ code: issue.code,
44
+ path: `$.tree${issue.at.map((index) => `.children[${index}]`).join('')}`,
45
+ message: issue.message,
46
+ });
47
+ }
48
+ diagnostics.push(...validateTree(input.tree, documents.catalog, {
49
+ portable,
50
+ path: '$.tree',
51
+ state,
52
+ flow,
53
+ root: contractRoot(documents.blueprintSchema, workflow?.schema),
54
+ workflow,
55
+ warnings,
56
+ }));
57
+ }
58
+ return {
59
+ ok: diagnostics.length === 0,
60
+ diagnostics,
61
+ ...(warnings.length > 0 ? { warnings } : {}),
62
+ };
63
+ }
64
+ // ---------------------------------------------------------------------------
65
+ // State and flow
66
+ /**
67
+ * Read the envelope's `state` (code `state`): at most 64 variables, safe
68
+ * names, the fields each type takes, and an initial value of the declared
69
+ * type. One diagnostic per refused declaration; a refused declaration stays
70
+ * declared (so bindings to it are not also `unknown-state`). Shape problems
71
+ * the schema reports are not repeated.
72
+ */
73
+ export function checkStateDeclarations(input, out, workflow) {
74
+ const declared = new Map();
75
+ if (!isRecord(input))
76
+ return declared;
77
+ const names = Object.keys(input);
78
+ if (names.length > SPEC_LIMITS.stateVars) {
79
+ out.push({
80
+ code: 'state',
81
+ path: '$.state',
82
+ message: `At most ${SPEC_LIMITS.stateVars} state variables (got ${names.length}).`,
83
+ });
84
+ }
85
+ for (const name of names) {
86
+ const path = `$.state.${name}`;
87
+ if (!SPEC_REGEX.stateName.test(name)) {
88
+ out.push({
89
+ code: 'state',
90
+ path,
91
+ message: `"${name}" is not a valid state name.`,
92
+ });
93
+ continue;
94
+ }
95
+ const raw = input[name];
96
+ if (!isRecord(raw) || !SPEC_STATE_TYPES.includes(raw.type))
97
+ continue;
98
+ const variable = readDeclaration(name, raw, path, out, workflow);
99
+ declared.set(name, variable);
100
+ }
101
+ return declared;
102
+ }
103
+ function readDeclaration(name, raw, path, out, workflow) {
104
+ const type = raw.type;
105
+ const writable = raw.source === undefined;
106
+ const refuse = (where, message) => {
107
+ out.push({ code: 'state', path: where, message });
108
+ return { type, valid: false, initial: undefined, writable };
109
+ };
110
+ const accept = (initial, bounds) => ({
111
+ type,
112
+ valid: true,
113
+ initial,
114
+ writable,
115
+ ...bounds,
116
+ });
117
+ // Unknown keys are the schema's to report; only fields of another type are judged here.
118
+ const extra = Object.keys(raw).find((key) => STATE_KEYS.has(key) && !STATE_FIELDS[type].includes(key));
119
+ if (extra !== undefined)
120
+ return refuse(`${path}.${extra}`, `A ${type} variable takes no "${extra}".`);
121
+ const has = (key) => Object.hasOwn(raw, key);
122
+ switch (type) {
123
+ case 'boolean': {
124
+ const initial = has('initial') ? raw.initial : false;
125
+ if (typeof initial !== 'boolean')
126
+ return refuse(`${path}.initial`, `"${name}" needs a boolean.`);
127
+ return accept(initial);
128
+ }
129
+ case 'number': {
130
+ const { min, max } = raw;
131
+ if (!finite(min) || !finite(max) || min > max) {
132
+ return refuse(path, `"${name}" needs finite min ≤ max.`);
133
+ }
134
+ const initial = has('initial') ? raw.initial : min;
135
+ if (!finite(initial) || initial < min || initial > max) {
136
+ return refuse(`${path}.initial`, `"${name}" initial must be between ${min} and ${max}.`);
137
+ }
138
+ return accept(initial, { min, max });
139
+ }
140
+ case 'enum': {
141
+ const values = raw.values;
142
+ if (!Array.isArray(values))
143
+ return refuse(path, `"${name}" needs values.`);
144
+ if (new Set(values).size !== values.length) {
145
+ return refuse(`${path}.values`, `"${name}" values must be distinct.`);
146
+ }
147
+ const initial = has('initial') ? raw.initial : values[0];
148
+ if (typeof initial !== 'string' || !values.includes(initial)) {
149
+ return refuse(`${path}.initial`, `"${name}" initial must be one of its values.`);
150
+ }
151
+ return accept(initial, { values: values });
152
+ }
153
+ case 'string': {
154
+ const maxLength = has('maxLength')
155
+ ? raw.maxLength
156
+ : SPEC_LIMITS.defaultStringLength;
157
+ const initial = has('initial') ? raw.initial : '';
158
+ if (typeof initial !== 'string' || initial.length > maxLength) {
159
+ return refuse(`${path}.initial`, `"${name}" initial must be a string of at most ${maxLength}.`);
160
+ }
161
+ return accept(initial, { maxLength });
162
+ }
163
+ case 'list': {
164
+ const maxItems = has('maxItems') ? raw.maxItems : SPEC_LIMITS.listItems;
165
+ const initial = has('initial') ? raw.initial : [];
166
+ const problem = listProblem(initial, maxItems);
167
+ if (problem)
168
+ return refuse(`${path}.initial${problem.at}`, problem.message);
169
+ return accept(initial, { maxItems });
170
+ }
171
+ case 'workflow': {
172
+ if (!has('initial'))
173
+ return refuse(path, `"${name}" needs an initial workflow graph.`);
174
+ if (!workflow) {
175
+ return refuse(`${path}.initial`, 'Workflow state needs the workflow schema and validator.');
176
+ }
177
+ const result = workflow.validate(raw.initial, workflow.schema);
178
+ for (const d of result.diagnostics) {
179
+ out.push({ ...d, path: `${path}.initial${d.path.slice(1)}` });
180
+ }
181
+ return { type, valid: result.ok, initial: raw.initial, writable };
182
+ }
183
+ }
184
+ }
185
+ /**
186
+ * A list value: at most `maxItems` items, each `{ id, ...fields }` with a
187
+ * unique item key and at most 16 scalar fields (string ≤ 1000, finite
188
+ * number, boolean) named like state variables. Strings follow the
189
+ * `listText` rule: a draggable item's text never hides or reorders itself.
190
+ */
191
+ export function listProblem(value, maxItems) {
192
+ if (!Array.isArray(value))
193
+ return { at: '', message: 'A list value is an array of items.' };
194
+ if (value.length > maxItems)
195
+ return { at: '', message: `At most ${maxItems} items.` };
196
+ const ids = new Set();
197
+ for (const [index, item] of value.entries()) {
198
+ const at = `[${index}]`;
199
+ if (!isRecord(item) || typeof item.id !== 'string' || !SPEC_REGEX.itemKey.test(item.id)) {
200
+ return { at, message: 'An item is an object with an item key "id".' };
201
+ }
202
+ if (ids.has(item.id))
203
+ return { at: `${at}.id`, message: `Item "${item.id}" appears twice.` };
204
+ ids.add(item.id);
205
+ const fields = Object.keys(item).filter((key) => key !== 'id');
206
+ if (fields.length > SPEC_LIMITS.listItemFields) {
207
+ return { at, message: `At most ${SPEC_LIMITS.listItemFields} fields besides "id".` };
208
+ }
209
+ for (const key of fields) {
210
+ const field = item[key];
211
+ const ok = SPEC_REGEX.stateName.test(key) &&
212
+ (typeof field === 'boolean' ||
213
+ finite(field) ||
214
+ (typeof field === 'string' && field.length <= SPEC_LIMITS.stringLength));
215
+ if (!ok) {
216
+ return {
217
+ at: `${at}.${key}`,
218
+ message: 'Item fields are named scalars (strings of at most 1000).',
219
+ };
220
+ }
221
+ if (typeof field === 'string' && !SPEC_REGEX.listText.test(field)) {
222
+ return {
223
+ at: `${at}.${key}`,
224
+ message: 'Item text cannot hold control, bidirectional or invisible formatting characters.',
225
+ };
226
+ }
227
+ }
28
228
  }
29
- return { ok: diagnostics.length === 0, diagnostics };
229
+ return null;
230
+ }
231
+ /**
232
+ * Read the envelope's `flow` (code `state`): distinct views, an initial and
233
+ * transitions that name declared views (or `*` as a source).
234
+ */
235
+ export function checkFlow(input, out) {
236
+ if (!isRecord(input) || !Array.isArray(input.views))
237
+ return null;
238
+ const views = input.views.filter((view) => typeof view === 'string');
239
+ const fail = (path, message) => out.push({ code: 'state', path, message });
240
+ if (new Set(views).size !== views.length)
241
+ fail('$.flow.views', 'Flow views must be distinct.');
242
+ if (input.initial !== undefined && !views.includes(input.initial)) {
243
+ fail('$.flow.initial', 'flow.initial must be one of the declared views.');
244
+ }
245
+ const events = new Set();
246
+ if (Array.isArray(input.transitions)) {
247
+ input.transitions.forEach((transition, index) => {
248
+ if (!isRecord(transition))
249
+ return;
250
+ const path = `$.flow.transitions[${index}]`;
251
+ const { from, to, on } = transition;
252
+ if (typeof from === 'string' && from !== '*' && !views.includes(from)) {
253
+ fail(`${path}.from`, 'from must be a declared view or "*".');
254
+ }
255
+ if (typeof to === 'string' && !views.includes(to))
256
+ fail(`${path}.to`, 'to must be a declared view.');
257
+ if (typeof on === 'string')
258
+ events.add(on);
259
+ });
260
+ }
261
+ return { views, events };
262
+ }
263
+ /** A context reporting into `out`. */
264
+ function reportInto(context) {
265
+ return {
266
+ ...context,
267
+ fail: (code, path, message) => context.out.push({ code, path, id: context.id, message }),
268
+ };
30
269
  }
31
270
  /** Check one catalog tree against the vocabulary. See {@link validateBlueprint}. */
32
271
  export function validateTree(tree, catalog, options = {}) {
33
272
  const byId = new Map(catalog.components.map((c) => [c.id, c]));
34
273
  const out = [];
35
- const visit = (node, path, depth, parent) => {
274
+ const state = options.state ?? new Map();
275
+ const flow = options.flow ?? null;
276
+ const root = options.root ?? { $defs: {} };
277
+ const warn = (warning) => options.warnings?.push(warning);
278
+ const visit = (node, path, depth, parent, insideForm) => {
36
279
  if (!isRecord(node) || typeof node.id !== 'string' || depth > MAX_TREE_DEPTH)
37
280
  return;
38
281
  checkSlot(node.slot, parent, node.id, path, out);
39
282
  const component = byId.get(node.id);
283
+ const context = reportInto({
284
+ id: node.id,
285
+ state,
286
+ flow,
287
+ root,
288
+ insideForm,
289
+ out,
290
+ workflow: options.workflow,
291
+ });
40
292
  if (!component) {
41
- out.push({
42
- code: 'unknown-id',
43
- path: `${path}.id`,
44
- id: node.id,
45
- message: `"${node.id}" is not in the catalog.`,
46
- });
293
+ context.fail('unknown-id', `${path}.id`, `"${node.id}" is not in the catalog.`);
47
294
  }
48
295
  else {
49
296
  if (options.portable && component.capability !== 'portable-core') {
50
- out.push({
51
- code: 'unsupported-profile',
52
- path,
53
- id: node.id,
54
- message: `"${node.id}" is ${component.capability}, not portable core.`,
55
- });
297
+ context.fail('unsupported-profile', path, `"${node.id}" is ${component.capability}, not portable core.`);
298
+ }
299
+ const props = isRecord(node.props) ? node.props : {};
300
+ const bind = isRecord(node.bind) ? node.bind : {};
301
+ checkProps(props, bind, component, `${path}.props`, context);
302
+ checkBind(bind, props, component, `${path}.bind`, context);
303
+ if (isRecord(node.on))
304
+ checkOn(node.on, component, `${path}.on`, context);
305
+ if (component.deprecated) {
306
+ warn(deprecation(`${path}.id`, node.id, `"${node.id}"`, component.deprecated));
307
+ }
308
+ for (const prop of component.props) {
309
+ if (!prop.deprecated)
310
+ continue;
311
+ const where = Object.hasOwn(props, prop.name)
312
+ ? `${path}.props.${prop.name}`
313
+ : Object.hasOwn(bind, prop.name)
314
+ ? `${path}.bind.${prop.name}`
315
+ : undefined;
316
+ if (where)
317
+ warn(deprecation(where, node.id, `"${prop.name}"`, prop.deprecated));
56
318
  }
57
- checkProps(node.props, component, `${path}.props`, out);
58
319
  }
59
320
  if (Array.isArray(node.children)) {
321
+ const inside = insideForm || node.id === FORM_ID;
60
322
  node.children.forEach((child, index) => {
61
- visit(child, `${path}.children[${index}]`, depth + 1, component);
323
+ visit(child, `${path}.children[${index}]`, depth + 1, component, inside);
62
324
  });
63
325
  }
64
326
  };
65
- visit(tree, options.path ?? '$', 0, null);
327
+ visit(tree, options.path ?? '$', 0, null, false);
66
328
  return out;
67
329
  }
330
+ function deprecation(path, id, what, info) {
331
+ const instead = info.replacement ? `; use "${info.replacement}"` : '';
332
+ return {
333
+ code: 'deprecated',
334
+ path,
335
+ id,
336
+ message: `${what} is deprecated since ${info.since}${instead}.`,
337
+ };
338
+ }
68
339
  /**
69
340
  * A node's `slot` (audit G04) must name a slot prop of its parent; omitted
70
341
  * or `children` is the default slot. A root fills no slot. An unknown
@@ -87,57 +358,447 @@ function checkSlot(slot, parent, id, path, out) {
87
358
  : `"${String(slot)}" is not a slot of "${parent.id}".`,
88
359
  });
89
360
  }
90
- function checkProps(props, component, path, out) {
91
- const given = isRecord(props) ? props : {};
92
- const id = component.id;
361
+ function kindProblem(prop, value) {
362
+ const kindOk = prop.kind === 'string' || prop.kind === 'enum'
363
+ ? typeof value === 'string'
364
+ : prop.kind === 'number'
365
+ ? finite(value)
366
+ : prop.kind === 'boolean'
367
+ ? typeof value === 'boolean'
368
+ : prop.kind === 'array'
369
+ ? Array.isArray(value)
370
+ : prop.kind === 'object'
371
+ ? isRecord(value)
372
+ : false;
373
+ if (!kindOk)
374
+ return 'prop-type';
375
+ if (prop.kind === 'enum' && !(prop.enumValues ?? []).includes(value)) {
376
+ return 'enum-value';
377
+ }
378
+ return null;
379
+ }
380
+ /** Whether a value reads as a binding (`$state` or `$data` as an own key). */
381
+ function isBindingShaped(value) {
382
+ return (isRecord(value) &&
383
+ (Object.hasOwn(value, STATE_BINDING_KEY) || Object.hasOwn(value, DATA_BINDING_KEY)));
384
+ }
385
+ /** The binding's key and name when it has exactly one key with a string name. */
386
+ function bindingTarget(value) {
387
+ const keys = Object.keys(value);
388
+ const key = keys[0];
389
+ if (keys.length !== 1 || (key !== STATE_BINDING_KEY && key !== DATA_BINDING_KEY))
390
+ return null;
391
+ const name = value[key];
392
+ return typeof name === 'string' ? { key, name } : null;
393
+ }
394
+ function checkProps(given, bind, component, path, context) {
395
+ const { id, fail } = context;
93
396
  const declared = new Map(component.props.map((p) => [p.name, p]));
94
397
  for (const [name, value] of Object.entries(given)) {
95
398
  const here = `${path}.${name}`;
96
399
  const prop = declared.get(name);
97
400
  if (!prop) {
98
- out.push({ code: 'unknown-prop', path: here, id, message: `"${id}" has no "${name}" prop.` });
401
+ fail('unknown-prop', here, `"${id}" has no "${name}" prop.`);
99
402
  continue;
100
403
  }
101
404
  if (prop.extension) {
102
- out.push({
103
- code: 'extension-prop',
104
- path: here,
105
- id,
106
- message: `"${name}" is a ${prop.extension} extension (${prop.kind}) and cannot come from data.`,
107
- });
405
+ fail('extension-prop', here, `"${name}" is a ${prop.extension} extension (${prop.kind}) and cannot come from data.`);
108
406
  continue;
109
407
  }
110
- const kindOk = prop.kind === 'string' || prop.kind === 'enum'
111
- ? typeof value === 'string'
112
- : prop.kind === 'number'
113
- ? typeof value === 'number' && Number.isFinite(value)
114
- : prop.kind === 'boolean'
115
- ? typeof value === 'boolean'
116
- : prop.kind === 'array'
117
- ? Array.isArray(value)
118
- : prop.kind === 'object'
119
- ? isRecord(value)
120
- : false;
121
- if (!kindOk) {
122
- out.push({ code: 'prop-type', path: here, id, message: `"${name}" must be ${prop.kind}.` });
123
- }
124
- else if (prop.kind === 'enum' && !(prop.enumValues ?? []).includes(value)) {
125
- out.push({
126
- code: 'enum-value',
127
- path: here,
128
- id,
129
- message: `"${name}" must be one of ${(prop.enumValues ?? []).join(', ')}.`,
130
- });
408
+ if (isBindingShaped(value)) {
409
+ checkPropBinding(value, prop, here, context);
410
+ continue;
411
+ }
412
+ const problem = kindProblem(prop, value);
413
+ if (problem === 'prop-type') {
414
+ fail('prop-type', here, `"${name}" must be ${prop.kind}.`);
415
+ }
416
+ else if (problem === 'enum-value') {
417
+ fail('enum-value', here, `"${name}" must be one of ${(prop.enumValues ?? []).join(', ')}.`);
418
+ }
419
+ else if (prop.schema && (prop.kind === 'array' || prop.kind === 'object')) {
420
+ checkPropSchemaValue(prop.name, prop.schema, value, here, context);
421
+ }
422
+ else if (prop.kind === 'array' || prop.kind === 'object') {
423
+ walkOpaque(value, here, 0, context);
131
424
  }
132
425
  }
133
426
  for (const prop of component.props) {
134
- if (prop.required && !prop.extension && !Object.hasOwn(given, prop.name)) {
135
- out.push({
136
- code: 'missing-required',
137
- path: `${path}.${prop.name}`,
138
- id,
139
- message: `"${id}" needs "${prop.name}".`,
140
- });
427
+ const given_ = Object.hasOwn(given, prop.name) || Object.hasOwn(bind, prop.name);
428
+ if (prop.required && !prop.extension && !given_) {
429
+ fail('missing-required', `${path}.${prop.name}`, `"${id}" needs "${prop.name}".`);
141
430
  }
142
431
  }
143
432
  }
433
+ /** Whether `value` is a legal value of `prop` (kind, enum and property schema). */
434
+ function fits(prop, value, root) {
435
+ if (kindProblem(prop, value) !== null)
436
+ return false;
437
+ if (prop.schema && (prop.kind === 'array' || prop.kind === 'object')) {
438
+ return validateInstance(toDialectSchema(prop.schema), value, root).length === 0;
439
+ }
440
+ return true;
441
+ }
442
+ /** A whole prop given as a binding: `$state` of a fitting variable, or `$data` for a scalar prop. */
443
+ function checkPropBinding(value, prop, path, context) {
444
+ const { state } = context;
445
+ const target = bindingTarget(value);
446
+ const fail = (code, message) => context.fail(code, path, message);
447
+ if (!target) {
448
+ fail('binding', BINDING_SHAPE);
449
+ return;
450
+ }
451
+ if (target.key === DATA_BINDING_KEY) {
452
+ if (!SPEC_REGEX.dataName.test(target.name) || target.name.length > 64) {
453
+ fail('binding', `"${target.name}" is not a host data name.`);
454
+ }
455
+ else if (!['string', 'number', 'boolean', 'enum'].includes(prop.kind)) {
456
+ fail('binding', 'Host data binds only a whole string, number, boolean or enum prop.');
457
+ }
458
+ return;
459
+ }
460
+ const variable = state.get(target.name);
461
+ if (!variable) {
462
+ fail('unknown-state', `No state variable named "${target.name}".`);
463
+ return;
464
+ }
465
+ if (variable.valid && !fits(prop, variable.initial, context.root)) {
466
+ fail('binding', `"${target.name}" (${variable.type}) does not fit "${prop.name}" (${prop.kind}).`);
467
+ }
468
+ }
469
+ /**
470
+ * Nested bindings inside an array or object prop without a property schema:
471
+ * `$state` names must be declared; `$data` binds only a whole prop.
472
+ */
473
+ function walkOpaque(value, path, depth, context) {
474
+ if (depth > SPEC_LIMITS.propDepth)
475
+ return;
476
+ if (Array.isArray(value)) {
477
+ value.forEach((item, index) => {
478
+ walkOpaque(item, `${path}[${index}]`, depth + 1, context);
479
+ });
480
+ return;
481
+ }
482
+ if (!isRecord(value))
483
+ return;
484
+ if (isBindingShaped(value)) {
485
+ nestedBinding(value, path, context, undefined);
486
+ return;
487
+ }
488
+ for (const [key, item] of Object.entries(value))
489
+ walkOpaque(item, `${path}.${key}`, depth + 1, context);
490
+ }
491
+ /** A binding nested in a prop value; `schema` (when known) is the position's schema. */
492
+ function nestedBinding(value, path, context, schema) {
493
+ const { state } = context;
494
+ const target = bindingTarget(value);
495
+ const fail = (code, message) => context.fail(code, path, message);
496
+ if (!target) {
497
+ fail('binding', BINDING_SHAPE);
498
+ }
499
+ else if (target.key === DATA_BINDING_KEY) {
500
+ fail('binding', 'Host data binds only a whole top-level prop.');
501
+ }
502
+ else {
503
+ const variable = state.get(target.name);
504
+ if (!variable)
505
+ fail('unknown-state', `No state variable named "${target.name}".`);
506
+ else if (schema &&
507
+ variable.valid &&
508
+ validateInstance(schema, variable.initial, context.root).length > 0) {
509
+ fail('binding', `"${target.name}" (${variable.type}) does not fit this position.`);
510
+ }
511
+ }
512
+ }
513
+ /**
514
+ * An array or object prop with a property schema: the dialect checks the
515
+ * value (its first issue is the prop's `prop-type`), then binding, reorder,
516
+ * action and workflow positions add their own diagnostics.
517
+ */
518
+ function checkPropSchemaValue(name, propSchema, value, path, context) {
519
+ const { id, root } = context;
520
+ const defs = root.$defs ?? {};
521
+ const extra = [];
522
+ const sub = reportInto({ ...context, out: extra });
523
+ const at = (relative) => `${path}${relative.slice(1)}`;
524
+ const issues = validateInstance(toDialectSchema(propSchema), value, root, {
525
+ value(node, current, relative) {
526
+ if (node === defs.binding) {
527
+ if (isRecord(current) && Object.keys(current).length !== 1) {
528
+ sub.fail('binding', at(relative), BINDING_SHAPE);
529
+ }
530
+ else if (isRecord(current) &&
531
+ typeof current.$state === 'string' &&
532
+ !context.state.has(current.$state)) {
533
+ sub.fail('unknown-state', at(relative), `No state variable named "${current.$state}".`);
534
+ }
535
+ return undefined;
536
+ }
537
+ if (node === defs.reorder) {
538
+ if (isRecord(current) && isRecord(current.bind)) {
539
+ reorderBinding(current.bind, at(`${relative}.bind`), sub);
540
+ }
541
+ return undefined;
542
+ }
543
+ if (node === defs.action) {
544
+ if (isRecord(current))
545
+ checkAction(current, at(relative), undefined, sub);
546
+ return undefined;
547
+ }
548
+ if (node === defs.workflowGraph) {
549
+ // contractRoot adds workflowGraph only with a validator; a caller-built root fails closed here.
550
+ if (!context.workflow)
551
+ return [{ code: 'bad-ref', path: relative, message: 'No validator.' }];
552
+ const schema = { ...node, $defs: defs };
553
+ const result = context.workflow.validate(current, schema);
554
+ const schemaIssues = [];
555
+ for (const d of result.diagnostics) {
556
+ if (d.code === 'schema')
557
+ schemaIssues.push({
558
+ code: 'schema',
559
+ path: `${relative}${d.path.slice(1)}`,
560
+ message: d.message,
561
+ });
562
+ else
563
+ extra.push({ ...d, path: at(`${relative}${d.path.slice(1)}`), id });
564
+ }
565
+ return schemaIssues;
566
+ }
567
+ if (isBindingShaped(current)) {
568
+ nestedBinding(current, at(relative), sub, node);
569
+ return [];
570
+ }
571
+ return undefined;
572
+ },
573
+ });
574
+ const first = issues[0];
575
+ if (first) {
576
+ context.fail('prop-type', at(first.path), `"${name}" does not match its schema: ${first.code}: ${first.message}`);
577
+ }
578
+ context.out.push(...extra);
579
+ }
580
+ /** `reorder.bind` must be `$state` of a writable list. */
581
+ function reorderBinding(bind, path, context) {
582
+ const name = bind.$state;
583
+ if (typeof name !== 'string' || Object.keys(bind).length !== 1) {
584
+ if (typeof bind.$data === 'string') {
585
+ context.fail('binding', path, 'reorder needs a writable list, not host data.');
586
+ }
587
+ return;
588
+ }
589
+ const variable = context.state.get(name);
590
+ if (variable?.valid && (variable.type !== 'list' || !variable.writable)) {
591
+ context.fail('binding', path, `reorder binds a writable list; "${name}" is not one.`);
592
+ }
593
+ }
594
+ /** `bind`: two-way bindings to bindable props, `$state` only, writable and fitting. */
595
+ function checkBind(bind, props, component, path, context) {
596
+ const { id, state } = context;
597
+ for (const [name, value] of Object.entries(bind)) {
598
+ const here = `${path}.${name}`;
599
+ const fail = (code, message) => context.fail(code, here, message);
600
+ const prop = component.props.find((p) => p.name === name);
601
+ if (!prop) {
602
+ fail('unknown-prop', `"${id}" has no "${name}" prop.`);
603
+ continue;
604
+ }
605
+ if (!(component.bindable ?? []).includes(name)) {
606
+ fail('binding', `"${name}" is not bindable on "${id}".`);
607
+ continue;
608
+ }
609
+ if (Object.hasOwn(props, name)) {
610
+ fail('binding', `"${name}" is set both as a prop and as a binding.`);
611
+ continue;
612
+ }
613
+ const target = isRecord(value) ? bindingTarget(value) : null;
614
+ if (!target || target.key !== STATE_BINDING_KEY) {
615
+ fail('binding', 'bind takes exactly {"$state": name}; host data is read-only.');
616
+ continue;
617
+ }
618
+ const variable = state.get(target.name);
619
+ if (!variable) {
620
+ fail('unknown-state', `No state variable named "${target.name}".`);
621
+ continue;
622
+ }
623
+ if (!variable.valid)
624
+ continue;
625
+ if (!variable.writable) {
626
+ fail('binding', `"${target.name}" is host-bound and read-only.`);
627
+ }
628
+ else if (!fits(prop, variable.initial, context.root)) {
629
+ fail('binding', `"${target.name}" (${variable.type}) does not fit "${name}" (${prop.kind}).`);
630
+ }
631
+ }
632
+ }
633
+ /** `on`: events the component declares, each with its closed actions. */
634
+ function checkOn(on, component, path, context) {
635
+ for (const [event, actions] of Object.entries(on)) {
636
+ const here = `${path}.${event}`;
637
+ if (!(component.events ?? []).includes(event)) {
638
+ context.fail('unknown-event', here, `"${component.id}" fires no "${event}" event.`);
639
+ }
640
+ if (!Array.isArray(actions))
641
+ continue;
642
+ actions.forEach((action, index) => {
643
+ if (isRecord(action))
644
+ checkAction(action, `${here}[${index}]`, event, context);
645
+ });
646
+ }
647
+ }
648
+ /**
649
+ * One closed action: the fields its op takes, then its targets. At most one
650
+ * diagnostic per action (the first problem). `event` is the firing event,
651
+ * unknown for an action given as a prop value.
652
+ */
653
+ function checkAction(action, path, event, context) {
654
+ const { state, flow, fail } = context;
655
+ const op = action.op;
656
+ if (!SPEC_ACTION_OPS.includes(op))
657
+ return;
658
+ const fields = ACTION_FIELDS[op];
659
+ // Unknown keys are the schema's to report; only fields of another op are judged here.
660
+ const extra = Object.keys(action).find((key) => ACTION_KEYS.has(key) && !fields.allowed.includes(key));
661
+ if (extra !== undefined) {
662
+ fail('action', `${path}.${extra}`, `${String(op)} takes no "${extra}".`);
663
+ return;
664
+ }
665
+ const missing = fields.required.find((key) => !Object.hasOwn(action, key));
666
+ if (missing !== undefined) {
667
+ fail('action', `${path}.${missing}`, `${String(op)} needs "${missing}".`);
668
+ return;
669
+ }
670
+ /** The declared variable `key` names, or null after reporting it. */
671
+ const variable = (key) => {
672
+ const name = action[key];
673
+ if (typeof name !== 'string')
674
+ return null;
675
+ const found = state.get(name);
676
+ if (!found) {
677
+ fail('unknown-state', `${path}.${key}`, `No state variable named "${name}".`);
678
+ return null;
679
+ }
680
+ // A refused declaration was reported once; judging its uses would only cascade.
681
+ if (!found.valid)
682
+ return null;
683
+ if (!found.writable) {
684
+ fail('action', `${path}.${key}`, `"${name}" is host-bound and read-only.`);
685
+ return null;
686
+ }
687
+ return found;
688
+ };
689
+ switch (op) {
690
+ case 'setState': {
691
+ const hasValue = Object.hasOwn(action, 'value');
692
+ if (hasValue === Object.hasOwn(action, 'fromEvent')) {
693
+ fail('action', path, 'setState takes exactly one of "value" and "fromEvent".');
694
+ return;
695
+ }
696
+ const target = variable('state');
697
+ if (!target)
698
+ return;
699
+ if (target.type === 'list' || target.type === 'workflow') {
700
+ fail('action', `${path}.state`, `setState cannot replace a ${target.type}; use moveItem.`);
701
+ }
702
+ else if (hasValue && target.valid && !fitsVariable(target, action.value)) {
703
+ fail('action', `${path}.value`, `The value does not fit "${String(action.state)}".`);
704
+ }
705
+ else if (!hasValue && event !== undefined && event !== 'change' && event !== 'select') {
706
+ fail('action', `${path}.fromEvent`, 'fromEvent reads the value of a change or select event.');
707
+ }
708
+ return;
709
+ }
710
+ case 'toggleState': {
711
+ const target = variable('state');
712
+ if (target && target.type !== 'boolean') {
713
+ fail('action', `${path}.state`, 'toggleState flips a boolean.');
714
+ }
715
+ return;
716
+ }
717
+ case 'moveItem': {
718
+ for (const key of ['from', 'to']) {
719
+ if (!Object.hasOwn(action, key))
720
+ continue;
721
+ const target = variable(key);
722
+ if (!target)
723
+ return;
724
+ if (target.type !== 'list') {
725
+ fail('action', `${path}.${key}`, 'moveItem moves items between lists.');
726
+ return;
727
+ }
728
+ }
729
+ return;
730
+ }
731
+ case 'flowGo': {
732
+ if (!flow)
733
+ fail('action', path, 'flowGo needs a declared flow.');
734
+ else if (!flow.events.has(action.event)) {
735
+ fail('action', `${path}.event`, `No transition handles "${String(action.event)}".`);
736
+ }
737
+ return;
738
+ }
739
+ case 'emit': {
740
+ if (!isRecord(action.values))
741
+ return;
742
+ const entries = Object.entries(action.values);
743
+ if (entries.length > SPEC_LIMITS.emitValues) {
744
+ fail('action', `${path}.values`, `At most ${SPEC_LIMITS.emitValues} values.`);
745
+ return;
746
+ }
747
+ for (const [name, value] of entries) {
748
+ const here = `${path}.values.${name}`;
749
+ if (!SPEC_REGEX.intentName.test(name)) {
750
+ fail('action', here, `"${name}" is not a value name.`);
751
+ return;
752
+ }
753
+ if (isRecord(value)) {
754
+ const target = bindingTarget(value);
755
+ if (!target || target.key !== STATE_BINDING_KEY) {
756
+ fail('action', here, 'An emitted value is a scalar or {"$state": name}.');
757
+ return;
758
+ }
759
+ const found = state.get(target.name);
760
+ if (!found) {
761
+ fail('unknown-state', here, `No state variable named "${target.name}".`);
762
+ return;
763
+ }
764
+ if (found.valid && (found.type === 'list' || found.type === 'workflow')) {
765
+ fail('action', here, 'Only scalar variables can be emitted.');
766
+ return;
767
+ }
768
+ }
769
+ else if (!(typeof value === 'boolean' ||
770
+ finite(value) ||
771
+ (typeof value === 'string' && value.length <= SPEC_LIMITS.emitValueLength))) {
772
+ fail('action', here, 'An emitted value is a boolean, a number or a short string.');
773
+ return;
774
+ }
775
+ }
776
+ return;
777
+ }
778
+ case 'submit': {
779
+ if (!context.insideForm)
780
+ fail('action', path, `submit runs only inside an ${FORM_ID}.`);
781
+ return;
782
+ }
783
+ }
784
+ }
785
+ /** Whether `value` is a legal value of a scalar variable. */
786
+ function fitsVariable(variable, value) {
787
+ switch (variable.type) {
788
+ case 'boolean':
789
+ return typeof value === 'boolean';
790
+ case 'number':
791
+ return (finite(value) &&
792
+ value >= (variable.min ?? Number.NEGATIVE_INFINITY) &&
793
+ value <= (variable.max ?? Number.POSITIVE_INFINITY));
794
+ case 'enum':
795
+ return typeof value === 'string' && (variable.values ?? []).includes(value);
796
+ case 'string':
797
+ return typeof value === 'string' && value.length <= (variable.maxLength ?? 0);
798
+ default:
799
+ return false;
800
+ }
801
+ }
802
+ function finite(value) {
803
+ return typeof value === 'number' && Number.isFinite(value);
804
+ }