@sakibcoolz/core 0.1.2 → 0.2.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 (142) hide show
  1. package/README.md +67 -7
  2. package/dist/acquisition.d.ts +29 -0
  3. package/dist/acquisition.d.ts.map +1 -0
  4. package/dist/acquisition.js +30 -0
  5. package/dist/acquisition.js.map +1 -0
  6. package/dist/assembly.d.ts +5 -2
  7. package/dist/assembly.d.ts.map +1 -1
  8. package/dist/assembly.js +7 -4
  9. package/dist/assembly.js.map +1 -1
  10. package/dist/attach.d.ts +4 -2
  11. package/dist/attach.d.ts.map +1 -1
  12. package/dist/attach.js +5 -3
  13. package/dist/attach.js.map +1 -1
  14. package/dist/clipboard.d.ts +6 -4
  15. package/dist/clipboard.d.ts.map +1 -1
  16. package/dist/clipboard.js +25 -4
  17. package/dist/clipboard.js.map +1 -1
  18. package/dist/collision.d.ts +2 -2
  19. package/dist/collision.js.map +1 -1
  20. package/dist/digest.d.ts +9 -0
  21. package/dist/digest.d.ts.map +1 -0
  22. package/dist/digest.js +68 -0
  23. package/dist/digest.js.map +1 -0
  24. package/dist/flow.d.ts +3 -2
  25. package/dist/flow.d.ts.map +1 -1
  26. package/dist/flow.js.map +1 -1
  27. package/dist/graph.d.ts +67 -7
  28. package/dist/graph.d.ts.map +1 -1
  29. package/dist/graph.js +195 -10
  30. package/dist/graph.js.map +1 -1
  31. package/dist/index.d.ts +13 -6
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +7 -3
  34. package/dist/index.js.map +1 -1
  35. package/dist/observation.d.ts +1 -1
  36. package/dist/observation.d.ts.map +1 -1
  37. package/dist/observation.js +4 -1
  38. package/dist/observation.js.map +1 -1
  39. package/dist/routing.d.ts +6 -3
  40. package/dist/routing.d.ts.map +1 -1
  41. package/dist/routing.js +12 -2
  42. package/dist/routing.js.map +1 -1
  43. package/dist/scene.d.ts +245 -5
  44. package/dist/scene.d.ts.map +1 -1
  45. package/dist/scene.js +94 -3
  46. package/dist/scene.js.map +1 -1
  47. package/dist/serialization.d.ts +7 -7
  48. package/dist/serialization.d.ts.map +1 -1
  49. package/dist/serialization.js +120 -8
  50. package/dist/serialization.js.map +1 -1
  51. package/dist/snapping.d.ts +2 -2
  52. package/dist/snapping.d.ts.map +1 -1
  53. package/dist/snapping.js +10 -7
  54. package/dist/snapping.js.map +1 -1
  55. package/dist/status.d.ts +56 -0
  56. package/dist/status.d.ts.map +1 -0
  57. package/dist/status.js +114 -0
  58. package/dist/status.js.map +1 -0
  59. package/dist/tags.d.ts +52 -0
  60. package/dist/tags.d.ts.map +1 -0
  61. package/dist/tags.js +103 -0
  62. package/dist/tags.js.map +1 -0
  63. package/dist/telemetry.d.ts +42 -4
  64. package/dist/telemetry.d.ts.map +1 -1
  65. package/dist/telemetry.js +84 -8
  66. package/dist/telemetry.js.map +1 -1
  67. package/dist/touching.d.ts +2 -2
  68. package/dist/touching.d.ts.map +1 -1
  69. package/dist/touching.js +7 -4
  70. package/dist/touching.js.map +1 -1
  71. package/dist/units.js +1 -1
  72. package/dist/units.js.map +1 -1
  73. package/dist/upgrade.d.ts +2 -2
  74. package/dist/upgrade.js.map +1 -1
  75. package/dist/validation.d.ts +33 -6
  76. package/dist/validation.d.ts.map +1 -1
  77. package/dist/validation.js +806 -39
  78. package/dist/validation.js.map +1 -1
  79. package/package.json +4 -2
  80. package/schema/fixtures/invalid/association-without-relationship.scene.json +69 -0
  81. package/schema/fixtures/invalid/binding-kind.scene.json +14 -0
  82. package/schema/fixtures/invalid/command-zero-step.scene.json +105 -0
  83. package/schema/fixtures/invalid/empty-name.scene.json +30 -0
  84. package/schema/fixtures/invalid/expected.json +70 -0
  85. package/schema/fixtures/invalid/extension-not-namespaced.scene.json +10 -0
  86. package/schema/fixtures/invalid/extension-prototype-key.scene.json +12 -0
  87. package/schema/fixtures/invalid/missing-document-type.scene.json +6 -0
  88. package/schema/fixtures/invalid/non-string-label.scene.json +32 -0
  89. package/schema/fixtures/invalid/underlay-opacity.scene.json +17 -0
  90. package/schema/fixtures/invalid/unknown-component-kind.scene.json +29 -0
  91. package/schema/fixtures/invalid/unknown-relationship.scene.json +63 -0
  92. package/schema/fixtures/invalid/unknown-root-field.scene.json +8 -0
  93. package/schema/fixtures/invalid/unsupported-version.scene.json +7 -0
  94. package/schema/fixtures/invalid/view-rotation.scene.json +139 -0
  95. package/schema/fixtures/invalid/writable-string.scene.json +93 -0
  96. package/schema/fixtures/invalid/writable-without-limits.scene.json +100 -0
  97. package/schema/fixtures/invalid/zero-scale.scene.json +29 -0
  98. package/schema/fixtures/migration/README.md +18 -0
  99. package/schema/fixtures/migration/v1-demo.scene.json +160 -0
  100. package/schema/fixtures/migration/v1-demo.v4.json +198 -0
  101. package/schema/fixtures/migration/v2-demo.scene.json +244 -0
  102. package/schema/fixtures/migration/v2-demo.v4.json +245 -0
  103. package/schema/fixtures/migration/v2-generated-12.scene.json +473 -0
  104. package/schema/fixtures/migration/v2-generated-12.v4.json +474 -0
  105. package/schema/fixtures/migration/v3-demo.scene.json +244 -0
  106. package/schema/fixtures/migration/v3-demo.v4.json +245 -0
  107. package/schema/fixtures/migration/v3-generated-12.scene.json +473 -0
  108. package/schema/fixtures/migration/v3-generated-12.v4.json +474 -0
  109. package/schema/fixtures/migration/v3-mostly-connected-plant.scene.json +386 -0
  110. package/schema/fixtures/migration/v3-mostly-connected-plant.v4.json +387 -0
  111. package/schema/fixtures/migration/v3-open-joints-plant.scene.json +288 -0
  112. package/schema/fixtures/migration/v3-open-joints-plant.v4.json +289 -0
  113. package/schema/fixtures/migration/v3-unconnected-plant.scene.json +14 -0
  114. package/schema/fixtures/migration/v3-unconnected-plant.v4.json +276 -0
  115. package/schema/fixtures/projection/acquisition.plan.json +50 -0
  116. package/schema/fixtures/projection/control-line.plan.json +86 -0
  117. package/schema/fixtures/valid/acquisition.scene.json +117 -0
  118. package/schema/fixtures/valid/associations.scene.json +79 -0
  119. package/schema/fixtures/valid/commands.scene.json +155 -0
  120. package/schema/fixtures/valid/control-line.scene.json +210 -0
  121. package/schema/fixtures/valid/empty.scene.json +7 -0
  122. package/schema/fixtures/valid/identity-and-extensions.scene.json +96 -0
  123. package/schema/fixtures/valid/tank-valve-line.scene.json +89 -0
  124. package/schema/fixtures/valid/views.scene.json +139 -0
  125. package/schema/fixtures/validator-only/association-id-reuses-connection.scene.json +70 -0
  126. package/schema/fixtures/validator-only/association-unknown-component.scene.json +70 -0
  127. package/schema/fixtures/validator-only/command-max-below-min.scene.json +104 -0
  128. package/schema/fixtures/validator-only/command-out-of-type-range.scene.json +97 -0
  129. package/schema/fixtures/validator-only/connection-domain-mismatch.scene.json +63 -0
  130. package/schema/fixtures/validator-only/credential-in-connection.scene.json +66 -0
  131. package/schema/fixtures/validator-only/credential-in-extension.scene.json +14 -0
  132. package/schema/fixtures/validator-only/credential-label.scene.json +32 -0
  133. package/schema/fixtures/validator-only/duplicate-component-id.scene.json +50 -0
  134. package/schema/fixtures/validator-only/endpoint-with-credentials.scene.json +74 -0
  135. package/schema/fixtures/validator-only/expected.json +66 -0
  136. package/schema/fixtures/validator-only/modbus-coil-float.scene.json +100 -0
  137. package/schema/fixtures/validator-only/non-unit-rotation.scene.json +29 -0
  138. package/schema/fixtures/validator-only/unknown-data-source.scene.json +100 -0
  139. package/schema/fixtures/validator-only/unknown-port.scene.json +62 -0
  140. package/schema/fixtures/validator-only/unregistered-host-type.scene.json +29 -0
  141. package/schema/fixtures/validator-only/view-unknown-component.scene.json +143 -0
  142. package/schema/industrial-scene.v4.schema.json +365 -0
@@ -1,10 +1,14 @@
1
- import { componentKinds, portsCompatible, } from './scene.js';
1
+ import { checkPorts, portCapacity, portKinds, isRelationship, relationshipKinds, SCENE_DOCUMENT_TYPE, isBuiltInKind, isNamespacedType, dataTypes, modbusAreas, protocols, viewKinds, ownValue, } from './scene.js';
2
2
  import { quaternionFromEuler } from './quaternion.js';
3
3
  import { isKnownUnit } from './units.js';
4
+ import { valueFitsDataType } from './telemetry.js';
4
5
  import { tupleToQuaternion, tupleToVector } from './vectors.js';
5
6
  export const defaultSceneLimits = {
6
7
  maxComponents: 10_000,
7
8
  maxConnections: 20_000,
9
+ maxAssociations: 20_000,
10
+ maxDataSources: 1_000,
11
+ maxVariables: 20_000,
8
12
  maxBindings: 20_000,
9
13
  maxIdLength: 128,
10
14
  maxProperties: 64,
@@ -15,16 +19,72 @@ const sceneKeys = {
15
19
  1: ['version', 'components', 'connections'],
16
20
  2: ['schemaVersion', 'units', 'components', 'connections', 'tagBindings'],
17
21
  3: ['schemaVersion', 'units', 'components', 'connections', 'tagBindings', 'underlay'],
22
+ 4: ['schemaVersion', 'documentType', 'units', 'project', 'components', 'connections', 'associations', 'dataSources', 'runtimeVariables', 'tagBindings', 'underlay', 'views', 'extensions'],
18
23
  };
24
+ const projectKeys = ['id', 'name', 'revision', 'description'];
25
+ const viewLayoutKeys = ['symbols', 'routes'];
26
+ const symbolKeys = ['x', 'y', 'rotation', 'hidden'];
27
+ /** Drawing coordinates (ADR-0026) stay within ±1,000,000 units; a route has 2 to 1,000 points. */
28
+ const MAX_VIEW_COORDINATE = 1_000_000;
29
+ const MAX_ROUTE_POINTS = 1_000;
19
30
  const underlayKeys = ['svg', 'position', 'width', 'rotation', 'opacity'];
20
31
  const componentKeys = {
21
32
  1: ['id', 'kind', 'position', 'rotation', 'properties'],
22
33
  2: ['id', 'type', 'position', 'rotation', 'scale', 'properties'],
23
34
  3: ['id', 'type', 'parentId', 'position', 'rotation', 'scale', 'properties'],
35
+ 4: ['id', 'name', 'type', 'typeVersion', 'manufacturer', 'model', 'parentId', 'position', 'rotation', 'scale', 'properties', 'labels', 'extensions'],
24
36
  };
37
+ /** Deepest nesting accepted inside one extension value, and most values in all extensions of a scene (ADR-0021). */
38
+ const MAX_EXTENSION_DEPTH = 16;
39
+ const MAX_EXTENSION_VALUES = 10_000;
40
+ /** Most JSON values in one variable address. Each address has its own budget, so `maxVariables` bounds the total. */
41
+ const MAX_ADDRESS_VALUES = 64;
42
+ /** Reverse-DNS namespace for extension keys, e.g. `com.example.app`. */
43
+ const NAMESPACE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i;
44
+ /** Keys that would reach an object's prototype if copied naively. */
45
+ const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
46
+ const SECRET_WORDS = ['password', 'passwd', 'passphrase', 'secret', 'credential', 'apikey', 'accesskey', 'privatekey'];
47
+ /** True for a key that names a credential: scene documents carry references to secrets, never secrets (ADR-0021). */
48
+ export function isSecretKey(key) {
49
+ const k = key.toLowerCase().replace(/[-_.\s]/g, '');
50
+ // "auth" only as a whole name or "authorization…": as a substring it would catch "author" (T-096).
51
+ return k === 'pwd' || k === 'auth' || k.startsWith('authorization') || k.endsWith('token') || SECRET_WORDS.some((w) => k.includes(w));
52
+ }
25
53
  /** Accepted deviation of a rotation quaternion's length from 1; larger values are rejected, never renormalised. */
26
54
  const UNIT_TOLERANCE = 1e-6;
27
- const connectionKeys = ['id', 'from', 'to'];
55
+ const connectionKeys = {
56
+ 1: ['id', 'from', 'to'],
57
+ 2: ['id', 'from', 'to'],
58
+ 3: ['id', 'from', 'to'],
59
+ 4: ['id', 'domain', 'relationship', 'from', 'to', 'properties'],
60
+ };
61
+ const associationKeys = ['id', 'relationship', 'from', 'to', 'properties'];
62
+ const dataSourceKeys = ['id', 'name', 'protocol', 'deviceId', 'endpointRef', 'properties'];
63
+ const variableKeys = ['id', 'name', 'componentId', 'dataType', 'unit', 'access', 'source', 'scaling', 'command'];
64
+ const commandKeys = ['min', 'max', 'step', 'confirm'];
65
+ const variableSourceKeys = ['dataSourceId', 'variable', 'address'];
66
+ const scalingKeys = ['kind', 'rawMin', 'rawMax', 'min', 'max'];
67
+ const opcUaKeys = ['nodeId', 'namespaceUri'];
68
+ const modbusKeys = ['area', 'address', 'unitId', 'byteOrder', 'wordOrder', 'bit'];
69
+ /** Registers a Modbus value spans (ADR-0024); `string` is not addressable in Modbus. */
70
+ const MODBUS_REGISTERS = { bool: 1, int16: 1, uint16: 1, int32: 2, uint32: 2, float32: 2, float64: 4 };
71
+ /** `scheme://user:password@host`: an endpoint carrying credentials. */
72
+ const URL_WITH_USER_INFO = /^[a-z][a-z0-9+.-]*:\/\/[^/?#]*@/i;
73
+ /** A URL query or matrix parameter named like a credential, e.g. `?password=…` or `;token=…` (T-096). */
74
+ const SECRET_PARAMETER = /[?&;]([^=&;#]*)=/g;
75
+ /** Whether an endpoint reference carries a credential: user info (also after leading spaces) or a secret-named parameter. */
76
+ function endpointCarriesSecret(ref) {
77
+ return URL_WITH_USER_INFO.test(ref.trim()) || [...ref.matchAll(SECRET_PARAMETER)].some((m) => isSecretKey(decodeSafe(m[1])));
78
+ }
79
+ const decodeSafe = (text) => {
80
+ try {
81
+ return decodeURIComponent(text);
82
+ }
83
+ catch {
84
+ return text;
85
+ }
86
+ };
87
+ const MAX_UNIT_LENGTH = 32;
28
88
  const bindingKeys = {
29
89
  property: ['id', 'tagId', 'kind', 'instanceId', 'property', 'unit'],
30
90
  flow: ['id', 'tagId', 'kind', 'connectionId', 'unit'],
@@ -148,6 +208,11 @@ function readProperties(value, path, limits, errors) {
148
208
  let valid = true;
149
209
  for (const [key, v] of entries) {
150
210
  const propertyPath = `${path}.${key}`;
211
+ if (isSecretKey(key)) {
212
+ errors.push({ code: 'SECRET_FIELD', path: propertyPath, message: `"${key}" looks like a credential; store a reference in the host instead.` });
213
+ valid = false;
214
+ continue;
215
+ }
151
216
  if (typeof v === 'string') {
152
217
  if (v.length > limits.maxStringLength) {
153
218
  errors.push({ code: 'LIMIT_EXCEEDED', path: propertyPath, message: `String longer than ${limits.maxStringLength} characters.` });
@@ -165,12 +230,161 @@ function readProperties(value, path, limits, errors) {
165
230
  }
166
231
  return valid ? properties : undefined;
167
232
  }
233
+ /** A non-empty string no longer than the string limit. */
234
+ function readText(value, path, limits, errors) {
235
+ if (typeof value !== 'string' || value.length === 0) {
236
+ errors.push({ code: 'INVALID_FIELD', path, message: 'Expected a non-empty string.' });
237
+ return undefined;
238
+ }
239
+ if (value.length > limits.maxStringLength) {
240
+ errors.push({ code: 'LIMIT_EXCEEDED', path, message: `String longer than ${limits.maxStringLength} characters.` });
241
+ return undefined;
242
+ }
243
+ return value;
244
+ }
245
+ /** Defines a key as plain data, so keys such as "__proto__" never touch the prototype. */
246
+ function put(target, key, value) {
247
+ Object.defineProperty(target, key, { value, enumerable: true, writable: true, configurable: true });
248
+ }
249
+ function readProject(value, limits, errors) {
250
+ if (!isObject(value)) {
251
+ errors.push({ code: 'INVALID_FIELD', path: 'project', message: 'Expected a project object.' });
252
+ return undefined;
253
+ }
254
+ checkKeys(value, projectKeys, 'project', errors);
255
+ const project = {};
256
+ if (value.id !== undefined) {
257
+ const id = readId(value.id, 'project.id', limits, errors);
258
+ if (id !== undefined)
259
+ project.id = id;
260
+ }
261
+ for (const key of ['name', 'revision', 'description']) {
262
+ if (value[key] !== undefined) {
263
+ const text = readText(value[key], `project.${key}`, limits, errors);
264
+ if (text !== undefined)
265
+ project[key] = text;
266
+ }
267
+ }
268
+ return project;
269
+ }
270
+ /** Identification labels: string keys to string values, no credentials (ADR-0021). */
271
+ function readLabels(value, path, limits, errors) {
272
+ if (!isObject(value)) {
273
+ errors.push({ code: 'INVALID_FIELD', path, message: 'Expected an object of labels.' });
274
+ return undefined;
275
+ }
276
+ const entries = Object.entries(value);
277
+ if (entries.length > limits.maxProperties) {
278
+ errors.push({ code: 'LIMIT_EXCEEDED', path, message: `More than ${limits.maxProperties} labels.` });
279
+ return undefined;
280
+ }
281
+ const labels = {};
282
+ for (const [key, v] of entries) {
283
+ const labelPath = `${path}.${key}`;
284
+ if (key.length === 0 || key.length > limits.maxIdLength) {
285
+ errors.push({ code: 'INVALID_FIELD', path: labelPath, message: `Label names must be 1 to ${limits.maxIdLength} characters.` });
286
+ continue;
287
+ }
288
+ if (isSecretKey(key)) {
289
+ errors.push({ code: 'SECRET_FIELD', path: labelPath, message: `"${key}" looks like a credential; store a reference in the host instead.` });
290
+ continue;
291
+ }
292
+ if (typeof v !== 'string') {
293
+ errors.push({ code: 'INVALID_FIELD', path: labelPath, message: 'Expected a string label.' });
294
+ continue;
295
+ }
296
+ if (v.length > limits.maxStringLength) {
297
+ errors.push({ code: 'LIMIT_EXCEEDED', path: labelPath, message: `String longer than ${limits.maxStringLength} characters.` });
298
+ continue;
299
+ }
300
+ put(labels, key, v);
301
+ }
302
+ return labels;
303
+ }
304
+ /**
305
+ * Extensions: reverse-DNS namespaces to plain JSON, bounded in depth and size, with no prototype
306
+ * keys and no credentials anywhere inside (ADR-0021). Returns a fresh copy.
307
+ */
308
+ function readExtensions(value, path, budget, limits, errors) {
309
+ if (!isObject(value)) {
310
+ errors.push({ code: 'INVALID_FIELD', path, message: 'Expected an object keyed by namespace.' });
311
+ return undefined;
312
+ }
313
+ const out = {};
314
+ for (const [key, v] of Object.entries(value)) {
315
+ if (!NAMESPACE.test(key)) {
316
+ errors.push({ code: 'INVALID_FIELD', path: `${path}.${key}`, message: 'Extension keys must be reverse-DNS namespaces such as "com.example.app".' });
317
+ continue;
318
+ }
319
+ const copy = readJson(v, `${path}.${key}`, 1, budget, limits, errors);
320
+ if (copy !== undefined)
321
+ put(out, key, copy);
322
+ }
323
+ return out;
324
+ }
325
+ function readJson(value, path, depth, budget, limits, errors) {
326
+ if (--budget.left < 0) {
327
+ if (budget.left === -1)
328
+ errors.push({ code: 'LIMIT_EXCEEDED', path, message: budget.exceeded });
329
+ return undefined;
330
+ }
331
+ if (depth > MAX_EXTENSION_DEPTH) {
332
+ errors.push({ code: 'LIMIT_EXCEEDED', path, message: `Extension values nest deeper than ${MAX_EXTENSION_DEPTH} levels.` });
333
+ return undefined;
334
+ }
335
+ if (value === null || typeof value === 'boolean')
336
+ return value;
337
+ if (typeof value === 'number') {
338
+ if (Number.isFinite(value))
339
+ return plusZero(value);
340
+ errors.push({ code: 'INVALID_FIELD', path, message: 'Expected a finite number.' });
341
+ return undefined;
342
+ }
343
+ if (typeof value === 'string') {
344
+ if (value.length <= limits.maxStringLength)
345
+ return value;
346
+ errors.push({ code: 'LIMIT_EXCEEDED', path, message: `String longer than ${limits.maxStringLength} characters.` });
347
+ return undefined;
348
+ }
349
+ if (Array.isArray(value)) {
350
+ const out = [];
351
+ for (let i = 0; i < value.length; i++) {
352
+ const item = readJson(value[i], `${path}[${i}]`, depth + 1, budget, limits, errors);
353
+ if (item === undefined)
354
+ return undefined;
355
+ out.push(item);
356
+ }
357
+ return out;
358
+ }
359
+ if (isObject(value)) {
360
+ const out = {};
361
+ for (const [key, v] of Object.entries(value)) {
362
+ const keyPath = `${path}.${key}`;
363
+ if (FORBIDDEN_KEYS.has(key)) {
364
+ errors.push({ code: 'INVALID_FIELD', path: keyPath, message: `"${key}" is not allowed as a key.` });
365
+ return undefined;
366
+ }
367
+ if (isSecretKey(key)) {
368
+ errors.push({ code: 'SECRET_FIELD', path: keyPath, message: `"${key}" looks like a credential; store a reference in the host instead.` });
369
+ return undefined;
370
+ }
371
+ const item = readJson(v, keyPath, depth + 1, budget, limits, errors);
372
+ if (item === undefined)
373
+ return undefined;
374
+ put(out, key, item);
375
+ }
376
+ return out;
377
+ }
378
+ errors.push({ code: 'INVALID_FIELD', path, message: 'Expected plain JSON.' });
379
+ return undefined;
380
+ }
168
381
  /**
169
382
  * Reads one component in the input's own schema version and returns it in the current model.
170
- * Version 3 uses named { x, y, z } / { x, y, z, w } objects. Version 2 uses arrays. Version 1 uses
171
- * `kind` and Euler XYZ radians and has no scale. Older versions migrate to the version 3 objects.
383
+ * Versions 3 and 4 use named { x, y, z } / { x, y, z, w } objects; version 4 adds identity, labels and
384
+ * extensions. Version 2 uses arrays. Version 1 uses `kind` and Euler XYZ radians and has no scale.
385
+ * Older versions migrate to the current model.
172
386
  */
173
- function readComponent(value, path, version, limits, errors) {
387
+ function readComponent(value, path, version, limits, errors, extensionBudget, resolveType) {
174
388
  if (!isObject(value)) {
175
389
  errors.push({ code: 'INVALID_FIELD', path, message: 'Expected a component object.' });
176
390
  return undefined;
@@ -180,14 +394,18 @@ function readComponent(value, path, version, limits, errors) {
180
394
  const id = readId(value.id, `${path}.id`, limits, errors);
181
395
  const typeField = version === 1 ? 'kind' : 'type';
182
396
  const type = value[typeField];
183
- if (!componentKinds.includes(type)) {
184
- errors.push({ code: 'UNKNOWN_COMPONENT_KIND', path: `${path}.${typeField}`, message: `Unknown component type ${describe(type)}.` });
397
+ if (!isBuiltInKind(type) && !isNamespacedType(type)) {
398
+ // Bare names belong to the SDK; a host's own types are namespaced (ADR-0022).
399
+ errors.push({ code: 'UNKNOWN_COMPONENT_KIND', path: `${path}.${typeField}`, message: `Unknown component type ${describe(type)}; custom types are namespaced, such as "com.example.part".` });
400
+ }
401
+ else if (resolveType ? !resolveType(type) : !isBuiltInKind(type)) {
402
+ errors.push({ code: 'UNKNOWN_COMPONENT_KIND', path: `${path}.${typeField}`, message: `Unknown component type ${describe(type)}; no definition is registered for it.` });
185
403
  }
186
404
  let position;
187
405
  let rotation;
188
406
  let scale;
189
- const parentId = version === 3 && value.parentId !== undefined ? readId(value.parentId, `${path}.parentId`, limits, errors) : undefined;
190
- if (version === 3) {
407
+ const parentId = version >= 3 && value.parentId !== undefined ? readId(value.parentId, `${path}.parentId`, limits, errors) : undefined;
408
+ if (version >= 3) {
191
409
  position = readVectorObject(value.position, `${path}.position`, errors);
192
410
  rotation = readQuaternionObject(value.rotation, `${path}.rotation`, errors);
193
411
  scale = readVectorObject(value.scale, `${path}.scale`, errors, true);
@@ -208,9 +426,32 @@ function readComponent(value, path, version, limits, errors) {
208
426
  }
209
427
  }
210
428
  const properties = readProperties(value.properties, `${path}.properties`, limits, errors);
429
+ const identity = {};
430
+ if (version === 4) {
431
+ for (const key of ['name', 'typeVersion', 'manufacturer', 'model']) {
432
+ if (value[key] !== undefined) {
433
+ const text = readText(value[key], `${path}.${key}`, limits, errors);
434
+ if (text !== undefined)
435
+ identity[key] = text;
436
+ }
437
+ }
438
+ if (value.labels !== undefined)
439
+ identity.labels = readLabels(value.labels, `${path}.labels`, limits, errors);
440
+ if (value.extensions !== undefined)
441
+ identity.extensions = readExtensions(value.extensions, `${path}.extensions`, extensionBudget, limits, errors);
442
+ }
211
443
  if (errors.length > before || !id || !position || !rotation || !scale || !properties)
212
444
  return undefined;
213
- return { id, type: type, ...(parentId ? { parentId } : {}), position, rotation, scale, properties };
445
+ return {
446
+ id,
447
+ type: type,
448
+ ...(parentId ? { parentId } : {}),
449
+ position,
450
+ rotation,
451
+ scale,
452
+ properties,
453
+ ...identity,
454
+ };
214
455
  }
215
456
  function readEndpoint(value, path, limits, errors) {
216
457
  if (!isObject(value)) {
@@ -225,19 +466,338 @@ function readEndpoint(value, path, limits, errors) {
225
466
  return undefined;
226
467
  return { instanceId, portId };
227
468
  }
228
- function readConnection(value, path, limits, errors) {
469
+ /** Edge data on a connection or association (ADR-0023): property value rules, and no credential-like names. */
470
+ function readEdgeProperties(value, path, limits, errors) {
471
+ const properties = readProperties(value, path, limits, errors);
472
+ for (const key of Object.keys(properties ?? {})) {
473
+ if (isSecretKey(key))
474
+ errors.push({ code: 'SECRET_FIELD', path: `${path}.${key}`, message: `"${key}" looks like a credential; store a reference in the host instead.` });
475
+ }
476
+ return properties;
477
+ }
478
+ function readAssociation(value, path, limits, errors) {
479
+ if (!isObject(value)) {
480
+ errors.push({ code: 'INVALID_FIELD', path, message: 'Expected an association object.' });
481
+ return undefined;
482
+ }
483
+ const before = errors.length;
484
+ checkKeys(value, associationKeys, path, errors);
485
+ const id = readId(value.id, `${path}.id`, limits, errors);
486
+ if (!isRelationship(value.relationship)) {
487
+ errors.push({ code: 'INVALID_FIELD', path: `${path}.relationship`, message: `Expected a relationship: one of ${relationshipKinds.join(', ')}, or a namespaced name such as "com.example.supervises".` });
488
+ }
489
+ const from = readId(value.from, `${path}.from`, limits, errors);
490
+ const to = readId(value.to, `${path}.to`, limits, errors);
491
+ const properties = value.properties !== undefined ? readEdgeProperties(value.properties, `${path}.properties`, limits, errors) : undefined;
492
+ if (errors.length > before || !id || !from || !to)
493
+ return undefined;
494
+ return { id, relationship: value.relationship, from, to, ...(properties ? { properties } : {}) };
495
+ }
496
+ function readDataSource(value, path, limits, errors) {
497
+ if (!isObject(value)) {
498
+ errors.push({ code: 'INVALID_FIELD', path, message: 'Expected a data source object.' });
499
+ return undefined;
500
+ }
501
+ const before = errors.length;
502
+ checkKeys(value, dataSourceKeys, path, errors);
503
+ const id = readId(value.id, `${path}.id`, limits, errors);
504
+ const source = {};
505
+ if (value.name !== undefined)
506
+ source.name = readText(value.name, `${path}.name`, limits, errors);
507
+ if (protocols.includes(value.protocol) || isNamespacedType(value.protocol))
508
+ source.protocol = value.protocol;
509
+ else
510
+ errors.push({ code: 'INVALID_FIELD', path: `${path}.protocol`, message: `Unknown protocol ${describe(value.protocol)}; expected one of ${protocols.join(', ')}, or a namespaced name such as "com.vendor.bus".` });
511
+ if (value.deviceId !== undefined)
512
+ source.deviceId = readId(value.deviceId, `${path}.deviceId`, limits, errors);
513
+ if (value.endpointRef !== undefined) {
514
+ const ref = readText(value.endpointRef, `${path}.endpointRef`, limits, errors);
515
+ if (ref !== undefined && endpointCarriesSecret(ref)) {
516
+ errors.push({ code: 'SECRET_FIELD', path: `${path}.endpointRef`, message: 'The endpoint carries a user name or password; store a reference the host resolves instead.' });
517
+ }
518
+ else if (ref !== undefined)
519
+ source.endpointRef = ref;
520
+ }
521
+ if (value.properties !== undefined)
522
+ source.properties = readEdgeProperties(value.properties, `${path}.properties`, limits, errors);
523
+ if (errors.length > before || !id)
524
+ return undefined;
525
+ return { id, ...source };
526
+ }
527
+ function readFinite(value, path, errors) {
528
+ if (typeof value === 'number' && Number.isFinite(value))
529
+ return plusZero(value);
530
+ errors.push({ code: 'INVALID_FIELD', path, message: 'Expected a finite number.' });
531
+ return undefined;
532
+ }
533
+ /** Reads a variable; its address stays plain JSON here and is checked against its source's protocol in `checkAcquisition`. */
534
+ function readVariable(value, path, limits, errors) {
535
+ if (!isObject(value)) {
536
+ errors.push({ code: 'INVALID_FIELD', path, message: 'Expected a runtime variable object.' });
537
+ return undefined;
538
+ }
539
+ const before = errors.length;
540
+ checkKeys(value, variableKeys, path, errors);
541
+ const id = readId(value.id, `${path}.id`, limits, errors);
542
+ const variable = {};
543
+ if (value.name !== undefined)
544
+ variable.name = readText(value.name, `${path}.name`, limits, errors);
545
+ if (value.componentId !== undefined)
546
+ variable.componentId = readId(value.componentId, `${path}.componentId`, limits, errors);
547
+ if (dataTypes.includes(value.dataType))
548
+ variable.dataType = value.dataType;
549
+ else
550
+ errors.push({ code: 'INVALID_FIELD', path: `${path}.dataType`, message: `Unknown data type ${describe(value.dataType)}; expected one of ${dataTypes.join(', ')}.` });
551
+ if (value.unit !== undefined) {
552
+ if (typeof value.unit === 'string' && value.unit.length <= MAX_UNIT_LENGTH)
553
+ variable.unit = value.unit;
554
+ else
555
+ errors.push({ code: 'INVALID_FIELD', path: `${path}.unit`, message: `Expected a unit of at most ${MAX_UNIT_LENGTH} characters.` });
556
+ }
557
+ if (value.access === 'read' || value.access === 'readWrite')
558
+ variable.access = value.access;
559
+ else
560
+ errors.push({ code: 'INVALID_FIELD', path: `${path}.access`, message: `Expected access "read" or "readWrite".` });
561
+ if (value.source !== undefined) {
562
+ const s = value.source;
563
+ if (!isObject(s))
564
+ errors.push({ code: 'INVALID_FIELD', path: `${path}.source`, message: 'Expected a source object.' });
565
+ else {
566
+ checkKeys(s, variableSourceKeys, `${path}.source`, errors);
567
+ const dataSourceId = readId(s.dataSourceId, `${path}.source.dataSourceId`, limits, errors);
568
+ const name = s.variable !== undefined ? readText(s.variable, `${path}.source.variable`, limits, errors) : undefined;
569
+ let address;
570
+ if (s.address !== undefined) {
571
+ if (!isObject(s.address))
572
+ errors.push({ code: 'INVALID_ADDRESS', path: `${path}.source.address`, message: 'Expected an address object.' });
573
+ else
574
+ address = readJson(s.address, `${path}.source.address`, 1, { left: MAX_ADDRESS_VALUES, exceeded: `An address holds more than ${MAX_ADDRESS_VALUES} values.` }, limits, errors);
575
+ }
576
+ if (dataSourceId)
577
+ variable.source = { dataSourceId, ...(name !== undefined ? { variable: name } : {}), ...(address !== undefined ? { address: address } : {}) };
578
+ }
579
+ }
580
+ if (value.scaling !== undefined) {
581
+ const sc = value.scaling;
582
+ if (!isObject(sc))
583
+ errors.push({ code: 'INVALID_FIELD', path: `${path}.scaling`, message: 'Expected a scaling object.' });
584
+ else {
585
+ checkKeys(sc, scalingKeys, `${path}.scaling`, errors);
586
+ if (sc.kind !== 'linear')
587
+ errors.push({ code: 'INVALID_FIELD', path: `${path}.scaling.kind`, message: 'Expected scaling kind "linear".' });
588
+ const [rawMin, rawMax, min, max] = ['rawMin', 'rawMax', 'min', 'max'].map((k) => readFinite(sc[k], `${path}.scaling.${k}`, errors));
589
+ if (rawMin !== undefined && rawMax !== undefined && rawMin === rawMax)
590
+ errors.push({ code: 'INVALID_FIELD', path: `${path}.scaling.rawMax`, message: 'rawMin and rawMax must differ.' });
591
+ if (rawMin !== undefined && rawMax !== undefined && min !== undefined && max !== undefined)
592
+ variable.scaling = { kind: 'linear', rawMin, rawMax, min, max };
593
+ }
594
+ }
595
+ if (value.command !== undefined)
596
+ variable.command = readCommand(value.command, `${path}.command`, limits, errors);
597
+ if (variable.dataType && variable.access)
598
+ checkCommand(variable, value, path, errors);
599
+ if (errors.length > before || !id)
600
+ return undefined;
601
+ return { id, ...variable };
602
+ }
603
+ function readCommand(value, path, limits, errors) {
604
+ if (!isObject(value)) {
605
+ errors.push({ code: 'INVALID_FIELD', path, message: 'Expected a command limits object.' });
606
+ return undefined;
607
+ }
608
+ checkKeys(value, commandKeys, path, errors);
609
+ const command = {};
610
+ for (const key of ['min', 'max', 'step'])
611
+ if (value[key] !== undefined)
612
+ command[key] = readFinite(value[key], `${path}.${key}`, errors);
613
+ if (value.confirm !== undefined)
614
+ command.confirm = readText(value.confirm, `${path}.confirm`, limits, errors);
615
+ return command;
616
+ }
617
+ /**
618
+ * The command boundary's document rules (ADR-0029 §2): only a `readWrite` variable has limits; it
619
+ * needs a source; strings cannot be commanded; numeric types are bounded within their data type.
620
+ */
621
+ function checkCommand(variable, value, path, errors) {
622
+ const bad = (field, message) => {
623
+ errors.push({ code: 'INVALID_FIELD', path: `${path}${field}`, message });
624
+ };
625
+ if (variable.access === 'read') {
626
+ if (value.command !== undefined)
627
+ bad('.command', 'Only a "readWrite" variable takes command limits.');
628
+ return;
629
+ }
630
+ if (variable.dataType === 'string')
631
+ return bad('.access', 'A string variable cannot be commanded (ADR-0029).');
632
+ if (value.source === undefined)
633
+ bad('.source', 'A writable variable needs a source the host writes to.');
634
+ const command = variable.command;
635
+ if (value.command === undefined)
636
+ return bad('.command', 'A writable variable needs command limits (ADR-0029).');
637
+ if (!command)
638
+ return;
639
+ if (command.step !== undefined && !(command.step > 0))
640
+ bad('.command.step', 'The step must be greater than zero.');
641
+ if (variable.dataType === 'bool') {
642
+ for (const key of ['min', 'max'])
643
+ if (command[key] !== undefined)
644
+ bad(`.command.${key}`, 'An on/off variable takes no minimum or maximum.');
645
+ return;
646
+ }
647
+ // Unbounded setpoints are refused: both limits, in order, and within the data type.
648
+ for (const key of ['min', 'max']) {
649
+ const limit = command[key];
650
+ if (limit === undefined) {
651
+ if (value.command && isObject(value.command) && value.command[key] === undefined)
652
+ bad(`.command.${key}`, `A numeric writable variable needs a ${key === 'min' ? 'minimum' : 'maximum'}.`);
653
+ }
654
+ else if (!valueFitsDataType(limit, variable.dataType, !!variable.scaling))
655
+ bad(`.command.${key}`, `${limit} does not fit ${variable.dataType}.`);
656
+ }
657
+ if (command.min !== undefined && command.max !== undefined && !(command.min < command.max))
658
+ bad('.command.max', 'The maximum must be greater than the minimum.');
659
+ }
660
+ /** Keys anywhere in plain JSON that name a credential. */
661
+ function secretKeys(value, path, errors) {
662
+ if (Array.isArray(value))
663
+ value.forEach((v, i) => secretKeys(v, `${path}[${i}]`, errors));
664
+ else if (value !== null && typeof value === 'object') {
665
+ for (const [k, v] of Object.entries(value)) {
666
+ if (isSecretKey(k))
667
+ errors.push({ code: 'SECRET_FIELD', path: `${path}.${k}`, message: `"${k}" looks like a credential; store a reference in the host instead.` });
668
+ else
669
+ secretKeys(v, `${path}.${k}`, errors);
670
+ }
671
+ }
672
+ }
673
+ /** Address rules per protocol (ADR-0024); every problem is `INVALID_ADDRESS` at the field's path. */
674
+ function checkAddress(protocol, variable, path, options, errors) {
675
+ const address = variable.source?.address;
676
+ const bad = (at, message) => {
677
+ errors.push({ code: 'INVALID_ADDRESS', path: at ? `${path}.${at}` : path, message });
678
+ };
679
+ const isInt = (v, lo, hi) => typeof v === 'number' && Number.isInteger(v) && v >= lo && v <= hi;
680
+ if (protocol === 'host') {
681
+ if (address !== undefined)
682
+ bad('', 'A host source reads by tag id or `variable`; it takes no address.');
683
+ return;
684
+ }
685
+ if (protocol === 'opcua' || protocol === 'modbus-tcp' || protocol === 'modbus-rtu') {
686
+ if (address === undefined)
687
+ return bad('', `A ${protocol} source needs an address.`);
688
+ if (protocol === 'opcua') {
689
+ for (const key of Object.keys(address))
690
+ if (!opcUaKeys.includes(key))
691
+ bad(key, `Unknown OPC UA address field "${key}".`);
692
+ if (typeof address.nodeId !== 'string' || address.nodeId.length === 0)
693
+ bad('nodeId', 'Expected a node id such as "ns=2;s=Line2.Speed".');
694
+ if (address.namespaceUri !== undefined && (typeof address.namespaceUri !== 'string' || address.namespaceUri.length === 0))
695
+ bad('namespaceUri', 'Expected a namespace URI.');
696
+ return;
697
+ }
698
+ for (const key of Object.keys(address))
699
+ if (!modbusKeys.includes(key))
700
+ bad(key, `Unknown Modbus address field "${key}".`);
701
+ const area = address.area;
702
+ if (!modbusAreas.includes(area))
703
+ return bad('area', `Expected an area: ${modbusAreas.join(', ')}.`);
704
+ if (!isInt(address.unitId, 0, 255))
705
+ bad('unitId', 'Expected a unit id 0–255.');
706
+ if (address.byteOrder !== undefined && address.byteOrder !== 'big' && address.byteOrder !== 'little')
707
+ bad('byteOrder', 'Expected "big" or "little".');
708
+ if (address.wordOrder !== undefined && address.wordOrder !== 'high-first' && address.wordOrder !== 'low-first')
709
+ bad('wordOrder', 'Expected "high-first" or "low-first".');
710
+ const registers = MODBUS_REGISTERS[variable.dataType];
711
+ if (registers === undefined)
712
+ return bad('', `A ${variable.dataType} cannot be read from Modbus.`);
713
+ if ((area === 'coil' || area === 'discrete') && variable.dataType !== 'bool')
714
+ bad('area', `A ${area} holds only bool, not ${variable.dataType}.`);
715
+ if (!isInt(address.address, 0, 65535))
716
+ bad('address', 'Expected an address 0–65535.');
717
+ else if (address.address + registers - 1 > 65535)
718
+ bad('address', `A ${variable.dataType} spans ${registers} registers and does not fit below 65536.`);
719
+ if (address.bit !== undefined) {
720
+ if (!isInt(address.bit, 0, 15))
721
+ bad('bit', 'Expected a bit 0–15.');
722
+ else if (variable.dataType !== 'bool' || (area !== 'holding' && area !== 'input'))
723
+ bad('bit', 'A bit applies only to a bool in a holding or input register.');
724
+ }
725
+ return;
726
+ }
727
+ // A host's own protocol: plain JSON, checked by the host when it can.
728
+ if (address === undefined)
729
+ return;
730
+ secretKeys(address, `${path}`, errors);
731
+ for (const message of options.validateAddress?.(protocol, address) ?? [])
732
+ bad('', message);
733
+ }
734
+ /** References and address rules of data sources and runtime variables (ADR-0024). */
735
+ function checkAcquisition(scene, options, errors) {
736
+ const components = new Set(scene.components.map((c) => c.id));
737
+ const sources = new Map();
738
+ scene.dataSources?.forEach((s, i) => {
739
+ const path = `dataSources[${i}]`;
740
+ if (sources.has(s.id))
741
+ errors.push({ code: 'DUPLICATE_DATA_SOURCE_ID', path: `${path}.id`, message: `Duplicate data source id "${s.id}".` });
742
+ else
743
+ sources.set(s.id, s);
744
+ if (s.deviceId !== undefined && !components.has(s.deviceId))
745
+ errors.push({ code: 'UNKNOWN_INSTANCE', path: `${path}.deviceId`, message: `No component "${s.deviceId}".` });
746
+ });
747
+ const ids = new Set();
748
+ scene.runtimeVariables?.forEach((v, i) => {
749
+ const path = `runtimeVariables[${i}]`;
750
+ if (ids.has(v.id)) {
751
+ errors.push({ code: 'DUPLICATE_VARIABLE_ID', path: `${path}.id`, message: `Duplicate runtime variable id "${v.id}".` });
752
+ return;
753
+ }
754
+ ids.add(v.id);
755
+ if (v.componentId !== undefined && !components.has(v.componentId))
756
+ errors.push({ code: 'UNKNOWN_INSTANCE', path: `${path}.componentId`, message: `No component "${v.componentId}".` });
757
+ if (!v.source)
758
+ return;
759
+ const source = sources.get(v.source.dataSourceId);
760
+ if (!source) {
761
+ errors.push({ code: 'UNKNOWN_DATA_SOURCE', path: `${path}.source.dataSourceId`, message: `No data source "${v.source.dataSourceId}".` });
762
+ return;
763
+ }
764
+ checkAddress(source.protocol, v, `${path}.source.address`, options, errors);
765
+ });
766
+ }
767
+ function readConnection(value, path, version, limits, errors) {
229
768
  if (!isObject(value)) {
230
769
  errors.push({ code: 'INVALID_FIELD', path, message: 'Expected a connection object.' });
231
770
  return undefined;
232
771
  }
233
772
  const before = errors.length;
234
- checkKeys(value, connectionKeys, path, errors);
773
+ checkKeys(value, connectionKeys[version], path, errors);
235
774
  const id = readId(value.id, `${path}.id`, limits, errors);
236
775
  const from = readEndpoint(value.from, `${path}.from`, limits, errors);
237
776
  const to = readEndpoint(value.to, `${path}.to`, limits, errors);
777
+ // Version 4 fields (ADR-0023); older versions reject them as unknown keys above.
778
+ const extra = {};
779
+ if (version === 4) {
780
+ if (value.domain !== undefined) {
781
+ if (portKinds.includes(value.domain))
782
+ extra.domain = value.domain;
783
+ else
784
+ errors.push({ code: 'INVALID_FIELD', path: `${path}.domain`, message: `Unknown connection domain ${describe(value.domain)}; expected one of ${portKinds.join(', ')}.` });
785
+ }
786
+ if (value.relationship !== undefined) {
787
+ if (isRelationship(value.relationship))
788
+ extra.relationship = value.relationship;
789
+ else
790
+ errors.push({ code: 'INVALID_FIELD', path: `${path}.relationship`, message: `Unknown relationship ${describe(value.relationship)}; expected one of ${relationshipKinds.join(', ')}, or a namespaced name such as "com.example.supervises".` });
791
+ }
792
+ if (value.properties !== undefined) {
793
+ const properties = readEdgeProperties(value.properties, `${path}.properties`, limits, errors);
794
+ if (properties)
795
+ extra.properties = properties;
796
+ }
797
+ }
238
798
  if (errors.length > before || !id || !from || !to)
239
799
  return undefined;
240
- return { id, from, to };
800
+ return { id, ...extra, from, to };
241
801
  }
242
802
  function readBinding(value, path, limits, errors) {
243
803
  if (!isObject(value)) {
@@ -272,6 +832,7 @@ function readBinding(value, path, limits, errors) {
272
832
  function checkBindings(scene, options, errors) {
273
833
  const components = new Map(scene.components.map((c) => [c.id, c]));
274
834
  const connections = new Set(scene.connections.map((c) => c.id));
835
+ const variables = new Map((scene.runtimeVariables ?? []).map((v) => [v.id, v]));
275
836
  const ids = new Set();
276
837
  const targets = new Set();
277
838
  scene.tagBindings?.forEach((binding, i) => {
@@ -290,7 +851,7 @@ function checkBindings(scene, options, errors) {
290
851
  return;
291
852
  }
292
853
  if (options.resolveBindable) {
293
- quantity = options.resolveBindable(owner.type)?.[binding.property];
854
+ quantity = ownValue(options.resolveBindable(owner.type), binding.property);
294
855
  if (!quantity) {
295
856
  errors.push({ code: 'UNBINDABLE_PROPERTY', path: `${path}.property`, message: `A ${owner.type} has no bindable property "${binding.property}".` });
296
857
  return;
@@ -306,6 +867,20 @@ function checkBindings(scene, options, errors) {
306
867
  quantity = 'flow';
307
868
  target = JSON.stringify(['flow', binding.connectionId]);
308
869
  }
870
+ const variable = variables.get(binding.tagId);
871
+ if (variable) {
872
+ // The tag is described (ADR-0024): its data type must fit the property, and its unit applies when the binding gives none.
873
+ const fits = variable.dataType === 'string' ? false : quantity === undefined ? true : (quantity === 'boolean') === (variable.dataType === 'bool');
874
+ if (!fits) {
875
+ const needs = quantity === 'boolean' ? 'a bool' : quantity ? `a number (${quantity})` : 'a bool or a number';
876
+ errors.push({ code: 'TYPE_MISMATCH', path: `${path}.tagId`, message: `Tag "${binding.tagId}" is ${variable.dataType}, but this binding needs ${needs}.` });
877
+ return;
878
+ }
879
+ if (quantity && binding.unit === undefined && variable.unit !== undefined && !isKnownUnit(quantity, variable.unit)) {
880
+ errors.push({ code: 'INVALID_UNIT', path: `${path}.tagId`, message: `Tag "${binding.tagId}" is in "${variable.unit}", which is not accepted for ${quantity}.` });
881
+ return;
882
+ }
883
+ }
309
884
  if (quantity && !isKnownUnit(quantity, binding.unit)) {
310
885
  errors.push({ code: 'INVALID_UNIT', path: `${path}.unit`, message: `Unit "${binding.unit}" is not accepted for ${quantity}.` });
311
886
  return;
@@ -327,11 +902,12 @@ function readList(value, path, max, errors, read) {
327
902
  return undefined;
328
903
  }
329
904
  const items = [];
330
- value.forEach((item, i) => {
331
- const parsed = read(item, `${path}[${i}]`);
905
+ // Every index, not forEach: a hole in a sparse array (`[, x]`) is an invalid entry, as `null` is in JSON.
906
+ for (let i = 0; i < value.length; i++) {
907
+ const parsed = read(value[i], `${path}[${i}]`);
332
908
  if (parsed)
333
909
  items.push(parsed);
334
- });
910
+ }
335
911
  return items;
336
912
  }
337
913
  function checkReferences(scene, options, errors) {
@@ -368,7 +944,7 @@ function checkReferences(scene, options, errors) {
368
944
  }
369
945
  });
370
946
  const connectionIds = new Set();
371
- const occupied = new Set();
947
+ const occupied = new Map();
372
948
  const pairs = new Set();
373
949
  const endpointKey = (e) => JSON.stringify([e.instanceId, e.portId]);
374
950
  scene.connections.forEach((connection, i) => {
@@ -399,8 +975,13 @@ function checkReferences(scene, options, errors) {
399
975
  errors.push({ code: 'SELF_CONNECTION', path, message: 'A component cannot connect to itself.' });
400
976
  return;
401
977
  }
402
- if (!portsCompatible(fromPort, toPort)) {
403
- errors.push({ code: 'INCOMPATIBLE_PORTS', path, message: 'Port domains or nominal sizes do not match.' });
978
+ const check = checkPorts(fromPort, toPort);
979
+ if (!check.ok) {
980
+ errors.push({ code: 'INCOMPATIBLE_PORTS', path, message: `Incompatible ports: ${check.reason}.` });
981
+ return;
982
+ }
983
+ if (connection.domain !== undefined && connection.domain !== fromPort.kind) {
984
+ errors.push({ code: 'INCOMPATIBLE_PORTS', path: `${path}.domain`, message: `The connection is marked ${connection.domain}, but its ports are ${fromPort.kind} ports.` });
404
985
  return;
405
986
  }
406
987
  const fromKey = endpointKey(connection.from);
@@ -411,28 +992,173 @@ function checkReferences(scene, options, errors) {
411
992
  return;
412
993
  }
413
994
  let free = true;
414
- for (const [side, key] of [['from', fromKey], ['to', toKey]]) {
415
- if (occupied.has(key)) {
416
- errors.push({ code: 'PORT_OCCUPIED', path: `${path}.${side}`, message: 'Port already has a connection.' });
995
+ for (const [side, key, port] of [['from', fromKey, fromPort], ['to', toKey, toPort]]) {
996
+ const capacity = portCapacity(port);
997
+ if ((occupied.get(key) ?? 0) >= capacity) {
998
+ errors.push({ code: 'PORT_OCCUPIED', path: `${path}.${side}`, message: capacity === 1 ? 'Port already has a connection.' : `Port already has ${capacity} connections, its maximum.` });
417
999
  free = false;
418
1000
  }
419
1001
  }
420
1002
  if (!free)
421
1003
  return;
422
1004
  pairs.add(pair);
423
- occupied.add(fromKey);
424
- occupied.add(toKey);
1005
+ occupied.set(fromKey, (occupied.get(fromKey) ?? 0) + 1);
1006
+ occupied.set(toKey, (occupied.get(toKey) ?? 0) + 1);
1007
+ });
1008
+ // Associations (ADR-0023): ids share the connections' namespace; directed, so only an identical
1009
+ // (from, to, relationship) repeats.
1010
+ const facts = new Set();
1011
+ scene.associations?.forEach((a, i) => {
1012
+ const path = `associations[${i}]`;
1013
+ if (connectionIds.has(a.id)) {
1014
+ errors.push({ code: 'DUPLICATE_CONNECTION_ID', path: `${path}.id`, message: `Duplicate connection or association id "${a.id}".` });
1015
+ return;
1016
+ }
1017
+ connectionIds.add(a.id);
1018
+ let known = true;
1019
+ for (const side of ['from', 'to']) {
1020
+ if (!componentsById.has(a[side])) {
1021
+ errors.push({ code: 'UNKNOWN_INSTANCE', path: `${path}.${side}`, message: `No component "${a[side]}".` });
1022
+ known = false;
1023
+ }
1024
+ }
1025
+ if (!known)
1026
+ return;
1027
+ if (a.from === a.to) {
1028
+ errors.push({ code: 'SELF_CONNECTION', path, message: 'A component cannot be associated with itself.' });
1029
+ return;
1030
+ }
1031
+ const fact = JSON.stringify([a.from, a.to, a.relationship]);
1032
+ if (facts.has(fact)) {
1033
+ errors.push({ code: 'DUPLICATE_EDGE', path, message: `"${a.from}" already ${a.relationship} "${a.to}".` });
1034
+ return;
1035
+ }
1036
+ facts.add(fact);
425
1037
  });
426
1038
  }
427
1039
  /**
428
- * Validates an untrusted value as a scene and returns it in the current (version 3) model.
429
- * Version 1 (`version: 1`) and version 2 (array vectors) input is migrated deterministically and
430
- * reported via `migratedFrom`.
431
- * Never mutates or repairs the input: on success it returns a fresh copy, otherwise every error
432
- * found, in input order, with paths in the input's own field names. Reference checks (ids, ports,
433
- * compatibility, occupancy, scale policy) run only once the structure is valid. Each port accepts
434
- * one connection.
1040
+ * Reads the optional 2D layouts (ADR-0026): per view, symbol placements by component id and routes by
1041
+ * connection id. Structure only; `checkViews` checks that the ids exist.
435
1042
  */
1043
+ function readViews(value, limits, errors) {
1044
+ if (!isObject(value)) {
1045
+ errors.push({ code: 'INVALID_FIELD', path: 'views', message: 'Expected an object of views.' });
1046
+ return undefined;
1047
+ }
1048
+ checkKeys(value, [...viewKinds], 'views', errors);
1049
+ const views = {};
1050
+ for (const kind of viewKinds) {
1051
+ if (value[kind] === undefined)
1052
+ continue;
1053
+ const layout = readViewLayout(value[kind], `views.${kind}`, limits, errors);
1054
+ if (layout)
1055
+ views[kind] = layout;
1056
+ }
1057
+ return views;
1058
+ }
1059
+ function readViewLayout(value, path, limits, errors) {
1060
+ if (!isObject(value)) {
1061
+ errors.push({ code: 'INVALID_FIELD', path, message: 'Expected a view layout object.' });
1062
+ return undefined;
1063
+ }
1064
+ checkKeys(value, viewLayoutKeys, path, errors);
1065
+ const layout = {};
1066
+ const coordinate = (v, at) => {
1067
+ if (typeof v !== 'number' || !Number.isFinite(v) || Math.abs(v) > MAX_VIEW_COORDINATE) {
1068
+ errors.push({ code: 'INVALID_FIELD', path: at, message: `Expected a finite number within ±${MAX_VIEW_COORDINATE}.` });
1069
+ return undefined;
1070
+ }
1071
+ return v === 0 ? 0 : v; // -0 is written as 0, so read it as 0
1072
+ };
1073
+ const entries = (field, max) => {
1074
+ const record = value[field];
1075
+ if (record === undefined)
1076
+ return undefined;
1077
+ const at = `${path}.${field}`;
1078
+ if (!isObject(record)) {
1079
+ errors.push({ code: 'INVALID_FIELD', path: at, message: 'Expected an object keyed by id.' });
1080
+ return undefined;
1081
+ }
1082
+ const list = Object.entries(record);
1083
+ if (list.length > max) {
1084
+ errors.push({ code: 'LIMIT_EXCEEDED', path: at, message: `More than ${max} entries.` });
1085
+ return undefined;
1086
+ }
1087
+ return list.filter(([key]) => readId(key, `${at}.${key}`, limits, errors) !== undefined).map(([key, v]) => [key, v, `${at}.${key}`]);
1088
+ };
1089
+ const symbolEntries = entries('symbols', limits.maxComponents);
1090
+ if (symbolEntries) {
1091
+ const symbols = {};
1092
+ for (const [id, v, at] of symbolEntries) {
1093
+ if (!isObject(v)) {
1094
+ errors.push({ code: 'INVALID_FIELD', path: at, message: 'Expected a symbol placement object.' });
1095
+ continue;
1096
+ }
1097
+ const before = errors.length;
1098
+ checkKeys(v, symbolKeys, at, errors);
1099
+ const x = coordinate(v.x, `${at}.x`), y = coordinate(v.y, `${at}.y`);
1100
+ if (v.rotation !== undefined && !(v.rotation === 0 || v.rotation === 90 || v.rotation === 180 || v.rotation === 270)) {
1101
+ errors.push({ code: 'INVALID_FIELD', path: `${at}.rotation`, message: 'Expected a rotation of 0, 90, 180 or 270.' });
1102
+ }
1103
+ if (v.hidden !== undefined && v.hidden !== true)
1104
+ errors.push({ code: 'INVALID_FIELD', path: `${at}.hidden`, message: 'Expected true, or no field.' });
1105
+ if (errors.length > before || x === undefined || y === undefined)
1106
+ continue;
1107
+ put(symbols, id, { x, y, ...(v.rotation !== undefined ? { rotation: v.rotation } : {}), ...(v.hidden ? { hidden: true } : {}) });
1108
+ }
1109
+ layout.symbols = symbols;
1110
+ }
1111
+ const routeEntries = entries('routes', limits.maxConnections + limits.maxAssociations);
1112
+ if (routeEntries) {
1113
+ const routes = {};
1114
+ for (const [id, v, at] of routeEntries) {
1115
+ if (!isObject(v)) {
1116
+ errors.push({ code: 'INVALID_FIELD', path: at, message: 'Expected a route object.' });
1117
+ continue;
1118
+ }
1119
+ checkKeys(v, ['points'], at, errors);
1120
+ const points = v.points;
1121
+ if (!Array.isArray(points) || points.length < 2 || points.length > MAX_ROUTE_POINTS) {
1122
+ errors.push({ code: 'INVALID_FIELD', path: `${at}.points`, message: `Expected 2 to ${MAX_ROUTE_POINTS} points.` });
1123
+ continue;
1124
+ }
1125
+ const read = [];
1126
+ for (let i = 0; i < points.length; i++) {
1127
+ const p = points[i];
1128
+ if (!Array.isArray(p) || p.length !== 2) {
1129
+ errors.push({ code: 'INVALID_FIELD', path: `${at}.points[${i}]`, message: 'Expected an [x, y] point.' });
1130
+ continue;
1131
+ }
1132
+ const x = coordinate(p[0], `${at}.points[${i}][0]`), y = coordinate(p[1], `${at}.points[${i}][1]`);
1133
+ if (x !== undefined && y !== undefined)
1134
+ read.push([x, y]);
1135
+ }
1136
+ if (read.length === points.length)
1137
+ put(routes, id, { points: read });
1138
+ }
1139
+ layout.routes = routes;
1140
+ }
1141
+ return layout;
1142
+ }
1143
+ /** Every placed symbol is a component, and every route a connection or association (ADR-0026). */
1144
+ function checkViews(scene, errors) {
1145
+ if (!scene.views)
1146
+ return;
1147
+ const components = new Set(scene.components.map((c) => c.id));
1148
+ const edges = new Set([...scene.connections.map((c) => c.id), ...(scene.associations ?? []).map((a) => a.id)]);
1149
+ for (const kind of viewKinds) {
1150
+ const layout = scene.views[kind];
1151
+ for (const id of Object.keys(layout?.symbols ?? {})) {
1152
+ if (!components.has(id))
1153
+ errors.push({ code: 'UNKNOWN_VIEW_REFERENCE', path: `views.${kind}.symbols.${id}`, message: `No component "${id}" to place.` });
1154
+ }
1155
+ for (const id of Object.keys(layout?.routes ?? {})) {
1156
+ if (!edges.has(id))
1157
+ errors.push({ code: 'UNKNOWN_VIEW_REFERENCE', path: `views.${kind}.routes.${id}`, message: `No connection or association "${id}" to route.` });
1158
+ }
1159
+ }
1160
+ }
1161
+ /** Reads the optional reference drawing (ADR-0012); sanitizing is the host's `validateUnderlay`. */
436
1162
  function readUnderlay(value, limits, options, errors) {
437
1163
  if (!isObject(value)) {
438
1164
  errors.push({ code: 'INVALID_FIELD', path: 'underlay', message: 'Expected an underlay object.' });
@@ -471,6 +1197,15 @@ function readUnderlay(value, limits, options, errors) {
471
1197
  }
472
1198
  return { svg, position: { x, z }, width, rotation, opacity };
473
1199
  }
1200
+ /**
1201
+ * Validates an untrusted value as a scene and returns it in the current (version 4) model.
1202
+ * Version 1 (`version: 1`), version 2 (array vectors) and version 3 input is migrated
1203
+ * deterministically and reported via `migratedFrom` (ADR-0021).
1204
+ * Never mutates or repairs the input: on success it returns a fresh copy, otherwise every error
1205
+ * found, in input order, with paths in the input's own field names. Reference checks (ids, ports,
1206
+ * compatibility, occupancy, scale policy) run only once the structure is valid. Each port accepts
1207
+ * one connection.
1208
+ */
474
1209
  export function validateScene(input, options) {
475
1210
  const limits = { ...defaultSceneLimits, ...options.limits };
476
1211
  const errors = [];
@@ -480,10 +1215,13 @@ export function validateScene(input, options) {
480
1215
  let version;
481
1216
  if ('schemaVersion' in input) {
482
1217
  // Unsupported numbers are reported and read with the newest rules.
483
- version = input.schemaVersion === 2 ? 2 : 3;
484
- if (input.schemaVersion !== 2 && input.schemaVersion !== 3) {
1218
+ version = input.schemaVersion === 2 || input.schemaVersion === 3 ? input.schemaVersion : 4;
1219
+ if (input.schemaVersion !== 2 && input.schemaVersion !== 3 && input.schemaVersion !== 4) {
485
1220
  errors.push({ code: 'UNSUPPORTED_VERSION', path: 'schemaVersion', message: `Unsupported schemaVersion ${describe(input.schemaVersion)}.` });
486
1221
  }
1222
+ if (input.schemaVersion === 4 && input.documentType !== SCENE_DOCUMENT_TYPE) {
1223
+ errors.push({ code: 'INVALID_FIELD', path: 'documentType', message: `Expected documentType "${SCENE_DOCUMENT_TYPE}".` });
1224
+ }
487
1225
  if (input.units !== 'm') {
488
1226
  errors.push({ code: 'INVALID_FIELD', path: 'units', message: 'Expected units "m".' });
489
1227
  }
@@ -495,19 +1233,48 @@ export function validateScene(input, options) {
495
1233
  }
496
1234
  }
497
1235
  checkKeys(input, sceneKeys[version], '', errors);
498
- const components = readList(input.components, 'components', limits.maxComponents, errors, (item, path) => readComponent(item, path, version, limits, errors));
499
- const connections = readList(input.connections, 'connections', limits.maxConnections, errors, (item, path) => readConnection(item, path, limits, errors));
1236
+ const extensionBudget = { left: MAX_EXTENSION_VALUES, exceeded: `Extensions hold more than ${MAX_EXTENSION_VALUES} values.` };
1237
+ const project = version === 4 && input.project !== undefined ? readProject(input.project, limits, errors) : undefined;
1238
+ const components = readList(input.components, 'components', limits.maxComponents, errors, (item, path) => readComponent(item, path, version, limits, errors, extensionBudget, options.resolveType));
1239
+ const connections = readList(input.connections, 'connections', limits.maxConnections, errors, (item, path) => readConnection(item, path, version, limits, errors));
1240
+ const associations = version === 4 && input.associations !== undefined
1241
+ ? readList(input.associations, 'associations', limits.maxAssociations, errors, (item, path) => readAssociation(item, path, limits, errors))
1242
+ : undefined;
1243
+ const dataSources = version === 4 && input.dataSources !== undefined
1244
+ ? readList(input.dataSources, 'dataSources', limits.maxDataSources, errors, (item, path) => readDataSource(item, path, limits, errors))
1245
+ : undefined;
1246
+ const runtimeVariables = version === 4 && input.runtimeVariables !== undefined
1247
+ ? readList(input.runtimeVariables, 'runtimeVariables', limits.maxVariables, errors, (item, path) => readVariable(item, path, limits, errors))
1248
+ : undefined;
500
1249
  const tagBindings = version !== 1 && input.tagBindings !== undefined
501
1250
  ? readList(input.tagBindings, 'tagBindings', limits.maxBindings, errors, (item, path) => readBinding(item, path, limits, errors))
502
1251
  : undefined;
503
- const underlay = version === 3 && input.underlay !== undefined ? readUnderlay(input.underlay, limits, options, errors) : undefined;
1252
+ const underlay = version >= 3 && input.underlay !== undefined ? readUnderlay(input.underlay, limits, options, errors) : undefined;
1253
+ const views = version === 4 && input.views !== undefined ? readViews(input.views, limits, errors) : undefined;
1254
+ const extensions = version === 4 && input.extensions !== undefined ? readExtensions(input.extensions, 'extensions', extensionBudget, limits, errors) : undefined;
504
1255
  if (errors.length > 0 || !components || !connections)
505
1256
  return { ok: false, errors };
506
- const scene = { schemaVersion: 3, units: 'm', components, connections, ...(tagBindings ? { tagBindings } : {}), ...(underlay ? { underlay } : {}) };
1257
+ const scene = {
1258
+ schemaVersion: 4,
1259
+ documentType: SCENE_DOCUMENT_TYPE,
1260
+ units: 'm',
1261
+ ...(project ? { project } : {}),
1262
+ components,
1263
+ connections,
1264
+ ...(associations ? { associations } : {}),
1265
+ ...(dataSources ? { dataSources } : {}),
1266
+ ...(runtimeVariables ? { runtimeVariables } : {}),
1267
+ ...(tagBindings ? { tagBindings } : {}),
1268
+ ...(underlay ? { underlay } : {}),
1269
+ ...(views ? { views } : {}),
1270
+ ...(extensions ? { extensions } : {}),
1271
+ };
507
1272
  checkReferences(scene, options, errors);
1273
+ checkViews(scene, errors);
508
1274
  checkBindings(scene, options, errors);
1275
+ checkAcquisition(scene, options, errors);
509
1276
  if (errors.length > 0)
510
1277
  return { ok: false, errors };
511
- return version === 3 ? { ok: true, scene } : { ok: true, scene, migratedFrom: version };
1278
+ return version === 4 ? { ok: true, scene } : { ok: true, scene, migratedFrom: version };
512
1279
  }
513
1280
  //# sourceMappingURL=validation.js.map