@texaryn/schema-json 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,4 @@
1
+ import { mergeNode } from 'json-schema-library';
1
2
  const VALID_TYPES = new Set([
2
3
  'string',
3
4
  'number',
@@ -13,7 +14,8 @@ function toPointer(value) {
13
14
  function escapeSegment(segment) {
14
15
  return segment.replace(/~/g, '~0').replace(/\//g, '~1');
15
16
  }
16
- function resolveType(schema) {
17
+ /** The `type` the schema itself declares, and nothing else. */
18
+ function resolveExplicitType(schema) {
17
19
  const type = schema.type;
18
20
  if (Array.isArray(type)) {
19
21
  return type.find((t) => VALID_TYPES.has(t));
@@ -23,6 +25,96 @@ function resolveType(schema) {
23
25
  }
24
26
  return undefined;
25
27
  }
28
+ /**
29
+ * Keywords that apply to exactly one JSON type, grouped by that type.
30
+ *
31
+ * Two different jobs are done with these, and only one of them infers
32
+ * anything. Every family takes part in detecting a conflict, because a schema
33
+ * drawing keywords from two families implies no single shape. Only `object`
34
+ * and `array` are shapes a form can be given, so those are the only two ever
35
+ * inferred: `string` and `number` are here to be noticed, not chosen.
36
+ *
37
+ * Inferring a scalar would need a rule that is right rather than symmetrical,
38
+ * and there is not one. `minimum` cannot tell `number` from `integer`, and a
39
+ * wrong scalar guess selects the wrong widget, which is harder to notice than
40
+ * a field that never appeared at all.
41
+ *
42
+ * `format` is deliberately absent: it annotates a string's contents rather
43
+ * than describing structure, and schemas apply it to non-strings in practice.
44
+ */
45
+ const KEYWORD_FAMILIES = {
46
+ object: [
47
+ 'properties',
48
+ 'patternProperties',
49
+ 'additionalProperties',
50
+ 'propertyNames',
51
+ 'required',
52
+ 'minProperties',
53
+ 'maxProperties',
54
+ 'dependentSchemas',
55
+ 'dependentRequired',
56
+ // json-schema-library normalises draft-07 `dependencies` into the two
57
+ // above at parse time. Listed anyway, so the rule does not rely on that.
58
+ 'dependencies',
59
+ 'unevaluatedProperties',
60
+ ],
61
+ array: [
62
+ 'items',
63
+ 'prefixItems',
64
+ 'additionalItems',
65
+ 'contains',
66
+ 'minItems',
67
+ 'maxItems',
68
+ 'uniqueItems',
69
+ 'minContains',
70
+ 'maxContains',
71
+ 'unevaluatedItems',
72
+ ],
73
+ string: ['minLength', 'maxLength', 'pattern'],
74
+ number: ['minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum', 'multipleOf'],
75
+ };
76
+ /** Which families a schema draws keywords from, computed over all of them at once. */
77
+ function projectionTypeFamilies(schema) {
78
+ const families = new Set();
79
+ for (const [family, keywords] of Object.entries(KEYWORD_FAMILIES)) {
80
+ if (keywords.some((keyword) => schema[keyword] !== undefined)) {
81
+ families.add(family);
82
+ }
83
+ }
84
+ return families;
85
+ }
86
+ /**
87
+ * Whether any branch of this node's `oneOf`/`anyOf` could be given a shape at
88
+ * all, for some value.
89
+ *
90
+ * This is what separates "the current value matches no branch" from "this
91
+ * composition is unrenderable whatever the value is". The first is validation's
92
+ * subject and transient; the second is a limitation of this adapter and worth
93
+ * reporting.
94
+ */
95
+ function compositionHasRenderableAlternative(node) {
96
+ const branches = [...(node.oneOf ?? []), ...(node.anyOf ?? [])];
97
+ return branches.some((branch) => {
98
+ const resolved = dereference(branch);
99
+ const schema = resolved.schema;
100
+ if (!schema || typeof schema !== 'object')
101
+ return false;
102
+ if (resolveExplicitType(schema))
103
+ return true;
104
+ return inferProjectionShape(schema).kind === 'resolved';
105
+ });
106
+ }
107
+ function inferProjectionShape(schema) {
108
+ const families = projectionTypeFamilies(schema);
109
+ if (families.size > 1)
110
+ return { kind: 'ambiguous', families: [...families].sort() };
111
+ if (families.size === 1) {
112
+ const [family] = families;
113
+ if (family === 'object' || family === 'array')
114
+ return { kind: 'resolved', type: family };
115
+ }
116
+ return { kind: 'none' };
117
+ }
26
118
  function extractConstraints(schema) {
27
119
  const constraints = {};
28
120
  if (typeof schema.minLength === 'number')
@@ -53,7 +145,13 @@ function extractConstraints(schema) {
53
145
  constraints.uniqueItems = schema.uniqueItems;
54
146
  return constraints;
55
147
  }
56
- function extractAnnotations(schema) {
148
+ function extractAnnotations(schema,
149
+ /**
150
+ * Set where two declarations apply to this location whatever the instance is
151
+ * and disagree. The merged schema still carries one of them, and which one is
152
+ * the merge's traversal order rather than an answer.
153
+ */
154
+ omitDefault = false) {
57
155
  const annotations = {};
58
156
  if (typeof schema.title === 'string')
59
157
  annotations.title = schema.title;
@@ -67,7 +165,7 @@ function extractAnnotations(schema) {
67
165
  annotations.deprecated = schema.deprecated;
68
166
  if (Array.isArray(schema.examples))
69
167
  annotations.examples = schema.examples;
70
- if ('default' in schema)
168
+ if (!omitDefault && 'default' in schema)
71
169
  annotations.default = schema.default;
72
170
  return annotations;
73
171
  }
@@ -81,45 +179,339 @@ function dereference(node) {
81
179
  return node.$ref ? node.resolveRef() : node;
82
180
  }
83
181
  /**
84
- * Discovers every property key that could ever appear on this node, across every
85
- * conditional branch (if/then/else, dependentSchemas, oneOf, anyOf). draft-07
86
- * `dependencies` is normalized by json-schema-library into
87
- * `dependentSchemas`/`dependentRequired` at parse time, so it needs no separate
88
- * handling here.
182
+ * The position of a schema within the document it was authored in, as a JSON
183
+ * Pointer with the root as the empty string.
89
184
  *
90
- * This is the "static" half of the inactive-node contract: a branch that is not
91
- * currently selected for the data still contributes its property pointers, just
92
- * with `active: false`.
185
+ * `schemaLocation` is already that, prefixed with the `#` of a URI fragment,
186
+ * and it names what a `$ref` resolves to rather than the reference. Both
187
+ * adapters report sources in this form so a diagnostic means the same thing
188
+ * whichever one produced it.
93
189
  */
94
- function collectCandidateProperties(node) {
95
- const candidates = {};
96
- const merge = (props) => {
97
- if (!props)
190
+ function documentPointer(node) {
191
+ const location = node.schemaLocation;
192
+ return typeof location === 'string' ? location.replace(/^#/, '') : '';
193
+ }
194
+ /**
195
+ * Visits every schema position that applies to one location whatever the
196
+ * instance is.
197
+ *
198
+ * The edges followed are the unconditional ones: a schema's own keywords, its
199
+ * `allOf` branches, and whatever a `$ref` resolves to. Nothing about an
200
+ * instance can make one of those not apply, so two of them declaring different
201
+ * values is a fact about the schema and there is no instance that resolves it.
202
+ *
203
+ * `oneOf`, `anyOf`, `if`/`then`/`else` and `dependentSchemas` are deliberately
204
+ * not followed, and not because their conflicts are less real. A declaration
205
+ * one of them carries competes with the base only while its branch is
206
+ * selected, so whether the disagreement holds is a state of the data, and
207
+ * `SchemaProjection.diagnostics` carries facts about schemas. Reporting it
208
+ * there would mean a diagnostic that appears and disappears as a discriminator
209
+ * is typed.
210
+ *
211
+ * `roots` is a set rather than one node because a location can be declared in
212
+ * several places at once: `properties/x` on the node's own schema and on each
213
+ * of its `allOf` branches are all declarations of the same location.
214
+ */
215
+ function eachUnconditional(roots, visitor) {
216
+ const visited = new Set();
217
+ const visit = (current) => {
218
+ const resolved = dereference(current);
219
+ // The same guard, and for the same measured reason, as
220
+ // `collectCandidateProperties`: `resolveRef()` returns a fresh node every
221
+ // call, so identity alone never closes a cycle, and an inline branch has no
222
+ // location of its own to key on.
223
+ const location = resolved.schemaLocation;
224
+ const key = typeof location === 'string' ? location : resolved;
225
+ if (visited.has(key))
98
226
  return;
99
- for (const [key, propNode] of Object.entries(props)) {
100
- if (!(key in candidates))
101
- candidates[key] = propNode;
227
+ visited.add(key);
228
+ visitor(resolved);
229
+ for (const branch of resolved.allOf ?? [])
230
+ visit(branch);
231
+ };
232
+ for (const root of roots)
233
+ visit(root);
234
+ }
235
+ function collectUnconditionalDefaults(roots) {
236
+ const declarations = [];
237
+ eachUnconditional(roots, (node) => {
238
+ const schema = node.schema;
239
+ if (schema !== null && typeof schema === 'object' && 'default' in schema) {
240
+ declarations.push({ value: schema.default, source: documentPointer(node) });
102
241
  }
242
+ });
243
+ return declarations;
244
+ }
245
+ /** The positions that unconditionally declare one property of `roots`. */
246
+ function unconditionalChildren(roots, key) {
247
+ const children = [];
248
+ eachUnconditional(roots, (node) => {
249
+ const child = node.properties?.[key];
250
+ if (child)
251
+ children.push(child);
252
+ });
253
+ return children;
254
+ }
255
+ /** The positions that unconditionally declare the elements of `roots`. */
256
+ function unconditionalItems(roots) {
257
+ const items = [];
258
+ eachUnconditional(roots, (node) => {
259
+ if (node.items)
260
+ items.push(node.items);
261
+ });
262
+ return items;
263
+ }
264
+ /**
265
+ * The declarations to report, or nothing where the schema states one answer.
266
+ *
267
+ * One declaration needs no report, and several that agree state the same
268
+ * answer more than once, which is not a disagreement however many times it is
269
+ * said. Compared by value, as ADR-003's own pass compares them: two branches
270
+ * declaring an equal object have nothing to choose between.
271
+ */
272
+ function disagreeingDefaults(declarations) {
273
+ const [first, ...rest] = declarations;
274
+ return rest.every((other) => deepEqual(other.value, first.value)) ? undefined : declarations;
275
+ }
276
+ /**
277
+ * Every property key that could appear at this node's own instance location,
278
+ * across every branch that might apply to it.
279
+ *
280
+ * This is the static half of the inactive-node contract: a branch the data does
281
+ * not currently select still contributes its property pointers, which then
282
+ * project with `active: false`. The dynamic half is `reduceNode`, which decides
283
+ * which of them apply now, and the two must stay separate. Making the candidate
284
+ * set data-driven would delete a pointer the moment its branch stopped
285
+ * matching, which is exactly the flicker the contract exists to prevent.
286
+ *
287
+ * **Recursion is through applicators only, and only those acting on this same
288
+ * instance location**: `if`, `then`, `else`, `allOf`, `anyOf`, `oneOf`,
289
+ * `dependentSchemas`, and whatever a `$ref` resolves to. draft-07
290
+ * `dependencies` needs no separate handling, because json-schema-library
291
+ * normalises it into `dependentSchemas` at parse time.
292
+ *
293
+ * Three things are deliberately not followed:
294
+ *
295
+ * - **`not`**, because a subschema inside it describes a shape the instance
296
+ * must *not* satisfy. Collecting its properties as candidates would invert
297
+ * its meaning.
298
+ * - **the values of `properties`**, because those describe a child location,
299
+ * not this one. `owner` is collected; the walk descends into `/owner`
300
+ * separately and collects `name` there. Following it here would make `name` a
301
+ * sibling of `owner`.
302
+ * - **`items` and `prefixItems`**, for the same reason: an array's items are
303
+ * their own locations.
304
+ *
305
+ * The cycle guard keys on `schemaLocation` rather than on node identity, which
306
+ * was measured rather than assumed and is the opposite of what it looks like it
307
+ * should be: `resolveRef()` returns a fresh `SchemaNode` on every call, and the
308
+ * raw `schema` object it wraps is fresh too, so an identity `Set` never matches
309
+ * and a recursive `$ref` would not terminate. `schemaLocation` is stable, is
310
+ * distinct for each inline branch position, and repeats when a reference closes
311
+ * a cycle, which is precisely the three properties needed. The set is per call,
312
+ * because deduplicating a schema location is only sound while collecting names
313
+ * for one instance location; the same schema legitimately recurs at a deeper
314
+ * pointer, and `walk` visits it again there.
315
+ */
316
+ function collectCandidateProperties(node) {
317
+ const candidates = new Map();
318
+ const visited = new Set();
319
+ const record = (key, propNode, direct) => {
320
+ const entry = candidates.get(key) ?? { alternatives: [] };
321
+ if (direct)
322
+ entry.direct ??= propNode;
323
+ else
324
+ entry.alternatives.push(propNode);
325
+ candidates.set(key, entry);
103
326
  };
104
- merge(node.properties);
105
- merge(node.if?.properties);
106
- merge(node.then?.properties);
107
- merge(node.else?.properties);
108
- if (node.dependentSchemas) {
109
- for (const dependency of Object.values(node.dependentSchemas)) {
327
+ const visit = (current, own) => {
328
+ const resolved = dereference(current);
329
+ // Keyed on `schemaLocation` where there is one, and on the node itself
330
+ // where there is not, which together cover every way the traversal can
331
+ // come back to where it started.
332
+ //
333
+ // Neither alone is sufficient and each covers what the other cannot. A
334
+ // cycle requires a `$ref`, because an inline schema cannot nest into
335
+ // itself, and `resolveRef()` returns a fresh `SchemaNode` on every call
336
+ // whose raw `schema` object is fresh too, so identity never matches across
337
+ // one; those nodes do carry a location. An inline branch is never resolved
338
+ // through a ref, so its identity is stable within one traversal.
339
+ //
340
+ // This deliberately replaced a depth cap. A cap terminates, but it does so
341
+ // by dropping candidates a valid schema declared, which is the silent
342
+ // disappearance the whole projection-diagnostic design exists to prevent:
343
+ // a field nested under 70 applicators is still a field.
344
+ const location = resolved.schemaLocation;
345
+ const key = typeof location === 'string' ? location : resolved;
346
+ if (visited.has(key))
347
+ return;
348
+ visited.add(key);
349
+ for (const [key, propNode] of Object.entries(resolved.properties ?? {})) {
350
+ record(key, propNode, own);
351
+ }
352
+ for (const branch of [resolved.if, resolved.then, resolved.else]) {
353
+ if (branch)
354
+ visit(branch, false);
355
+ }
356
+ for (const branches of [resolved.allOf, resolved.anyOf, resolved.oneOf]) {
357
+ for (const branch of branches ?? [])
358
+ visit(branch, false);
359
+ }
360
+ for (const dependency of Object.values(resolved.dependentSchemas ?? {})) {
110
361
  if (dependency && typeof dependency === 'object') {
111
- merge(dependency.properties);
362
+ visit(dependency, false);
112
363
  }
113
364
  }
114
- }
115
- for (const branch of node.allOf ?? [])
116
- merge(branch.properties);
117
- for (const branch of node.oneOf ?? [])
118
- merge(branch.properties);
119
- for (const branch of node.anyOf ?? [])
120
- merge(branch.properties);
365
+ };
366
+ visit(node, true);
121
367
  return candidates;
122
368
  }
369
+ /**
370
+ * The schema to project for a candidate when no branch is active.
371
+ *
372
+ * A hidden node is still compiled, so it needs some shape, and an inactive key
373
+ * defined differently by two branches has no single right answer. The rule is
374
+ * therefore stated rather than left to traversal order: the node's own
375
+ * declaration wins, and failing that the first branch the schema declares. It
376
+ * is a stable placeholder and deliberately not a claim that one branch matters
377
+ * more, which is why `CandidateProperty` keeps the alternatives instead of
378
+ * discarding them.
379
+ *
380
+ * Last of three, and only reached when no branch has claimed the location: an
381
+ * active branch gives `walk` the reduced node, and a provisionally selected one
382
+ * gives it `composeChild`, so this is consulted for neither.
383
+ */
384
+ function candidatePrototype(candidate) {
385
+ return candidate.direct ?? candidate.alternatives[0];
386
+ }
387
+ /**
388
+ * The two statements that both apply to a child location, as one node.
389
+ *
390
+ * A selected branch beats an unselected sibling, and it does not beat the
391
+ * node's own unconditional declaration: in JSON Schema the two are conjunctive,
392
+ * so the instance has to satisfy both. Replacing in either direction is wrong,
393
+ * and visibly so from the form. Dropping the base constraint makes the form
394
+ * accept what the submission rejects. Dropping the branch's makes an unrelated
395
+ * limit appear from nowhere the moment the branch activates, when all the user
396
+ * supplied was the property that completed it.
397
+ *
398
+ * `mergeNode` is the library's own routine, the one `reduceNode` uses to fold a
399
+ * reducer's result into the node it is building, called here in that same order:
400
+ * base first, branch second, so the branch wins a keyword both sides declare.
401
+ * Matching it is the point. It makes the provisional projection *equal* the
402
+ * active projection of the same branch rather than merely resemble it, which is
403
+ * what a hand-written conjunction could not promise.
404
+ *
405
+ * Both sides are dereferenced first because `mergeNode` carries `$ref` over
406
+ * from the base, and `walk` dereferences whatever it is handed: an unresolved
407
+ * `$ref` on the result would resolve back to the base alone and discard the
408
+ * branch.
409
+ */
410
+ function composeChild(base, branch) {
411
+ if (!base || !branch)
412
+ return base ?? branch;
413
+ return mergeNode(dereference(base), dereference(branch));
414
+ }
415
+ /**
416
+ * The `oneOf` branch the current data uniquely identifies, when the evaluator
417
+ * selected none.
418
+ *
419
+ * `oneOf` selects on full validity, so a branch the data plainly identifies
420
+ * stays unselected while one of its own required properties is absent, and
421
+ * hiding it leaves the user no way to supply the property that would make it
422
+ * apply. This picks that branch so a form can expose it, and the node it
423
+ * returns is reported `provisional` rather than `active`: JSON Schema still
424
+ * says the branch does not apply, and that fact is not this function's to
425
+ * overwrite.
426
+ *
427
+ * Deliberately narrow, because a form that guesses is worse than one that shows
428
+ * nothing:
429
+ *
430
+ * - only an explicit `const` or `enum` on a property discriminates, and both
431
+ * together are conjunctive rather than alternatives
432
+ * - a discriminator has to be present in the data by own-property presence,
433
+ * never read from a `default` annotation, since a declared default is not a
434
+ * value anyone supplied
435
+ * - a key discriminates only if every branch constrains it, so branches that
436
+ * simply differ in shape do not vote
437
+ * - every present discriminator has to agree on one branch
438
+ * - zero or several surviving branches select nothing
439
+ *
440
+ * `type` is excluded: it says almost nothing about intent when every branch is
441
+ * an object. `anyOf` is excluded because several of its branches may apply at
442
+ * once, which is a different question from which one the user means.
443
+ *
444
+ * Nothing else about a branch participates. A branch stays identified when some
445
+ * other constraint of its own fails, or selection would quietly become validity
446
+ * again and the field that completes the branch would stay hidden for a second
447
+ * reason.
448
+ */
449
+ function selectProvisionalBranch(node, dataRecord) {
450
+ const branches = node.oneOf;
451
+ if (!branches || branches.length === 0 || dataRecord === undefined)
452
+ return undefined;
453
+ const resolvedBranches = branches.map(dereference);
454
+ const discriminators = [...discriminatorKeys(resolvedBranches)].filter((key) => Object.prototype.hasOwnProperty.call(dataRecord, key));
455
+ if (discriminators.length === 0)
456
+ return undefined;
457
+ const accepted = resolvedBranches.filter((branch) => discriminators.every((key) => branchAccepts(branch, key, dataRecord[key])));
458
+ return accepted.length === 1 ? accepted[0] : undefined;
459
+ }
460
+ /** Keys every branch constrains with `const` or `enum`. */
461
+ function discriminatorKeys(branches) {
462
+ const first = branches[0];
463
+ if (!first)
464
+ return new Set();
465
+ const shared = new Set(Object.keys(first.schema.properties ?? {}).filter((key) => isDiscriminator(first, key)));
466
+ for (const branch of branches.slice(1)) {
467
+ for (const key of [...shared]) {
468
+ if (!isDiscriminator(branch, key))
469
+ shared.delete(key);
470
+ }
471
+ }
472
+ return shared;
473
+ }
474
+ function discriminatorSchema(branch, key) {
475
+ const property = branch.properties?.[key];
476
+ if (!property)
477
+ return undefined;
478
+ return dereference(property).schema;
479
+ }
480
+ function isDiscriminator(branch, key) {
481
+ const schema = discriminatorSchema(branch, key);
482
+ if (!schema)
483
+ return false;
484
+ return 'const' in schema || Array.isArray(schema.enum);
485
+ }
486
+ /** `const` and `enum` are separate constraints, so a value has to satisfy both. */
487
+ function branchAccepts(branch, key, value) {
488
+ const schema = discriminatorSchema(branch, key);
489
+ if (!schema)
490
+ return false;
491
+ if ('const' in schema && !deepEqual(schema.const, value))
492
+ return false;
493
+ if (Array.isArray(schema.enum) && !schema.enum.some((member) => deepEqual(member, value))) {
494
+ return false;
495
+ }
496
+ return true;
497
+ }
498
+ function deepEqual(a, b) {
499
+ if (a === b)
500
+ return true;
501
+ if (Array.isArray(a) && Array.isArray(b)) {
502
+ return a.length === b.length && a.every((item, index) => deepEqual(item, b[index]));
503
+ }
504
+ if (isPlainObject(a) && isPlainObject(b)) {
505
+ const left = Object.keys(a);
506
+ const right = Object.keys(b);
507
+ return (left.length === right.length &&
508
+ left.every((key) => Object.prototype.hasOwnProperty.call(b, key) && deepEqual(a[key], b[key])));
509
+ }
510
+ return false;
511
+ }
512
+ function isPlainObject(value) {
513
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
514
+ }
123
515
  /**
124
516
  * Computes the currently-required property keys for an object node given its data.
125
517
  *
@@ -158,35 +550,59 @@ function computeRequiredSet(resolved, reducedSchema, dataRecord) {
158
550
  * NodeProjection a form can render, with `active` reflecting whether that branch is
159
551
  * currently selected for `data`.
160
552
  */
161
- function walk(node, pointer, data, active, nodes) {
553
+ function walk(node, pointer, data, active,
554
+ /**
555
+ * Whether an ancestor's provisionally selected branch exposes this node. A
556
+ * node is never both: `active` is what the schema says, and this is what the
557
+ * projection selected so the user can complete it.
558
+ */
559
+ provisional, nodes, diagnostics,
560
+ /**
561
+ * The schema positions that declare this location whatever the instance is,
562
+ * which `node` alone cannot supply: by the time the caller has a node to walk
563
+ * it holds the merge of them, and the merge is what loses a disagreement.
564
+ */
565
+ declaredAt) {
162
566
  const original = dereference(node);
163
567
  const originalSchema = original.schema;
164
568
  if (typeof originalSchema !== 'object' || originalSchema === null)
165
569
  return;
166
570
  let resolved = original;
167
571
  let schema = originalSchema;
168
- let type = resolveType(schema);
572
+ let type = resolveExplicitType(schema);
169
573
  // Whether this node's own active branch could be determined. Stays true for every
170
574
  // node except a typeless oneOf/anyOf wrapper whose branch could not be resolved
171
- // (see below) — that node's own existence in the projection is itself provisional,
172
- // not just its children's.
575
+ // (see below), where the node's own presence in the projection is the thing in
576
+ // doubt rather than only its children's.
173
577
  let branchResolved = true;
174
578
  // A node whose own schema carries no `type` keyword, only `oneOf`/`anyOf` branches
175
579
  // (e.g. a property schema like `{ oneOf: [{ type: 'object', ... }, ...] }`), has no
176
580
  // type until its active branch is resolved against `data`. `original` is kept
177
581
  // separately so every branch's properties can still be collected below for the
178
582
  // inactive-node contract, even though only the matching branch's schema is used here.
583
+ // Whether a branch of this node's own `oneOf`/`anyOf` was resolved against
584
+ // `data`. Recorded so an unresolved shape can be attributed correctly below:
585
+ // on this path, "no shape" means the current value matches no branch, which
586
+ // is a fact about the data rather than about the schema.
587
+ let composedAgainstData = false;
588
+ /**
589
+ * Whether a typeless wrapper's branch was picked by the discriminator rather
590
+ * than by the evaluator. The wrapper is then shown provisionally: the schema
591
+ * still applies no branch, and that is what `active` reports.
592
+ */
593
+ let wrapperIdentified = false;
179
594
  if (!type && (original.oneOf || original.anyOf)) {
595
+ composedAgainstData = true;
180
596
  const { node: branchNode } = original.reduceNode(data);
181
597
  if (branchNode) {
182
598
  resolved = branchNode;
183
599
  schema = branchNode.schema;
184
- type = resolveType(schema);
600
+ type = resolveExplicitType(schema);
185
601
  }
186
602
  else {
187
603
  // reduceNode() returned no node at all: this is oneOf's behavior when data
188
604
  // matches zero or multiple branches (anyOf instead returns a node with an
189
- // empty merged schema in that case, which resolveType() also fails to type,
605
+ // empty merged schema in that case, which carries no explicit type either,
190
606
  // but which does not reach this branch). This test suite's oneOf wrappers put
191
607
  // an object schema on every branch, so 'object' is a safe stand-in type here:
192
608
  // it lets this pointer and every candidate branch property still appear in the
@@ -195,11 +611,117 @@ function walk(node, pointer, data, active, nodes) {
195
611
  // primitives (not objects) is not handled by this fallback.
196
612
  type = 'object';
197
613
  branchResolved = false;
614
+ // The wrapper's own branch could not be resolved, so the schema applies
615
+ // none of them. It may still be identifiable: a discriminator present in
616
+ // the data picks one, and the wrapper is then exposed provisionally
617
+ // rather than not at all. Without that, the same schema behaves
618
+ // differently for having declared `type` or not, since the typed path
619
+ // reaches the selector below and this one used to stop here.
620
+ //
621
+ // Only the flag is recorded, deliberately. Assigning the branch to
622
+ // `resolved` erases the difference between what the evaluator resolved
623
+ // and what this projection selected, and the object path reads `resolved`
624
+ // as the former: it would take the branch's `required` as active
625
+ // requiredness, find `nodeActive` false because no branch applies, and
626
+ // report the field as neither required nor provisionally required.
627
+ // Left alone, that path selects the same branch through
628
+ // `selectProvisionalBranch` and attributes it to the right one of the two.
629
+ //
630
+ // Gated on this node already being exposed, for the reason the object
631
+ // path is: selection is local, exposure is the ancestor's to grant.
632
+ wrapperIdentified =
633
+ (active || provisional) &&
634
+ selectProvisionalBranch(original, typeof data === 'object' && data !== null ? data : undefined) !== undefined;
635
+ }
636
+ }
637
+ /**
638
+ * Whether the absence of a shape here is only the current value's fault.
639
+ *
640
+ * Two conditions, and both are needed. Nothing was merged in from a branch,
641
+ * which is what reducing against a value that satisfies none of them leaves
642
+ * behind; and some branch could have been rendered for a value that did
643
+ * satisfy it, so the composition is not unrenderable in itself.
644
+ *
645
+ * The first condition is what a coarser check missed. Entering the
646
+ * composition path says nothing on its own: a branch can be selected and
647
+ * still supply no shape, as `{ anyOf: [{ minLength: 1 }, { pattern: '^a' }] }`
648
+ * does for `"abc"`, where both branches match and neither describes anything
649
+ * this adapter renders, because scalar shapes are deliberately not inferred.
650
+ * That schema is genuinely unprojectable and has to be reported.
651
+ */
652
+ const isTransientBranchMiss = () => Object.keys(schema).length === 0 && compositionHasRenderableAlternative(original);
653
+ // Last resort, after the oneOf/anyOf branch above has had its chance: that
654
+ // path handles a typeless wrapper whose type only exists once a branch is
655
+ // chosen, and inferring first would take a schema carrying both `properties`
656
+ // and `oneOf` down the object path without ever resolving its branch.
657
+ if (!type) {
658
+ const shape = inferProjectionShape(schema);
659
+ if (shape.kind === 'resolved') {
660
+ type = shape.type;
661
+ }
662
+ else if (shape.kind === 'none' && composedAgainstData && isTransientBranchMiss()) {
663
+ // Deliberately silent, and only for this one case. A projection
664
+ // diagnostic describes a schema this adapter cannot turn into a shape,
665
+ // and a value that matches none of a composition's branches is not that:
666
+ // the same schema renders for a value that does match one. That is
667
+ // validation's subject, it is already reported there, and a form's data
668
+ // is in this state for most of the time someone is filling it in, so a
669
+ // diagnostic here would appear and disappear on each keystroke and train
670
+ // a caller to ignore the channel.
671
+ }
672
+ else {
673
+ // Reported rather than dropped in silence. The field cannot be drawn
674
+ // without a shape, so the caller is told which pointer was skipped and
675
+ // why, instead of finding out from a form that never collected the
676
+ // value.
677
+ diagnostics.push(shape.kind === 'ambiguous'
678
+ ? {
679
+ pointer: toPointer(pointer),
680
+ code: 'ambiguous-projection-shape',
681
+ message: `No explicit "type", and keywords from more than one type apply ` +
682
+ `(${shape.families.join(', ')}), so the shape to render is undecidable. ` +
683
+ `Declare "type" on this schema to resolve it.`,
684
+ }
685
+ : {
686
+ pointer: toPointer(pointer),
687
+ code: 'unresolved-projection-shape',
688
+ message: `No explicit "type", and no keyword that implies one, so there is no ` +
689
+ `shape to render. Declare "type" on this schema.` +
690
+ // Said only where it applies, because inferring `string` from
691
+ // an enum is the tempting wrong rule and the reason deserves
692
+ // to travel with the case rather than every message.
693
+ (schema.enum !== undefined
694
+ ? ` An "enum" alone does not imply a type, because its members may be of different types.`
695
+ : ''),
696
+ });
198
697
  }
199
698
  }
200
699
  if (!type)
201
700
  return;
701
+ // Reported at the location it is about, and after the shape gate above: a
702
+ // pointer with no node has nothing to omit an annotation from, and saying
703
+ // that its `default` is undecidable on top of saying it cannot be drawn at
704
+ // all would be the same schema reported twice.
705
+ const ambiguousDefault = disagreeingDefaults(collectUnconditionalDefaults(declaredAt));
706
+ if (ambiguousDefault) {
707
+ diagnostics.push({
708
+ pointer: toPointer(pointer),
709
+ code: 'ambiguous-default',
710
+ message: `${ambiguousDefault.length} "default" declarations apply here whatever the ` +
711
+ `instance is, and they disagree, so there is no value to report. Declare one ` +
712
+ `of them, or make them equal.`,
713
+ sources: ambiguousDefault.map((declaration) => declaration.source),
714
+ });
715
+ }
202
716
  const nodeActive = active && branchResolved;
717
+ // A node the schema applies is never also provisional; the two report
718
+ // different facts and only the second is a choice this projection made. The
719
+ // `!nodeActive` term is belt and braces: `provisional` only ever arrives true
720
+ // from a parent that computed `!childActive`, so no caller can reach here
721
+ // with both, and dropping the term changes no observable behaviour.
722
+ const nodeProvisional = !nodeActive && (provisional || wrapperIdentified) && (branchResolved || wrapperIdentified);
723
+ // Whether a form shows this node at all, which is what a descendant inherits.
724
+ const nodeExposed = nodeActive || nodeProvisional;
203
725
  if (type === 'object') {
204
726
  const dataRecord = typeof data === 'object' && data !== null ? data : undefined;
205
727
  // reduceNode() resolves if/then/else, dependentSchemas (which also covers
@@ -222,17 +744,45 @@ function walk(node, pointer, data, active, nodes) {
222
744
  {};
223
745
  const activeKeys = new Set(Object.keys(reducedProperties));
224
746
  const requiredSet = computeRequiredSet(resolved, reducedSchema, dataRecord);
747
+ // Two conditions, and they are not the same kind of condition.
748
+ //
749
+ // Exposure is load-bearing. Selection is local, but whether this node is
750
+ // shown at all is its ancestors' to decide, and a subtree nothing exposes
751
+ // must not select a branch off data it happens to retain: the compiler
752
+ // collapses `required || provisionalRequired` into the one flag a binding
753
+ // reads, so a hidden field would acquire a requirement.
754
+ //
755
+ // The evaluator having selected nothing is where provisional selection
756
+ // exists to help, and that half is unobservable rather than wrong: a
757
+ // resolved branch is fully valid, so it accepts every present discriminator
758
+ // and the selector would return that same branch, whose keys are already
759
+ // the active set. Kept because doing the work is pointless and the
760
+ // condition states the intent.
761
+ const provisionalBranch = nodeExposed && reducedNode === undefined
762
+ ? selectProvisionalBranch(original, dataRecord)
763
+ : undefined;
764
+ const provisionalSchema = provisionalBranch?.schema;
765
+ const provisionalKeys = new Set(Object.keys(provisionalSchema?.properties ?? {}));
766
+ const provisionalRequired = new Set(Array.isArray(provisionalSchema?.required) ? provisionalSchema.required : []);
225
767
  // Candidates are collected from `original`, not `resolved`, so every oneOf/anyOf
226
768
  // branch's properties are represented (the matching branch alone, via `resolved`,
227
769
  // would only expose its own properties).
228
770
  const candidateProps = collectCandidateProperties(original);
229
- const propKeys = Object.keys(candidateProps);
771
+ const propKeys = [...candidateProps.keys()];
230
772
  const children = propKeys.length > 0
231
- ? propKeys.map((key) => ({
232
- pointer: toPointer(`${pointer}/${escapeSegment(key)}`),
233
- key,
234
- required: requiredSet.has(key),
235
- }))
773
+ ? propKeys.map((key) => {
774
+ // Gated on the node applying at all. A branch that does not apply
775
+ // demands nothing, so reporting its `required` array as a
776
+ // requirement would attribute to the validator something it is not
777
+ // asking for.
778
+ const required = nodeActive && requiredSet.has(key);
779
+ return {
780
+ pointer: toPointer(`${pointer}/${escapeSegment(key)}`),
781
+ key,
782
+ required,
783
+ provisionalRequired: !required && provisionalRequired.has(key) ? true : undefined,
784
+ };
785
+ })
236
786
  : undefined;
237
787
  nodes.set(toPointer(pointer), {
238
788
  type,
@@ -241,14 +791,33 @@ function walk(node, pointer, data, active, nodes) {
241
791
  children,
242
792
  enumValues: extractEnumValues(schema),
243
793
  active: nodeActive,
244
- annotations: extractAnnotations(schema),
794
+ provisional: nodeProvisional ? true : undefined,
795
+ annotations: extractAnnotations(schema, ambiguousDefault !== undefined),
245
796
  });
246
797
  for (const key of propKeys) {
247
798
  const childPointer = `${pointer}/${escapeSegment(key)}`;
248
799
  const childActive = nodeActive && activeKeys.has(key);
800
+ // A descendant inherits exposure, not activity: under a provisionally
801
+ // selected ancestor its own locally applicable properties are
802
+ // provisional too, while a locally inactive one stays inactive.
803
+ const childProvisional = !childActive && nodeExposed && (activeKeys.has(key) || provisionalKeys.has(key));
249
804
  const reducedChildNode = reducedNode?.properties?.[key];
250
- const childNode = reducedChildNode ?? candidateProps[key];
251
- walk(childNode, childPointer, dataRecord?.[key], childActive, nodes);
805
+ // Precedence matters as much as the selection does. Marking the right
806
+ // branch provisional while taking its shape from `candidatePrototype`,
807
+ // which is first-wins across branches, would render another branch's
808
+ // widget and annotations under the selected branch's name, and would hand
809
+ // ADR-003's pass another branch's `default`.
810
+ const provisionalChildNode = provisionalBranch?.properties?.[key];
811
+ const candidate = candidateProps.get(key);
812
+ // Three states, in order of authority. What the evaluator reduced against
813
+ // the data is the schema as it actually applies. Failing that, the node's
814
+ // own declaration composed with the selected branch, which is what the
815
+ // reduction would have produced had the branch been complete. Failing
816
+ // both, a stated placeholder for a location no branch has claimed.
817
+ const childNode = reducedChildNode ??
818
+ composeChild(candidate.direct, provisionalChildNode) ??
819
+ candidatePrototype(candidate);
820
+ walk(childNode, childPointer, dataRecord?.[key], childActive, childProvisional, nodes, diagnostics, unconditionalChildren([original], key));
252
821
  }
253
822
  return;
254
823
  }
@@ -259,14 +828,22 @@ function walk(node, pointer, data, active, nodes) {
259
828
  children: undefined,
260
829
  enumValues: extractEnumValues(schema),
261
830
  active: nodeActive,
262
- annotations: extractAnnotations(schema),
831
+ provisional: nodeProvisional ? true : undefined,
832
+ annotations: extractAnnotations(schema, ambiguousDefault !== undefined),
833
+ // Deliberately not given the flag above. A conflict under `items` belongs
834
+ // to the element locations, and each of those reports its own; this field
835
+ // describes the item schema rather than being one of those locations, so
836
+ // omitting its `default` would be an omission no pointer could report. It
837
+ // is also the only annotation set nothing reads a `default` from, and the
838
+ // only one the other adapter does not produce at all.
263
839
  itemAnnotations: type === 'array' && resolved.items
264
840
  ? extractAnnotations(dereference(resolved.items).schema)
265
841
  : undefined,
266
842
  });
267
843
  if (type === 'array' && resolved.items && Array.isArray(data)) {
844
+ const itemPositions = unconditionalItems([original]);
268
845
  data.forEach((item, index) => {
269
- walk(resolved.items, `${pointer}/${index}`, item, nodeActive, nodes);
846
+ walk(resolved.items, `${pointer}/${index}`, item, nodeActive, nodeProvisional, nodes, diagnostics, itemPositions);
270
847
  });
271
848
  }
272
849
  }
@@ -282,7 +859,8 @@ function walk(node, pointer, data, active, nodes) {
282
859
  */
283
860
  export function buildProjection(root, data) {
284
861
  const nodes = new Map();
285
- walk(root, '', data, true, nodes);
286
- return { nodes };
862
+ const diagnostics = [];
863
+ walk(root, '', data, true, false, nodes, diagnostics, [root]);
864
+ return { nodes, diagnostics };
287
865
  }
288
866
  //# sourceMappingURL=projection.js.map