@mikrojs/native 0.18.3-next.20260829153835 → 0.19.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.
@@ -1,1040 +0,0 @@
1
- /* Host-side helpers for config schemas, shared by the CLI and registries.
2
- * Not part of the mikro/schema device bundle: the device never validates a
3
- * schema AST (its manifest copy was written by the CLI) and never derives an
4
- * overlay. Imports core.ts only, so hosts load it without resolving mikro/*
5
- * builtins; results are plain {ok} shapes for the same reason. */
6
-
7
- import {
8
- applyDefaults,
9
- type Format,
10
- type ObjectSchema,
11
- type Schema,
12
- SchemaError,
13
- type Unit,
14
- validate,
15
- } from './core.js'
16
-
17
- export type SchemaCheck<T> = {ok: true; value: T} | {ok: false; error: SchemaError}
18
-
19
- /* The format expressions live here, not in core.ts, because core.ts is bundled
20
- * into the device and a config schema is never validated there. Not a
21
- * caller-supplied `pattern`: a registry runs these against operator input, so a
22
- * publisher-supplied regular expression would be a denial-of-service vector. */
23
- const FORMAT_PATTERNS: Record<Format, RegExp> = {
24
- url: /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^\s/?#]+\S*$/,
25
- hostname:
26
- /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/,
27
- ipv4: /^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/,
28
- // Separators do not mix: aa:bb-cc:dd:ee:ff is not an address.
29
- mac: /^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$|^([0-9a-fA-F]{2}-){5}[0-9a-fA-F]{2}$/,
30
- email: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
31
- }
32
-
33
- export const FORMATS = Object.keys(FORMAT_PATTERNS) as readonly Format[]
34
-
35
- /**
36
- * Validates a value against a config schema, constraints included. `validate()`
37
- * in core.ts checks structure only, because it ships to the device and a config
38
- * schema never does; every host-side path that validates an operator's value
39
- * goes through this instead.
40
- */
41
- export function validateConfig(schema: Schema, value: unknown): SchemaCheck<unknown> {
42
- const structural = validate(schema, value, '')
43
- if (structural !== null) return {ok: false, error: structural.error}
44
- const constraint = checkValueConstraints(schema, value, '')
45
- if (constraint !== null) return constraint
46
- return {ok: true, value}
47
- }
48
-
49
- /* Mirrors validate()'s walk, applying only the constraint checks. Runs after
50
- * the structural pass, so every value here is already the right shape. */
51
- function checkValueConstraints(
52
- schema: Schema,
53
- value: unknown,
54
- path: string,
55
- ): ReturnType<typeof fail> | null {
56
- switch (schema.kind) {
57
- case 'string': {
58
- const text = value as string
59
- const {minLength, maxLength, format} = schema
60
- if (minLength !== undefined && text.length < minLength) {
61
- return fail(`shorter than ${minLength} characters`, path)
62
- }
63
- if (maxLength !== undefined && text.length > maxLength) {
64
- return fail(`longer than ${maxLength} characters`, path)
65
- }
66
- if (format !== undefined && !FORMAT_PATTERNS[format].test(text)) {
67
- return fail(`not a valid ${format}`, path)
68
- }
69
- // The pattern bounds each label at 63 characters; the whole name has its
70
- // own limit that no per-label rule can express.
71
- if (format === 'hostname' && text.length > 253) {
72
- return fail('not a valid hostname', path)
73
- }
74
- return null
75
- }
76
- case 'number': {
77
- const num = value as number
78
- const {min, max} = schema
79
- if (schema.integer === true && !Number.isInteger(num)) {
80
- return fail(`expected a whole number, got ${num}`, path)
81
- }
82
- if (min !== undefined && num < min) return fail(`below the minimum of ${min}`, path)
83
- if (max !== undefined && num > max) return fail(`above the maximum of ${max}`, path)
84
- return null
85
- }
86
- case 'array': {
87
- const items = value as unknown[]
88
- const {minItems, maxItems} = schema
89
- if (minItems !== undefined && items.length < minItems) {
90
- return fail(`fewer than ${minItems} items`, path)
91
- }
92
- if (maxItems !== undefined && items.length > maxItems) {
93
- return fail(`more than ${maxItems} items`, path)
94
- }
95
- for (let i = 0; i < items.length; i++) {
96
- const result = checkValueConstraints(schema.element, items[i], `${path}[${i}]`)
97
- if (result !== null) return result
98
- }
99
- return null
100
- }
101
- case 'object': {
102
- const obj = value as Record<string, unknown>
103
- for (const key of Object.keys(schema.shape)) {
104
- if (!Object.hasOwn(obj, key)) continue
105
- const result = checkValueConstraints(schema.shape[key]!, obj[key], `${path}.${key}`)
106
- if (result !== null) return result
107
- }
108
- return null
109
- }
110
- case 'optional':
111
- return value === undefined ? null : checkValueConstraints(schema.inner, value, path)
112
- case 'tuple': {
113
- const items = value as unknown[]
114
- for (let i = 0; i < schema.elements.length; i++) {
115
- const result = checkValueConstraints(schema.elements[i]!, items[i], `${path}[${i}]`)
116
- if (result !== null) return result
117
- }
118
- return null
119
- }
120
- case 'union': {
121
- /* A union accepts what ANY member accepts, so the constraint pass has to
122
- * agree with the structural one. Applying only the first structurally
123
- * matching member's constraints would reject a value a later member
124
- * allows: in union([number({max: 10}), number({min: 100})]), 150 matches
125
- * the first member's shape, fails its bound, and would be refused even
126
- * though the second member exists for exactly that value.
127
- *
128
- * When nothing passes, report the first member's constraint failure
129
- * rather than a generic "no member matched": for the ordinary union whose
130
- * members differ in shape, that is the specific and useful message. */
131
- let firstFailure: ReturnType<typeof fail> | null = null
132
- for (const member of schema.members) {
133
- if (validate(member, value, '') !== null) continue
134
- const result = checkValueConstraints(member, value, path)
135
- if (result === null) return null
136
- firstFailure ??= result
137
- }
138
- return firstFailure
139
- }
140
- case 'taggedUnion': {
141
- const obj = value as Record<string, unknown>
142
- const branch = schema.branches[obj[schema.key] as string]
143
- return branch === undefined ? null : checkValueConstraints(branch, value, path)
144
- }
145
- default:
146
- return null
147
- }
148
- }
149
-
150
- /** How a unit relates to the primary it measures in, plus the symbol to render
151
- * when the ASCII key is not what a person should read.
152
- *
153
- * `scale` and `offset` exist so a form can show a read-only hint beside a
154
- * field ("30000000 us (30 s)"). They MUST NOT be used to convert a stored
155
- * value. The registry stores only deviations from the schema defaults and
156
- * hashes the effective document for its rev, so a lossy round trip stops a
157
- * default-equal value being stripped and two operators entering the same
158
- * thing produce different revs. Having the numbers here makes converting look
159
- * easy; it is still wrong. */
160
- export interface UnitDefinition {
161
- readonly primary: Unit
162
- readonly scale: number
163
- readonly offset: number
164
- /* What to render beside the number, when the ASCII key is not it. Absent
165
- * means the key is already the right symbol. An EMPTY string means render no
166
- * suffix at all: the dimensionless units are named `/` and `count` in the
167
- * registry, and "0.8 /" is not something to show an operator. */
168
- readonly symbol?: string
169
- }
170
-
171
- /* Declared as Record<Unit, ...> on purpose: the compiler then refuses a table
172
- * that is missing a member of the union or carries one that is not in it, so
173
- * the names are declared once in core.ts and cannot drift from this. */
174
- export const UNITS: Record<Unit, UnitDefinition> = {
175
- m: {primary: 'm', scale: 1, offset: 0},
176
- kg: {primary: 'kg', scale: 1, offset: 0},
177
- s: {primary: 's', scale: 1, offset: 0},
178
- A: {primary: 'A', scale: 1, offset: 0},
179
- K: {primary: 'K', scale: 1, offset: 0},
180
- cd: {primary: 'cd', scale: 1, offset: 0},
181
- mol: {primary: 'mol', scale: 1, offset: 0},
182
- Hz: {primary: 'Hz', scale: 1, offset: 0},
183
- rad: {primary: 'rad', scale: 1, offset: 0},
184
- sr: {primary: 'sr', scale: 1, offset: 0},
185
- N: {primary: 'N', scale: 1, offset: 0},
186
- Pa: {primary: 'Pa', scale: 1, offset: 0},
187
- J: {primary: 'J', scale: 1, offset: 0},
188
- W: {primary: 'W', scale: 1, offset: 0},
189
- C: {primary: 'C', scale: 1, offset: 0},
190
- V: {primary: 'V', scale: 1, offset: 0},
191
- F: {primary: 'F', scale: 1, offset: 0},
192
- Ohm: {primary: 'Ohm', scale: 1, offset: 0, symbol: 'Ω'},
193
- S: {primary: 'S', scale: 1, offset: 0},
194
- Wb: {primary: 'Wb', scale: 1, offset: 0},
195
- T: {primary: 'T', scale: 1, offset: 0},
196
- H: {primary: 'H', scale: 1, offset: 0},
197
- Cel: {primary: 'Cel', scale: 1, offset: 0, symbol: '°C'},
198
- lm: {primary: 'lm', scale: 1, offset: 0},
199
- lx: {primary: 'lx', scale: 1, offset: 0},
200
- Bq: {primary: 'Bq', scale: 1, offset: 0},
201
- Gy: {primary: 'Gy', scale: 1, offset: 0},
202
- Sv: {primary: 'Sv', scale: 1, offset: 0},
203
- kat: {primary: 'kat', scale: 1, offset: 0},
204
- m2: {primary: 'm2', scale: 1, offset: 0, symbol: 'm²'},
205
- m3: {primary: 'm3', scale: 1, offset: 0, symbol: 'm³'},
206
- 'm/s': {primary: 'm/s', scale: 1, offset: 0},
207
- 'm/s2': {primary: 'm/s2', scale: 1, offset: 0, symbol: 'm/s²'},
208
- 'm3/s': {primary: 'm3/s', scale: 1, offset: 0, symbol: 'm³/s'},
209
- 'W/m2': {primary: 'W/m2', scale: 1, offset: 0, symbol: 'W/m²'},
210
- 'cd/m2': {primary: 'cd/m2', scale: 1, offset: 0, symbol: 'cd/m²'},
211
- bit: {primary: 'bit', scale: 1, offset: 0},
212
- 'bit/s': {primary: 'bit/s', scale: 1, offset: 0},
213
- lat: {primary: 'lat', scale: 1, offset: 0},
214
- lon: {primary: 'lon', scale: 1, offset: 0},
215
- pH: {primary: 'pH', scale: 1, offset: 0},
216
- dB: {primary: 'dB', scale: 1, offset: 0},
217
- dBW: {primary: 'dBW', scale: 1, offset: 0},
218
- count: {primary: 'count', scale: 1, offset: 0, symbol: ''},
219
- '/': {primary: '/', scale: 1, offset: 0, symbol: ''},
220
- '%RH': {primary: '%RH', scale: 1, offset: 0},
221
- '%EL': {primary: '%EL', scale: 1, offset: 0},
222
- EL: {primary: 'EL', scale: 1, offset: 0},
223
- '1/s': {primary: '1/s', scale: 1, offset: 0},
224
- 'S/m': {primary: 'S/m', scale: 1, offset: 0},
225
- B: {primary: 'B', scale: 1, offset: 0},
226
- VA: {primary: 'VA', scale: 1, offset: 0},
227
- VAs: {primary: 'VAs', scale: 1, offset: 0},
228
- var: {primary: 'var', scale: 1, offset: 0},
229
- vars: {primary: 'vars', scale: 1, offset: 0},
230
- 'J/m': {primary: 'J/m', scale: 1, offset: 0},
231
- 'kg/m3': {primary: 'kg/m3', scale: 1, offset: 0, symbol: 'kg/m³'},
232
- deg: {primary: 'deg', scale: 1, offset: 0, symbol: '°'},
233
- NTU: {primary: 'NTU', scale: 1, offset: 0},
234
- ms: {primary: 's', scale: 1 / 1000, offset: 0},
235
- min: {primary: 's', scale: 60, offset: 0},
236
- h: {primary: 's', scale: 3600, offset: 0},
237
- MHz: {primary: 'Hz', scale: 1000000, offset: 0},
238
- kW: {primary: 'W', scale: 1000, offset: 0},
239
- kVA: {primary: 'VA', scale: 1000, offset: 0},
240
- kvar: {primary: 'var', scale: 1000, offset: 0},
241
- Ah: {primary: 'C', scale: 3600, offset: 0},
242
- Wh: {primary: 'J', scale: 3600, offset: 0},
243
- kWh: {primary: 'J', scale: 3600000, offset: 0},
244
- varh: {primary: 'vars', scale: 3600, offset: 0},
245
- kvarh: {primary: 'vars', scale: 3600000, offset: 0},
246
- kVAh: {primary: 'VAs', scale: 3600000, offset: 0},
247
- 'Wh/km': {primary: 'J/m', scale: 3.6, offset: 0},
248
- KiB: {primary: 'B', scale: 1024, offset: 0},
249
- GB: {primary: 'B', scale: 1e9, offset: 0},
250
- 'Mbit/s': {primary: 'bit/s', scale: 1000000, offset: 0},
251
- 'B/s': {primary: 'bit/s', scale: 8, offset: 0},
252
- 'MB/s': {primary: 'bit/s', scale: 8000000, offset: 0},
253
- mV: {primary: 'V', scale: 1 / 1000, offset: 0},
254
- mA: {primary: 'A', scale: 1 / 1000, offset: 0},
255
- dBm: {primary: 'dBW', scale: 1, offset: -30},
256
- 'ug/m3': {primary: 'kg/m3', scale: 1e-9, offset: 0, symbol: 'µg/m³'},
257
- 'mm/h': {primary: 'm/s', scale: 1 / 3600000, offset: 0},
258
- 'm/h': {primary: 'm/s', scale: 1 / 3600, offset: 0},
259
- ppm: {primary: '/', scale: 1e-6, offset: 0},
260
- '/100': {primary: '/', scale: 1 / 100, offset: 0, symbol: '%'},
261
- '/1000': {primary: '/', scale: 1 / 1000, offset: 0, symbol: '‰'},
262
- hPa: {primary: 'Pa', scale: 100, offset: 0},
263
- mm: {primary: 'm', scale: 1 / 1000, offset: 0},
264
- cm: {primary: 'm', scale: 1 / 100, offset: 0},
265
- km: {primary: 'm', scale: 1000, offset: 0},
266
- 'km/h': {primary: 'm/s', scale: 1 / 3.6, offset: 0},
267
- ppb: {primary: '/', scale: 1e-9, offset: 0},
268
- ppt: {primary: '/', scale: 1e-12, offset: 0},
269
- VAh: {primary: 'VAs', scale: 3600, offset: 0},
270
- 'mg/l': {primary: 'kg/m3', scale: 1 / 1000, offset: 0},
271
- 'ug/l': {primary: 'kg/m3', scale: 1e-6, offset: 0, symbol: 'µg/l'},
272
- 'g/l': {primary: 'kg/m3', scale: 1, offset: 0},
273
- us: {primary: 's', scale: 1 / 1000000, offset: 0, symbol: 'µs'},
274
- kHz: {primary: 'Hz', scale: 1000, offset: 0},
275
- GHz: {primary: 'Hz', scale: 1000000000, offset: 0},
276
- mW: {primary: 'W', scale: 1 / 1000, offset: 0},
277
- uA: {primary: 'A', scale: 1 / 1000000, offset: 0, symbol: 'µA'},
278
- uV: {primary: 'V', scale: 1 / 1000000, offset: 0, symbol: 'µV'},
279
- mAh: {primary: 'C', scale: 3.6, offset: 0},
280
- MiB: {primary: 'B', scale: 1048576, offset: 0},
281
- kB: {primary: 'B', scale: 1000, offset: 0},
282
- MB: {primary: 'B', scale: 1000000, offset: 0},
283
- 'kbit/s': {primary: 'bit/s', scale: 1000, offset: 0},
284
- 'KiB/s': {primary: 'bit/s', scale: 8192, offset: 0},
285
- kohm: {primary: 'Ohm', scale: 1000, offset: 0, symbol: 'kΩ'},
286
- Mohm: {primary: 'Ohm', scale: 1000000, offset: 0, symbol: 'MΩ'},
287
- kPa: {primary: 'Pa', scale: 1000, offset: 0},
288
- bar: {primary: 'Pa', scale: 100000, offset: 0},
289
- Bd: {primary: '1/s', scale: 1, offset: 0},
290
- }
291
-
292
- const MAX_DEPTH = 8
293
-
294
- /* Caps on operator-visible annotation strings. A title is a field label and a
295
- * description a sentence or two; both count toward the caller's encoded-size
296
- * cap, so bound them here rather than letting one field crowd out a schema. */
297
- const MAX_TITLE_LENGTH = 80
298
- const MAX_DESCRIPTION_LENGTH = 500
299
-
300
- const KINDS = new Set([
301
- 'string',
302
- 'number',
303
- 'boolean',
304
- 'unknown',
305
- 'literal',
306
- 'array',
307
- 'object',
308
- 'optional',
309
- 'tuple',
310
- 'union',
311
- 'taggedUnion',
312
- ])
313
-
314
- function fail(message: string, path: string) {
315
- return {ok: false as const, error: SchemaError.ValidationFailed(message, path)}
316
- }
317
-
318
- /* JSON.parse creates `__proto__` as an own key, and downstream walks assign
319
- * overlay values through `out[key] = …` — with these keys that assignment
320
- * writes the prototype, not a property. The AST is untrusted, so refuse them
321
- * outright. */
322
- const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype'])
323
-
324
- /**
325
- * Validates an untrusted serialized schema AST as a config schema: well-formed
326
- * nodes only, an object at the root, no `unknown()`, no `optional()` around an
327
- * object or array (an overlay needs every absence to mean exactly one thing),
328
- * defaults that match their own node, and nesting of at most 8 levels.
329
- * The size cap is the caller's, since only the caller sees encoded bytes.
330
- */
331
- export function parseConfigSchema(value: unknown): SchemaCheck<Schema> {
332
- if (!isPlainObject(value) || (value as {kind?: unknown}).kind !== 'object') {
333
- return fail('config schema root must be an object()', '')
334
- }
335
- const result = walk(value, '', 1)
336
- if (result !== null) return result
337
- return {ok: true, value: value as unknown as Schema}
338
- }
339
-
340
- function isPlainObject(value: unknown): value is Record<string, unknown> {
341
- return typeof value === 'object' && value !== null && !Array.isArray(value)
342
- }
343
-
344
- /* A default below a wholesale unit never applies: applyDefaults replaces the
345
- * unit whole, so only the unit's own whole-value default fills anything. The
346
- * constructors reject it where it is written (core.ts, rejectInnerDefaults);
347
- * a schema that arrived as JSON ran none of them, so the same rule is enforced
348
- * here. The whole subtree is walked, not just the plain-object path, because
349
- * nothing checked a nested unit's contents on the way in. Structure is already
350
- * validated by `walk`, so a non-node here is left for it to report. */
351
- function rejectInnerDefaults(
352
- value: unknown,
353
- path: string,
354
- unit: string,
355
- self: string,
356
- ): ReturnType<typeof fail> | null {
357
- if (!isPlainObject(value)) return null
358
- if (value.default !== undefined) {
359
- return fail(
360
- `a default under ${unit} never applies; give ${self} itself a whole-value default instead`,
361
- path,
362
- )
363
- }
364
- const children: [unknown, string][] = []
365
- for (const key of ['shape', 'branches'] as const) {
366
- const map = value[key]
367
- if (isPlainObject(map)) {
368
- for (const name of Object.keys(map)) children.push([map[name], `${path}.${name}`])
369
- }
370
- }
371
- for (const key of ['element', 'inner'] as const) {
372
- if (value[key] !== undefined) children.push([value[key], path])
373
- }
374
- for (const key of ['elements', 'members'] as const) {
375
- const items = value[key]
376
- if (Array.isArray(items)) {
377
- for (let i = 0; i < items.length; i++) children.push([items[i], `${path}[${i}]`])
378
- }
379
- }
380
- for (const [child, childPath] of children) {
381
- const result = rejectInnerDefaults(child, childPath, unit, self)
382
- if (result !== null) return result
383
- }
384
- return null
385
- }
386
-
387
- function walk(value: unknown, path: string, depth: number): ReturnType<typeof fail> | null {
388
- if (depth > MAX_DEPTH) return fail(`nesting deeper than ${MAX_DEPTH} levels`, path)
389
- if (!isPlainObject(value)) return fail('expected a schema node', path)
390
- const node = value
391
- const kind = node.kind
392
- if (typeof kind !== 'string' || !KINDS.has(kind)) {
393
- return fail(`unknown schema kind ${JSON.stringify(kind)}`, path)
394
- }
395
- if (kind === 'unknown') return fail('unknown() is not allowed in a config schema', path)
396
-
397
- switch (kind) {
398
- case 'literal': {
399
- const t = typeof node.value
400
- if (t !== 'string' && t !== 'number' && t !== 'boolean') {
401
- return fail('literal value must be a primitive', path)
402
- }
403
- break
404
- }
405
- case 'array': {
406
- const result = walk(node.element, `${path}.element`, depth + 1)
407
- if (result !== null) return result
408
- const inner = rejectInnerDefaults(node.element, `${path}.element`, 'an array', 'the array')
409
- if (inner !== null) return inner
410
- break
411
- }
412
- case 'object': {
413
- if (!isPlainObject(node.shape)) return fail('object shape must be a map', path)
414
- if (node.default !== undefined) return fail('object() cannot carry a default', path)
415
- for (const key of Object.keys(node.shape)) {
416
- if (UNSAFE_KEYS.has(key)) return fail(`unsafe field name ${JSON.stringify(key)}`, path)
417
- const result = walk(node.shape[key], `${path}.${key}`, depth + 1)
418
- if (result !== null) return result
419
- }
420
- break
421
- }
422
- case 'optional': {
423
- const inner = node.inner
424
- if (isPlainObject(inner) && (inner.kind === 'object' || inner.kind === 'array')) {
425
- return fail(`optional() cannot wrap an ${inner.kind} in a config schema`, path)
426
- }
427
- if (node.default !== undefined) {
428
- // The wrapper never carries one (the constructor forbids it); in an
429
- // untrusted AST it would validate here and then be ignored by
430
- // applyDefaults, a default that silently never applies.
431
- return fail('optional() cannot carry a default', path)
432
- }
433
- if (isPlainObject(inner) && inner.default !== undefined) {
434
- return fail('optional() cannot wrap a schema with a default', path)
435
- }
436
- const result = walk(inner, path, depth + 1)
437
- if (result !== null) return result
438
- break
439
- }
440
- case 'tuple':
441
- case 'union': {
442
- const items = kind === 'tuple' ? node.elements : node.members
443
- if (!Array.isArray(items)) return fail(`${kind} items must be an array`, path)
444
- if (kind === 'union' && items.length === 0) {
445
- // Unsatisfiable: no value matches an empty union, so it would only
446
- // fail later, at serve, one device at a time.
447
- return fail('union needs at least one member', path)
448
- }
449
- for (let i = 0; i < items.length; i++) {
450
- const result = walk(items[i], `${path}[${i}]`, depth + 1)
451
- if (result !== null) return result
452
- const unit = kind === 'tuple' ? 'a tuple' : 'a union'
453
- const inner = rejectInnerDefaults(items[i], `${path}[${i}]`, unit, `the ${kind}`)
454
- if (inner !== null) return inner
455
- }
456
- break
457
- }
458
- case 'taggedUnion': {
459
- if (typeof node.key !== 'string') return fail('taggedUnion key must be a string', path)
460
- if (!isPlainObject(node.branches)) return fail('taggedUnion branches must be a map', path)
461
- if (Object.keys(node.branches).length === 0) {
462
- // Unsatisfiable, like an empty union: it would only fail at serve.
463
- return fail('taggedUnion needs at least one branch', path)
464
- }
465
- for (const tag of Object.keys(node.branches)) {
466
- if (UNSAFE_KEYS.has(tag)) return fail(`unsafe branch tag ${JSON.stringify(tag)}`, path)
467
- const branch = node.branches[tag]
468
- if (!isPlainObject(branch) || branch.kind !== 'object') {
469
- return fail('taggedUnion branch must be an object()', `${path}.${tag}`)
470
- }
471
- const result = walk(branch, `${path}.${tag}`, depth + 1)
472
- if (result !== null) return result
473
- const inner = rejectInnerDefaults(branch, `${path}.${tag}`, 'a taggedUnion', 'the union')
474
- if (inner !== null) return inner
475
- }
476
- break
477
- }
478
- }
479
-
480
- const annotations = checkAnnotations(node, kind, path)
481
- if (annotations !== null) return annotations
482
-
483
- if (node.default !== undefined) {
484
- // Constraint-aware on purpose: the constructors cannot do this any more,
485
- // since core.ts no longer carries the checks, so a default that breaks its
486
- // own bound must be caught here, at pack, which is moments later.
487
- const check = validateConfig(node as unknown as Schema, node.default)
488
- if (!check.ok) {
489
- return fail(`default does not match the schema: ${check.error.message}`, path)
490
- }
491
- }
492
- return null
493
- }
494
-
495
- /* The constructors' own TypeErrors never run against a JSON-sourced AST, so
496
- * every annotation the constructors accept is re-checked here. optional() is
497
- * the wrapper the annotations do not belong on: it expresses absence, the node
498
- * it wraps expresses identity. */
499
- function checkAnnotations(
500
- node: Record<string, unknown>,
501
- kind: string,
502
- path: string,
503
- ): ReturnType<typeof fail> | null {
504
- const onWrapper = kind === 'optional' || kind === 'unknown'
505
- const text = (key: 'title' | 'description', max: number) => {
506
- const value = node[key]
507
- if (value === undefined) return null
508
- if (onWrapper) return fail(`${kind}() cannot carry a ${key}; annotate what it wraps`, path)
509
- if (typeof value !== 'string') return fail(`${key} must be a string`, path)
510
- if (value.length === 0) return fail(`${key} must not be empty; omit it instead`, path)
511
- if (value.length > max) return fail(`${key} is longer than ${max} characters`, path)
512
- return null
513
- }
514
- const title = text('title', MAX_TITLE_LENGTH)
515
- if (title !== null) return title
516
- const description = text('description', MAX_DESCRIPTION_LENGTH)
517
- if (description !== null) return description
518
-
519
- if (node.mask !== undefined) {
520
- if (kind !== 'string' && kind !== 'number') {
521
- return fail(`mask is only allowed on string() and number()`, path)
522
- }
523
- if (typeof node.mask !== 'boolean') return fail('mask must be a boolean', path)
524
- if (node.mask === true && node.default !== undefined) {
525
- // A masked field with a default ships the same placeholder credential to
526
- // every device, which is the opposite of what masking is for. Only when
527
- // it is actually masked: `mask: false` is the ordinary state and says
528
- // nothing about defaults.
529
- return fail('a masked field cannot carry a default', path)
530
- }
531
- }
532
- return checkConstraints(node, kind, path)
533
- }
534
-
535
- /* Which constraints each kind accepts, and whether the value must be a
536
- * non-negative whole number (a count) or merely finite (a bound). */
537
- const CONSTRAINTS_BY_KIND: Record<string, readonly [string, string]> = {
538
- string: ['minLength', 'maxLength'],
539
- number: ['min', 'max'],
540
- array: ['minItems', 'maxItems'],
541
- }
542
- const COUNT_CONSTRAINTS = ['minLength', 'maxLength', 'minItems', 'maxItems']
543
- const ALL_CONSTRAINTS = ['minLength', 'maxLength', 'min', 'max', 'minItems', 'maxItems']
544
-
545
- function checkConstraints(
546
- node: Record<string, unknown>,
547
- kind: string,
548
- path: string,
549
- ): ReturnType<typeof fail> | null {
550
- const allowed = CONSTRAINTS_BY_KIND[kind]
551
- for (const key of ALL_CONSTRAINTS) {
552
- const value = node[key]
553
- if (value === undefined) continue
554
- if (allowed === undefined || !allowed.includes(key)) {
555
- return fail(`${key} is not allowed on ${kind}()`, path)
556
- }
557
- if (typeof value !== 'number' || !Number.isFinite(value)) {
558
- return fail(`${key} must be a finite number`, path)
559
- }
560
- if (COUNT_CONSTRAINTS.includes(key) && (!Number.isInteger(value) || value < 0)) {
561
- return fail(`${key} must be a non-negative whole number`, path)
562
- }
563
- }
564
- if (allowed !== undefined) {
565
- const [lower, upper] = allowed
566
- const low = node[lower]
567
- const high = node[upper]
568
- if (typeof low === 'number' && typeof high === 'number' && low > high) {
569
- return fail(`${lower} is greater than ${upper}`, path)
570
- }
571
- }
572
- if (node.integer !== undefined) {
573
- if (kind !== 'number') return fail('integer is not allowed on ' + kind + '()', path)
574
- if (typeof node.integer !== 'boolean') return fail('integer must be a boolean', path)
575
- }
576
- if (node.unit !== undefined) {
577
- if (kind !== 'number') return fail(`unit is not allowed on ${kind}()`, path)
578
- if (typeof node.unit !== 'string' || !Object.hasOwn(UNITS, node.unit)) {
579
- return fail(`unknown unit ${JSON.stringify(node.unit)}`, path)
580
- }
581
- }
582
- if (node.format !== undefined) {
583
- if (kind !== 'string') return fail(`format is not allowed on ${kind}()`, path)
584
- // Fail closed. An unrecognised display annotation may be ignored; an
585
- // unrecognised constraint may not, since ignoring it means accepting a
586
- // value the author ruled out. Rejecting at publish puts it in front of the
587
- // one person who can fix it.
588
- if (typeof node.format !== 'string' || !FORMATS.includes(node.format as never)) {
589
- return fail(
590
- `unknown format ${JSON.stringify(node.format)} (known: ${FORMATS.join(', ')})`,
591
- path,
592
- )
593
- }
594
- }
595
- return null
596
- }
597
-
598
- /**
599
- * Derives the overlay to store or serve from operator-supplied values: drops
600
- * keys the schema does not know, strips values structurally equal to the
601
- * schema default, and prunes empty objects and arrays. Returns undefined when
602
- * nothing deviates from the defaults. Wholesale nodes (arrays, tuples, unions)
603
- * are compared and kept as units; nothing inside them is stripped.
604
- */
605
- export function deriveOverlay(schema: Schema, values: unknown): unknown {
606
- switch (schema.kind) {
607
- case 'object': {
608
- if (values === undefined) return undefined
609
- // A defined value of the wrong kind is kept, not dropped: dropping it
610
- // would derive a clean overlay from garbage, so a typo'd save would
611
- // succeed while storing nothing and rule 5 would never gate on it.
612
- // Kept, it fails the merge-validate that every caller runs next.
613
- if (!isPlainObject(values)) return values
614
- const out: Record<string, unknown> = {}
615
- for (const key of Object.keys(schema.shape)) {
616
- const field = schema.shape[key]!
617
- const value = values[key]
618
- if (value === undefined) continue
619
- const derived =
620
- field.kind === 'optional'
621
- ? deriveOverlay(field.inner, value)
622
- : deriveOverlay(field, value)
623
- if (derived !== undefined) out[key] = derived
624
- }
625
- return Object.keys(out).length > 0 ? out : undefined
626
- }
627
- case 'array': {
628
- if (values === undefined) return undefined
629
- // Wrong kind kept for the same reason as objects above; only a real
630
- // empty array prunes (emptiness is never a deliberate overlay state).
631
- if (!Array.isArray(values)) return values
632
- if (values.length === 0) return undefined
633
- if (schema.default !== undefined && structuralEquals(values, schema.default)) {
634
- return undefined
635
- }
636
- return stripDangerousKeys(values)
637
- }
638
- default: {
639
- const fallback = (schema as {default?: unknown}).default
640
- if (fallback !== undefined && structuralEquals(values, fallback)) return undefined
641
- return stripDangerousKeys(values)
642
- }
643
- }
644
- }
645
-
646
- /** Wholesale units travel with their unknown keys intact, but the three
647
- * prototype-writing names never survive into a stored or served value:
648
- * downstream walks and consumers assign through `out[key]`. */
649
- function stripDangerousKeys(value: unknown): unknown {
650
- if (Array.isArray(value)) return value.map(stripDangerousKeys)
651
- if (isPlainObject(value)) {
652
- const out: Record<string, unknown> = {}
653
- for (const key of Object.keys(value)) {
654
- if (UNSAFE_KEYS.has(key)) continue
655
- out[key] = stripDangerousKeys(value[key])
656
- }
657
- return out
658
- }
659
- return value
660
- }
661
-
662
- export function structuralEquals(a: unknown, b: unknown): boolean {
663
- if (a === b) return true
664
- if (Array.isArray(a) && Array.isArray(b)) {
665
- if (a.length !== b.length) return false
666
- for (let i = 0; i < a.length; i++) {
667
- if (!structuralEquals(a[i], b[i])) return false
668
- }
669
- return true
670
- }
671
- if (isPlainObject(a) && isPlainObject(b)) {
672
- const keysA = Object.keys(a)
673
- const keysB = Object.keys(b)
674
- if (keysA.length !== keysB.length) return false
675
- for (const key of keysA) {
676
- if (!Object.hasOwn(b, key) || !structuralEquals(a[key], b[key])) return false
677
- }
678
- return true
679
- }
680
- return false
681
- }
682
-
683
- /**
684
- * The effective config for an overlay: defaults filled in, then validated.
685
- * What a registry runs before serving and what `ota.config()` runs on read.
686
- */
687
- export function parseEffective(schema: Schema, overlay: unknown): SchemaCheck<unknown> {
688
- const effective = applyDefaults(schema, overlay)
689
- // validateConfig, not validate: this is the gate an operator's config passes
690
- // through, and constraints only bind if they are checked here.
691
- const result = validateConfig(schema, effective)
692
- return result.ok ? {ok: true, value: effective} : result
693
- }
694
-
695
- /**
696
- * The partial defaults a schema materializes with no overrides: every field a
697
- * default covers, and nothing else. Unlike parseEffective it never fails on a
698
- * required defaultless field, it omits it. This is what pack bakes into the
699
- * manifest and what a device reads when it holds no served document.
700
- *
701
- * Plain objects compose, so a nested one is included only when defaults fill
702
- * it completely: a half-filled object would not validate, and the read type
703
- * makes that field optional anyway. Wholesale units (array, tuple, union,
704
- * taggedUnion) need a whole-value default on the node itself, matching
705
- * applyDefaults, where a default inside an element or branch is a form hint.
706
- * Optional fields rest on absence, so they are omitted too.
707
- */
708
- export function materializeDefaults(schema: Schema): Record<string, unknown> {
709
- return schema.kind === 'object' ? fillObject(schema).value : {}
710
- }
711
-
712
- /** What defaults cover in an object shape. `complete` (every field covered or
713
- * optional) is what makes a NESTED object safe to include. */
714
- function fillObject(node: ObjectSchema): {value: Record<string, unknown>; complete: boolean} {
715
- const out: Record<string, unknown> = {}
716
- let complete = true
717
- for (const key of Object.keys(node.shape)) {
718
- const field = node.shape[key]!
719
- if (field.kind === 'optional') continue
720
- const filled = fillField(field)
721
- if (filled === undefined) complete = false
722
- else out[key] = filled.value
723
- }
724
- return {value: out, complete}
725
- }
726
-
727
- /** The value defaults give one field, or undefined when nothing covers it. */
728
- function fillField(node: Schema): {value: unknown} | undefined {
729
- if (node.kind === 'object') {
730
- const filled = fillObject(node)
731
- return filled.complete ? {value: filled.value} : undefined
732
- }
733
- const fallback = (node as {default?: unknown}).default
734
- return fallback === undefined ? undefined : {value: fallback}
735
- }
736
-
737
- /** A node with its annotations removed, recursively, so two schemas can be
738
- * compared on structure alone. */
739
- /* Cosmetic: a change to one of these must never read as a structural change. */
740
- const DISPLAY_KEYS = ['default', 'title', 'description', 'mask']
741
-
742
- /* Semantic, so they survive stripAnnotations and are reported on their own
743
- * terms. Stripped only for the type comparison, where a tightened bound must
744
- * not masquerade as a changed type. */
745
- const CONSTRAINT_KEYS = [
746
- 'minLength',
747
- 'maxLength',
748
- 'min',
749
- 'max',
750
- 'integer',
751
- 'minItems',
752
- 'maxItems',
753
- 'format',
754
- ]
755
-
756
- /* `unit` renders as a suffix, but it is not cosmetic: it reinterprets every
757
- * stored value, since `interval: 30` means one thing under `s` and another
758
- * under `ms`. Nothing fails validation, the device just behaves differently. So
759
- * it survives stripAnnotations and is reported on its own terms, and is
760
- * stripped only for the type comparison. */
761
- const SHAPE_KEYS = [...DISPLAY_KEYS, ...CONSTRAINT_KEYS, 'unit']
762
-
763
- function stripKeys(node: unknown, keys: readonly string[]): unknown {
764
- if (!isPlainObject(node)) return node
765
- const out: Record<string, unknown> = {}
766
- for (const key of Object.keys(node)) {
767
- if (keys.includes(key)) continue
768
- const value = node[key]
769
- if (key === 'shape' || key === 'branches') {
770
- const map = value as Record<string, unknown>
771
- const stripped: Record<string, unknown> = {}
772
- for (const k of Object.keys(map)) stripped[k] = stripKeys(map[k], keys)
773
- out[key] = stripped
774
- } else if (key === 'element' || key === 'inner') {
775
- out[key] = stripKeys(value, keys)
776
- } else if (key === 'elements' || key === 'members') {
777
- out[key] = (value as unknown[]).map((item) => stripKeys(item, keys))
778
- } else {
779
- out[key] = value
780
- }
781
- }
782
- return out
783
- }
784
-
785
- /** A node reduced to its shape alone, for asking "did the type change?" without
786
- * a tightened bound answering yes.
787
- *
788
- * This is now the only comparison the diff makes. Constraints used to be kept
789
- * in some comparisons so that a change to one registered as a difference, but
790
- * every constraint is reported explicitly by constraintWarnings, and keeping
791
- * them here only made a changed bound masquerade as a changed type. */
792
- function stripToShape(node: unknown): unknown {
793
- return stripKeys(node, SHAPE_KEYS)
794
- }
795
-
796
- /* Tightening a bound can invalidate a value an operator already stored, so it
797
- * gates the same way a removed union member does. Loosening cannot, and is
798
- * silent. Reported at release time on the schemas alone; rule 5 catches which
799
- * devices are actually affected when an offer is considered. */
800
- function constraintWarnings(prev: unknown, curr: unknown, path: string, out: string[]): void {
801
- if (!isPlainObject(prev) || !isPlainObject(curr)) return
802
- const report = (key: string, lowerIsLooser: boolean): void => {
803
- const before = prev[key]
804
- const after = curr[key]
805
- if (after === undefined) return
806
- const gate = (how: string): void => {
807
- out.push(
808
- `requires an operator: ${path} ${how} ${key} (stored overrides may no longer validate)`,
809
- )
810
- }
811
- if (before === undefined) return gate('added')
812
- if (typeof before !== 'number' || typeof after !== 'number') return
813
- if (lowerIsLooser ? after > before : after < before) gate(lowerIsLooser ? 'raised' : 'lowered')
814
- }
815
- for (const key of ['min', 'minLength', 'minItems']) report(key, true)
816
- for (const key of ['max', 'maxLength', 'maxItems']) report(key, false)
817
- if (curr.integer === true && prev.integer !== true) {
818
- out.push(
819
- `requires an operator: ${path} now requires a whole number ` +
820
- `(stored overrides may no longer validate)`,
821
- )
822
- }
823
- if (curr.format !== undefined && prev.format !== curr.format) {
824
- out.push(
825
- `requires an operator: ${path} now requires format ${JSON.stringify(curr.format)} ` +
826
- `(stored overrides may no longer validate)`,
827
- )
828
- }
829
- if (prev.unit !== curr.unit) {
830
- out.push(
831
- `requires an operator: ${path} changed unit from ${JSON.stringify(prev.unit ?? null)} ` +
832
- `to ${JSON.stringify(curr.unit ?? null)} (stored values are reinterpreted)`,
833
- )
834
- }
835
-
836
- /* Descend where the outer walk does not. It recurses through object shapes
837
- * only, so without this a tightened bound on an array element or a tuple
838
- * position is silent: stripToShape removes constraints recursively, so the
839
- * type comparison sees no change, and the checks above only read this node's
840
- * own keys. A stranded override with no operator gate is exactly what the
841
- * taxonomy exists to prevent. */
842
- if (prev.kind === 'array' && curr.kind === 'array') {
843
- constraintWarnings(prev.element, curr.element, `${path}[]`, out)
844
- } else if (prev.kind === 'tuple' && curr.kind === 'tuple') {
845
- const elements = curr.elements as unknown[]
846
- const previous = prev.elements as unknown[]
847
- for (let i = 0; i < Math.min(previous.length, elements.length); i++) {
848
- constraintWarnings(previous[i], elements[i], `${path}[${i}]`, out)
849
- }
850
- } else if (prev.kind === 'optional' && curr.kind === 'optional') {
851
- constraintWarnings(prev.inner, curr.inner, path, out)
852
- } else if (prev.kind === 'object' && curr.kind === 'object') {
853
- /* array(object({port: number({max: 65535})})) is an ordinary config shape,
854
- * and without this the port's tightened bound is silent: the outer walk
855
- * hands the array to stripToShape, which is equal, and the descent above
856
- * reaches the element object and stops.
857
- *
858
- * No double-reporting with the outer walk: its object branch recurses per
859
- * field and returns before reaching constraintWarnings, so an object is
860
- * either walked or descended here, never both. */
861
- const prevShape = prev.shape as Record<string, unknown>
862
- const currShape = curr.shape as Record<string, unknown>
863
- for (const key of Object.keys(currShape)) {
864
- if (Object.hasOwn(prevShape, key)) {
865
- constraintWarnings(prevShape[key], currShape[key], `${path}.${key}`, out)
866
- }
867
- }
868
- } else if (prev.kind === 'taggedUnion' && curr.kind === 'taggedUnion') {
869
- const prevBranches = prev.branches as Record<string, unknown>
870
- const currBranches = curr.branches as Record<string, unknown>
871
- for (const tag of Object.keys(currBranches)) {
872
- if (Object.hasOwn(prevBranches, tag)) {
873
- constraintWarnings(prevBranches[tag], currBranches[tag], `${path}.${tag}`, out)
874
- }
875
- }
876
- }
877
- }
878
-
879
- /** required / defaulted / optional, per the spec's scalar-leaf classes.
880
- * Containers have no class; objects report 'object' so the walk descends. */
881
- function leafClass(node: Schema): 'object' | 'optional' | 'defaulted' | 'required' | 'array' {
882
- if (node.kind === 'optional') return 'optional'
883
- if (node.kind === 'object') return 'object'
884
- if (node.kind === 'array') return 'array'
885
- return (node as {default?: unknown}).default !== undefined ? 'defaulted' : 'required'
886
- }
887
-
888
- /** Every required scalar leaf reachable in `node`, for reporting an added
889
- * subtree that will gate offers until an operator supplies values. */
890
- function requiredLeaves(node: Schema, path: string, out: string[]): void {
891
- if (node.kind === 'object') {
892
- for (const key of Object.keys(node.shape)) {
893
- requiredLeaves(node.shape[key]!, `${path}.${key}`, out)
894
- }
895
- return
896
- }
897
- if (leafClass(node) === 'required') out.push(path)
898
- }
899
-
900
- /**
901
- * Human-readable warnings for what changed between two releases' config
902
- * schemas, per the spec's change taxonomy (registry-spec.md, "Schema changes
903
- * between releases"). Safe changes (new defaulted or optional fields, added
904
- * union members, loosened requirements) produce nothing. "requires an
905
- * operator" lines gate offers under rule 5 until someone supplies or fixes a
906
- * value; "note" lines are compatible but worth telling the operator about.
907
- */
908
- export function diffConfigSchemas(previous: Schema, next: Schema): string[] {
909
- const warnings: string[] = []
910
-
911
- function walk(prev: Schema, curr: Schema, path: string): void {
912
- const prevInner = prev.kind === 'optional' ? prev.inner : prev
913
- const currInner = curr.kind === 'optional' ? curr.inner : curr
914
-
915
- if (prevInner.kind === 'object' && currInner.kind === 'object') {
916
- const prevShape = prevInner.shape
917
- const currShape = currInner.shape
918
- for (const key of Object.keys(currShape)) {
919
- const fieldPath = `${path}.${key}`
920
- if (Object.hasOwn(prevShape, key)) {
921
- walk(prevShape[key]!, currShape[key]!, fieldPath)
922
- } else {
923
- const added: string[] = []
924
- requiredLeaves(currShape[key]!, fieldPath, added)
925
- for (const leaf of added) {
926
- warnings.push(
927
- `requires an operator: new required field ${leaf} (devices are not offered ` +
928
- `this release until a value is set)`,
929
- )
930
- }
931
- }
932
- }
933
- for (const key of Object.keys(prevShape)) {
934
- if (!Object.hasOwn(currShape, key)) {
935
- warnings.push(
936
- `note: removed field ${path}.${key} (stored overrides for it stay, but are no ` +
937
- `longer served)`,
938
- )
939
- }
940
- }
941
- return
942
- }
943
-
944
- if (prevInner.kind === 'union' && currInner.kind === 'union') {
945
- // Member sets, not wholesale structure: adding a member (the common
946
- // safe widening) must not read as a type change. Only removals can
947
- // invalidate a stored override.
948
- /* Counted by shape group, not tested for membership. Comparing on shape
949
- * is what stops a merely loosened bound reading as a removal, but it also
950
- * makes two differently bounded members of the same kind indistinguishable
951
- * here: asking "does a member of this shape still exist?" answers yes when
952
- * one of the two has gone. Dropping one of
953
- * union([number({max: 10}), number({min: 100})]) would then be silent,
954
- * stranding any override only the removed range accepted.
955
- *
956
- * A group whose count fell has lost a member. Counting keeps the loosened
957
- * bound safe, since that leaves the count unchanged. */
958
- const countOfShape = (members: readonly unknown[], shape: unknown) =>
959
- members.filter((member) => structuralEquals(stripToShape(member), shape)).length
960
-
961
- let removed = 0
962
- const groups: unknown[] = []
963
- for (const prevMember of prevInner.members) {
964
- const shape = stripToShape(prevMember)
965
- if (groups.some((seen) => structuralEquals(seen, shape))) continue
966
- groups.push(shape)
967
- const before = countOfShape(prevInner.members, shape)
968
- const after = countOfShape(currInner.members, shape)
969
- if (after < before) removed += before - after
970
- }
971
- if (removed > 0) {
972
- warnings.push(
973
- `requires an operator: ${path} removed ${removed} union member(s) ` +
974
- `(stored overrides using them no longer validate)`,
975
- )
976
- }
977
- // Constraint changes within members, index-wise. Members are ordered and
978
- // an edit that also reorders them is not something this can attribute, so
979
- // only compare when the lists still line up.
980
- if (prevInner.members.length === currInner.members.length) {
981
- for (let i = 0; i < prevInner.members.length; i++) {
982
- constraintWarnings(prevInner.members[i], currInner.members[i], `${path}|${i}`, warnings)
983
- }
984
- }
985
- } else if (
986
- prevInner.kind === 'taggedUnion' &&
987
- currInner.kind === 'taggedUnion' &&
988
- prevInner.key === currInner.key
989
- ) {
990
- // Same idea per branch: added branches are safe, removed or reshaped
991
- // ones can strand a stored override.
992
- for (const tag of Object.keys(prevInner.branches)) {
993
- const prevBranch = prevInner.branches[tag]!
994
- const currBranch = currInner.branches[tag]
995
- if (currBranch === undefined || !Object.hasOwn(currInner.branches, tag)) {
996
- warnings.push(
997
- `requires an operator: ${path} removed branch ${JSON.stringify(tag)} ` +
998
- `(stored overrides using it no longer validate)`,
999
- )
1000
- } else if (!structuralEquals(stripToShape(prevBranch), stripToShape(currBranch))) {
1001
- // Shape, not constraints: a branch whose bound merely changed has not
1002
- // changed type, and the descent in constraintWarnings reports it on
1003
- // its own terms rather than as a reshaped branch.
1004
- warnings.push(
1005
- `requires an operator: ${path}.${tag} changed type ` +
1006
- `(stored overrides may no longer validate)`,
1007
- )
1008
- }
1009
- }
1010
- } else if (!structuralEquals(stripToShape(prevInner), stripToShape(currInner))) {
1011
- warnings.push(
1012
- `requires an operator: ${path} changed type (stored overrides may no longer validate)`,
1013
- )
1014
- return
1015
- }
1016
-
1017
- constraintWarnings(prevInner, currInner, path, warnings)
1018
-
1019
- const prevClass = leafClass(prev)
1020
- const currClass = leafClass(curr)
1021
- if (currClass === 'required' && prevClass !== 'required') {
1022
- warnings.push(
1023
- `requires an operator: ${path} is now required (devices without a value are not ` +
1024
- `offered this release)`,
1025
- )
1026
- }
1027
- const prevDefault = (prevInner as {default?: unknown}).default
1028
- const currDefault = (currInner as {default?: unknown}).default
1029
- // A removed default is the required-transition above, not also a default
1030
- // change.
1031
- if (currDefault !== undefined && !structuralEquals(prevDefault, currDefault)) {
1032
- warnings.push(
1033
- `note: default of ${path} changed (takes effect on every device without an override)`,
1034
- )
1035
- }
1036
- }
1037
-
1038
- walk(previous, next, '')
1039
- return warnings
1040
- }