@jarenjs/emit 0.34.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.
- package/ARCHITECTURE.md +97 -0
- package/README.md +300 -0
- package/dist/types/cli.d.ts +2 -0
- package/dist/types/index.d.ts +3 -0
- package/dist/types/markdown.d.ts +102 -0
- package/dist/types/model.d.ts +170 -0
- package/dist/types/typescript.d.ts +156 -0
- package/docs/EMIT-FORMAT.md +273 -0
- package/package.json +75 -0
- package/schemas/jaren-emit-model.schema.json +171 -0
- package/src/cli.js +191 -0
- package/src/index.js +21 -0
- package/src/markdown.js +105 -0
- package/src/model.js +993 -0
- package/src/typescript.js +276 -0
package/src/model.js
ADDED
|
@@ -0,0 +1,993 @@
|
|
|
1
|
+
//#region the type model
|
|
2
|
+
// Stage one of `@jarenjs/emit`: a JSON Schema graph in, a TYPE MODEL out.
|
|
3
|
+
//
|
|
4
|
+
// A schema graph is not shaped like a declaration file, and no template can
|
|
5
|
+
// fix that. `$ref`s point sideways and in cycles, subschemas nest anonymously,
|
|
6
|
+
// composition keywords mean intersection or union depending on which one they
|
|
7
|
+
// are, and half the vocabulary (`pattern`, `multipleOf`, `format`) has no
|
|
8
|
+
// type-level meaning at all. This module does that flattening once, into a
|
|
9
|
+
// plain JSON document that a stylesheet can walk top to bottom.
|
|
10
|
+
//
|
|
11
|
+
// Two rules govern every decision here:
|
|
12
|
+
//
|
|
13
|
+
// 1. **Widen honestly, never pretend.** A constraint with no type-level
|
|
14
|
+
// equivalent does not disappear — it is recorded on the member as a
|
|
15
|
+
// dropped constraint so the emitter can carry it into a doc comment. A
|
|
16
|
+
// reader of the generated file learns that `pattern` exists and is not
|
|
17
|
+
// enforced by the type; silently emitting `string` would be a lie of
|
|
18
|
+
// omission. The same rule has a directional half: the generated type may
|
|
19
|
+
// be WIDER than the schema (and says where), but never narrower — a type
|
|
20
|
+
// that rejects a document the validator accepts is the one defect class
|
|
21
|
+
// this package must not have.
|
|
22
|
+
// 2. **Deterministic output.** Same input, byte-identical model. Members
|
|
23
|
+
// keep schema declaration order, declarations keep discovery order, and
|
|
24
|
+
// nothing iterates a Set or a Map whose order depends on insertion
|
|
25
|
+
// history across merges.
|
|
26
|
+
//
|
|
27
|
+
// The model is a published format (schemas/jaren-emit-model.schema.json), not
|
|
28
|
+
// a private intermediate: a third-party stylesheet targets it, and `emit`'s
|
|
29
|
+
// own TypeScript and Markdown emitters have no privileged access.
|
|
30
|
+
|
|
31
|
+
import { isJsonObject } from '@jarenjs/core/object';
|
|
32
|
+
import {
|
|
33
|
+
NUMERIC_CONSTRAINTS, STRING_CONSTRAINTS,
|
|
34
|
+
ARRAY_CONSTRAINTS, OBJECT_CONSTRAINTS,
|
|
35
|
+
} from '@jarenjs/core/schema';
|
|
36
|
+
import { parseJSONPointer } from '@jarenjs/json';
|
|
37
|
+
import {
|
|
38
|
+
collectSameDocumentAnchors,
|
|
39
|
+
resolveNormalizeSwitch,
|
|
40
|
+
resolveSameDocumentRef,
|
|
41
|
+
} from '@jarenjs/validate/normalize';
|
|
42
|
+
|
|
43
|
+
/** The model format version this module produces and consumes. */
|
|
44
|
+
export const EMIT_MODEL_VERSION = '0.1';
|
|
45
|
+
|
|
46
|
+
/** @typedef {import('@jarenjs/validate/normalize').NormalizeOptions} NormalizeOptions */
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A constraint the source schema states that the emitted type cannot carry.
|
|
50
|
+
* @typedef {object} EmitConstraint
|
|
51
|
+
* @property {string} keyword - The schema keyword
|
|
52
|
+
* @property {any} [value] - The keyword's value in the source schema
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A type reference in the model. `kind` is always present and is what an
|
|
57
|
+
* emitter dispatches on; the other members depend on it (EMIT-FORMAT.md §5).
|
|
58
|
+
* @typedef {object} EmitTypeRef
|
|
59
|
+
* @property {'unknown'|'never'|'primitive'|'literal'|'ref'|'array'|'tuple'|'optional'|'record'|'union'|'intersection'|'object'} kind
|
|
60
|
+
* @property {'string'|'number'|'boolean'|'null'} [primitive] - For `primitive`
|
|
61
|
+
* @property {any} [value] - The JSON value of a `literal`, or the value type of a `record`
|
|
62
|
+
* @property {string} [ref] - For `ref`: the referenced declaration name
|
|
63
|
+
* @property {EmitTypeRef|EmitTypeRef[]} [items] - `array` item type, or `tuple` positional items
|
|
64
|
+
* @property {EmitTypeRef} [rest] - For `tuple`: the rest type, when the tuple is open
|
|
65
|
+
* @property {EmitTypeRef} [item] - For `optional`: the wrapped tuple element
|
|
66
|
+
* @property {EmitTypeRef[]} [options] - For `union` (at least two)
|
|
67
|
+
* @property {EmitTypeRef[]} [parts] - For `intersection` (at least two)
|
|
68
|
+
* @property {EmitMember[]} [members] - For `object`
|
|
69
|
+
* @property {EmitTypeRef} [index] - For `object`: the index-signature value type
|
|
70
|
+
*/
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A declared member of an object type.
|
|
74
|
+
* @typedef {object} EmitMember
|
|
75
|
+
* @property {'member'} kind
|
|
76
|
+
* @property {string} name - The property name, verbatim
|
|
77
|
+
* @property {EmitTypeRef} type
|
|
78
|
+
* @property {boolean} required
|
|
79
|
+
* @property {any} [default] - The schema default, when it declares one
|
|
80
|
+
* @property {EmitConstraint[]} constraints
|
|
81
|
+
* @property {string[]} doc
|
|
82
|
+
*/
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A named declaration.
|
|
86
|
+
* @typedef {object} EmitDeclaration
|
|
87
|
+
* @property {'declaration'} kind
|
|
88
|
+
* @property {string} name - Unique, identifier-safe
|
|
89
|
+
* @property {EmitTypeRef} type
|
|
90
|
+
* @property {EmitConstraint[]} constraints
|
|
91
|
+
* @property {string[]} doc
|
|
92
|
+
* @property {'accepted'|'normalized'} [variant] - Which side of normalization this declaration describes
|
|
93
|
+
* @property {string} [variantOf] - For an accepted variant, its normalized counterpart
|
|
94
|
+
*/
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The type model document — the published contract every emitter reads.
|
|
98
|
+
* @typedef {object} EmitModel
|
|
99
|
+
* @property {string} $emit - The model format version
|
|
100
|
+
* @property {string|null} source - Where the model came from
|
|
101
|
+
* @property {string|null} root - The declaration name of the schema's root
|
|
102
|
+
* @property {true} [variants] - Present when accepted/normalized pairs were derived
|
|
103
|
+
* @property {EmitDeclaration[]} declarations
|
|
104
|
+
*/
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Options for {@link compileEmitModel}.
|
|
108
|
+
* @typedef {object} EmitModelOptions
|
|
109
|
+
* @property {string} [name='Root'] - The name for the root declaration
|
|
110
|
+
* @property {string} [source] - A source identifier recorded in the model
|
|
111
|
+
* @property {'open'|'closed'} [openObjects='open'] - How to treat an object
|
|
112
|
+
* whose `additionalProperties` is omitted. JSON Schema says such an object
|
|
113
|
+
* is open, so the default emits an index signature; `'closed'` opts into the
|
|
114
|
+
* tighter type, which regains excess-property checking at the cost of
|
|
115
|
+
* rejecting documents the schema accepts.
|
|
116
|
+
* @property {NormalizeOptions|null} [normalize] - When set, derive
|
|
117
|
+
* accepted/normalized variant pairs with exactly these `compileNormalizer`
|
|
118
|
+
* options
|
|
119
|
+
* @property {string} [variantSuffix='Input'] - The suffix for accepted-variant
|
|
120
|
+
* declaration names
|
|
121
|
+
* @property {string[]} [reserved] - Declaration names already taken outside
|
|
122
|
+
* this model. Bundling concatenates models into one file, so each model
|
|
123
|
+
* must be able to avoid the names its predecessors used.
|
|
124
|
+
* @property {string[]} [extensions] - Extension keyword names (typically
|
|
125
|
+
* `x-*`) to PRESERVE: when a property schema carries one of these, the
|
|
126
|
+
* member node gains `extensions: { '<keyword>': value }` with the value
|
|
127
|
+
* copied verbatim. The compiler itself never interprets them — a
|
|
128
|
+
* downstream consumer of the model does. Keywords are read from the
|
|
129
|
+
* property node itself (a vocabulary declares them inline, not through
|
|
130
|
+
* `$ref`). Absent by default, so existing models are byte-identical.
|
|
131
|
+
*/
|
|
132
|
+
|
|
133
|
+
/** Keywords that constrain a value without narrowing its TYPE — the
|
|
134
|
+
* shared constraint groups plus the emit-specific extras. */
|
|
135
|
+
const DROPPED_CONSTRAINTS = [
|
|
136
|
+
...STRING_CONSTRAINTS,
|
|
137
|
+
...NUMERIC_CONSTRAINTS,
|
|
138
|
+
...ARRAY_CONSTRAINTS, 'contains', 'minContains', 'maxContains',
|
|
139
|
+
...OBJECT_CONSTRAINTS, 'propertyNames', 'dependentRequired',
|
|
140
|
+
'dependentSchemas', 'dependencies', 'not',
|
|
141
|
+
'$query', 'data', '$data',
|
|
142
|
+
];
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* What a normalizer will ACCEPT for each declared scalar type, beyond the
|
|
146
|
+
* type itself. This mirrors `coerceToType` in `@jarenjs/validate/normalize`:
|
|
147
|
+
* if that table grows a conversion, this one has to grow the same row, or the
|
|
148
|
+
* accepted variant would claim an input the normalizer cannot actually take.
|
|
149
|
+
* The switch resolution itself is imported rather than reimplemented, which is
|
|
150
|
+
* the half most likely to drift.
|
|
151
|
+
*/
|
|
152
|
+
const COERCIBLE_FROM = {
|
|
153
|
+
string: ['number', 'boolean'],
|
|
154
|
+
number: ['string'],
|
|
155
|
+
integer: ['string'],
|
|
156
|
+
boolean: ['string'],
|
|
157
|
+
null: ['string'],
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
/** JSON Schema type names that map to a TypeScript primitive. */
|
|
161
|
+
const PRIMITIVES = {
|
|
162
|
+
string: 'string',
|
|
163
|
+
number: 'number',
|
|
164
|
+
integer: 'number',
|
|
165
|
+
boolean: 'boolean',
|
|
166
|
+
null: 'null',
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
/** A type reference the emitters understand. Constructors, so the shapes
|
|
170
|
+
* stay in one place and the model schema has one thing to describe. */
|
|
171
|
+
const T = {
|
|
172
|
+
unknown: () => ({ kind: 'unknown' }),
|
|
173
|
+
never: () => ({ kind: 'never' }),
|
|
174
|
+
primitive: (name) => ({ kind: 'primitive', primitive: name }),
|
|
175
|
+
literal: (value) => ({ kind: 'literal', value }),
|
|
176
|
+
ref: (name) => ({ kind: 'ref', ref: name }),
|
|
177
|
+
array: (items) => ({ kind: 'array', items }),
|
|
178
|
+
record: (value) => ({ kind: 'record', value }),
|
|
179
|
+
optional: (item) => ({ kind: 'optional', item }),
|
|
180
|
+
tuple: (items, rest) => (rest == null
|
|
181
|
+
? { kind: 'tuple', items }
|
|
182
|
+
: { kind: 'tuple', items, rest }),
|
|
183
|
+
union: (options) => ({ kind: 'union', options }),
|
|
184
|
+
intersection: (parts) => ({ kind: 'intersection', parts }),
|
|
185
|
+
object: (members, indexValue) => (indexValue == null
|
|
186
|
+
? { kind: 'object', members }
|
|
187
|
+
: { kind: 'object', members, index: indexValue }),
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Turn an arbitrary name into a TypeScript-safe PascalCase identifier.
|
|
192
|
+
* Deterministic: the same input always yields the same identifier.
|
|
193
|
+
* @param {string} raw
|
|
194
|
+
* @returns {string}
|
|
195
|
+
*/
|
|
196
|
+
function toIdentifier(raw) {
|
|
197
|
+
const cleaned = String(raw).replace(/[^A-Za-z0-9_$]+/g, ' ').trim();
|
|
198
|
+
if (cleaned === '') return 'Anonymous';
|
|
199
|
+
const parts = cleaned.split(/\s+/);
|
|
200
|
+
let out = '';
|
|
201
|
+
for (let i = 0; i < parts.length; i++) {
|
|
202
|
+
const p = parts[i];
|
|
203
|
+
out += p.charAt(0).toUpperCase() + p.slice(1);
|
|
204
|
+
}
|
|
205
|
+
if (/^[0-9]/.test(out)) out = '_' + out;
|
|
206
|
+
return out;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The documentation lines for a node: its description, then a summary of the
|
|
211
|
+
* constraints the type cannot carry. Flattening these here is stage-one work
|
|
212
|
+
* — an emitter should print lines, not decide what belongs in a comment — and
|
|
213
|
+
* it is what lets a template emit one comment block without a conditional.
|
|
214
|
+
* @param {object} node - The schema node
|
|
215
|
+
* @param {object[]} constraints - Its dropped constraints
|
|
216
|
+
* @returns {string[]} Lines, empty when there is nothing to say
|
|
217
|
+
*/
|
|
218
|
+
function docLinesFor(node, constraints) {
|
|
219
|
+
const lines = [];
|
|
220
|
+
if (typeof node.description === 'string' && node.description !== '')
|
|
221
|
+
lines.push(node.description);
|
|
222
|
+
if (constraints.length > 0) {
|
|
223
|
+
const stated = constraints
|
|
224
|
+
.map((c) => `${c.keyword}=${JSON.stringify(c.value)}`)
|
|
225
|
+
.join(', ');
|
|
226
|
+
lines.push(`Schema constraints this type cannot express: ${stated}`);
|
|
227
|
+
}
|
|
228
|
+
return lines;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Collect the constraints this schema states that a type cannot carry. */
|
|
232
|
+
function droppedConstraints(node) {
|
|
233
|
+
const out = [];
|
|
234
|
+
// `integer` emits as `number`: integer-ness has no TypeScript equivalent,
|
|
235
|
+
// so it is a dropped constraint like any other.
|
|
236
|
+
const types = Array.isArray(node.type) ? node.type : [node.type];
|
|
237
|
+
if (types.indexOf('integer') !== -1)
|
|
238
|
+
out.push({ keyword: 'type', value: 'integer' });
|
|
239
|
+
for (let i = 0; i < DROPPED_CONSTRAINTS.length; i++) {
|
|
240
|
+
const keyword = DROPPED_CONSTRAINTS[i];
|
|
241
|
+
if (node[keyword] !== undefined)
|
|
242
|
+
out.push({ keyword, value: node[keyword] });
|
|
243
|
+
}
|
|
244
|
+
// A conditional constrains only when `if` is present — a lone `then` or
|
|
245
|
+
// `else` asserts nothing, and recording it would claim a constraint that
|
|
246
|
+
// does not exist.
|
|
247
|
+
if (node.if !== undefined) {
|
|
248
|
+
for (const keyword of ['if', 'then', 'else']) {
|
|
249
|
+
if (node[keyword] !== undefined)
|
|
250
|
+
out.push({ keyword, value: node[keyword] });
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
// `unevaluated*: true` asserts nothing either.
|
|
254
|
+
for (const keyword of ['unevaluatedProperties', 'unevaluatedItems']) {
|
|
255
|
+
if (node[keyword] !== undefined && node[keyword] !== true)
|
|
256
|
+
out.push({ keyword, value: node[keyword] });
|
|
257
|
+
}
|
|
258
|
+
// The index signature carries the VALUE types of patternProperties, but no
|
|
259
|
+
// emitted type restricts which keys a pattern admits.
|
|
260
|
+
if (isJsonObject(node.patternProperties)) {
|
|
261
|
+
const patterns = Object.getOwnPropertyNames(node.patternProperties);
|
|
262
|
+
if (patterns.length > 0)
|
|
263
|
+
out.push({ keyword: 'patternProperties', value: patterns });
|
|
264
|
+
}
|
|
265
|
+
return out;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Resolve a same-document `$ref` to `{ node, name }`, or null when it
|
|
270
|
+
* addresses nothing this compiler can reach. The resolution itself is
|
|
271
|
+
* imported from `@jarenjs/validate/normalize` so a reference resolves here
|
|
272
|
+
* with exactly the rules the runtime normalizer uses — including plain
|
|
273
|
+
* `#anchor` refs and the embedded-`$id` scope boundary. `name` is what a
|
|
274
|
+
* reader of the schema calls the target: the pointer's last token, the
|
|
275
|
+
* anchor name, or `Root`.
|
|
276
|
+
*/
|
|
277
|
+
function resolveRef(ref, ctx) {
|
|
278
|
+
const node = resolveSameDocumentRef(ref, ctx.root, ctx.anchors);
|
|
279
|
+
if (node === undefined) return null;
|
|
280
|
+
let name = null;
|
|
281
|
+
if (ref === '#') name = 'Root';
|
|
282
|
+
else if (ref.startsWith('#/')) {
|
|
283
|
+
const tokens = parseJSONPointer(ref.slice(1));
|
|
284
|
+
name = tokens.length > 0 ? tokens[tokens.length - 1] : 'Root';
|
|
285
|
+
}
|
|
286
|
+
else name = ref.slice(1);
|
|
287
|
+
return { node, name };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Deduplicate structurally identical type refs, preserving first-seen order. */
|
|
291
|
+
function dedupeTypes(types) {
|
|
292
|
+
const out = [];
|
|
293
|
+
const seen = [];
|
|
294
|
+
for (let i = 0; i < types.length; i++) {
|
|
295
|
+
const key = JSON.stringify(types[i]);
|
|
296
|
+
if (seen.indexOf(key) !== -1) continue;
|
|
297
|
+
seen.push(key);
|
|
298
|
+
out.push(types[i]);
|
|
299
|
+
}
|
|
300
|
+
return out;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Flatten nested unions so `A | (B | C)` prints as `A | B | C`. */
|
|
304
|
+
function flattenUnion(types) {
|
|
305
|
+
const out = [];
|
|
306
|
+
for (let i = 0; i < types.length; i++) {
|
|
307
|
+
const t = types[i];
|
|
308
|
+
if (t.kind === 'union') out.push(...t.options);
|
|
309
|
+
else out.push(t);
|
|
310
|
+
}
|
|
311
|
+
return out;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Build a union type ref, collapsing the degenerate cases. */
|
|
315
|
+
function unionOf(types) {
|
|
316
|
+
const flat = dedupeTypes(flattenUnion(types.filter((t) => t.kind !== 'never')));
|
|
317
|
+
if (flat.length === 0) return T.never();
|
|
318
|
+
if (flat.length === 1) return flat[0];
|
|
319
|
+
if (flat.some((t) => t.kind === 'unknown')) return T.unknown();
|
|
320
|
+
return T.union(flat);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Whether normalization changes the TYPE of this subtree.
|
|
325
|
+
*
|
|
326
|
+
* Only two of the four normalizations do. `useDefaults` makes a defaulted
|
|
327
|
+
* member optional on input and present on output; `coerceTypes` widens what a
|
|
328
|
+
* scalar accepts. `trimStrings` is string-to-string, and `removeAdditional`
|
|
329
|
+
* removes members the type never declared — neither changes a declared type,
|
|
330
|
+
* so neither justifies a second declaration.
|
|
331
|
+
*
|
|
332
|
+
* The walk descends exactly what `compileNormalizer` descends — `$ref`,
|
|
333
|
+
* `allOf`, the object keywords, and the tuple spelling the normalizer would
|
|
334
|
+
* read — and deliberately NOT `anyOf`/`oneOf`, because the normalizer does
|
|
335
|
+
* not descend union branches. A default that exists only under a union branch
|
|
336
|
+
* is never materialized at runtime, so it must not earn a twin here: this
|
|
337
|
+
* analysis answering differently from the runtime is precisely the defect the
|
|
338
|
+
* variants exist to rule out.
|
|
339
|
+
*
|
|
340
|
+
* Computed bottom-up and memoized, because a type differs if anything it
|
|
341
|
+
* contains differs. A node reached while it is still being analyzed is a
|
|
342
|
+
* cycle, and a cycle alone introduces no difference, so it answers `false`.
|
|
343
|
+
* @param {any} node - The schema node
|
|
344
|
+
* @param {object} ctx - The compile context
|
|
345
|
+
* @returns {boolean}
|
|
346
|
+
*/
|
|
347
|
+
function normalizationChangesType(node, ctx) {
|
|
348
|
+
const options = ctx.normalize;
|
|
349
|
+
if (options === null || !isJsonObject(node)) return false;
|
|
350
|
+
|
|
351
|
+
const memo = ctx.differs;
|
|
352
|
+
const cached = memo.get(node);
|
|
353
|
+
if (cached !== undefined) return cached;
|
|
354
|
+
if (ctx.analyzing.has(node)) return false;
|
|
355
|
+
ctx.analyzing.add(node);
|
|
356
|
+
|
|
357
|
+
let differs = false;
|
|
358
|
+
|
|
359
|
+
if (typeof node.$ref === 'string') {
|
|
360
|
+
const target = resolveSameDocumentRef(node.$ref, ctx.root, ctx.anchors);
|
|
361
|
+
if (target !== undefined && target !== node)
|
|
362
|
+
differs = normalizationChangesType(target, ctx);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (!differs && typeof node.type === 'string'
|
|
366
|
+
&& COERCIBLE_FROM[node.type] !== undefined
|
|
367
|
+
&& resolveNormalizeSwitch(options.coerceTypes, node)) {
|
|
368
|
+
differs = true;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const properties = isJsonObject(node.properties) ? node.properties : null;
|
|
372
|
+
if (!differs && properties !== null) {
|
|
373
|
+
const keys = Object.getOwnPropertyNames(properties);
|
|
374
|
+
for (let i = 0; i < keys.length && !differs; i++) {
|
|
375
|
+
const sub = properties[keys[i]];
|
|
376
|
+
if (isJsonObject(sub) && sub.default !== undefined
|
|
377
|
+
&& resolveNormalizeSwitch(options.useDefaults, sub)) {
|
|
378
|
+
differs = true;
|
|
379
|
+
break;
|
|
380
|
+
}
|
|
381
|
+
differs = normalizationChangesType(sub, ctx);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// The array walk mirrors buildArrayStep: the tuple spelling decides which
|
|
386
|
+
// keywords the normalizer reads, so it decides which ones this reads.
|
|
387
|
+
if (!differs) {
|
|
388
|
+
const itemsIsTuple = Array.isArray(node.items);
|
|
389
|
+
const prefixSource = itemsIsTuple ? node.items : node.prefixItems;
|
|
390
|
+
const restSource = itemsIsTuple ? node.additionalItems : node.items;
|
|
391
|
+
if (Array.isArray(prefixSource)) {
|
|
392
|
+
for (let i = 0; i < prefixSource.length && !differs; i++)
|
|
393
|
+
differs = normalizationChangesType(prefixSource[i], ctx);
|
|
394
|
+
}
|
|
395
|
+
if (!differs && !Array.isArray(restSource))
|
|
396
|
+
differs = normalizationChangesType(restSource, ctx);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (!differs)
|
|
400
|
+
differs = normalizationChangesType(node.additionalProperties, ctx);
|
|
401
|
+
|
|
402
|
+
if (!differs && Array.isArray(node.allOf)) {
|
|
403
|
+
for (let i = 0; i < node.allOf.length && !differs; i++)
|
|
404
|
+
differs = normalizationChangesType(node.allOf[i], ctx);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (!differs && isJsonObject(node.patternProperties)) {
|
|
408
|
+
const keys = Object.getOwnPropertyNames(node.patternProperties);
|
|
409
|
+
for (let i = 0; i < keys.length && !differs; i++)
|
|
410
|
+
differs = normalizationChangesType(node.patternProperties[keys[i]], ctx);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
ctx.analyzing.delete(node);
|
|
414
|
+
memo.set(node, differs);
|
|
415
|
+
return differs;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** The compile context: one per pass of `compileEmitModel`. */
|
|
419
|
+
function createContext(root, options, shared) {
|
|
420
|
+
return {
|
|
421
|
+
root,
|
|
422
|
+
options,
|
|
423
|
+
/** Normalize options, or null when no variants are being derived. */
|
|
424
|
+
normalize: options.normalize ?? null,
|
|
425
|
+
/** Which variant is being built: 'normalized' | 'accepted' | null. */
|
|
426
|
+
variant: null,
|
|
427
|
+
/** The suffix for accepted-variant declaration names. */
|
|
428
|
+
suffix: options.variantSuffix ?? 'Input',
|
|
429
|
+
/** @type {Map<string, object>} the document's $anchor declarations */
|
|
430
|
+
anchors: shared.anchors,
|
|
431
|
+
/** @type {Map<object, boolean>} node -> normalization changes its type */
|
|
432
|
+
differs: shared.differs,
|
|
433
|
+
/** @type {Set<object>} nodes being analyzed, for cycle detection */
|
|
434
|
+
analyzing: shared.analyzing,
|
|
435
|
+
/** @type {object[]} declarations in discovery order */
|
|
436
|
+
declarations: [],
|
|
437
|
+
/** @type {Map<object|boolean, string>} schema node -> declaration name */
|
|
438
|
+
named: new Map(),
|
|
439
|
+
/** @type {Set<object>} nodes currently being built, for cycle detection */
|
|
440
|
+
building: new Set(),
|
|
441
|
+
/** @type {string[]} names already taken */
|
|
442
|
+
taken: [],
|
|
443
|
+
/** The plain universe's name state, shared across passes (see below). */
|
|
444
|
+
plainNamed: shared.plainNamed,
|
|
445
|
+
plainBuilding: shared.plainBuilding,
|
|
446
|
+
/** @type {object|null} this pass's plain universe, created on demand */
|
|
447
|
+
plain: null,
|
|
448
|
+
/** @type {object|undefined} set on a PLAIN context: the pass it belongs to */
|
|
449
|
+
plainOf: undefined,
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* The PLAIN universe: compilation with normalization inert.
|
|
455
|
+
*
|
|
456
|
+
* `anyOf`/`oneOf` branches compile here when variants are being derived,
|
|
457
|
+
* because `compileNormalizer` does not descend union branches — a default
|
|
458
|
+
* under one is never materialized and a coercion never applies there. A
|
|
459
|
+
* branch that referenced the normalized declaration would require output the
|
|
460
|
+
* runtime never produces, and one that referenced the accepted twin would
|
|
461
|
+
* promise coercions that never run; both disagree with the runtime, so the
|
|
462
|
+
* branch gets the schema's as-declared reading instead. A node whose type
|
|
463
|
+
* normalization does not change reads identically in every universe and
|
|
464
|
+
* shares the main declaration; one that differs gains a `Plain`-suffixed
|
|
465
|
+
* declaration, shared by both passes since it is variant-less by
|
|
466
|
+
* construction.
|
|
467
|
+
* @param {object} ctx - The pass context this universe belongs to
|
|
468
|
+
* @returns {object} The plain compile context
|
|
469
|
+
*/
|
|
470
|
+
function createPlainContext(ctx) {
|
|
471
|
+
return {
|
|
472
|
+
root: ctx.root,
|
|
473
|
+
options: ctx.options,
|
|
474
|
+
normalize: null,
|
|
475
|
+
variant: null,
|
|
476
|
+
suffix: ctx.suffix,
|
|
477
|
+
anchors: ctx.anchors,
|
|
478
|
+
differs: ctx.differs,
|
|
479
|
+
analyzing: ctx.analyzing,
|
|
480
|
+
// Same array: plain declarations are emitted in discovery order among
|
|
481
|
+
// the pass's own, and one name space covers both.
|
|
482
|
+
declarations: ctx.declarations,
|
|
483
|
+
named: ctx.plainNamed,
|
|
484
|
+
building: ctx.plainBuilding,
|
|
485
|
+
taken: ctx.taken,
|
|
486
|
+
plainNamed: ctx.plainNamed,
|
|
487
|
+
plainBuilding: ctx.plainBuilding,
|
|
488
|
+
plain: null,
|
|
489
|
+
plainOf: ctx,
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** Reserve a unique declaration name. */
|
|
494
|
+
function reserveName(ctx, preferred) {
|
|
495
|
+
const base = toIdentifier(preferred);
|
|
496
|
+
let name = base;
|
|
497
|
+
let n = 2;
|
|
498
|
+
while (ctx.taken.indexOf(name) !== -1) {
|
|
499
|
+
name = `${base}${n}`;
|
|
500
|
+
n++;
|
|
501
|
+
}
|
|
502
|
+
ctx.taken.push(name);
|
|
503
|
+
return name;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Compile a schema node into a type ref, declaring it by name when it is a
|
|
508
|
+
* named or cyclic node.
|
|
509
|
+
* @param {any} node - The schema node
|
|
510
|
+
* @param {object} ctx - The compile context
|
|
511
|
+
* @param {string} hint - A name to use if this node has to be hoisted
|
|
512
|
+
* @returns {object} A type ref
|
|
513
|
+
*/
|
|
514
|
+
function typeOf(node, ctx, hint) {
|
|
515
|
+
if (node === true || node === undefined) return T.unknown();
|
|
516
|
+
if (node === false) return T.never();
|
|
517
|
+
if (!isJsonObject(node)) return T.unknown();
|
|
518
|
+
|
|
519
|
+
// Already declared: refer to it by name.
|
|
520
|
+
const existing = ctx.named.get(node);
|
|
521
|
+
if (existing !== undefined) return T.ref(existing);
|
|
522
|
+
|
|
523
|
+
// A node reached while it is still being built is a cycle. Hoisting it to
|
|
524
|
+
// a declaration is what breaks the recursion: the reference is by name,
|
|
525
|
+
// and the declaration is completed by the frame already building it.
|
|
526
|
+
if (ctx.building.has(node)) {
|
|
527
|
+
const name = reserveName(ctx, hint);
|
|
528
|
+
ctx.named.set(node, name);
|
|
529
|
+
return T.ref(name);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
return shapeOf(node, ctx, hint);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Ensure a node has a NAMED declaration and return a ref to it. Used for
|
|
537
|
+
* `$ref` targets and `$defs` members — the things a reader already thinks of
|
|
538
|
+
* as types.
|
|
539
|
+
* @param {any} node - The schema node
|
|
540
|
+
* @param {object} ctx - The compile context
|
|
541
|
+
* @param {string} hint - The preferred declaration name
|
|
542
|
+
* @param {boolean} [forceOwn] - Emit a declaration under this hint even when
|
|
543
|
+
* an equal boolean schema already has one — the `$defs` loop uses this so
|
|
544
|
+
* every name a reader can import exists
|
|
545
|
+
* @returns {object} A `ref` type ref
|
|
546
|
+
*/
|
|
547
|
+
function declare(node, ctx, hint, forceOwn = false) {
|
|
548
|
+
// In the plain universe, a node whose type normalization does not change
|
|
549
|
+
// reads identically everywhere, so it shares the main declaration rather
|
|
550
|
+
// than gaining a twin.
|
|
551
|
+
if (ctx.plainOf !== undefined) {
|
|
552
|
+
if (!isJsonObject(node) || !normalizationChangesType(node, ctx.plainOf))
|
|
553
|
+
return declare(node, ctx.plainOf, hint, forceOwn);
|
|
554
|
+
hint = `${hint}Plain`;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const existing = ctx.named.get(node);
|
|
558
|
+
if (existing !== undefined && !forceOwn) return T.ref(existing);
|
|
559
|
+
|
|
560
|
+
// `true` and `false` are whole schemas, so a boolean ROOT or def still
|
|
561
|
+
// deserves a name — emitting nothing left a consumer importing a type that
|
|
562
|
+
// was never written. Booleans memoize by VALUE (every `true` schema is the
|
|
563
|
+
// same schema), which is also what stops a second reference or a second
|
|
564
|
+
// pass from emitting a duplicate declaration.
|
|
565
|
+
if (typeof node === 'boolean') {
|
|
566
|
+
// Normalization cannot change a boolean schema, so the accepted pass
|
|
567
|
+
// reuses pass one's declaration instead of emitting a twin.
|
|
568
|
+
if (ctx.variant === 'accepted') {
|
|
569
|
+
const shared = ctx.shared.get(node);
|
|
570
|
+
if (shared !== undefined) return T.ref(shared);
|
|
571
|
+
}
|
|
572
|
+
const boolName = reserveName(ctx, hint);
|
|
573
|
+
if (existing === undefined) ctx.named.set(node, boolName);
|
|
574
|
+
ctx.declarations.push({
|
|
575
|
+
kind: 'declaration', name: boolName,
|
|
576
|
+
type: node === true ? T.unknown() : T.never(),
|
|
577
|
+
constraints: [], doc: [],
|
|
578
|
+
});
|
|
579
|
+
return T.ref(boolName);
|
|
580
|
+
}
|
|
581
|
+
if (!isJsonObject(node)) return typeOf(node, ctx, hint);
|
|
582
|
+
|
|
583
|
+
// In the accepted pass, only a node whose type actually changes earns its
|
|
584
|
+
// own declaration; everything else refers to the single shared one, so a
|
|
585
|
+
// schema with one defaulted field does not double every type in the file.
|
|
586
|
+
if (ctx.variant === 'accepted' && !normalizationChangesType(node, ctx)) {
|
|
587
|
+
const shared = ctx.shared.get(node);
|
|
588
|
+
if (shared !== undefined) return T.ref(shared);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const name = reserveName(ctx,
|
|
592
|
+
ctx.variant === 'accepted' ? `${hint}${ctx.suffix}` : hint);
|
|
593
|
+
ctx.named.set(node, name);
|
|
594
|
+
ctx.building.add(node);
|
|
595
|
+
const constraints = droppedConstraints(node);
|
|
596
|
+
const doc = docLinesFor(node, constraints);
|
|
597
|
+
if (ctx.variant === 'accepted') {
|
|
598
|
+
doc.push(`Accepted input for ${ctx.shared.get(node) ?? hint}: the shape before `
|
|
599
|
+
+ 'normalization, where defaulted members may be absent and coercible '
|
|
600
|
+
+ 'values may still be in their transport form.');
|
|
601
|
+
}
|
|
602
|
+
if (ctx.plainOf !== undefined) {
|
|
603
|
+
doc.push('The declared shape of this schema where normalization does not '
|
|
604
|
+
+ 'reach: inside anyOf/oneOf branches the normalizer neither '
|
|
605
|
+
+ 'materializes defaults nor coerces.');
|
|
606
|
+
}
|
|
607
|
+
const declaration = {
|
|
608
|
+
kind: 'declaration',
|
|
609
|
+
name,
|
|
610
|
+
type: shapeOf(node, ctx, hint),
|
|
611
|
+
constraints,
|
|
612
|
+
doc,
|
|
613
|
+
};
|
|
614
|
+
if (ctx.variant !== null) declaration.variant = ctx.variant;
|
|
615
|
+
if (ctx.variant === 'accepted') {
|
|
616
|
+
const of = ctx.shared.get(node);
|
|
617
|
+
if (of !== undefined) declaration.variantOf = of;
|
|
618
|
+
}
|
|
619
|
+
ctx.building.delete(node);
|
|
620
|
+
ctx.declarations.push(declaration);
|
|
621
|
+
return T.ref(name);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Which coercion SOURCE primitives can reach at least one of `values`, for a
|
|
626
|
+
* literal (`const`/`enum`) node. Mirrors `coerceToType`: a source is admitted
|
|
627
|
+
* only when some value of it actually converts to a member of the literal
|
|
628
|
+
* set, so an integer enum widens by `string` (`"2"` normalizes to `2`) while
|
|
629
|
+
* a string enum of words does not widen by `number` at all.
|
|
630
|
+
*
|
|
631
|
+
* Gated exactly as `buildScalarStep` gates coercion: the accepted variant,
|
|
632
|
+
* a single string-valued `type`, and the same resolved switch.
|
|
633
|
+
* @param {object} node - The schema node carrying the literal
|
|
634
|
+
* @param {any[]} values - The literal values
|
|
635
|
+
* @param {object} ctx - The compile context
|
|
636
|
+
* @returns {string[]} JSON Schema type names to widen by
|
|
637
|
+
*/
|
|
638
|
+
function coercionSources(node, values, ctx) {
|
|
639
|
+
if (ctx.variant !== 'accepted' || ctx.normalize === null) return [];
|
|
640
|
+
if (typeof node.type !== 'string') return [];
|
|
641
|
+
if (!resolveNormalizeSwitch(ctx.normalize.coerceTypes, node)) return [];
|
|
642
|
+
const from = COERCIBLE_FROM[node.type];
|
|
643
|
+
if (from === undefined) return [];
|
|
644
|
+
const out = [];
|
|
645
|
+
for (const source of from) {
|
|
646
|
+
if (values.some((v) => coercionCanProduce(source, node.type, v)))
|
|
647
|
+
out.push(source);
|
|
648
|
+
}
|
|
649
|
+
return out;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/** Whether `coerceToType` can turn SOME value of `source` type into `value`. */
|
|
653
|
+
function coercionCanProduce(source, type, value) {
|
|
654
|
+
switch (type) {
|
|
655
|
+
case 'number':
|
|
656
|
+
return typeof value === 'number' && Number.isFinite(value);
|
|
657
|
+
case 'integer':
|
|
658
|
+
return typeof value === 'number' && Number.isInteger(value);
|
|
659
|
+
case 'boolean':
|
|
660
|
+
return typeof value === 'boolean';
|
|
661
|
+
case 'null':
|
|
662
|
+
return value === null;
|
|
663
|
+
case 'string': {
|
|
664
|
+
if (typeof value !== 'string') return false;
|
|
665
|
+
if (source === 'boolean') return value === 'true' || value === 'false';
|
|
666
|
+
const num = Number(value);
|
|
667
|
+
return Number.isFinite(num) && String(num) === value;
|
|
668
|
+
}
|
|
669
|
+
default:
|
|
670
|
+
return false;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/** The structural shape of a schema node. */
|
|
675
|
+
function shapeOf(node, ctx, hint) {
|
|
676
|
+
const parts = [];
|
|
677
|
+
|
|
678
|
+
// `$ref` composes with its siblings: since 2019-09 the other keywords
|
|
679
|
+
// apply ALONGSIDE the reference, so the target is one intersection part
|
|
680
|
+
// rather than a substitute for the node — ignoring the siblings emitted a
|
|
681
|
+
// type wider than the schema in one place and narrower in another. A bare
|
|
682
|
+
// `$ref` with nothing else collapses to a plain alias below, and an
|
|
683
|
+
// unresolvable one contributes nothing, leaving the node honestly wider.
|
|
684
|
+
if (typeof node.$ref === 'string') {
|
|
685
|
+
const target = resolveRef(node.$ref, ctx);
|
|
686
|
+
// A ref that resolves to the node itself asserts nothing — and spelled
|
|
687
|
+
// out it would be a circular alias, which is not a type.
|
|
688
|
+
if (target !== null && target.node !== node)
|
|
689
|
+
parts.push(declare(target.node, ctx, target.name ?? hint));
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// const and enum are the most precise things a schema can say. On the
|
|
693
|
+
// accepted side the literal set still admits what the normalizer coerces
|
|
694
|
+
// INTO a member — `"2"` for an integer enum — so it widens by the source
|
|
695
|
+
// primitives that can actually reach one.
|
|
696
|
+
if (node.const !== undefined || Array.isArray(node.enum)) {
|
|
697
|
+
const values = node.const !== undefined ? [node.const] : node.enum;
|
|
698
|
+
const literals = values.map((v) => T.literal(v));
|
|
699
|
+
for (const source of coercionSources(node, values, ctx))
|
|
700
|
+
literals.push(T.primitive(PRIMITIVES[source]));
|
|
701
|
+
parts.push(unionOf(literals));
|
|
702
|
+
}
|
|
703
|
+
else {
|
|
704
|
+
// allOf is intersection.
|
|
705
|
+
if (Array.isArray(node.allOf) && node.allOf.length > 0) {
|
|
706
|
+
const branches = node.allOf.map((b, i) => typeOf(b, ctx, `${hint}Part${i + 1}`));
|
|
707
|
+
const usable = branches.filter((t) => t.kind !== 'unknown');
|
|
708
|
+
if (usable.length === 1) parts.push(usable[0]);
|
|
709
|
+
else if (usable.length > 1) parts.push(T.intersection(usable));
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// anyOf and oneOf are both unions at the type level. oneOf's exclusivity
|
|
713
|
+
// is a validation property with no type-level equivalent, so it widens to
|
|
714
|
+
// the same union rather than being faked. When variants are being
|
|
715
|
+
// derived, branches compile in the PLAIN universe: the runtime
|
|
716
|
+
// normalizer does not descend them (see createPlainContext).
|
|
717
|
+
for (const key of ['anyOf', 'oneOf']) {
|
|
718
|
+
if (Array.isArray(node[key]) && node[key].length > 0) {
|
|
719
|
+
const branchCtx = ctx.normalize === null
|
|
720
|
+
? ctx
|
|
721
|
+
: (ctx.plain ??= createPlainContext(ctx));
|
|
722
|
+
parts.push(unionOf(node[key].map((b, i) =>
|
|
723
|
+
typeOf(b, branchCtx, `${hint}${toIdentifier(key)}${i + 1}`))));
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
const own = ownShape(node, ctx, hint);
|
|
728
|
+
if (own !== null) parts.push(own);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
if (parts.length === 0) return T.unknown();
|
|
732
|
+
if (parts.length === 1) return parts[0];
|
|
733
|
+
return T.intersection(parts);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/** The shape from this node's own type/properties/items keywords. */
|
|
737
|
+
function ownShape(node, ctx, hint) {
|
|
738
|
+
const declared = node.type;
|
|
739
|
+
const types = Array.isArray(declared)
|
|
740
|
+
? declared
|
|
741
|
+
: (typeof declared === 'string' ? [declared] : null);
|
|
742
|
+
|
|
743
|
+
const hasObjectKeywords = node.properties !== undefined
|
|
744
|
+
|| node.patternProperties !== undefined
|
|
745
|
+
|| node.additionalProperties !== undefined;
|
|
746
|
+
const hasArrayKeywords = node.items !== undefined || node.prefixItems !== undefined;
|
|
747
|
+
|
|
748
|
+
// No `type`: the applicator keywords describe the container cases, but
|
|
749
|
+
// they do not IMPLY them — `properties` applies only when the value
|
|
750
|
+
// happens to be an object, and the validator accepts a primitive without
|
|
751
|
+
// reading it. Inferring `object` here emitted a type NARROWER than the
|
|
752
|
+
// schema, so the described shape is one union arm and every other JSON
|
|
753
|
+
// kind honestly fills in the rest.
|
|
754
|
+
if (types === null) {
|
|
755
|
+
if (!hasObjectKeywords && !hasArrayKeywords) return null;
|
|
756
|
+
const arms = [];
|
|
757
|
+
if (hasObjectKeywords) arms.push(objectShape(node, ctx, hint));
|
|
758
|
+
if (hasArrayKeywords) arms.push(arrayShape(node, ctx, hint));
|
|
759
|
+
if (!hasObjectKeywords) arms.push(T.record(T.unknown()));
|
|
760
|
+
if (!hasArrayKeywords) arms.push(T.array(T.unknown()));
|
|
761
|
+
arms.push(T.primitive('string'), T.primitive('number'),
|
|
762
|
+
T.primitive('boolean'), T.primitive('null'));
|
|
763
|
+
return unionOf(arms);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// The accepted variant also admits whatever the normalizer will convert
|
|
767
|
+
// FROM, which is what makes `port: '9000'` type-check on input and
|
|
768
|
+
// `port: number` type-check afterwards. The gate mirrors `buildScalarStep`
|
|
769
|
+
// exactly: coercion runs only for a single string-valued `type`, so a
|
|
770
|
+
// union type widens nothing.
|
|
771
|
+
const coerceFrom = ctx.variant === 'accepted' && ctx.normalize !== null
|
|
772
|
+
&& typeof declared === 'string'
|
|
773
|
+
&& resolveNormalizeSwitch(ctx.normalize.coerceTypes, node)
|
|
774
|
+
? COERCIBLE_FROM[declared] ?? []
|
|
775
|
+
: [];
|
|
776
|
+
|
|
777
|
+
const alternatives = [];
|
|
778
|
+
for (let i = 0; i < types.length; i++) {
|
|
779
|
+
const t = types[i];
|
|
780
|
+
if (t === 'object') alternatives.push(objectShape(node, ctx, hint));
|
|
781
|
+
else if (t === 'array') alternatives.push(arrayShape(node, ctx, hint));
|
|
782
|
+
else if (PRIMITIVES[t] !== undefined) {
|
|
783
|
+
alternatives.push(T.primitive(PRIMITIVES[t]));
|
|
784
|
+
for (const from of coerceFrom)
|
|
785
|
+
alternatives.push(T.primitive(PRIMITIVES[from]));
|
|
786
|
+
}
|
|
787
|
+
else alternatives.push(T.unknown());
|
|
788
|
+
}
|
|
789
|
+
return unionOf(alternatives);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/** An object type: declared members plus an optional index signature. */
|
|
793
|
+
function objectShape(node, ctx, hint) {
|
|
794
|
+
const properties = isJsonObject(node.properties) ? node.properties : null;
|
|
795
|
+
const required = Array.isArray(node.required) ? node.required : [];
|
|
796
|
+
const members = [];
|
|
797
|
+
|
|
798
|
+
if (properties !== null) {
|
|
799
|
+
const keys = Object.getOwnPropertyNames(properties);
|
|
800
|
+
for (let i = 0; i < keys.length; i++) {
|
|
801
|
+
const key = keys[i];
|
|
802
|
+
const sub = properties[key];
|
|
803
|
+
const subNode = isJsonObject(sub) ? sub : {};
|
|
804
|
+
const constraints = droppedConstraints(subNode);
|
|
805
|
+
const defaulted = subNode.default !== undefined && ctx.normalize !== null
|
|
806
|
+
&& resolveNormalizeSwitch(ctx.normalize.useDefaults, subNode);
|
|
807
|
+
// A defaulted member is optional for a caller — even when `required`
|
|
808
|
+
// lists it, because the normalizer materializes it before validation
|
|
809
|
+
// runs — and present afterwards. That asymmetry is the whole reason
|
|
810
|
+
// the two variants exist.
|
|
811
|
+
const declaredRequired = required.indexOf(key) !== -1;
|
|
812
|
+
const member = {
|
|
813
|
+
kind: 'member',
|
|
814
|
+
name: key,
|
|
815
|
+
type: typeOf(sub, ctx, `${hint}${toIdentifier(key)}`),
|
|
816
|
+
required: defaulted
|
|
817
|
+
? ctx.variant !== 'accepted'
|
|
818
|
+
: declaredRequired,
|
|
819
|
+
constraints,
|
|
820
|
+
doc: docLinesFor(subNode, constraints),
|
|
821
|
+
};
|
|
822
|
+
if (subNode.default !== undefined) member.default = subNode.default;
|
|
823
|
+
// the extension seam: declared keywords ride the member verbatim,
|
|
824
|
+
// uninterpreted — a downstream consumer of the model reads them
|
|
825
|
+
const declaredExtensions = ctx.options.extensions;
|
|
826
|
+
if (Array.isArray(declaredExtensions)) {
|
|
827
|
+
let carried = null;
|
|
828
|
+
for (const keyword of declaredExtensions) {
|
|
829
|
+
if (subNode[keyword] === undefined) continue;
|
|
830
|
+
if (carried === null) carried = {};
|
|
831
|
+
carried[keyword] = subNode[keyword];
|
|
832
|
+
}
|
|
833
|
+
if (carried !== null) member.extensions = carried;
|
|
834
|
+
}
|
|
835
|
+
members.push(member);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// An index signature comes from additionalProperties or patternProperties.
|
|
840
|
+
// `additionalProperties: false` is the closed case and adds nothing; `true`
|
|
841
|
+
// or a schema opens the object up.
|
|
842
|
+
let index = null;
|
|
843
|
+
const additional = node.additionalProperties;
|
|
844
|
+
if (additional !== undefined && additional !== false)
|
|
845
|
+
index = typeOf(additional, ctx, `${hint}Additional`);
|
|
846
|
+
else if (additional === undefined && ctx.options.openObjects !== 'closed') {
|
|
847
|
+
// JSON Schema objects are OPEN unless they say otherwise. Emitting a
|
|
848
|
+
// closed interface makes the type NARROWER than the schema, so it rejects
|
|
849
|
+
// a document the validator accepts — the one direction this generator
|
|
850
|
+
// promises never to take. `openObjects: 'closed'` opts into the tighter,
|
|
851
|
+
// unsound type for a consumer who prefers excess-property checking.
|
|
852
|
+
index = T.unknown();
|
|
853
|
+
}
|
|
854
|
+
else if (isJsonObject(node.patternProperties)) {
|
|
855
|
+
const patterns = Object.getOwnPropertyNames(node.patternProperties);
|
|
856
|
+
if (patterns.length > 0) {
|
|
857
|
+
index = unionOf(patterns.map((p, i) =>
|
|
858
|
+
typeOf(node.patternProperties[p], ctx, `${hint}Pattern${i + 1}`)));
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
// TypeScript requires an index signature to cover every declared member,
|
|
863
|
+
// so widen it to include their types rather than emitting something that
|
|
864
|
+
// will not compile.
|
|
865
|
+
if (index !== null && members.length > 0)
|
|
866
|
+
index = unionOf([index, ...members.map((m) => m.type)]);
|
|
867
|
+
|
|
868
|
+
// A closed object with NO members is `Record<string, never>`: an empty
|
|
869
|
+
// interface is TypeScript's weak-type escape hatch — a primitive satisfies
|
|
870
|
+
// it — so it would certify data the validator rejects.
|
|
871
|
+
if (members.length === 0 && index === null) return T.record(T.never());
|
|
872
|
+
|
|
873
|
+
return T.object(members, index);
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/** An array or tuple type. */
|
|
877
|
+
function arrayShape(node, ctx, hint) {
|
|
878
|
+
const itemsIsTuple = Array.isArray(node.items);
|
|
879
|
+
const prefixSource = itemsIsTuple
|
|
880
|
+
? node.items
|
|
881
|
+
: (Array.isArray(node.prefixItems) ? node.prefixItems : null);
|
|
882
|
+
|
|
883
|
+
if (prefixSource !== null) {
|
|
884
|
+
// JSON Schema tuples are not fixed-length: `prefixItems` constrains the
|
|
885
|
+
// positions that exist, `minItems` says how many must exist, and an
|
|
886
|
+
// omitted rest schema leaves the array OPEN. Emitting every position
|
|
887
|
+
// required and the tuple closed rejected arrays the validator accepts.
|
|
888
|
+
const restSource = itemsIsTuple ? node.additionalItems : node.items;
|
|
889
|
+
const rest = restSource === false
|
|
890
|
+
? null
|
|
891
|
+
: restSource === undefined || restSource === true
|
|
892
|
+
? T.unknown()
|
|
893
|
+
: typeOf(restSource, ctx, `${hint}Rest`);
|
|
894
|
+
const requiredCount = Math.min(
|
|
895
|
+
typeof node.minItems === 'number' ? node.minItems : 0,
|
|
896
|
+
prefixSource.length);
|
|
897
|
+
const items = prefixSource.map((s, i) => {
|
|
898
|
+
const itemType = typeOf(s, ctx, `${hint}Item${i + 1}`);
|
|
899
|
+
return i < requiredCount ? itemType : T.optional(itemType);
|
|
900
|
+
});
|
|
901
|
+
// The degenerate tuple collapses (EMIT-FORMAT §5): no positional items
|
|
902
|
+
// with a rest type is just an array — and `[, ...T[]]` is not TypeScript.
|
|
903
|
+
if (items.length === 0) return rest === null ? T.tuple(items) : T.array(rest);
|
|
904
|
+
return T.tuple(items, rest);
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
if (node.items === undefined) return T.array(T.unknown());
|
|
908
|
+
return T.array(typeOf(node.items, ctx, `${hint}Item`));
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/**
|
|
912
|
+
* Compile one or more JSON Schemas into a type model.
|
|
913
|
+
*
|
|
914
|
+
* The model is a plain JSON document. It is the contract every emitter reads,
|
|
915
|
+
* and it is published as a schema so a third-party emitter can target it too.
|
|
916
|
+
* @param {object|boolean} schema - The root schema
|
|
917
|
+
* @param {EmitModelOptions} [options] - Compile options
|
|
918
|
+
* @returns {EmitModel} The type model document
|
|
919
|
+
* @example
|
|
920
|
+
* const model = compileEmitModel({
|
|
921
|
+
* $defs: { Id: { type: 'string' } },
|
|
922
|
+
* type: 'object',
|
|
923
|
+
* properties: { id: { $ref: '#/$defs/Id' } },
|
|
924
|
+
* required: ['id'],
|
|
925
|
+
* }, { name: 'User' });
|
|
926
|
+
*/
|
|
927
|
+
export function compileEmitModel(schema, options = {}) {
|
|
928
|
+
const name = options.name ?? 'Root';
|
|
929
|
+
const normalize = options.normalize ?? null;
|
|
930
|
+
|
|
931
|
+
// State shared across the two passes: the anchor map, the normalization
|
|
932
|
+
// analysis memos, and the PLAIN universe's names — plain declarations are
|
|
933
|
+
// variant-less by construction, so one set serves both sides.
|
|
934
|
+
const shared = {
|
|
935
|
+
anchors: collectSameDocumentAnchors(schema),
|
|
936
|
+
differs: new Map(),
|
|
937
|
+
analyzing: new Set(),
|
|
938
|
+
plainNamed: new Map(),
|
|
939
|
+
plainBuilding: new Set(),
|
|
940
|
+
};
|
|
941
|
+
|
|
942
|
+
/** One pass over the schema, in one variant. */
|
|
943
|
+
const run = (variant, sharedNames, taken) => {
|
|
944
|
+
const ctx = createContext(schema, options, shared);
|
|
945
|
+
ctx.variant = variant;
|
|
946
|
+
ctx.shared = sharedNames;
|
|
947
|
+
ctx.taken = taken
|
|
948
|
+
?? (Array.isArray(options.reserved) ? options.reserved.slice() : []);
|
|
949
|
+
|
|
950
|
+
// `$defs`/`definitions` are declared first and in document order: they are
|
|
951
|
+
// the names a reader of the schema already uses, and declaring them up
|
|
952
|
+
// front keeps the emitted file's order stable and readable.
|
|
953
|
+
if (isJsonObject(schema)) {
|
|
954
|
+
for (const container of ['$defs', 'definitions']) {
|
|
955
|
+
const defs = schema[container];
|
|
956
|
+
if (!isJsonObject(defs)) continue;
|
|
957
|
+
const keys = Object.getOwnPropertyNames(defs);
|
|
958
|
+
for (let i = 0; i < keys.length; i++) {
|
|
959
|
+
// forceOwn for a boolean def: two `true` defs are the same schema
|
|
960
|
+
// VALUE, but each name a reader can import must exist.
|
|
961
|
+
declare(defs[keys[i]], ctx, keys[i],
|
|
962
|
+
typeof defs[keys[i]] === 'boolean');
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
declare(schema, ctx, name);
|
|
967
|
+
return ctx;
|
|
968
|
+
};
|
|
969
|
+
|
|
970
|
+
// Pass one is the schema's own shape, which is also the NORMALIZED shape
|
|
971
|
+
// when normalization is configured: normalizing is what produces it.
|
|
972
|
+
const base = run(normalize === null ? null : 'normalized', new Map(), null);
|
|
973
|
+
const declarations = base.declarations;
|
|
974
|
+
|
|
975
|
+
// Pass two derives the ACCEPTED shape — what a caller may hand in before
|
|
976
|
+
// normalization. It reuses pass one's names for everything normalization
|
|
977
|
+
// does not change, so only the types that genuinely differ gain a twin.
|
|
978
|
+
if (normalize !== null) {
|
|
979
|
+
const accepted = run('accepted', base.named, base.taken);
|
|
980
|
+
declarations.push(...accepted.declarations);
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
const model = {
|
|
984
|
+
$emit: EMIT_MODEL_VERSION,
|
|
985
|
+
source: options.source ?? null,
|
|
986
|
+
root: base.named.get(schema) ?? name,
|
|
987
|
+
declarations,
|
|
988
|
+
};
|
|
989
|
+
if (normalize !== null) model.variants = true;
|
|
990
|
+
return model;
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
//#endregion
|