@deepblend/dsh-blender-contracts 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,337 @@
1
+ /**
2
+ * A small, dependency-free JSON Schema validator for the DeepBlend schemas.
3
+ *
4
+ * WHY NOT A LIBRARY
5
+ * -----------------
6
+ * The DeepBlend schemas use a deliberately narrow, closed subset of JSON Schema
7
+ * draft 2020-12: type, additionalProperties, required, properties, items,
8
+ * minItems/maxItems, minimum/maximum/exclusiveMinimum/exclusiveMaximum,
9
+ * minLength/maxLength, pattern, enum, const, oneOf and local `#/$defs/...` refs.
10
+ * That subset is ~200 lines of straightforward code, and writing it here buys
11
+ * three things a dependency would not:
12
+ *
13
+ * 1. **Zero install surface.** This repository has no pnpm and no build step
14
+ * (M0 decision D4, SPEC §6.1 deviation #2). A dependency would have to be
15
+ * vendored by hand into a profile's node_modules.
16
+ * 2. **Issue-path fidelity.** Validation output is consumed by the model, so a
17
+ * failure must read like `entities[2].transform.location[1]` and name the
18
+ * keyword that rejected it — not a library-formatted error string.
19
+ * 3. **Honest failure on the unsupported subset.** Any keyword this validator
20
+ * does not implement is reported as an error while loading the SCHEMA, not
21
+ * silently ignored while validating DATA. A silently ignored constraint is
22
+ * worse than no validator at all, because it converts a rejected scene into
23
+ * an accepted one.
24
+ *
25
+ * The schemas themselves live in `deepblend/schemas/` per SPEC §5.2 and are
26
+ * mirrored into this package's `lib/schemas/` so the published package is
27
+ * self-contained. A contract test asserts the mirror is byte-identical, so the
28
+ * two copies cannot drift.
29
+ *
30
+ * Owner: DeepBlend Studio — M1
31
+ */
32
+
33
+ /** Keywords this validator understands. Anything else fails schema loading. */
34
+ const SUPPORTED_KEYWORDS = new Set([
35
+ '$schema', '$id', '$defs', '$ref',
36
+ 'title', 'description', 'default', 'examples',
37
+ 'type', 'properties', 'required', 'additionalProperties',
38
+ 'items', 'minItems', 'maxItems',
39
+ 'minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum',
40
+ 'minLength', 'maxLength', 'pattern', 'enum', 'const',
41
+ 'oneOf',
42
+ ])
43
+
44
+ /** JSON types as JSON Schema spells them. `integer` is handled separately. */
45
+ const JSON_TYPES = new Set(['object', 'array', 'string', 'number', 'integer', 'boolean', 'null'])
46
+
47
+ /**
48
+ * @typedef {object} SchemaIssue
49
+ * @property {string} path - JSON Pointer-ish data path, `''` for the root.
50
+ * @property {string} keyword - the JSON Schema keyword that rejected the value.
51
+ * @property {string} message - human-readable, safe to hand to the model.
52
+ */
53
+
54
+ /** Thrown when a schema document itself is malformed or uses unsupported keywords. */
55
+ export class SchemaDefinitionError extends Error {
56
+ /** @param {string} message */
57
+ constructor(message) {
58
+ super(message)
59
+ this.name = 'SchemaDefinitionError'
60
+ }
61
+ }
62
+
63
+ /** Render a data path as `a.b[0].c`, or `<root>`. */
64
+ function formatPath(segments) {
65
+ if (segments.length === 0) return '<root>'
66
+ let text = ''
67
+ for (const segment of segments) {
68
+ if (typeof segment === 'number') text += `[${segment}]`
69
+ else if (text.length === 0) text = segment
70
+ else text += `.${segment}`
71
+ }
72
+ return text
73
+ }
74
+
75
+ /** True when `value` satisfies one JSON type name. */
76
+ function matchesType(value, type) {
77
+ switch (type) {
78
+ case 'object': return typeof value === 'object' && value !== null && !Array.isArray(value)
79
+ case 'array': return Array.isArray(value)
80
+ case 'string': return typeof value === 'string'
81
+ case 'number': return typeof value === 'number' && Number.isFinite(value)
82
+ // An integer is a number without a fractional part; 3.0 is a valid integer.
83
+ case 'integer': return typeof value === 'number' && Number.isFinite(value) && Number.isInteger(value)
84
+ case 'boolean': return typeof value === 'boolean'
85
+ case 'null': return value === null
86
+ default: return false
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Compile a schema document into a reusable validator.
92
+ *
93
+ * Compilation walks the whole schema once and throws {@link SchemaDefinitionError}
94
+ * for any unsupported keyword, so a typo in a schema can never become a silently
95
+ * unenforced rule at validation time.
96
+ *
97
+ * @param {object} schema - a parsed JSON Schema document.
98
+ * @param {{ id?: string }} [options]
99
+ * @returns {(value: unknown) => SchemaIssue[]} validator returning `[]` when valid.
100
+ */
101
+ export function compileSchema(schema, options = {}) {
102
+ const label = options.id ?? schema?.$id ?? '<anonymous schema>'
103
+ const root = schema
104
+
105
+ /** Assert that only supported keywords appear anywhere in the document. */
106
+ function assertSupported(node, trail) {
107
+ if (typeof node !== 'object' || node === null) return
108
+ if (Array.isArray(node)) {
109
+ throw new SchemaDefinitionError(`${label}: unexpected array at ${trail}`)
110
+ }
111
+ for (const key of Object.keys(node)) {
112
+ if (!SUPPORTED_KEYWORDS.has(key)) {
113
+ throw new SchemaDefinitionError(
114
+ `${label}: unsupported JSON Schema keyword "${key}" at ${trail || '<root>'}. ` +
115
+ `Add it to SUPPORTED_KEYWORDS with real enforcement, or remove it — an ` +
116
+ `unimplemented keyword would be silently unenforced.`,
117
+ )
118
+ }
119
+ }
120
+ for (const [key, value] of Object.entries(node)) {
121
+ // `$defs` holds named sub-schemas; `oneOf` holds a list of sub-schemas.
122
+ if (key === '$defs' && typeof value === 'object' && value !== null) {
123
+ for (const [name, sub] of Object.entries(value)) assertSupported(sub, `$defs/${name}`)
124
+ } else if (key === 'oneOf' && Array.isArray(value)) {
125
+ value.forEach((sub, index) => assertSupported(sub, `${trail}#oneOf/${index}`))
126
+ } else if (key === 'properties' && typeof value === 'object' && value !== null) {
127
+ for (const [name, sub] of Object.entries(value)) assertSupported(sub, `${trail}/properties/${name}`)
128
+ } else if ((key === 'items' || key === 'additionalProperties') && typeof value === 'object' && value !== null) {
129
+ assertSupported(value, `${trail}/${key}`)
130
+ }
131
+ }
132
+ }
133
+
134
+ assertSupported(root, '')
135
+
136
+ /** Resolve a local `#/...` JSON pointer. External refs are not supported. */
137
+ function resolveRef(reference) {
138
+ if (typeof reference !== 'string' || !reference.startsWith('#/')) {
139
+ throw new SchemaDefinitionError(
140
+ `${label}: only local "#/..." $ref values are supported, got "${reference}".`,
141
+ )
142
+ }
143
+ let node = root
144
+ for (const rawToken of reference.slice(2).split('/')) {
145
+ const token = rawToken.replace(/~1/g, '/').replace(/~0/g, '~')
146
+ if (typeof node !== 'object' || node === null || !(token in node)) {
147
+ throw new SchemaDefinitionError(`${label}: $ref "${reference}" does not resolve.`)
148
+ }
149
+ node = node[token]
150
+ }
151
+ return node
152
+ }
153
+
154
+ /**
155
+ * Validate `value` against `node`, appending to `issues`.
156
+ * @param {unknown} value
157
+ * @param {any} node
158
+ * @param {(string|number)[]} path
159
+ * @param {SchemaIssue[]} issues
160
+ */
161
+ function visit(value, node, path, issues) {
162
+ if (typeof node !== 'object' || node === null) return
163
+
164
+ if (node.$ref !== undefined) {
165
+ // A `$ref` in this subset is the only constraint on the node, matching the
166
+ // "sibling keywords are ignored" behaviour of the 2020-12 dialect.
167
+ visit(value, resolveRef(node.$ref), path, issues)
168
+ return
169
+ }
170
+
171
+ if (Array.isArray(node.oneOf)) {
172
+ const survivors = []
173
+ for (const branch of node.oneOf) {
174
+ const branchIssues = []
175
+ visit(value, branch, path, branchIssues)
176
+ if (branchIssues.length === 0) survivors.push(branch)
177
+ }
178
+ if (survivors.length !== 1) {
179
+ issues.push({
180
+ path: formatPath(path),
181
+ keyword: 'oneOf',
182
+ message: survivors.length === 0
183
+ ? `value matches none of the ${node.oneOf.length} allowed operation shapes`
184
+ : `value matches ${survivors.length} of the ${node.oneOf.length} allowed operation shapes`,
185
+ })
186
+ return
187
+ }
188
+ return
189
+ }
190
+
191
+ if (node.type !== undefined) {
192
+ const declared = Array.isArray(node.type) ? node.type : [node.type]
193
+ for (const type of declared) {
194
+ if (!JSON_TYPES.has(type)) {
195
+ throw new SchemaDefinitionError(`${label}: unknown type "${type}" at ${formatPath(path)}`)
196
+ }
197
+ }
198
+ if (!declared.some(type => matchesType(value, type))) {
199
+ issues.push({
200
+ path: formatPath(path),
201
+ keyword: 'type',
202
+ message: `expected ${declared.join(' or ')}, received ${
203
+ value === null ? 'null' : Array.isArray(value) ? 'array' : typeof value
204
+ }`,
205
+ })
206
+ // Every remaining check would be guesswork against the wrong type.
207
+ return
208
+ }
209
+ }
210
+
211
+ if (node.const !== undefined && value !== node.const) {
212
+ issues.push({
213
+ path: formatPath(path),
214
+ keyword: 'const',
215
+ message: `expected the literal ${JSON.stringify(node.const)}, received ${JSON.stringify(value)}`,
216
+ })
217
+ }
218
+
219
+ if (Array.isArray(node.enum) && !node.enum.some(allowed => allowed === value)) {
220
+ issues.push({
221
+ path: formatPath(path),
222
+ keyword: 'enum',
223
+ message: `expected one of ${node.enum.map(entry => JSON.stringify(entry)).join(', ')}, received ${JSON.stringify(value)}`,
224
+ })
225
+ }
226
+
227
+ if (typeof value === 'string') {
228
+ if (typeof node.minLength === 'number' && value.length < node.minLength) {
229
+ issues.push({
230
+ path: formatPath(path),
231
+ keyword: 'minLength',
232
+ message: `string is ${value.length} characters, minimum is ${node.minLength}`,
233
+ })
234
+ }
235
+ if (typeof node.maxLength === 'number' && value.length > node.maxLength) {
236
+ issues.push({
237
+ path: formatPath(path),
238
+ keyword: 'maxLength',
239
+ message: `string is ${value.length} characters, maximum is ${node.maxLength}`,
240
+ })
241
+ }
242
+ if (typeof node.pattern === 'string' && !new RegExp(node.pattern).test(value)) {
243
+ issues.push({
244
+ path: formatPath(path),
245
+ keyword: 'pattern',
246
+ message: `"${value}" does not match ${node.pattern}`,
247
+ })
248
+ }
249
+ }
250
+
251
+ if (typeof value === 'number') {
252
+ if (typeof node.minimum === 'number' && value < node.minimum) {
253
+ issues.push({ path: formatPath(path), keyword: 'minimum', message: `${value} is below ${node.minimum}` })
254
+ }
255
+ if (typeof node.maximum === 'number' && value > node.maximum) {
256
+ issues.push({ path: formatPath(path), keyword: 'maximum', message: `${value} is above ${node.maximum}` })
257
+ }
258
+ if (typeof node.exclusiveMinimum === 'number' && value <= node.exclusiveMinimum) {
259
+ issues.push({
260
+ path: formatPath(path),
261
+ keyword: 'exclusiveMinimum',
262
+ message: `${value} must be greater than ${node.exclusiveMinimum}`,
263
+ })
264
+ }
265
+ if (typeof node.exclusiveMaximum === 'number' && value >= node.exclusiveMaximum) {
266
+ issues.push({
267
+ path: formatPath(path),
268
+ keyword: 'exclusiveMaximum',
269
+ message: `${value} must be less than ${node.exclusiveMaximum}`,
270
+ })
271
+ }
272
+ }
273
+
274
+ if (Array.isArray(value)) {
275
+ if (typeof node.minItems === 'number' && value.length < node.minItems) {
276
+ issues.push({
277
+ path: formatPath(path),
278
+ keyword: 'minItems',
279
+ message: `array has ${value.length} items, minimum is ${node.minItems}`,
280
+ })
281
+ }
282
+ if (typeof node.maxItems === 'number' && value.length > node.maxItems) {
283
+ issues.push({
284
+ path: formatPath(path),
285
+ keyword: 'maxItems',
286
+ message: `array has ${value.length} items, maximum is ${node.maxItems}`,
287
+ })
288
+ }
289
+ if (node.items !== undefined) {
290
+ value.forEach((item, index) => visit(item, node.items, [...path, index], issues))
291
+ }
292
+ }
293
+
294
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
295
+ const properties = node.properties ?? {}
296
+ for (const name of node.required ?? []) {
297
+ if (!Object.hasOwn(value, name) || value[name] === undefined) {
298
+ issues.push({
299
+ path: formatPath([...path, name]),
300
+ keyword: 'required',
301
+ message: `required property "${name}" is missing`,
302
+ })
303
+ }
304
+ }
305
+ for (const [name, member] of Object.entries(value)) {
306
+ if (Object.hasOwn(properties, name)) {
307
+ visit(member, properties[name], [...path, name], issues)
308
+ continue
309
+ }
310
+ if (node.additionalProperties === false) {
311
+ issues.push({
312
+ path: formatPath([...path, name]),
313
+ keyword: 'additionalProperties',
314
+ message: `unknown property "${name}" is not permitted here`,
315
+ })
316
+ } else if (typeof node.additionalProperties === 'object' && node.additionalProperties !== null) {
317
+ visit(member, node.additionalProperties, [...path, name], issues)
318
+ }
319
+ }
320
+ }
321
+ }
322
+
323
+ return function validate(value) {
324
+ const issues = []
325
+ visit(value, root, [], issues)
326
+ return issues
327
+ }
328
+ }
329
+
330
+ /**
331
+ * Render issues as a compact, model-readable list.
332
+ * @param {SchemaIssue[]} issues
333
+ * @returns {string}
334
+ */
335
+ export function formatIssues(issues) {
336
+ return issues.map(issue => `${issue.path}: ${issue.message} [${issue.keyword}]`).join('\n')
337
+ }