@texaryn/schema-json 0.3.0 → 0.4.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')
@@ -81,45 +173,244 @@ function dereference(node) {
81
173
  return node.$ref ? node.resolveRef() : node;
82
174
  }
83
175
  /**
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.
176
+ * Every property key that could appear at this node's own instance location,
177
+ * across every branch that might apply to it.
178
+ *
179
+ * This is the static half of the inactive-node contract: a branch the data does
180
+ * not currently select still contributes its property pointers, which then
181
+ * project with `active: false`. The dynamic half is `reduceNode`, which decides
182
+ * which of them apply now, and the two must stay separate. Making the candidate
183
+ * set data-driven would delete a pointer the moment its branch stopped
184
+ * matching, which is exactly the flicker the contract exists to prevent.
185
+ *
186
+ * **Recursion is through applicators only, and only those acting on this same
187
+ * instance location**: `if`, `then`, `else`, `allOf`, `anyOf`, `oneOf`,
188
+ * `dependentSchemas`, and whatever a `$ref` resolves to. draft-07
189
+ * `dependencies` needs no separate handling, because json-schema-library
190
+ * normalises it into `dependentSchemas` at parse time.
191
+ *
192
+ * Three things are deliberately not followed:
89
193
  *
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`.
194
+ * - **`not`**, because a subschema inside it describes a shape the instance
195
+ * must *not* satisfy. Collecting its properties as candidates would invert
196
+ * its meaning.
197
+ * - **the values of `properties`**, because those describe a child location,
198
+ * not this one. `owner` is collected; the walk descends into `/owner`
199
+ * separately and collects `name` there. Following it here would make `name` a
200
+ * sibling of `owner`.
201
+ * - **`items` and `prefixItems`**, for the same reason: an array's items are
202
+ * their own locations.
203
+ *
204
+ * The cycle guard keys on `schemaLocation` rather than on node identity, which
205
+ * was measured rather than assumed and is the opposite of what it looks like it
206
+ * should be: `resolveRef()` returns a fresh `SchemaNode` on every call, and the
207
+ * raw `schema` object it wraps is fresh too, so an identity `Set` never matches
208
+ * and a recursive `$ref` would not terminate. `schemaLocation` is stable, is
209
+ * distinct for each inline branch position, and repeats when a reference closes
210
+ * a cycle, which is precisely the three properties needed. The set is per call,
211
+ * because deduplicating a schema location is only sound while collecting names
212
+ * for one instance location; the same schema legitimately recurs at a deeper
213
+ * pointer, and `walk` visits it again there.
93
214
  */
94
215
  function collectCandidateProperties(node) {
95
- const candidates = {};
96
- const merge = (props) => {
97
- if (!props)
216
+ const candidates = new Map();
217
+ const visited = new Set();
218
+ const record = (key, propNode, direct) => {
219
+ const entry = candidates.get(key) ?? { alternatives: [] };
220
+ if (direct)
221
+ entry.direct ??= propNode;
222
+ else
223
+ entry.alternatives.push(propNode);
224
+ candidates.set(key, entry);
225
+ };
226
+ const visit = (current, own) => {
227
+ const resolved = dereference(current);
228
+ // Keyed on `schemaLocation` where there is one, and on the node itself
229
+ // where there is not, which together cover every way the traversal can
230
+ // come back to where it started.
231
+ //
232
+ // Neither alone is sufficient and each covers what the other cannot. A
233
+ // cycle requires a `$ref`, because an inline schema cannot nest into
234
+ // itself, and `resolveRef()` returns a fresh `SchemaNode` on every call
235
+ // whose raw `schema` object is fresh too, so identity never matches across
236
+ // one; those nodes do carry a location. An inline branch is never resolved
237
+ // through a ref, so its identity is stable within one traversal.
238
+ //
239
+ // This deliberately replaced a depth cap. A cap terminates, but it does so
240
+ // by dropping candidates a valid schema declared, which is the silent
241
+ // disappearance the whole projection-diagnostic design exists to prevent:
242
+ // a field nested under 70 applicators is still a field.
243
+ const location = resolved.schemaLocation;
244
+ const key = typeof location === 'string' ? location : resolved;
245
+ if (visited.has(key))
98
246
  return;
99
- for (const [key, propNode] of Object.entries(props)) {
100
- if (!(key in candidates))
101
- candidates[key] = propNode;
247
+ visited.add(key);
248
+ for (const [key, propNode] of Object.entries(resolved.properties ?? {})) {
249
+ record(key, propNode, own);
102
250
  }
103
- };
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)) {
251
+ for (const branch of [resolved.if, resolved.then, resolved.else]) {
252
+ if (branch)
253
+ visit(branch, false);
254
+ }
255
+ for (const branches of [resolved.allOf, resolved.anyOf, resolved.oneOf]) {
256
+ for (const branch of branches ?? [])
257
+ visit(branch, false);
258
+ }
259
+ for (const dependency of Object.values(resolved.dependentSchemas ?? {})) {
110
260
  if (dependency && typeof dependency === 'object') {
111
- merge(dependency.properties);
261
+ visit(dependency, false);
112
262
  }
113
263
  }
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);
264
+ };
265
+ visit(node, true);
121
266
  return candidates;
122
267
  }
268
+ /**
269
+ * The schema to project for a candidate when no branch is active.
270
+ *
271
+ * A hidden node is still compiled, so it needs some shape, and an inactive key
272
+ * defined differently by two branches has no single right answer. The rule is
273
+ * therefore stated rather than left to traversal order: the node's own
274
+ * declaration wins, and failing that the first branch the schema declares. It
275
+ * is a stable placeholder and deliberately not a claim that one branch matters
276
+ * more, which is why `CandidateProperty` keeps the alternatives instead of
277
+ * discarding them.
278
+ *
279
+ * Last of three, and only reached when no branch has claimed the location: an
280
+ * active branch gives `walk` the reduced node, and a provisionally selected one
281
+ * gives it `composeChild`, so this is consulted for neither.
282
+ */
283
+ function candidatePrototype(candidate) {
284
+ return candidate.direct ?? candidate.alternatives[0];
285
+ }
286
+ /**
287
+ * The two statements that both apply to a child location, as one node.
288
+ *
289
+ * A selected branch beats an unselected sibling, and it does not beat the
290
+ * node's own unconditional declaration: in JSON Schema the two are conjunctive,
291
+ * so the instance has to satisfy both. Replacing in either direction is wrong,
292
+ * and visibly so from the form. Dropping the base constraint makes the form
293
+ * accept what the submission rejects. Dropping the branch's makes an unrelated
294
+ * limit appear from nowhere the moment the branch activates, when all the user
295
+ * supplied was the property that completed it.
296
+ *
297
+ * `mergeNode` is the library's own routine, the one `reduceNode` uses to fold a
298
+ * reducer's result into the node it is building, called here in that same order:
299
+ * base first, branch second, so the branch wins a keyword both sides declare.
300
+ * Matching it is the point. It makes the provisional projection *equal* the
301
+ * active projection of the same branch rather than merely resemble it, which is
302
+ * what a hand-written conjunction could not promise.
303
+ *
304
+ * Both sides are dereferenced first because `mergeNode` carries `$ref` over
305
+ * from the base, and `walk` dereferences whatever it is handed: an unresolved
306
+ * `$ref` on the result would resolve back to the base alone and discard the
307
+ * branch.
308
+ */
309
+ function composeChild(base, branch) {
310
+ if (!base || !branch)
311
+ return base ?? branch;
312
+ return mergeNode(dereference(base), dereference(branch));
313
+ }
314
+ /**
315
+ * The `oneOf` branch the current data uniquely identifies, when the evaluator
316
+ * selected none.
317
+ *
318
+ * `oneOf` selects on full validity, so a branch the data plainly identifies
319
+ * stays unselected while one of its own required properties is absent, and
320
+ * hiding it leaves the user no way to supply the property that would make it
321
+ * apply. This picks that branch so a form can expose it, and the node it
322
+ * returns is reported `provisional` rather than `active`: JSON Schema still
323
+ * says the branch does not apply, and that fact is not this function's to
324
+ * overwrite.
325
+ *
326
+ * Deliberately narrow, because a form that guesses is worse than one that shows
327
+ * nothing:
328
+ *
329
+ * - only an explicit `const` or `enum` on a property discriminates, and both
330
+ * together are conjunctive rather than alternatives
331
+ * - a discriminator has to be present in the data by own-property presence,
332
+ * never read from a `default` annotation, since a declared default is not a
333
+ * value anyone supplied
334
+ * - a key discriminates only if every branch constrains it, so branches that
335
+ * simply differ in shape do not vote
336
+ * - every present discriminator has to agree on one branch
337
+ * - zero or several surviving branches select nothing
338
+ *
339
+ * `type` is excluded: it says almost nothing about intent when every branch is
340
+ * an object. `anyOf` is excluded because several of its branches may apply at
341
+ * once, which is a different question from which one the user means.
342
+ *
343
+ * Nothing else about a branch participates. A branch stays identified when some
344
+ * other constraint of its own fails, or selection would quietly become validity
345
+ * again and the field that completes the branch would stay hidden for a second
346
+ * reason.
347
+ */
348
+ function selectProvisionalBranch(node, dataRecord) {
349
+ const branches = node.oneOf;
350
+ if (!branches || branches.length === 0 || dataRecord === undefined)
351
+ return undefined;
352
+ const resolvedBranches = branches.map(dereference);
353
+ const discriminators = [...discriminatorKeys(resolvedBranches)].filter((key) => Object.prototype.hasOwnProperty.call(dataRecord, key));
354
+ if (discriminators.length === 0)
355
+ return undefined;
356
+ const accepted = resolvedBranches.filter((branch) => discriminators.every((key) => branchAccepts(branch, key, dataRecord[key])));
357
+ return accepted.length === 1 ? accepted[0] : undefined;
358
+ }
359
+ /** Keys every branch constrains with `const` or `enum`. */
360
+ function discriminatorKeys(branches) {
361
+ const first = branches[0];
362
+ if (!first)
363
+ return new Set();
364
+ const shared = new Set(Object.keys(first.schema.properties ?? {}).filter((key) => isDiscriminator(first, key)));
365
+ for (const branch of branches.slice(1)) {
366
+ for (const key of [...shared]) {
367
+ if (!isDiscriminator(branch, key))
368
+ shared.delete(key);
369
+ }
370
+ }
371
+ return shared;
372
+ }
373
+ function discriminatorSchema(branch, key) {
374
+ const property = branch.properties?.[key];
375
+ if (!property)
376
+ return undefined;
377
+ return dereference(property).schema;
378
+ }
379
+ function isDiscriminator(branch, key) {
380
+ const schema = discriminatorSchema(branch, key);
381
+ if (!schema)
382
+ return false;
383
+ return 'const' in schema || Array.isArray(schema.enum);
384
+ }
385
+ /** `const` and `enum` are separate constraints, so a value has to satisfy both. */
386
+ function branchAccepts(branch, key, value) {
387
+ const schema = discriminatorSchema(branch, key);
388
+ if (!schema)
389
+ return false;
390
+ if ('const' in schema && !deepEqual(schema.const, value))
391
+ return false;
392
+ if (Array.isArray(schema.enum) && !schema.enum.some((member) => deepEqual(member, value))) {
393
+ return false;
394
+ }
395
+ return true;
396
+ }
397
+ function deepEqual(a, b) {
398
+ if (a === b)
399
+ return true;
400
+ if (Array.isArray(a) && Array.isArray(b)) {
401
+ return a.length === b.length && a.every((item, index) => deepEqual(item, b[index]));
402
+ }
403
+ if (isPlainObject(a) && isPlainObject(b)) {
404
+ const left = Object.keys(a);
405
+ const right = Object.keys(b);
406
+ return (left.length === right.length &&
407
+ left.every((key) => Object.prototype.hasOwnProperty.call(b, key) && deepEqual(a[key], b[key])));
408
+ }
409
+ return false;
410
+ }
411
+ function isPlainObject(value) {
412
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
413
+ }
123
414
  /**
124
415
  * Computes the currently-required property keys for an object node given its data.
125
416
  *
@@ -158,35 +449,53 @@ function computeRequiredSet(resolved, reducedSchema, dataRecord) {
158
449
  * NodeProjection a form can render, with `active` reflecting whether that branch is
159
450
  * currently selected for `data`.
160
451
  */
161
- function walk(node, pointer, data, active, nodes) {
452
+ function walk(node, pointer, data, active,
453
+ /**
454
+ * Whether an ancestor's provisionally selected branch exposes this node. A
455
+ * node is never both: `active` is what the schema says, and this is what the
456
+ * projection selected so the user can complete it.
457
+ */
458
+ provisional, nodes, diagnostics) {
162
459
  const original = dereference(node);
163
460
  const originalSchema = original.schema;
164
461
  if (typeof originalSchema !== 'object' || originalSchema === null)
165
462
  return;
166
463
  let resolved = original;
167
464
  let schema = originalSchema;
168
- let type = resolveType(schema);
465
+ let type = resolveExplicitType(schema);
169
466
  // Whether this node's own active branch could be determined. Stays true for every
170
467
  // 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.
468
+ // (see below), where the node's own presence in the projection is the thing in
469
+ // doubt rather than only its children's.
173
470
  let branchResolved = true;
174
471
  // A node whose own schema carries no `type` keyword, only `oneOf`/`anyOf` branches
175
472
  // (e.g. a property schema like `{ oneOf: [{ type: 'object', ... }, ...] }`), has no
176
473
  // type until its active branch is resolved against `data`. `original` is kept
177
474
  // separately so every branch's properties can still be collected below for the
178
475
  // inactive-node contract, even though only the matching branch's schema is used here.
476
+ // Whether a branch of this node's own `oneOf`/`anyOf` was resolved against
477
+ // `data`. Recorded so an unresolved shape can be attributed correctly below:
478
+ // on this path, "no shape" means the current value matches no branch, which
479
+ // is a fact about the data rather than about the schema.
480
+ let composedAgainstData = false;
481
+ /**
482
+ * Whether a typeless wrapper's branch was picked by the discriminator rather
483
+ * than by the evaluator. The wrapper is then shown provisionally: the schema
484
+ * still applies no branch, and that is what `active` reports.
485
+ */
486
+ let wrapperIdentified = false;
179
487
  if (!type && (original.oneOf || original.anyOf)) {
488
+ composedAgainstData = true;
180
489
  const { node: branchNode } = original.reduceNode(data);
181
490
  if (branchNode) {
182
491
  resolved = branchNode;
183
492
  schema = branchNode.schema;
184
- type = resolveType(schema);
493
+ type = resolveExplicitType(schema);
185
494
  }
186
495
  else {
187
496
  // reduceNode() returned no node at all: this is oneOf's behavior when data
188
497
  // 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,
498
+ // empty merged schema in that case, which carries no explicit type either,
190
499
  // but which does not reach this branch). This test suite's oneOf wrappers put
191
500
  // an object schema on every branch, so 'object' is a safe stand-in type here:
192
501
  // it lets this pointer and every candidate branch property still appear in the
@@ -195,11 +504,102 @@ function walk(node, pointer, data, active, nodes) {
195
504
  // primitives (not objects) is not handled by this fallback.
196
505
  type = 'object';
197
506
  branchResolved = false;
507
+ // The wrapper's own branch could not be resolved, so the schema applies
508
+ // none of them. It may still be identifiable: a discriminator present in
509
+ // the data picks one, and the wrapper is then exposed provisionally
510
+ // rather than not at all. Without that, the same schema behaves
511
+ // differently for having declared `type` or not, since the typed path
512
+ // reaches the selector below and this one used to stop here.
513
+ //
514
+ // Only the flag is recorded, deliberately. Assigning the branch to
515
+ // `resolved` erases the difference between what the evaluator resolved
516
+ // and what this projection selected, and the object path reads `resolved`
517
+ // as the former: it would take the branch's `required` as active
518
+ // requiredness, find `nodeActive` false because no branch applies, and
519
+ // report the field as neither required nor provisionally required.
520
+ // Left alone, that path selects the same branch through
521
+ // `selectProvisionalBranch` and attributes it to the right one of the two.
522
+ //
523
+ // Gated on this node already being exposed, for the reason the object
524
+ // path is: selection is local, exposure is the ancestor's to grant.
525
+ wrapperIdentified =
526
+ (active || provisional) &&
527
+ selectProvisionalBranch(original, typeof data === 'object' && data !== null ? data : undefined) !== undefined;
528
+ }
529
+ }
530
+ /**
531
+ * Whether the absence of a shape here is only the current value's fault.
532
+ *
533
+ * Two conditions, and both are needed. Nothing was merged in from a branch,
534
+ * which is what reducing against a value that satisfies none of them leaves
535
+ * behind; and some branch could have been rendered for a value that did
536
+ * satisfy it, so the composition is not unrenderable in itself.
537
+ *
538
+ * The first condition is what a coarser check missed. Entering the
539
+ * composition path says nothing on its own: a branch can be selected and
540
+ * still supply no shape, as `{ anyOf: [{ minLength: 1 }, { pattern: '^a' }] }`
541
+ * does for `"abc"`, where both branches match and neither describes anything
542
+ * this adapter renders, because scalar shapes are deliberately not inferred.
543
+ * That schema is genuinely unprojectable and has to be reported.
544
+ */
545
+ const isTransientBranchMiss = () => Object.keys(schema).length === 0 && compositionHasRenderableAlternative(original);
546
+ // Last resort, after the oneOf/anyOf branch above has had its chance: that
547
+ // path handles a typeless wrapper whose type only exists once a branch is
548
+ // chosen, and inferring first would take a schema carrying both `properties`
549
+ // and `oneOf` down the object path without ever resolving its branch.
550
+ if (!type) {
551
+ const shape = inferProjectionShape(schema);
552
+ if (shape.kind === 'resolved') {
553
+ type = shape.type;
554
+ }
555
+ else if (shape.kind === 'none' && composedAgainstData && isTransientBranchMiss()) {
556
+ // Deliberately silent, and only for this one case. A projection
557
+ // diagnostic describes a schema this adapter cannot turn into a shape,
558
+ // and a value that matches none of a composition's branches is not that:
559
+ // the same schema renders for a value that does match one. That is
560
+ // validation's subject, it is already reported there, and a form's data
561
+ // is in this state for most of the time someone is filling it in, so a
562
+ // diagnostic here would appear and disappear on each keystroke and train
563
+ // a caller to ignore the channel.
564
+ }
565
+ else {
566
+ // Reported rather than dropped in silence. The field cannot be drawn
567
+ // without a shape, so the caller is told which pointer was skipped and
568
+ // why, instead of finding out from a form that never collected the
569
+ // value.
570
+ diagnostics.push(shape.kind === 'ambiguous'
571
+ ? {
572
+ pointer: toPointer(pointer),
573
+ code: 'ambiguous-projection-shape',
574
+ message: `No explicit "type", and keywords from more than one type apply ` +
575
+ `(${shape.families.join(', ')}), so the shape to render is undecidable. ` +
576
+ `Declare "type" on this schema to resolve it.`,
577
+ }
578
+ : {
579
+ pointer: toPointer(pointer),
580
+ code: 'unresolved-projection-shape',
581
+ message: `No explicit "type", and no keyword that implies one, so there is no ` +
582
+ `shape to render. Declare "type" on this schema.` +
583
+ // Said only where it applies, because inferring `string` from
584
+ // an enum is the tempting wrong rule and the reason deserves
585
+ // to travel with the case rather than every message.
586
+ (schema.enum !== undefined
587
+ ? ` An "enum" alone does not imply a type, because its members may be of different types.`
588
+ : ''),
589
+ });
198
590
  }
199
591
  }
200
592
  if (!type)
201
593
  return;
202
594
  const nodeActive = active && branchResolved;
595
+ // A node the schema applies is never also provisional; the two report
596
+ // different facts and only the second is a choice this projection made. The
597
+ // `!nodeActive` term is belt and braces: `provisional` only ever arrives true
598
+ // from a parent that computed `!childActive`, so no caller can reach here
599
+ // with both, and dropping the term changes no observable behaviour.
600
+ const nodeProvisional = !nodeActive && (provisional || wrapperIdentified) && (branchResolved || wrapperIdentified);
601
+ // Whether a form shows this node at all, which is what a descendant inherits.
602
+ const nodeExposed = nodeActive || nodeProvisional;
203
603
  if (type === 'object') {
204
604
  const dataRecord = typeof data === 'object' && data !== null ? data : undefined;
205
605
  // reduceNode() resolves if/then/else, dependentSchemas (which also covers
@@ -222,17 +622,45 @@ function walk(node, pointer, data, active, nodes) {
222
622
  {};
223
623
  const activeKeys = new Set(Object.keys(reducedProperties));
224
624
  const requiredSet = computeRequiredSet(resolved, reducedSchema, dataRecord);
625
+ // Two conditions, and they are not the same kind of condition.
626
+ //
627
+ // Exposure is load-bearing. Selection is local, but whether this node is
628
+ // shown at all is its ancestors' to decide, and a subtree nothing exposes
629
+ // must not select a branch off data it happens to retain: the compiler
630
+ // collapses `required || provisionalRequired` into the one flag a binding
631
+ // reads, so a hidden field would acquire a requirement.
632
+ //
633
+ // The evaluator having selected nothing is where provisional selection
634
+ // exists to help, and that half is unobservable rather than wrong: a
635
+ // resolved branch is fully valid, so it accepts every present discriminator
636
+ // and the selector would return that same branch, whose keys are already
637
+ // the active set. Kept because doing the work is pointless and the
638
+ // condition states the intent.
639
+ const provisionalBranch = nodeExposed && reducedNode === undefined
640
+ ? selectProvisionalBranch(original, dataRecord)
641
+ : undefined;
642
+ const provisionalSchema = provisionalBranch?.schema;
643
+ const provisionalKeys = new Set(Object.keys(provisionalSchema?.properties ?? {}));
644
+ const provisionalRequired = new Set(Array.isArray(provisionalSchema?.required) ? provisionalSchema.required : []);
225
645
  // Candidates are collected from `original`, not `resolved`, so every oneOf/anyOf
226
646
  // branch's properties are represented (the matching branch alone, via `resolved`,
227
647
  // would only expose its own properties).
228
648
  const candidateProps = collectCandidateProperties(original);
229
- const propKeys = Object.keys(candidateProps);
649
+ const propKeys = [...candidateProps.keys()];
230
650
  const children = propKeys.length > 0
231
- ? propKeys.map((key) => ({
232
- pointer: toPointer(`${pointer}/${escapeSegment(key)}`),
233
- key,
234
- required: requiredSet.has(key),
235
- }))
651
+ ? propKeys.map((key) => {
652
+ // Gated on the node applying at all. A branch that does not apply
653
+ // demands nothing, so reporting its `required` array as a
654
+ // requirement would attribute to the validator something it is not
655
+ // asking for.
656
+ const required = nodeActive && requiredSet.has(key);
657
+ return {
658
+ pointer: toPointer(`${pointer}/${escapeSegment(key)}`),
659
+ key,
660
+ required,
661
+ provisionalRequired: !required && provisionalRequired.has(key) ? true : undefined,
662
+ };
663
+ })
236
664
  : undefined;
237
665
  nodes.set(toPointer(pointer), {
238
666
  type,
@@ -241,14 +669,33 @@ function walk(node, pointer, data, active, nodes) {
241
669
  children,
242
670
  enumValues: extractEnumValues(schema),
243
671
  active: nodeActive,
672
+ provisional: nodeProvisional ? true : undefined,
244
673
  annotations: extractAnnotations(schema),
245
674
  });
246
675
  for (const key of propKeys) {
247
676
  const childPointer = `${pointer}/${escapeSegment(key)}`;
248
677
  const childActive = nodeActive && activeKeys.has(key);
678
+ // A descendant inherits exposure, not activity: under a provisionally
679
+ // selected ancestor its own locally applicable properties are
680
+ // provisional too, while a locally inactive one stays inactive.
681
+ const childProvisional = !childActive && nodeExposed && (activeKeys.has(key) || provisionalKeys.has(key));
249
682
  const reducedChildNode = reducedNode?.properties?.[key];
250
- const childNode = reducedChildNode ?? candidateProps[key];
251
- walk(childNode, childPointer, dataRecord?.[key], childActive, nodes);
683
+ // Precedence matters as much as the selection does. Marking the right
684
+ // branch provisional while taking its shape from `candidatePrototype`,
685
+ // which is first-wins across branches, would render another branch's
686
+ // widget and annotations under the selected branch's name, and would hand
687
+ // ADR-003's pass another branch's `default`.
688
+ const provisionalChildNode = provisionalBranch?.properties?.[key];
689
+ const candidate = candidateProps.get(key);
690
+ // Three states, in order of authority. What the evaluator reduced against
691
+ // the data is the schema as it actually applies. Failing that, the node's
692
+ // own declaration composed with the selected branch, which is what the
693
+ // reduction would have produced had the branch been complete. Failing
694
+ // both, a stated placeholder for a location no branch has claimed.
695
+ const childNode = reducedChildNode ??
696
+ composeChild(candidate.direct, provisionalChildNode) ??
697
+ candidatePrototype(candidate);
698
+ walk(childNode, childPointer, dataRecord?.[key], childActive, childProvisional, nodes, diagnostics);
252
699
  }
253
700
  return;
254
701
  }
@@ -259,6 +706,7 @@ function walk(node, pointer, data, active, nodes) {
259
706
  children: undefined,
260
707
  enumValues: extractEnumValues(schema),
261
708
  active: nodeActive,
709
+ provisional: nodeProvisional ? true : undefined,
262
710
  annotations: extractAnnotations(schema),
263
711
  itemAnnotations: type === 'array' && resolved.items
264
712
  ? extractAnnotations(dereference(resolved.items).schema)
@@ -266,7 +714,7 @@ function walk(node, pointer, data, active, nodes) {
266
714
  });
267
715
  if (type === 'array' && resolved.items && Array.isArray(data)) {
268
716
  data.forEach((item, index) => {
269
- walk(resolved.items, `${pointer}/${index}`, item, nodeActive, nodes);
717
+ walk(resolved.items, `${pointer}/${index}`, item, nodeActive, nodeProvisional, nodes, diagnostics);
270
718
  });
271
719
  }
272
720
  }
@@ -282,7 +730,8 @@ function walk(node, pointer, data, active, nodes) {
282
730
  */
283
731
  export function buildProjection(root, data) {
284
732
  const nodes = new Map();
285
- walk(root, '', data, true, nodes);
286
- return { nodes };
733
+ const diagnostics = [];
734
+ walk(root, '', data, true, false, nodes, diagnostics);
735
+ return { nodes, diagnostics };
287
736
  }
288
737
  //# sourceMappingURL=projection.js.map