@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
package/src/dialect.ts ADDED
@@ -0,0 +1,428 @@
1
+ // Dialect/vocabulary registry and the keyword behavior interface (DESIGN.md
2
+ // D2, §3): keywords are identified by URI, a vocabulary is a named map of
3
+ // keyword behaviors, a dialect is an ordered set of vocabularies. Built-in
4
+ // drafts and user extensions use the same mechanism — nothing here is
5
+ // privileged.
6
+
7
+ import { JsonValue } from "./json.js";
8
+ import { Cursor } from "./cursor.js";
9
+ import { SchemaRef } from "./ref.js";
10
+ import { CompiledRegex } from "./regex.js";
11
+ import { LoweringContext } from "./lowering.js";
12
+
13
+ /**
14
+ * Static contribution of one keyword to the evaluated-property-name set of
15
+ * its schema object (D9a). A coverage is a *predicate description*, not
16
+ * necessarily a finite set: `patterns` still lowers (hoisted regexes in the
17
+ * sweep test); only `dynamic` forces runtime evaluated-set tracking.
18
+ */
19
+ export type NameCoverage =
20
+ | { kind: "names"; names: readonly string[] }
21
+ | { kind: "patterns"; patterns: readonly string[] }
22
+ | { kind: "all" }
23
+ | { kind: "dynamic" };
24
+
25
+ /** Static contribution to the evaluated-index coverage of an array (D9a). */
26
+ export type IndexCoverage =
27
+ | { kind: "prefix"; count: number }
28
+ | { kind: "allFrom"; start: number }
29
+ | { kind: "all" }
30
+ | { kind: "dynamic" };
31
+
32
+ /**
33
+ * How a keyword applies one subschema — the compilation planner's edge
34
+ * facts, richer than `subschemas` (which only says where children live and
35
+ * keeps driving the registration walk).
36
+ */
37
+ export interface SubschemaApplication {
38
+ /** position relative to the keyword's value; [] is the value itself. A leading "../name" is expressed via `sibling` instead. */
39
+ path: readonly (string | number)[];
40
+ /** set when the applied subschema is a sibling keyword's value (if → then/else) */
41
+ sibling?: string;
42
+ /** for reference keywords: the reference value, resolved against the lexical base at plan time (path is ignored) */
43
+ ref?: string;
44
+ mode:
45
+ | "inPlace" // same cursor (allOf/anyOf/oneOf/not/if/$ref)
46
+ | "childByKey" // fixed property name (properties entries)
47
+ | "childByIndex" // fixed array index (prefixItems entries)
48
+ | "childSweep" // runtime-determined children (items, *Properties sweeps)
49
+ | "propertyName"; // applied to the property NAME as instance
50
+ /** application depends on runtime branching (anyOf/oneOf alternatives, if-guarded), not merely instance shape */
51
+ conditional: boolean;
52
+ /** the subschema's verdict feeds this keyword's verdict (false for if's condition role and contains' per-item probes) */
53
+ asserts: boolean;
54
+ /**
55
+ * The verdict feeds NEGATED (`not`): subschema success fails the keyword.
56
+ * Coverage analysis (D9a) skips inverted edges — their records never
57
+ * survive into the parent on the parent-success path (success fails the
58
+ * node; failure discards the frame).
59
+ */
60
+ inverted?: boolean;
61
+ }
62
+
63
+ /**
64
+ * Static facts about one keyword occurrence, derived from its value alone.
65
+ * This is the compiler tier's entire window into keyword semantics (D1), and
66
+ * it also drives the registry's schema-position walk.
67
+ */
68
+ export interface StaticFacts {
69
+ /** paths to child schemas, relative to the keyword's value */
70
+ subschemas?: readonly (readonly (string | number)[])[];
71
+ /**
72
+ * reference URIs this keyword's value points at (relative to the lexical
73
+ * base); drives transitive resource loading (D7)
74
+ */
75
+ references?: readonly string[];
76
+ /**
77
+ * behavior ids of dependency records this keyword can emit — its own id;
78
+ * `produce()` refuses an undeclared producer (engine.ts
79
+ * UndeclaredProductionError). Annotation keywords declare nothing.
80
+ */
81
+ produces?: readonly string[];
82
+ /** behavior ids of dependency records this keyword reads from the channel */
83
+ consumes?: readonly string[];
84
+ /**
85
+ * regular-expression values this keyword compiles (e.g. `pattern`, the
86
+ * property-name patterns of `patternProperties`); screened by
87
+ * `rejectUnsafeRegex` at registration (see regex.ts)
88
+ */
89
+ regexes?: readonly string[];
90
+ /**
91
+ * format names this keyword needs a table entry for (the compiler hoists
92
+ * one lookup per name); screened by `UnknownFormatError` at registration
93
+ * under the refuse-unknown posture (see keywords/format.ts)
94
+ */
95
+ formats?: readonly string[];
96
+ /** participates in dynamic scope resolution ($dynamicRef and friends) */
97
+ dynamicScopeSensitive?: boolean;
98
+ /** static evaluated-name contribution (D9a; see {@link NameCoverage}) */
99
+ evaluatesNames?: NameCoverage;
100
+ /** static evaluated-index contribution (D9a; see {@link IndexCoverage}) */
101
+ evaluatesIndexes?: IndexCoverage;
102
+ /**
103
+ * how this keyword applies its subschemas — planner edge facts
104
+ * ({@link SubschemaApplication}); `subschemas` remains the registration
105
+ * walk's position list
106
+ */
107
+ applications?: readonly SubschemaApplication[];
108
+ }
109
+
110
+ /**
111
+ * Context for {@link KeywordBehavior.analyze}: the keyword's containing
112
+ * schema object, for sibling-dependent facts (`items` starts after
113
+ * `prefixItems`; `if` declares applications for sibling `then`/`else`) —
114
+ * the same sibling reads `evaluate()` performs through `ctx.schema`.
115
+ */
116
+ export interface AnalyzeContext {
117
+ readonly schema: Readonly<Record<string, JsonValue>>;
118
+ }
119
+
120
+ /** Minimal view of a dependency record, for consumer keywords. */
121
+ export interface DependencyView {
122
+ behaviorId: string;
123
+ data: unknown;
124
+ }
125
+
126
+ /**
127
+ * The engine services available to one keyword application. This is the only
128
+ * path to subschema application, the channel, and error reporting — the
129
+ * engine owns path/scope/frame bookkeeping in exactly one place (DESIGN.md
130
+ * §3), which is what makes locations compile-time constants for the M6
131
+ * compiler.
132
+ */
133
+ /**
134
+ * Structured data about one failure, keyed per keyword (D13). Values are
135
+ * plain JSON so downstream mappers (ajv-compat) consume them mechanically
136
+ * instead of parsing message strings.
137
+ */
138
+ export type ErrorParams = Readonly<Record<string, JsonValue>>;
139
+
140
+ export interface KeywordContext {
141
+ /** the current schema object (this keyword's siblings included) */
142
+ readonly schema: Record<string, JsonValue>;
143
+ readonly cursor: Cursor;
144
+ /** apply the subschema at `segments` (relative to the current schema object) */
145
+ apply(segments: readonly (string | number)[], cursor: Cursor): boolean;
146
+ /** resolve a reference against the current lexical base */
147
+ resolveRef(ref: string): SchemaRef;
148
+ /** resolve a `$dynamicRef`-class reference, with dynamic-scope rebinding (D8; see engine.ts resolveDynamic) */
149
+ resolveDynamic(ref: string): SchemaRef;
150
+ /** resolve a 2019-09 `$recursiveRef`, D8's degenerate case (see engine.ts resolveRecursive) */
151
+ resolveRecursive(ref: string): SchemaRef;
152
+ /** apply a resolved reference target at the current cursor */
153
+ applyResolved(target: SchemaRef): boolean;
154
+ /** compile a `pattern`/`patternProperties` regex through the engine's regex engine and cache (see regex.ts) */
155
+ compileRegex(pattern: string): CompiledRegex;
156
+ /**
157
+ * record this keyword's value as an annotation at the current cursor —
158
+ * the annotation value is the keyword value (draft-03 §12.9), so there is
159
+ * nothing to pass
160
+ */
161
+ annotate(): void;
162
+ /**
163
+ * communicate computed dependency data to other keywords at the current
164
+ * cursor; never output. `analyze().produces` must list this keyword's id.
165
+ */
166
+ produce(data: unknown): void;
167
+ /**
168
+ * dependency records visible at the current cursor from the listed
169
+ * behaviors; `analyze().consumes` must list them. Draft-03 §12.3 allows a
170
+ * dependency on the same dynamic scope or on successful subscopes: `"all"`
171
+ * (default) sees both — this schema object's keywords plus records merged
172
+ * from successful in-place sub-applications, what `unevaluated*` needs;
173
+ * `"adjacent"` sees only this schema object's own keywords, what `then`
174
+ * and `else` need from `if`.
175
+ */
176
+ visible(
177
+ behaviorIds: readonly string[],
178
+ scope?: "all" | "adjacent",
179
+ ): readonly DependencyView[];
180
+ /** report an assertion failure for this keyword, with optional structured params (D13) */
181
+ error(message: string, params?: ErrorParams): void;
182
+ }
183
+
184
+ /** A keyword's static analysis and evaluation semantics. */
185
+ export interface KeywordBehavior {
186
+ /** keyword URI — the stable identity, independent of its name in a dialect */
187
+ readonly id: string;
188
+ /**
189
+ * Evaluation phase within a schema object: phase 1 keywords (unevaluated*)
190
+ * run after all phase 0 keywords have merged their records.
191
+ */
192
+ readonly phase?: 0 | 1;
193
+ /**
194
+ * An identifier or reserved-location keyword (`$id`, `$defs`, `$comment`,
195
+ * ...): it evaluates to nothing and appears in no output unit (IETF
196
+ * draft-03 §12.6, §12.10), so a trace records no keyword entry for it.
197
+ */
198
+ readonly structural?: boolean;
199
+ /**
200
+ * Static facts; also drives the registration walk's descent. The context
201
+ * (sibling access) is supplied by every caller; implementations that need
202
+ * no sibling facts ignore it.
203
+ */
204
+ analyze?(value: JsonValue, context?: AnalyzeContext): StaticFacts;
205
+ /**
206
+ * Evaluates the keyword at `cursor`. A keyword that reports an error
207
+ * through `ctx.error()` must return `false`: the engine drops the errors
208
+ * of an accepting keyword's sub-evaluations (draft-03 §12.2) and throws
209
+ * `KeywordContractError` when the keyword itself reported one.
210
+ */
211
+ evaluate(value: JsonValue, cursor: Cursor, ctx: KeywordContext): boolean;
212
+ /**
213
+ * Optional compiler lowering (D1/D9): describe this keyword's compiled
214
+ * form as IR through the {@link LoweringContext} — never JavaScript text.
215
+ * Absent ⇒ schema objects containing this keyword become interpreted
216
+ * units (the trampoline fallback). Keep `lower` beside `evaluate` and
217
+ * share their error-message builders: the differential gate compares
218
+ * error text.
219
+ */
220
+ lower?(value: JsonValue, lctx: LoweringContext): void;
221
+ }
222
+
223
+ /** A keyword's binding within one dialect: its name there, behavior, and owning vocabulary. */
224
+ export interface DialectKeyword {
225
+ name: string;
226
+ behavior: KeywordBehavior;
227
+ vocabularyUri: string;
228
+ }
229
+
230
+ /**
231
+ * Identifiers found in one schema object, per the dialect's identifier
232
+ * syntax (D18; see {@link IdentifierExtractor}).
233
+ */
234
+ export interface IdentifierFacts {
235
+ /** value that changes the lexical base (and starts a schema resource) */
236
+ baseId?: string;
237
+ /** plain-name anchors minted at this schema object */
238
+ anchors?: readonly string[];
239
+ /** anchor participating in $dynamicRef rebinding (D8) */
240
+ dynamicAnchor?: string;
241
+ /** 2019-09 $recursiveAnchor; effective at a resource root */
242
+ recursiveAnchor?: boolean;
243
+ }
244
+
245
+ /**
246
+ * Extracts identifier facts from a schema object. Identifier syntax varies
247
+ * by draft (D18): 2020-12 has `$id`/`$anchor`/`$dynamicAnchor`; 2019-09
248
+ * replaces the dynamic pair with boolean `$recursiveAnchor`; draft-07/06
249
+ * mint anchors from plain-fragment `$id` and have no anchor keywords at all.
250
+ * The extractor is dialect data consumed by the registry's walk and pointer
251
+ * navigation — keyword behaviors stay syntax-free.
252
+ */
253
+ export type IdentifierExtractor = (
254
+ node: Record<string, JsonValue>,
255
+ ) => IdentifierFacts;
256
+
257
+ /** Identifier syntax for the 2020-12 dialect: `$id`/`$anchor`/`$dynamicAnchor`. */
258
+ export const identifiers2020: IdentifierExtractor = (node) => ({
259
+ ...(typeof node.$id === "string" ? { baseId: node.$id } : {}),
260
+ ...(typeof node.$anchor === "string" ? { anchors: [node.$anchor] } : {}),
261
+ ...(typeof node.$dynamicAnchor === "string"
262
+ ? { dynamicAnchor: node.$dynamicAnchor }
263
+ : {}),
264
+ });
265
+
266
+ /** Identifier syntax for the 2019-09 dialect: `$id`/`$anchor`/boolean `$recursiveAnchor`. */
267
+ export const identifiers2019: IdentifierExtractor = (node) => ({
268
+ ...(typeof node.$id === "string" ? { baseId: node.$id } : {}),
269
+ ...(typeof node.$anchor === "string" ? { anchors: [node.$anchor] } : {}),
270
+ ...(node.$recursiveAnchor === true ? { recursiveAnchor: true } : {}),
271
+ });
272
+
273
+ /**
274
+ * Identifier syntax for legacy draft-07/06 dialects: a schema object
275
+ * containing `$ref` has no identifiers at all (the suite's "`$ref` prevents
276
+ * a sibling `$id` from changing the base uri"), and a plain-fragment `$id`
277
+ * is an anchor rather than a base change.
278
+ */
279
+ export const identifiersLegacy: IdentifierExtractor = (node) => {
280
+ if (Object.hasOwn(node, "$ref")) return {};
281
+ const id = node.$id;
282
+ if (typeof id !== "string") return {};
283
+ if (id.startsWith("#")) {
284
+ return id.length > 1 ? { anchors: [id.slice(1)] } : {};
285
+ }
286
+ return { baseId: id };
287
+ };
288
+
289
+ /** An ordered set of vocabularies with identifier and `$ref` semantics (D18). */
290
+ export interface Dialect {
291
+ uri: string;
292
+ /** name -\> entry */
293
+ keywords: ReadonlyMap<string, DialectKeyword>;
294
+ /** evaluation order: phase 0 entries then phase 1 entries */
295
+ ordered: readonly DialectKeyword[];
296
+ /** the vocabularies this dialect was assembled from, in order */
297
+ vocabularyUris: readonly string[];
298
+ allowUnknownKeywords: boolean;
299
+ identifiers: IdentifierExtractor;
300
+ /** draft-07/06: siblings of $ref are treated as if absent */
301
+ refIgnoresSiblings: boolean;
302
+ }
303
+
304
+ /** Options for {@link DialectRegistry.registerDialect}. */
305
+ export interface DialectOptions {
306
+ /** unknown keywords are collected as annotations (spec SHOULD); default true */
307
+ allowUnknownKeywords?: boolean;
308
+ /** identifier syntax for this dialect (D18); default 2020-12 */
309
+ identifiers?: IdentifierExtractor;
310
+ /** draft-07/06 $ref semantics: siblings are ignored, not evaluated */
311
+ refIgnoresSiblings?: boolean;
312
+ }
313
+
314
+ /** Thrown when a dialect or vocabulary URI has no registered entry. */
315
+ export class UnknownDialectError extends Error {}
316
+ /** Thrown when a `$vocabulary` URI is required but not registered. */
317
+ export class UnknownVocabularyError extends Error {}
318
+ /** Thrown when a registry snapshot (a read-only view) is asked to register. */
319
+ export class ReadOnlyRegistryError extends Error {}
320
+
321
+ /** Registry of vocabularies and the dialects assembled from them. */
322
+ export class DialectRegistry {
323
+ private vocabularies = new Map<
324
+ string,
325
+ Readonly<Record<string, KeywordBehavior>>
326
+ >();
327
+ private dialects = new Map<string, Dialect>();
328
+ // Snapshots share these maps copy-on-write: the source copies them before
329
+ // its first registration after a snapshot, so views stay frozen for free.
330
+ private shared = false;
331
+ private readOnly = false;
332
+
333
+ /**
334
+ * A read-only view of the current registrations. Later registrations on
335
+ * this registry are invisible to the view, and registering into the view
336
+ * throws {@link ReadOnlyRegistryError}. A compiled artifact binds to one
337
+ * so that a compilation boundary cannot change dialect lookups.
338
+ */
339
+ snapshot(): DialectRegistry {
340
+ const view = new DialectRegistry();
341
+ view.vocabularies = this.vocabularies;
342
+ view.dialects = this.dialects;
343
+ view.readOnly = true;
344
+ this.shared = true;
345
+ return view;
346
+ }
347
+
348
+ private mutable(): void {
349
+ if (this.readOnly) {
350
+ throw new ReadOnlyRegistryError("a registry snapshot is read-only");
351
+ }
352
+ if (!this.shared) return;
353
+ this.vocabularies = new Map(this.vocabularies);
354
+ this.dialects = new Map(this.dialects);
355
+ this.shared = false;
356
+ }
357
+
358
+ /** Registers a vocabulary's keyword behaviors under its URI. */
359
+ registerVocabulary(
360
+ uri: string,
361
+ keywords: Readonly<Record<string, KeywordBehavior>>,
362
+ ): void {
363
+ this.mutable();
364
+ this.vocabularies.set(uri, keywords);
365
+ }
366
+
367
+ /**
368
+ * Assembles a dialect from already-registered vocabularies.
369
+ * @throws UnknownDialectError if a listed vocabulary is not registered.
370
+ */
371
+ registerDialect(
372
+ uri: string,
373
+ vocabularyUris: readonly string[],
374
+ options: DialectOptions = {},
375
+ ): void {
376
+ this.mutable();
377
+ const keywords = new Map<string, DialectKeyword>();
378
+ for (const vocabularyUri of vocabularyUris) {
379
+ const vocab = this.vocabularies.get(vocabularyUri);
380
+ if (!vocab) {
381
+ throw new UnknownDialectError(
382
+ `dialect '${uri}' requires unregistered vocabulary '${vocabularyUri}'`,
383
+ );
384
+ }
385
+ for (const [name, behavior] of Object.entries(vocab)) {
386
+ keywords.set(name, { name, behavior, vocabularyUri });
387
+ }
388
+ }
389
+ const entries = [...keywords.values()];
390
+ const ordered = [
391
+ ...entries.filter((k) => (k.behavior.phase ?? 0) === 0),
392
+ ...entries.filter((k) => k.behavior.phase === 1),
393
+ ];
394
+ this.dialects.set(uri, {
395
+ uri,
396
+ keywords,
397
+ ordered,
398
+ vocabularyUris: [...vocabularyUris],
399
+ allowUnknownKeywords: options.allowUnknownKeywords ?? true,
400
+ identifiers: options.identifiers ?? identifiers2020,
401
+ refIgnoresSiblings: options.refIgnoresSiblings ?? false,
402
+ });
403
+ }
404
+
405
+ /**
406
+ * Looks up a registered dialect by URI.
407
+ * @throws UnknownDialectError if the URI has no registered dialect.
408
+ */
409
+ getDialect(uri: string): Dialect {
410
+ const dialect = this.dialects.get(uri);
411
+ if (!dialect) throw new UnknownDialectError(`unknown dialect '${uri}'`);
412
+ return dialect;
413
+ }
414
+
415
+ /** True if a dialect is registered under this URI. */
416
+ hasDialect(uri: string): boolean {
417
+ return this.dialects.has(uri);
418
+ }
419
+
420
+ /** True if a vocabulary is registered under this URI. */
421
+ hasVocabulary(uri: string): boolean {
422
+ return this.vocabularies.has(uri);
423
+ }
424
+ }
425
+
426
+ /** Behavior id for annotations from keywords the dialect doesn't know. */
427
+ export const unknownKeywordId = (name: string): string =>
428
+ `urn:jse:keyword:unknown#${name}`;