@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/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