@json-schema-engine/core 0.0.1

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.
Files changed (143) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +50 -0
  3. package/dist/coverage.d.ts +30 -0
  4. package/dist/coverage.d.ts.map +1 -0
  5. package/dist/coverage.js +81 -0
  6. package/dist/coverage.js.map +1 -0
  7. package/dist/cursor.d.ts +16 -0
  8. package/dist/cursor.d.ts.map +1 -0
  9. package/dist/cursor.js +28 -0
  10. package/dist/cursor.js.map +1 -0
  11. package/dist/dialect.d.ts +316 -0
  12. package/dist/dialect.d.ts.map +1 -0
  13. package/dist/dialect.js +135 -0
  14. package/dist/dialect.js.map +1 -0
  15. package/dist/engine.d.ts +203 -0
  16. package/dist/engine.d.ts.map +1 -0
  17. package/dist/engine.js +493 -0
  18. package/dist/engine.js.map +1 -0
  19. package/dist/index.d.ts +182 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +370 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/json.d.ts +49 -0
  24. package/dist/json.d.ts.map +1 -0
  25. package/dist/json.js +128 -0
  26. package/dist/json.js.map +1 -0
  27. package/dist/keywords/applicator.d.ts +57 -0
  28. package/dist/keywords/applicator.d.ts.map +1 -0
  29. package/dist/keywords/applicator.js +789 -0
  30. package/dist/keywords/applicator.js.map +1 -0
  31. package/dist/keywords/core.d.ts +42 -0
  32. package/dist/keywords/core.d.ts.map +1 -0
  33. package/dist/keywords/core.js +139 -0
  34. package/dist/keywords/core.js.map +1 -0
  35. package/dist/keywords/format.d.ts +43 -0
  36. package/dist/keywords/format.d.ts.map +1 -0
  37. package/dist/keywords/format.js +101 -0
  38. package/dist/keywords/format.js.map +1 -0
  39. package/dist/keywords/metaschemas2019.d.ts +3 -0
  40. package/dist/keywords/metaschemas2019.d.ts.map +1 -0
  41. package/dist/keywords/metaschemas2019.js +406 -0
  42. package/dist/keywords/metaschemas2019.js.map +1 -0
  43. package/dist/keywords/metaschemas2020.d.ts +3 -0
  44. package/dist/keywords/metaschemas2020.d.ts.map +1 -0
  45. package/dist/keywords/metaschemas2020.js +441 -0
  46. package/dist/keywords/metaschemas2020.js.map +1 -0
  47. package/dist/keywords/metaschemas6.d.ts +3 -0
  48. package/dist/keywords/metaschemas6.d.ts.map +1 -0
  49. package/dist/keywords/metaschemas6.js +221 -0
  50. package/dist/keywords/metaschemas6.js.map +1 -0
  51. package/dist/keywords/metaschemas7.d.ts +3 -0
  52. package/dist/keywords/metaschemas7.d.ts.map +1 -0
  53. package/dist/keywords/metaschemas7.js +248 -0
  54. package/dist/keywords/metaschemas7.js.map +1 -0
  55. package/dist/keywords/unevaluated.d.ts +14 -0
  56. package/dist/keywords/unevaluated.d.ts.map +1 -0
  57. package/dist/keywords/unevaluated.js +296 -0
  58. package/dist/keywords/unevaluated.js.map +1 -0
  59. package/dist/keywords/validation.d.ts +21 -0
  60. package/dist/keywords/validation.d.ts.map +1 -0
  61. package/dist/keywords/validation.js +332 -0
  62. package/dist/keywords/validation.js.map +1 -0
  63. package/dist/keywords/vocab2019.d.ts +36 -0
  64. package/dist/keywords/vocab2019.d.ts.map +1 -0
  65. package/dist/keywords/vocab2019.js +585 -0
  66. package/dist/keywords/vocab2019.js.map +1 -0
  67. package/dist/keywords/vocab2020.d.ts +20 -0
  68. package/dist/keywords/vocab2020.d.ts.map +1 -0
  69. package/dist/keywords/vocab2020.js +55 -0
  70. package/dist/keywords/vocab2020.js.map +1 -0
  71. package/dist/keywords/vocab7.d.ts +46 -0
  72. package/dist/keywords/vocab7.d.ts.map +1 -0
  73. package/dist/keywords/vocab7.js +305 -0
  74. package/dist/keywords/vocab7.js.map +1 -0
  75. package/dist/loader.d.ts +41 -0
  76. package/dist/loader.d.ts.map +1 -0
  77. package/dist/loader.js +13 -0
  78. package/dist/loader.js.map +1 -0
  79. package/dist/lowering.d.ts +371 -0
  80. package/dist/lowering.d.ts.map +1 -0
  81. package/dist/lowering.js +59 -0
  82. package/dist/lowering.js.map +1 -0
  83. package/dist/output.d.ts +251 -0
  84. package/dist/output.d.ts.map +1 -0
  85. package/dist/output.js +357 -0
  86. package/dist/output.js.map +1 -0
  87. package/dist/records.d.ts +71 -0
  88. package/dist/records.d.ts.map +1 -0
  89. package/dist/records.js +150 -0
  90. package/dist/records.js.map +1 -0
  91. package/dist/ref.d.ts +10 -0
  92. package/dist/ref.d.ts.map +1 -0
  93. package/dist/ref.js +5 -0
  94. package/dist/ref.js.map +1 -0
  95. package/dist/regex.d.ts +45 -0
  96. package/dist/regex.d.ts.map +1 -0
  97. package/dist/regex.js +163 -0
  98. package/dist/regex.js.map +1 -0
  99. package/dist/registry.d.ts +141 -0
  100. package/dist/registry.d.ts.map +1 -0
  101. package/dist/registry.js +407 -0
  102. package/dist/registry.js.map +1 -0
  103. package/dist/result.d.ts +109 -0
  104. package/dist/result.d.ts.map +1 -0
  105. package/dist/result.js +112 -0
  106. package/dist/result.js.map +1 -0
  107. package/dist/uri.d.ts +18 -0
  108. package/dist/uri.d.ts.map +1 -0
  109. package/dist/uri.js +27 -0
  110. package/dist/uri.js.map +1 -0
  111. package/dist/walk.d.ts +25 -0
  112. package/dist/walk.d.ts.map +1 -0
  113. package/dist/walk.js +65 -0
  114. package/dist/walk.js.map +1 -0
  115. package/package.json +32 -0
  116. package/src/coverage.ts +88 -0
  117. package/src/cursor.ts +42 -0
  118. package/src/dialect.ts +428 -0
  119. package/src/engine.ts +741 -0
  120. package/src/index.ts +699 -0
  121. package/src/json.ts +137 -0
  122. package/src/keywords/applicator.ts +895 -0
  123. package/src/keywords/core.ts +168 -0
  124. package/src/keywords/format.ts +143 -0
  125. package/src/keywords/metaschemas2019.ts +410 -0
  126. package/src/keywords/metaschemas2020.ts +443 -0
  127. package/src/keywords/metaschemas6.ts +226 -0
  128. package/src/keywords/metaschemas7.ts +253 -0
  129. package/src/keywords/unevaluated.ts +329 -0
  130. package/src/keywords/validation.ts +627 -0
  131. package/src/keywords/vocab2019.ts +676 -0
  132. package/src/keywords/vocab2020.ts +82 -0
  133. package/src/keywords/vocab7.ts +393 -0
  134. package/src/loader.ts +60 -0
  135. package/src/lowering.ts +414 -0
  136. package/src/output.ts +651 -0
  137. package/src/records.ts +226 -0
  138. package/src/ref.ts +14 -0
  139. package/src/regex.ts +171 -0
  140. package/src/registry.ts +481 -0
  141. package/src/result.ts +252 -0
  142. package/src/uri.ts +35 -0
  143. package/src/walk.ts +88 -0
@@ -0,0 +1,481 @@
1
+ // Schema registration and static reference resolution.
2
+ //
3
+ // The registration walk descends only into *schema positions*, so `$id` or
4
+ // `$anchor` inside `enum` data or an unknown keyword's value is never treated
5
+ // as an identifier. Unlike the F2 prototype, the positions are not hard-coded:
6
+ // the walk asks each keyword behavior's analyze() for its subschema positions
7
+ // (DESIGN.md D1/D2), so a custom applicator registered through the dialect
8
+ // registry gets correct identifier handling for free.
9
+
10
+ import { JsonValue, isObject, escapeSegment, unescapeSegment } from "./json.js";
11
+ import { resolveUri, splitFragment, UnresolvableRefError } from "./uri.js";
12
+ import { SchemaRef } from "./ref.js";
13
+ import { Dialect, DialectRegistry, ReadOnlyRegistryError } from "./dialect.js";
14
+ import { SourceRange } from "./loader.js";
15
+
16
+ /**
17
+ * Where a schema resource physically lives: the registered document
18
+ * containing it and the JSON Pointer from that document's root to the
19
+ * resource's root (D17 bridge; see loader.ts).
20
+ */
21
+ export interface DocumentLocation {
22
+ documentUri: string;
23
+ pointer: string;
24
+ }
25
+
26
+ /**
27
+ * A value that is not a schema (neither an object nor a boolean) was found
28
+ * where a schema is required (D19): in a keyword-claimed schema position at
29
+ * registration, or applied as a schema during evaluation. Keyword-value
30
+ * validity beyond schema shape is not checked here — that is metaschema
31
+ * validation's job ({@link EngineOptions.validateSchemas}).
32
+ *
33
+ * When thrown during registration, the document may be partially indexed;
34
+ * re-register a corrected document under the same URI, or discard the
35
+ * engine.
36
+ */
37
+ export class InvalidSchemaError extends Error {}
38
+
39
+ /**
40
+ * Schema nesting (at registration) or schema-application nesting (at
41
+ * evaluation) exceeded {@link EngineOptions.maxDepth}. A bounded, catchable
42
+ * failure that replaces the native stack overflow deep input would otherwise
43
+ * cause; the engine remains usable afterward (each evaluation runs in fresh
44
+ * state). Raise `maxDepth` for legitimately deep documents, within what the
45
+ * runtime's own stack allows.
46
+ */
47
+ export class MaxDepthExceededError extends Error {}
48
+
49
+ /**
50
+ * Default schema/application nesting bound. Chosen below the native
51
+ * call-stack ceiling so the typed {@link MaxDepthExceededError} fires before
52
+ * a `RangeError`, while staying generous for real-world documents.
53
+ */
54
+ export const DEFAULT_MAX_DEPTH = 512;
55
+
56
+ /** One-line description of a non-schema value for error messages. */
57
+ export function describeNonSchema(node: JsonValue): string {
58
+ if (node === null) return "null";
59
+ return Array.isArray(node) ? "array" : typeof node;
60
+ }
61
+
62
+ /** Schema registration, identifier indexing, and reference resolution. */
63
+ export class SchemaRegistry {
64
+ private documents = new Map<string, JsonValue>(); // resource URI -> schema node
65
+ private anchors = new Map<string, SchemaRef>(); // "resource#anchor"
66
+ private dynamicAnchors = new Map<string, SchemaRef>(); // $dynamicAnchor only (D8)
67
+ private recursiveRoots = new Set<string>(); // 2019-09 $recursiveAnchor at root
68
+ // Unions of StaticFacts.produces / consumes over every registered keyword
69
+ // occurrence: produce()'s declaration guard and the elision predicate's
70
+ // "someone might read this" side (D5/M5.5).
71
+ private producedBehaviorIds = new Set<string>();
72
+ private consumedBehaviorIds = new Set<string>();
73
+ // Consumed ids whose producer declares evaluated coverage (D9a facts): the
74
+ // only dependency data the coverage channel's shape-dispatching folds
75
+ // understand (coverage.ts).
76
+ private coverageProducerIds = new Set<string>();
77
+ private coverageConsumedIds = new Set<string>();
78
+ private documentDialects = new Map<string, string>(); // resource URI -> dialect URI
79
+ private resourceLocations = new Map<string, DocumentLocation>();
80
+ // Retrieval URI -> declared $id base, when they differ: the document must
81
+ // be reachable under both, but anchors and lexical bases live under $id.
82
+ private aliases = new Map<string, string>();
83
+ // External resources seen in reference values during registration walks,
84
+ // drained by the load closure (D7).
85
+ private pendingResources = new Set<string>();
86
+ private documentRanges = new Map<
87
+ string,
88
+ (pointer: string) => SourceRange | undefined
89
+ >();
90
+ /**
91
+ * Called for each `pattern`/`patternProperties` regex during a
92
+ * registration walk, when set. The Engine installs this (after registering
93
+ * its trusted metaschemas) to enforce `rejectUnsafeRegex`.
94
+ */
95
+ onRegex?: (pattern: string, location: string) => void;
96
+ // Snapshots share the indexes above copy-on-write: the source copies them
97
+ // before its first registration after a snapshot, so a view stays frozen
98
+ // at no cost until the source changes.
99
+ private shared = false;
100
+ private readOnly = false;
101
+
102
+ constructor(
103
+ private dialectRegistry: DialectRegistry,
104
+ private defaultDialectUri: string,
105
+ private maxDepth: number = DEFAULT_MAX_DEPTH,
106
+ ) {}
107
+
108
+ /**
109
+ * A read-only view of the registry's current contents, over a view of the
110
+ * dialect registry. Later registrations on this registry are invisible to
111
+ * the view, and registering into the view throws
112
+ * {@link ReadOnlyRegistryError}. A compiled artifact binds to one so that
113
+ * a compilation boundary cannot change reference resolution (E1).
114
+ */
115
+ snapshot(): SchemaRegistry {
116
+ const view = new SchemaRegistry(
117
+ this.dialectRegistry.snapshot(),
118
+ this.defaultDialectUri,
119
+ this.maxDepth,
120
+ );
121
+ view.documents = this.documents;
122
+ view.anchors = this.anchors;
123
+ view.dynamicAnchors = this.dynamicAnchors;
124
+ view.recursiveRoots = this.recursiveRoots;
125
+ view.producedBehaviorIds = this.producedBehaviorIds;
126
+ view.consumedBehaviorIds = this.consumedBehaviorIds;
127
+ view.coverageProducerIds = this.coverageProducerIds;
128
+ view.coverageConsumedIds = this.coverageConsumedIds;
129
+ view.documentDialects = this.documentDialects;
130
+ view.resourceLocations = this.resourceLocations;
131
+ view.aliases = this.aliases;
132
+ view.documentRanges = this.documentRanges;
133
+ view.readOnly = true;
134
+ this.shared = true;
135
+ return view;
136
+ }
137
+
138
+ private mutable(): void {
139
+ if (this.readOnly) {
140
+ throw new ReadOnlyRegistryError("a registry snapshot is read-only");
141
+ }
142
+ if (!this.shared) return;
143
+ this.documents = new Map(this.documents);
144
+ this.anchors = new Map(this.anchors);
145
+ this.dynamicAnchors = new Map(this.dynamicAnchors);
146
+ this.recursiveRoots = new Set(this.recursiveRoots);
147
+ this.producedBehaviorIds = new Set(this.producedBehaviorIds);
148
+ this.consumedBehaviorIds = new Set(this.consumedBehaviorIds);
149
+ this.coverageProducerIds = new Set(this.coverageProducerIds);
150
+ this.coverageConsumedIds = new Set(this.coverageConsumedIds);
151
+ this.documentDialects = new Map(this.documentDialects);
152
+ this.resourceLocations = new Map(this.resourceLocations);
153
+ this.aliases = new Map(this.aliases);
154
+ this.documentRanges = new Map(this.documentRanges);
155
+ this.shared = false;
156
+ }
157
+
158
+ /**
159
+ * Register a schema document. The dialect comes from `$schema` when present
160
+ * (and must already be registered), else `dialectUri`, else the default.
161
+ * Returns the document's canonical base URI.
162
+ */
163
+ register(
164
+ schema: JsonValue,
165
+ retrievalUri: string,
166
+ dialectUri?: string,
167
+ getRange?: (pointer: string) => SourceRange | undefined,
168
+ ): string {
169
+ this.mutable();
170
+ // Dialect URIs are compared fragment-free: "…/draft-07/schema#" (the
171
+ // canonical in-the-wild $schema spelling) names the same dialect.
172
+ let effectiveDialect = splitFragment(
173
+ dialectUri ?? this.defaultDialectUri,
174
+ ).resource;
175
+ if (isObject(schema) && typeof schema.$schema === "string") {
176
+ effectiveDialect = splitFragment(
177
+ resolveUri(schema.$schema, retrievalUri),
178
+ ).resource;
179
+ }
180
+ const dialect = this.dialectRegistry.getDialect(effectiveDialect);
181
+
182
+ const retrievalResource = splitFragment(retrievalUri).resource;
183
+ let baseUri = retrievalResource;
184
+ const rootIds = isObject(schema) ? dialect.identifiers(schema) : {};
185
+ if (rootIds.baseId !== undefined) {
186
+ baseUri = splitFragment(resolveUri(rootIds.baseId, baseUri)).resource;
187
+ }
188
+ if (baseUri !== retrievalResource)
189
+ this.aliases.set(retrievalResource, baseUri);
190
+ this.documents.set(baseUri, schema);
191
+ this.documentDialects.set(baseUri, effectiveDialect);
192
+ this.resourceLocations.set(baseUri, { documentUri: baseUri, pointer: "" });
193
+ if (getRange) this.documentRanges.set(baseUri, getRange);
194
+ this.walk(schema, baseUri, "", baseUri, "", dialect, 0);
195
+ return baseUri;
196
+ }
197
+
198
+ private walk(
199
+ node: JsonValue,
200
+ baseUri: string,
201
+ pointer: string,
202
+ documentUri: string,
203
+ docPointer: string, // pointer from the registered document's root
204
+ dialect: Dialect,
205
+ depth: number,
206
+ ): void {
207
+ if (depth > this.maxDepth) {
208
+ throw new MaxDepthExceededError(
209
+ `schema nesting exceeds maxDepth (${this.maxDepth}) at ` +
210
+ `'${baseUri}#${pointer}'`,
211
+ );
212
+ }
213
+ if (typeof node === "boolean") return;
214
+ if (!isObject(node)) {
215
+ throw new InvalidSchemaError(
216
+ `non-schema value (${describeNonSchema(node)}) in schema position ` +
217
+ `'${baseUri}#${pointer}'`,
218
+ );
219
+ }
220
+
221
+ const ids = dialect.identifiers(node);
222
+ if (pointer !== "" && ids.baseId !== undefined) {
223
+ baseUri = splitFragment(resolveUri(ids.baseId, baseUri)).resource;
224
+ pointer = "";
225
+ this.documents.set(baseUri, node);
226
+ this.documentDialects.set(baseUri, dialect.uri);
227
+ this.resourceLocations.set(baseUri, { documentUri, pointer: docPointer });
228
+ }
229
+ for (const anchor of ids.anchors ?? []) {
230
+ this.anchors.set(`${baseUri}#${anchor}`, { node, baseUri, pointer });
231
+ }
232
+ // A dynamic anchor is also a plain anchor for $ref purposes; only the
233
+ // dynamic-anchor index participates in $dynamicRef rebinding (D8).
234
+ if (ids.dynamicAnchor !== undefined) {
235
+ const ref = { node, baseUri, pointer };
236
+ this.anchors.set(`${baseUri}#${ids.dynamicAnchor}`, ref);
237
+ this.dynamicAnchors.set(`${baseUri}#${ids.dynamicAnchor}`, ref);
238
+ }
239
+ // $recursiveAnchor participates in rebinding only at a resource root.
240
+ if (ids.recursiveAnchor === true && pointer === "") {
241
+ this.recursiveRoots.add(baseUri);
242
+ }
243
+
244
+ for (const [name, value] of Object.entries(node)) {
245
+ const behavior = dialect.keywords.get(name)?.behavior;
246
+ const facts = behavior?.analyze?.(value, { schema: node });
247
+ if (!facts) continue;
248
+ for (const p of facts.produces ?? []) this.producedBehaviorIds.add(p);
249
+ for (const c of facts.consumes ?? []) {
250
+ this.consumedBehaviorIds.add(c);
251
+ if (this.coverageProducerIds.has(c)) this.coverageConsumedIds.add(c);
252
+ }
253
+ if (
254
+ facts.evaluatesNames !== undefined ||
255
+ facts.evaluatesIndexes !== undefined
256
+ ) {
257
+ this.coverageProducerIds.add(behavior!.id);
258
+ if (this.consumedBehaviorIds.has(behavior!.id)) {
259
+ this.coverageConsumedIds.add(behavior!.id);
260
+ }
261
+ }
262
+ if (this.onRegex) {
263
+ const keywordLocation = `${baseUri}#${pointer}/${escapeSegment(name)}`;
264
+ for (const rx of facts.regexes ?? []) this.onRegex(rx, keywordLocation);
265
+ }
266
+ for (const ref of facts.references ?? []) {
267
+ try {
268
+ this.pendingResources.add(
269
+ splitFragment(resolveUri(ref, baseUri)).resource,
270
+ );
271
+ } catch {
272
+ // Unresolvable now is not an error: evaluation reports it if the
273
+ // reference is actually followed.
274
+ }
275
+ }
276
+ const positions = facts.subschemas;
277
+ if (!positions) continue;
278
+ for (const relPath of positions) {
279
+ let child: JsonValue = value;
280
+ let suffix = "/" + escapeSegment(name);
281
+ for (const seg of relPath) {
282
+ child = (
283
+ Array.isArray(child)
284
+ ? child[seg as number]
285
+ : (child as Record<string, JsonValue>)[seg as string]
286
+ ) as JsonValue;
287
+ suffix += "/" + escapeSegment(String(seg));
288
+ }
289
+ this.walk(
290
+ child,
291
+ baseUri,
292
+ pointer + suffix,
293
+ documentUri,
294
+ docPointer + suffix,
295
+ dialect,
296
+ depth + 1,
297
+ );
298
+ }
299
+ }
300
+ }
301
+
302
+ /**
303
+ * The registered document containing a schema resource, and the resource
304
+ * root's pointer from that document's root (D17 bridge; see loader.ts).
305
+ * Undefined for resources the registration walk never saw (e.g. an `$id`
306
+ * inside an unknown keyword reached only by pointer navigation).
307
+ */
308
+ documentLocation(resourceUri: string): DocumentLocation | undefined {
309
+ return this.resourceLocations.get(this.canonical(resourceUri));
310
+ }
311
+
312
+ /** Source range for a document-rooted pointer, when the loader supplied one (D17). */
313
+ range(documentUri: string, pointer: string): SourceRange | undefined {
314
+ return this.documentRanges.get(documentUri)?.(pointer);
315
+ }
316
+
317
+ /** True if a resource is registered, directly or via a retrieval-URI alias. */
318
+ has(resourceUri: string): boolean {
319
+ return this.documents.has(resourceUri) || this.aliases.has(resourceUri);
320
+ }
321
+
322
+ /** The schema node at a resource's root, if registered. */
323
+ document(resourceUri: string): JsonValue | undefined {
324
+ return this.documents.get(this.canonical(resourceUri));
325
+ }
326
+
327
+ /** External resources referenced but not yet registered; drained per call. */
328
+ takeUnresolved(): string[] {
329
+ const missing = [...this.pendingResources].filter((r) => !this.has(r));
330
+ this.pendingResources.clear();
331
+ return missing;
332
+ }
333
+
334
+ /** The `$dynamicAnchor` target for a name in a resource, if one was registered (D8). */
335
+ dynamicAnchor(resourceUri: string, name: string): SchemaRef | undefined {
336
+ return this.dynamicAnchors.get(`${this.canonical(resourceUri)}#${name}`);
337
+ }
338
+
339
+ /** True if a resource's root carries 2019-09 `$recursiveAnchor: true`. */
340
+ hasRecursiveRoot(resourceUri: string): boolean {
341
+ return this.recursiveRoots.has(this.canonical(resourceUri));
342
+ }
343
+
344
+ /** Behavior ids some registered keyword declares it produces dependency data under. */
345
+ producedIds(): ReadonlySet<string> {
346
+ return this.producedBehaviorIds;
347
+ }
348
+
349
+ /** Behavior ids some registered keyword declares it consumes. */
350
+ consumedIds(): ReadonlySet<string> {
351
+ return this.consumedBehaviorIds;
352
+ }
353
+
354
+ /** The consumed subset of {@link consumedIds} whose producer declares evaluated coverage: what the coverage channel carries. */
355
+ coverageIds(): ReadonlySet<string> {
356
+ return this.coverageConsumedIds;
357
+ }
358
+
359
+ private canonical(resourceUri: string): string {
360
+ return this.aliases.get(resourceUri) ?? resourceUri;
361
+ }
362
+
363
+ /**
364
+ * The dialect URI a resource was registered under.
365
+ * @throws UnresolvableRefError if the resource is not registered.
366
+ */
367
+ dialectUriFor(baseUri: string): string {
368
+ const uri = this.documentDialects.get(this.canonical(baseUri));
369
+ if (uri === undefined)
370
+ throw new UnresolvableRefError(`unknown schema '${baseUri}'`);
371
+ return uri;
372
+ }
373
+
374
+ /**
375
+ * The dialect a resource was registered under.
376
+ * @throws UnresolvableRefError if the resource is not registered.
377
+ */
378
+ dialectFor(baseUri: string): Dialect {
379
+ return this.dialectRegistry.getDialect(this.dialectUriFor(baseUri));
380
+ }
381
+
382
+ /**
383
+ * Resolves a URI to its resource's root schema.
384
+ * @throws UnresolvableRefError if the resource is not registered.
385
+ */
386
+ rootRef(uri: string): SchemaRef {
387
+ const { resource: rawResource, fragment } = splitFragment(uri);
388
+ const resource = this.canonical(rawResource);
389
+ if (fragment !== null && fragment !== "") {
390
+ return this.resolveRef(uri, resource);
391
+ }
392
+ const node = this.documents.get(resource);
393
+ if (node === undefined)
394
+ throw new UnresolvableRefError(`unknown schema '${resource}'`);
395
+ return { node, baseUri: resource, pointer: "" };
396
+ }
397
+
398
+ /**
399
+ * Resolves a reference value against the referring schema's base URI.
400
+ * @throws UnresolvableRefError if the resource, anchor, or pointer target
401
+ * does not exist.
402
+ */
403
+ resolveRef(ref: string, currentBase: string): SchemaRef {
404
+ const resolved = splitFragment(resolveUri(ref, currentBase));
405
+ const resource = this.canonical(resolved.resource);
406
+ const fragment = resolved.fragment;
407
+
408
+ if (fragment !== null && fragment !== "" && !fragment.startsWith("/")) {
409
+ const hit = this.anchors.get(`${resource}#${fragment}`);
410
+ if (!hit)
411
+ throw new UnresolvableRefError(
412
+ `unknown anchor '${resource}#${fragment}'`,
413
+ );
414
+ return hit;
415
+ }
416
+
417
+ const root = this.documents.get(resource);
418
+ if (root === undefined)
419
+ throw new UnresolvableRefError(`unknown schema '${resource}'`);
420
+ if (fragment === null || fragment === "") {
421
+ return { node: root, baseUri: resource, pointer: "" };
422
+ }
423
+
424
+ // JSON Pointer navigation, tracking identifier-induced base changes on
425
+ // the way, per the target document's dialect (D18).
426
+ const identifiers = this.dialectFor(resource).identifiers;
427
+ let node: JsonValue | undefined = root;
428
+ let baseUri = resource;
429
+ let pointer = "";
430
+ for (const rawSeg of fragment.slice(1).split("/")) {
431
+ const seg = unescapeSegment(rawSeg);
432
+ if (Array.isArray(node)) {
433
+ node = node[Number(seg)];
434
+ } else if (isObject(node) && Object.hasOwn(node, seg)) {
435
+ node = node[seg];
436
+ } else {
437
+ node = undefined;
438
+ }
439
+ if (node === undefined) {
440
+ throw new UnresolvableRefError(
441
+ `pointer '${fragment}' not found in '${resource}'`,
442
+ );
443
+ }
444
+ pointer += "/" + escapeSegment(seg);
445
+ if (isObject(node)) {
446
+ const baseId = identifiers(node).baseId;
447
+ if (baseId !== undefined) {
448
+ baseUri = splitFragment(resolveUri(baseId, baseUri)).resource;
449
+ pointer = "";
450
+ }
451
+ }
452
+ }
453
+ return { node, baseUri, pointer };
454
+ }
455
+
456
+ /**
457
+ * Descend from a schema position into keyword/index children, maintaining
458
+ * canonical location and lexical base.
459
+ */
460
+ child(ref: SchemaRef, segments: readonly (string | number)[]): SchemaRef {
461
+ const identifiers = this.dialectFor(ref.baseUri).identifiers;
462
+ let node: JsonValue = ref.node;
463
+ let { baseUri, pointer } = ref;
464
+ for (const seg of segments) {
465
+ node = (
466
+ Array.isArray(node)
467
+ ? node[seg as number]
468
+ : (node as Record<string, JsonValue>)[seg as string]
469
+ ) as JsonValue;
470
+ pointer += "/" + escapeSegment(String(seg));
471
+ if (isObject(node)) {
472
+ const baseId = identifiers(node).baseId;
473
+ if (baseId !== undefined) {
474
+ baseUri = splitFragment(resolveUri(baseId, baseUri)).resource;
475
+ pointer = "";
476
+ }
477
+ }
478
+ }
479
+ return { node, baseUri, pointer };
480
+ }
481
+ }