@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.
- package/LICENSE +21 -0
- package/README.md +50 -0
- package/dist/coverage.d.ts +30 -0
- package/dist/coverage.d.ts.map +1 -0
- package/dist/coverage.js +81 -0
- package/dist/coverage.js.map +1 -0
- package/dist/cursor.d.ts +16 -0
- package/dist/cursor.d.ts.map +1 -0
- package/dist/cursor.js +28 -0
- package/dist/cursor.js.map +1 -0
- package/dist/dialect.d.ts +316 -0
- package/dist/dialect.d.ts.map +1 -0
- package/dist/dialect.js +135 -0
- package/dist/dialect.js.map +1 -0
- package/dist/engine.d.ts +203 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +493 -0
- package/dist/engine.js.map +1 -0
- package/dist/index.d.ts +182 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +370 -0
- package/dist/index.js.map +1 -0
- package/dist/json.d.ts +49 -0
- package/dist/json.d.ts.map +1 -0
- package/dist/json.js +128 -0
- package/dist/json.js.map +1 -0
- package/dist/keywords/applicator.d.ts +57 -0
- package/dist/keywords/applicator.d.ts.map +1 -0
- package/dist/keywords/applicator.js +789 -0
- package/dist/keywords/applicator.js.map +1 -0
- package/dist/keywords/core.d.ts +42 -0
- package/dist/keywords/core.d.ts.map +1 -0
- package/dist/keywords/core.js +139 -0
- package/dist/keywords/core.js.map +1 -0
- package/dist/keywords/format.d.ts +43 -0
- package/dist/keywords/format.d.ts.map +1 -0
- package/dist/keywords/format.js +101 -0
- package/dist/keywords/format.js.map +1 -0
- package/dist/keywords/metaschemas2019.d.ts +3 -0
- package/dist/keywords/metaschemas2019.d.ts.map +1 -0
- package/dist/keywords/metaschemas2019.js +406 -0
- package/dist/keywords/metaschemas2019.js.map +1 -0
- package/dist/keywords/metaschemas2020.d.ts +3 -0
- package/dist/keywords/metaschemas2020.d.ts.map +1 -0
- package/dist/keywords/metaschemas2020.js +441 -0
- package/dist/keywords/metaschemas2020.js.map +1 -0
- package/dist/keywords/metaschemas6.d.ts +3 -0
- package/dist/keywords/metaschemas6.d.ts.map +1 -0
- package/dist/keywords/metaschemas6.js +221 -0
- package/dist/keywords/metaschemas6.js.map +1 -0
- package/dist/keywords/metaschemas7.d.ts +3 -0
- package/dist/keywords/metaschemas7.d.ts.map +1 -0
- package/dist/keywords/metaschemas7.js +248 -0
- package/dist/keywords/metaschemas7.js.map +1 -0
- package/dist/keywords/unevaluated.d.ts +14 -0
- package/dist/keywords/unevaluated.d.ts.map +1 -0
- package/dist/keywords/unevaluated.js +296 -0
- package/dist/keywords/unevaluated.js.map +1 -0
- package/dist/keywords/validation.d.ts +21 -0
- package/dist/keywords/validation.d.ts.map +1 -0
- package/dist/keywords/validation.js +332 -0
- package/dist/keywords/validation.js.map +1 -0
- package/dist/keywords/vocab2019.d.ts +36 -0
- package/dist/keywords/vocab2019.d.ts.map +1 -0
- package/dist/keywords/vocab2019.js +585 -0
- package/dist/keywords/vocab2019.js.map +1 -0
- package/dist/keywords/vocab2020.d.ts +20 -0
- package/dist/keywords/vocab2020.d.ts.map +1 -0
- package/dist/keywords/vocab2020.js +55 -0
- package/dist/keywords/vocab2020.js.map +1 -0
- package/dist/keywords/vocab7.d.ts +46 -0
- package/dist/keywords/vocab7.d.ts.map +1 -0
- package/dist/keywords/vocab7.js +305 -0
- package/dist/keywords/vocab7.js.map +1 -0
- package/dist/loader.d.ts +41 -0
- package/dist/loader.d.ts.map +1 -0
- package/dist/loader.js +13 -0
- package/dist/loader.js.map +1 -0
- package/dist/lowering.d.ts +371 -0
- package/dist/lowering.d.ts.map +1 -0
- package/dist/lowering.js +59 -0
- package/dist/lowering.js.map +1 -0
- package/dist/output.d.ts +251 -0
- package/dist/output.d.ts.map +1 -0
- package/dist/output.js +357 -0
- package/dist/output.js.map +1 -0
- package/dist/records.d.ts +71 -0
- package/dist/records.d.ts.map +1 -0
- package/dist/records.js +150 -0
- package/dist/records.js.map +1 -0
- package/dist/ref.d.ts +10 -0
- package/dist/ref.d.ts.map +1 -0
- package/dist/ref.js +5 -0
- package/dist/ref.js.map +1 -0
- package/dist/regex.d.ts +45 -0
- package/dist/regex.d.ts.map +1 -0
- package/dist/regex.js +163 -0
- package/dist/regex.js.map +1 -0
- package/dist/registry.d.ts +141 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +407 -0
- package/dist/registry.js.map +1 -0
- package/dist/result.d.ts +109 -0
- package/dist/result.d.ts.map +1 -0
- package/dist/result.js +112 -0
- package/dist/result.js.map +1 -0
- package/dist/uri.d.ts +18 -0
- package/dist/uri.d.ts.map +1 -0
- package/dist/uri.js +27 -0
- package/dist/uri.js.map +1 -0
- package/dist/walk.d.ts +25 -0
- package/dist/walk.d.ts.map +1 -0
- package/dist/walk.js +65 -0
- package/dist/walk.js.map +1 -0
- package/package.json +32 -0
- package/src/coverage.ts +88 -0
- package/src/cursor.ts +42 -0
- package/src/dialect.ts +428 -0
- package/src/engine.ts +741 -0
- package/src/index.ts +699 -0
- package/src/json.ts +137 -0
- package/src/keywords/applicator.ts +895 -0
- package/src/keywords/core.ts +168 -0
- package/src/keywords/format.ts +143 -0
- package/src/keywords/metaschemas2019.ts +410 -0
- package/src/keywords/metaschemas2020.ts +443 -0
- package/src/keywords/metaschemas6.ts +226 -0
- package/src/keywords/metaschemas7.ts +253 -0
- package/src/keywords/unevaluated.ts +329 -0
- package/src/keywords/validation.ts +627 -0
- package/src/keywords/vocab2019.ts +676 -0
- package/src/keywords/vocab2020.ts +82 -0
- package/src/keywords/vocab7.ts +393 -0
- package/src/loader.ts +60 -0
- package/src/lowering.ts +414 -0
- package/src/output.ts +651 -0
- package/src/records.ts +226 -0
- package/src/ref.ts +14 -0
- package/src/regex.ts +171 -0
- package/src/registry.ts +481 -0
- package/src/result.ts +252 -0
- package/src/uri.ts +35 -0
- 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}`;
|