@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/engine.ts ADDED
@@ -0,0 +1,741 @@
1
+ // Evaluation engine: frame-scoped record channel (DESIGN.md §4, normative),
2
+ // evaluation-path tracking with lazy string materialization, cycle guard,
3
+ // annotation retention.
4
+ //
5
+ // Channel rules implemented here and nowhere else:
6
+ // 1. each schema application pushes a frame;
7
+ // 2. annotate() appends an annotation record (the keyword's own value) and
8
+ // produce() a dependency record (computed data for other keywords) to
9
+ // the current frame;
10
+ // 3. on success the frame merges into its parent, on failure it is
11
+ // discarded;
12
+ // 4. visibility = the current frame's dependency records filtered by
13
+ // cursor identity;
14
+ // 5. the annotation result is the root frame's annotation records filtered
15
+ // by the annotation selection; selection never affects rule 4;
16
+ // 6. relevance (draft-03 §12.2): a keyword that accepts makes the errors of
17
+ // its rejecting sub-evaluations irrelevant — evaluateKeyword drops them
18
+ // (kept aside only when tracing, for verbose output); rule 3 is the same
19
+ // transition for a rejecting schema object's accepting sub-evaluations.
20
+
21
+ import { JsonValue, isObject, escapeSegment } from "./json.js";
22
+ import { resolveUri, splitFragment } from "./uri.js";
23
+ import { Cursor, rootCursor } from "./cursor.js";
24
+ import { SchemaRef } from "./ref.js";
25
+ import {
26
+ DependencyView,
27
+ Dialect,
28
+ DialectKeyword,
29
+ ErrorParams,
30
+ KeywordContext,
31
+ unknownKeywordId,
32
+ } from "./dialect.js";
33
+ import {
34
+ DEFAULT_MAX_DEPTH,
35
+ InvalidSchemaError,
36
+ MaxDepthExceededError,
37
+ SchemaRegistry,
38
+ describeNonSchema,
39
+ } from "./registry.js";
40
+ import { CompiledRegex, RegexCache } from "./regex.js";
41
+
42
+ /** Thrown when a schema is re-entered at the same instance location (D8/cycle guard). */
43
+ export class InfiniteLoopError extends Error {}
44
+ /** Thrown when a dialect disallows unknown keywords and one is present. */
45
+ export class UnknownKeywordError extends Error {}
46
+ /** Thrown when a keyword reads a dependency behavior id it never declared via `analyze().consumes`. */
47
+ export class UndeclaredConsumptionError extends Error {}
48
+ /** Thrown when a keyword produces dependency data without declaring its own id via `analyze().produces`. */
49
+ export class UndeclaredProductionError extends Error {}
50
+ /** Thrown when a keyword reports an error through `ctx.error()` yet accepts the input. */
51
+ export class KeywordContractError extends Error {}
52
+
53
+ /**
54
+ * Annotation elision (D5/M5.5): when set, an annotation record is recorded
55
+ * only if this returns true, and dependency records are recorded only for
56
+ * behavior ids some registered keyword consumes. Applies at every output
57
+ * level: a deselected keyword's annotation is never rendered, relevant or
58
+ * not.
59
+ */
60
+ export type RecordPredicate = (
61
+ behaviorId: string,
62
+ keywordName: string,
63
+ vocabularyUri: string | null,
64
+ ) => boolean;
65
+
66
+ /**
67
+ * Evaluation-path node: one pre-escaped segment, parent-linked, materialized
68
+ * only when a unit escapes to output.
69
+ */
70
+ export interface PathNode {
71
+ readonly parent: PathNode | null;
72
+ readonly segment: string;
73
+ }
74
+
75
+ /** Materializes a path node chain into its JSON Pointer string. */
76
+ export function materializePath(node: PathNode | null): string {
77
+ let s = "";
78
+ for (let n = node; n !== null; n = n.parent) s = "/" + n.segment + s;
79
+ return s;
80
+ }
81
+
82
+ /**
83
+ * An annotation (DESIGN.md §4): application-facing output whose value is
84
+ * the keyword's own value (draft-03 §12.9). The only record kind renderers
85
+ * accept.
86
+ */
87
+ export interface AnnotationRecord {
88
+ readonly kind: "annotation";
89
+ behaviorId: string;
90
+ keywordName: string;
91
+ /** `null` for unknown keywords. */
92
+ vocabularyUri: string | null;
93
+ schemaRef: SchemaRef;
94
+ /** Path of the schema object; the keyword segment is appended on render. */
95
+ pathNode: PathNode | null;
96
+ cursor: Cursor;
97
+ value: unknown;
98
+ }
99
+
100
+ /**
101
+ * Dependency information (DESIGN.md §4; draft-03 Appendix D): computed data
102
+ * one keyword communicates to another through the channel. Never rendered.
103
+ */
104
+ export interface DependencyRecord {
105
+ readonly kind: "dependency";
106
+ behaviorId: string;
107
+ keywordName: string;
108
+ schemaRef: SchemaRef;
109
+ /** Path of the schema object the producing keyword belongs to. */
110
+ pathNode: PathNode | null;
111
+ cursor: Cursor;
112
+ data: unknown;
113
+ }
114
+
115
+ /** One assertion failure. */
116
+ export interface ErrorRecord {
117
+ /** `null` when the schema itself failed (boolean `false`). */
118
+ keywordName: string | null;
119
+ /** `null` when the schema itself failed (boolean `false`). */
120
+ vocabularyUri: string | null;
121
+ schemaRef: SchemaRef;
122
+ pathNode: PathNode | null;
123
+ cursor: Cursor;
124
+ message: string;
125
+ params?: ErrorParams;
126
+ }
127
+
128
+ // Appends with a loop: an argument spread puts every element on the native
129
+ // stack, so `dst.push(...src)` throws past ~120k elements — a ceiling a wide
130
+ // instance reaches long before maxDepth does.
131
+ function appendAll<T>(dst: T[], src: readonly T[]): void {
132
+ for (const x of src) dst.push(x);
133
+ }
134
+
135
+ /** One channel frame: the records of an in-flight schema application. */
136
+ export interface Frame {
137
+ annotations: AnnotationRecord[];
138
+ dependencies: DependencyRecord[];
139
+ }
140
+
141
+ /** One keyword evaluation within a traced schema application. */
142
+ export interface KeywordTrace {
143
+ name: string;
144
+ valid: boolean;
145
+ }
146
+
147
+ /**
148
+ * One schema application, recorded only when tracing (structured outputs):
149
+ * the structured renderers need application boundaries, per-branch
150
+ * validity, and each keyword's verdict in evaluation order (draft-03 Verbose
151
+ * renders one node per keyword), which the flat error list cannot
152
+ * reconstruct.
153
+ */
154
+ export interface TraceNode {
155
+ schemaRef: SchemaRef;
156
+ pathNode: PathNode | null;
157
+ cursor: Cursor;
158
+ valid: boolean;
159
+ /** keyword evaluations of this application, in order (structural keywords excluded) */
160
+ keywords: KeywordTrace[];
161
+ children: TraceNode[];
162
+ }
163
+
164
+ /** Mutable state for one evaluation run: frames, errors, dynamic scope, and tracing. */
165
+ export class EvalState {
166
+ frames: Frame[] = [{ annotations: [], dependencies: [] }];
167
+ /** Relevant errors, in encounter order (rule 6). */
168
+ errors: ErrorRecord[] = [];
169
+ // Errors made irrelevant by an accepting ancestor keyword (rule 6), kept
170
+ // only when tracing so verbose output can show them.
171
+ droppedErrors: ErrorRecord[] | null = null;
172
+ // Dynamic scope (D8): resources entered by schema application, outermost
173
+ // first. Duplicates are fine — resolution takes the first (outermost) hit.
174
+ dynamicScope: string[] = [];
175
+ // Tracing (opt-in, zero cost when off): every application as a tree, and
176
+ // every record regardless of frame discard — failed-branch annotations
177
+ // surface as droppedAnnotations in verbose outputs; dependency records are
178
+ // kept for diagnostics and never rendered.
179
+ traceRoot: TraceNode | null = null;
180
+ allAnnotations: AnnotationRecord[] | null = null;
181
+ allDependencies: DependencyRecord[] | null = null;
182
+ // Active schema-application nesting, bounded by maxDepth (see applySchema).
183
+ depth = 0;
184
+ private traceStack: TraceNode[] = [];
185
+ private active = new Map<Cursor, Set<string>>();
186
+
187
+ constructor(
188
+ public registry: SchemaRegistry,
189
+ tracing = false,
190
+ public shouldRecord: RecordPredicate | null = null,
191
+ public regexCache: RegexCache = new RegexCache(),
192
+ public maxDepth: number = DEFAULT_MAX_DEPTH,
193
+ ) {
194
+ if (tracing) {
195
+ this.allAnnotations = [];
196
+ this.allDependencies = [];
197
+ this.droppedErrors = [];
198
+ }
199
+ }
200
+
201
+ /** True when tracing is active for this run. */
202
+ get tracing(): boolean {
203
+ return this.allAnnotations !== null;
204
+ }
205
+
206
+ /** Removes the errors pushed since `mark`: rejecting sub-evaluations of a keyword that accepted (rule 6). */
207
+ dropErrorsFrom(mark: number): void {
208
+ const dropped = this.errors.splice(mark);
209
+ if (this.droppedErrors !== null) appendAll(this.droppedErrors, dropped);
210
+ }
211
+
212
+ /** Opens a trace node for a schema application and links it under the current one. */
213
+ traceEnter(
214
+ schemaRef: SchemaRef,
215
+ pathNode: PathNode | null,
216
+ cursor: Cursor,
217
+ ): TraceNode {
218
+ const node: TraceNode = {
219
+ schemaRef,
220
+ pathNode,
221
+ cursor,
222
+ valid: true,
223
+ keywords: [],
224
+ children: [],
225
+ };
226
+ const parent = this.traceStack[this.traceStack.length - 1];
227
+ if (parent) parent.children.push(node);
228
+ else this.traceRoot = node;
229
+ this.traceStack.push(node);
230
+ return node;
231
+ }
232
+
233
+ /** Closes the current trace node with its final validity. */
234
+ traceExit(node: TraceNode, valid: boolean): void {
235
+ node.valid = valid;
236
+ this.traceStack.pop();
237
+ }
238
+
239
+ /** Records one keyword evaluation's verdict on the current trace node. */
240
+ traceKeyword(name: string, valid: boolean): void {
241
+ this.traceStack[this.traceStack.length - 1]?.keywords.push({ name, valid });
242
+ }
243
+
244
+ /** The innermost open frame. */
245
+ get frame(): Frame {
246
+ return this.frames[this.frames.length - 1]!;
247
+ }
248
+ /** Annotations surviving at the root frame — the annotation result before retention filtering. */
249
+ get rootAnnotations(): AnnotationRecord[] {
250
+ return this.frames[0]!.annotations;
251
+ }
252
+ /** Dependency records surviving at the root frame. */
253
+ get rootDependencies(): DependencyRecord[] {
254
+ return this.frames[0]!.dependencies;
255
+ }
256
+
257
+ /**
258
+ * Records entry into a schema application for cycle detection.
259
+ * @throws InfiniteLoopError if this schema is already active at this cursor.
260
+ */
261
+ enter(schemaRef: SchemaRef, cursor: Cursor): void {
262
+ const key = `${schemaRef.baseUri}#${schemaRef.pointer}`;
263
+ let keys = this.active.get(cursor);
264
+ if (keys === undefined) {
265
+ keys = new Set();
266
+ this.active.set(cursor, keys);
267
+ } else if (keys.has(key)) {
268
+ throw new InfiniteLoopError(
269
+ `schema '${key}' re-entered at the same instance location`,
270
+ );
271
+ }
272
+ keys.add(key);
273
+ }
274
+
275
+ /** Records exit from a schema application, releasing its cycle-detection entry. */
276
+ exit(schemaRef: SchemaRef, cursor: Cursor): void {
277
+ const keys = this.active.get(cursor)!;
278
+ keys.delete(`${schemaRef.baseUri}#${schemaRef.pointer}`);
279
+ if (keys.size === 0) this.active.delete(cursor);
280
+ }
281
+ }
282
+
283
+ class KeywordContextImpl implements KeywordContext {
284
+ /** Set once this keyword reports an error of its own (the contract check in evaluateKeyword). */
285
+ reported = false;
286
+
287
+ constructor(
288
+ private state: EvalState,
289
+ private schemaRef: SchemaRef,
290
+ private entry: {
291
+ name: string;
292
+ behaviorId: string;
293
+ vocabularyUri: string | null;
294
+ value: JsonValue;
295
+ },
296
+ public cursor: Cursor,
297
+ private pathNode: PathNode | null,
298
+ ) {}
299
+
300
+ get schema(): Record<string, JsonValue> {
301
+ return this.schemaRef.node as Record<string, JsonValue>;
302
+ }
303
+
304
+ apply(segments: readonly (string | number)[], cursor: Cursor): boolean {
305
+ const child = this.state.registry.child(this.schemaRef, segments);
306
+ let pathNode = this.pathNode;
307
+ for (const seg of segments) {
308
+ pathNode = { parent: pathNode, segment: escapeSegment(String(seg)) };
309
+ }
310
+ return applySchema(this.state, child, cursor, pathNode);
311
+ }
312
+
313
+ resolveRef(ref: string): SchemaRef {
314
+ return this.state.registry.resolveRef(ref, this.schemaRef.baseUri);
315
+ }
316
+
317
+ resolveDynamic(ref: string): SchemaRef {
318
+ const registry = this.state.registry;
319
+ // Lexical resolution first (spec: the initial target must exist); the
320
+ // rebinding below only applies to plain-name fragments minted by a
321
+ // dynamic anchor — pointer fragments behave exactly like $ref.
322
+ const target = registry.resolveRef(ref, this.schemaRef.baseUri);
323
+ const { resource, fragment } = splitFragment(
324
+ resolveUri(ref, this.schemaRef.baseUri),
325
+ );
326
+ if (fragment === null || fragment === "" || fragment.startsWith("/"))
327
+ return target;
328
+ if (registry.dynamicAnchor(resource, fragment) === undefined) return target;
329
+ for (const scopeUri of this.state.dynamicScope) {
330
+ const hit = registry.dynamicAnchor(scopeUri, fragment);
331
+ if (hit !== undefined) return hit;
332
+ }
333
+ return target;
334
+ }
335
+
336
+ resolveRecursive(ref: string): SchemaRef {
337
+ const registry = this.state.registry;
338
+ // 2019-09: the reference is "#"; anything with a non-empty fragment
339
+ // behaves like $ref. Rebinding is all-or-nothing on the root-level
340
+ // $recursiveAnchor flag rather than a named anchor.
341
+ const target = registry.resolveRef(ref, this.schemaRef.baseUri);
342
+ const { resource, fragment } = splitFragment(
343
+ resolveUri(ref, this.schemaRef.baseUri),
344
+ );
345
+ if (fragment !== null && fragment !== "") return target;
346
+ if (!registry.hasRecursiveRoot(resource)) return target;
347
+ for (const scopeUri of this.state.dynamicScope) {
348
+ if (registry.hasRecursiveRoot(scopeUri))
349
+ return registry.rootRef(scopeUri);
350
+ }
351
+ return target;
352
+ }
353
+
354
+ applyResolved(target: SchemaRef): boolean {
355
+ const pathNode = {
356
+ parent: this.pathNode,
357
+ segment: escapeSegment(this.entry.name),
358
+ };
359
+ return applySchema(this.state, target, this.cursor, pathNode);
360
+ }
361
+
362
+ compileRegex(pattern: string): CompiledRegex {
363
+ return this.state.regexCache.compile(pattern);
364
+ }
365
+
366
+ annotate(): void {
367
+ const record = this.state.shouldRecord;
368
+ if (
369
+ record &&
370
+ !record(this.entry.behaviorId, this.entry.name, this.entry.vocabularyUri)
371
+ ) {
372
+ return;
373
+ }
374
+ const annotation: AnnotationRecord = {
375
+ kind: "annotation",
376
+ behaviorId: this.entry.behaviorId,
377
+ keywordName: this.entry.name,
378
+ vocabularyUri: this.entry.vocabularyUri,
379
+ schemaRef: this.schemaRef,
380
+ pathNode: this.pathNode,
381
+ cursor: this.cursor,
382
+ value: this.entry.value,
383
+ };
384
+ this.state.frame.annotations.push(annotation);
385
+ // Frames discard on failure; the trace keeps everything so verbose
386
+ // outputs can report droppedAnnotations.
387
+ this.state.allAnnotations?.push(annotation);
388
+ }
389
+
390
+ produce(data: unknown): void {
391
+ // A producer nobody declared is invisible to elision analysis and to the
392
+ // compiler's channel routing — fail loud, not wrong. The check is per
393
+ // behavior id: the registry unions every occurrence's declarations.
394
+ const registry = this.state.registry;
395
+ if (!registry.producedIds().has(this.entry.behaviorId)) {
396
+ throw new UndeclaredProductionError(
397
+ `'${this.entry.behaviorId}' produces dependency data without declaring it in analyze().produces`,
398
+ );
399
+ }
400
+ // Under elision, dependency data nobody consumes is never read.
401
+ if (
402
+ this.state.shouldRecord !== null &&
403
+ !registry.consumedIds().has(this.entry.behaviorId)
404
+ ) {
405
+ return;
406
+ }
407
+ const dependency: DependencyRecord = {
408
+ kind: "dependency",
409
+ behaviorId: this.entry.behaviorId,
410
+ keywordName: this.entry.name,
411
+ schemaRef: this.schemaRef,
412
+ pathNode: this.pathNode,
413
+ cursor: this.cursor,
414
+ data,
415
+ };
416
+ this.state.frame.dependencies.push(dependency);
417
+ this.state.allDependencies?.push(dependency);
418
+ }
419
+
420
+ visible(
421
+ behaviorIds: readonly string[],
422
+ scope: "all" | "adjacent" = "all",
423
+ ): readonly DependencyView[] {
424
+ // Under elision, reading an id nobody declared via StaticFacts.consumes
425
+ // means the records may already be gone — fail loud, not wrong.
426
+ if (this.state.shouldRecord !== null) {
427
+ const consumed = this.state.registry.consumedIds();
428
+ for (const id of behaviorIds) {
429
+ if (!consumed.has(id)) {
430
+ throw new UndeclaredConsumptionError(
431
+ `'${this.entry.behaviorId}' reads '${id}' without declaring it in analyze().consumes`,
432
+ );
433
+ }
434
+ }
435
+ }
436
+ // Every keyword of one schema application shares its pathNode, so
437
+ // identity picks out the adjacent keywords' records.
438
+ return this.state.frame.dependencies.filter(
439
+ (d) =>
440
+ d.cursor === this.cursor &&
441
+ behaviorIds.includes(d.behaviorId) &&
442
+ (scope === "all" || d.pathNode === this.pathNode),
443
+ );
444
+ }
445
+
446
+ error(message: string, params?: ErrorParams): void {
447
+ this.reported = true;
448
+ this.state.errors.push({
449
+ keywordName: this.entry.name,
450
+ vocabularyUri: this.entry.vocabularyUri,
451
+ schemaRef: this.schemaRef,
452
+ pathNode: this.pathNode,
453
+ cursor: this.cursor,
454
+ message,
455
+ ...(params === undefined ? {} : { params }),
456
+ });
457
+ }
458
+ }
459
+
460
+ /**
461
+ * Applies one schema to one instance cursor: pushes a frame, evaluates the
462
+ * dialect's keywords in order, merges or discards the frame per DESIGN.md §4.
463
+ * @throws InfiniteLoopError if the schema is already active at this cursor.
464
+ * @throws UnknownKeywordError if the dialect disallows an unknown keyword present in the schema.
465
+ */
466
+ export function applySchema(
467
+ state: EvalState,
468
+ schemaRef: SchemaRef,
469
+ cursor: Cursor,
470
+ pathNode: PathNode | null,
471
+ ): boolean {
472
+ if (state.depth >= state.maxDepth) {
473
+ throw new MaxDepthExceededError(
474
+ `schema application exceeds maxDepth (${state.maxDepth}) at ` +
475
+ `'${schemaRef.baseUri}#${schemaRef.pointer}'`,
476
+ );
477
+ }
478
+ state.depth++;
479
+ try {
480
+ return applySchemaAtDepth(state, schemaRef, cursor, pathNode);
481
+ } finally {
482
+ state.depth--;
483
+ }
484
+ }
485
+
486
+ function applySchemaAtDepth(
487
+ state: EvalState,
488
+ schemaRef: SchemaRef,
489
+ cursor: Cursor,
490
+ pathNode: PathNode | null,
491
+ ): boolean {
492
+ const node = schemaRef.node;
493
+ if (typeof node === "boolean") {
494
+ if (!node) {
495
+ state.errors.push({
496
+ keywordName: null,
497
+ vocabularyUri: null,
498
+ schemaRef,
499
+ pathNode,
500
+ cursor,
501
+ message: "schema is false",
502
+ });
503
+ }
504
+ if (state.tracing) {
505
+ state.traceExit(state.traceEnter(schemaRef, pathNode, cursor), node);
506
+ }
507
+ return node;
508
+ }
509
+ // Backstop for the registration walk's eager D19 check: a position the
510
+ // walk never saw (e.g. a $ref whose pointer lands inside an unknown
511
+ // keyword's value) still fails loud when applied, never silently passes.
512
+ if (!isObject(node)) {
513
+ throw new InvalidSchemaError(
514
+ `non-schema value (${describeNonSchema(node)}) applied as a schema ` +
515
+ `at '${schemaRef.baseUri}#${schemaRef.pointer}'`,
516
+ );
517
+ }
518
+
519
+ const dialect: Dialect = state.registry.dialectFor(schemaRef.baseUri);
520
+
521
+ // draft-07/06 (D18): a $ref makes every sibling keyword act as if absent.
522
+ const refOnly = dialect.refIgnoresSiblings && Object.hasOwn(node, "$ref");
523
+
524
+ state.enter(schemaRef, cursor);
525
+ state.dynamicScope.push(schemaRef.baseUri);
526
+ state.frames.push({ annotations: [], dependencies: [] });
527
+ const traceNode = state.tracing
528
+ ? state.traceEnter(schemaRef, pathNode, cursor)
529
+ : null;
530
+ let valid = true;
531
+ try {
532
+ for (const entry of dialect.ordered) {
533
+ if (refOnly && entry.name !== "$ref") continue;
534
+ if (!Object.hasOwn(node, entry.name)) continue;
535
+ if (!evaluateKeyword(state, schemaRef, entry, cursor, pathNode))
536
+ valid = false;
537
+ }
538
+ for (const name of Object.keys(node)) {
539
+ if (refOnly) break;
540
+ if (dialect.keywords.has(name)) continue;
541
+ if (!dialect.allowUnknownKeywords) {
542
+ throw new UnknownKeywordError(
543
+ `dialect '${dialect.uri}' does not allow unknown keyword '${name}'`,
544
+ );
545
+ }
546
+ // Unknown keywords are collected as annotations: the keyword's value is
547
+ // the annotation value (spec SHOULD).
548
+ const behaviorId = unknownKeywordId(name);
549
+ if (state.tracing) state.traceKeyword(name, true);
550
+ if (state.shouldRecord && !state.shouldRecord(behaviorId, name, null))
551
+ continue;
552
+ const annotation: AnnotationRecord = {
553
+ kind: "annotation",
554
+ behaviorId,
555
+ keywordName: name,
556
+ vocabularyUri: null,
557
+ schemaRef,
558
+ pathNode,
559
+ cursor,
560
+ value: node[name],
561
+ };
562
+ state.frame.annotations.push(annotation);
563
+ state.allAnnotations?.push(annotation);
564
+ }
565
+ } finally {
566
+ const frame = state.frames.pop()!;
567
+ if (valid) {
568
+ const parent = state.frame;
569
+ appendAll(parent.annotations, frame.annotations);
570
+ appendAll(parent.dependencies, frame.dependencies);
571
+ }
572
+ if (traceNode) state.traceExit(traceNode, valid);
573
+ state.dynamicScope.pop();
574
+ state.exit(schemaRef, cursor);
575
+ }
576
+ return valid;
577
+ }
578
+
579
+ function evaluateKeyword(
580
+ state: EvalState,
581
+ schemaRef: SchemaRef,
582
+ entry: DialectKeyword,
583
+ cursor: Cursor,
584
+ pathNode: PathNode | null,
585
+ ): boolean {
586
+ const value = (schemaRef.node as Record<string, JsonValue>)[entry.name]!;
587
+ const ctx = new KeywordContextImpl(
588
+ state,
589
+ schemaRef,
590
+ {
591
+ name: entry.name,
592
+ behaviorId: entry.behavior.id,
593
+ vocabularyUri: entry.vocabularyUri,
594
+ value,
595
+ },
596
+ cursor,
597
+ pathNode,
598
+ );
599
+ const mark = state.errors.length;
600
+ const ok = entry.behavior.evaluate(value, cursor, ctx);
601
+ if (ok) {
602
+ // Rule 6 keys on the keyword's verdict, so a keyword that reports and
603
+ // still accepts would have its own error silently dropped — fail loud.
604
+ if (ctx.reported) {
605
+ throw new KeywordContractError(
606
+ `'${entry.behavior.id}' reported an error but accepted the input`,
607
+ );
608
+ }
609
+ if (state.errors.length > mark) state.dropErrorsFrom(mark);
610
+ }
611
+ // Identifier and reserved-location keywords evaluate to nothing and appear
612
+ // in no output unit (draft-03 §12.6, §12.10).
613
+ if (state.tracing && entry.behavior.structural !== true) {
614
+ state.traceKeyword(entry.name, ok);
615
+ }
616
+ return ok;
617
+ }
618
+
619
+ /** Evaluates an instance against a registered root schema, returning validity and final state. */
620
+ export function runEvaluation(
621
+ registry: SchemaRegistry,
622
+ schemaUri: string,
623
+ instance: JsonValue,
624
+ tracing = false,
625
+ shouldRecord: RecordPredicate | null = null,
626
+ regexCache: RegexCache = new RegexCache(),
627
+ maxDepth: number = DEFAULT_MAX_DEPTH,
628
+ ): { valid: boolean; state: EvalState } {
629
+ const state = new EvalState(
630
+ registry,
631
+ tracing,
632
+ shouldRecord,
633
+ regexCache,
634
+ maxDepth,
635
+ );
636
+ const valid = applyWithOverflowBackstop(
637
+ state,
638
+ registry.rootRef(schemaUri),
639
+ rootCursor(instance),
640
+ null,
641
+ maxDepth,
642
+ );
643
+ return { valid, state };
644
+ }
645
+
646
+ // Backstop: if maxDepth is set above the runtime's own stack ceiling, a
647
+ // native overflow surfaces as a RangeError. Convert it to the same typed,
648
+ // catchable error so callers never face an uncatchable-by-type crash.
649
+ function applyWithOverflowBackstop(
650
+ state: EvalState,
651
+ target: SchemaRef,
652
+ cursor: Cursor,
653
+ pathNode: PathNode | null,
654
+ maxDepth: number,
655
+ ): boolean {
656
+ try {
657
+ return applySchema(state, target, cursor, pathNode);
658
+ } catch (err) {
659
+ if (err instanceof RangeError && /call stack/i.test(err.message)) {
660
+ throw new MaxDepthExceededError(
661
+ `evaluation exceeded the native call stack (maxDepth=${maxDepth}); ` +
662
+ `reduce nesting or lower maxDepth`,
663
+ );
664
+ }
665
+ throw err;
666
+ }
667
+ }
668
+
669
+ /** Options for {@link evaluateFragment}: state pre-seeded by a compiled caller. */
670
+ export interface FragmentOptions {
671
+ /** dynamic scope inherited from the caller's path, outermost first (D8) */
672
+ dynamicScope?: readonly string[];
673
+ /** evaluation-path prefix for output locations */
674
+ pathNode?: PathNode | null;
675
+ /** depth already consumed by the caller's nesting (D20 combined budget) */
676
+ depth?: number;
677
+ /** annotation elision predicate (D5); null records everything */
678
+ shouldRecord?: RecordPredicate | null;
679
+ regexCache?: RegexCache;
680
+ maxDepth?: number;
681
+ /**
682
+ * Record the trace: every branch runs (a trace is verbose demand, ADR
683
+ * 0003) and the irrelevant records are retained, so a compiled evaluator
684
+ * can graft the fragment under its own located tree.
685
+ */
686
+ tracing?: boolean;
687
+ }
688
+
689
+ /**
690
+ * Evaluates one schema fragment with pre-seeded state: the compiled tier's
691
+ * trampoline into the interpreter (M6), used for dynamic islands and for
692
+ * fallback units alike. Returns the fragment's verdict, its errors, and its
693
+ * root frame's surviving annotation and dependency records — cursor
694
+ * identities intact, so a compiled caller can merge them under channel rule
695
+ * 3 and filter under rule 4 exactly as an interpreted parent would. With
696
+ * `tracing`, also the trace and the retained irrelevant records.
697
+ */
698
+ export function evaluateFragment(
699
+ registry: SchemaRegistry,
700
+ target: SchemaRef,
701
+ cursor: Cursor,
702
+ options: FragmentOptions = {},
703
+ ): {
704
+ valid: boolean;
705
+ errors: ErrorRecord[];
706
+ annotations: AnnotationRecord[];
707
+ dependencies: DependencyRecord[];
708
+ /** the fragment's application tree; null unless `tracing` */
709
+ traceRoot: TraceNode | null;
710
+ /** errors dropped by an accepting keyword, in drop order; empty unless `tracing` */
711
+ droppedErrors: ErrorRecord[];
712
+ /** every annotation recorded, relevant or not; empty unless `tracing` */
713
+ allAnnotations: AnnotationRecord[];
714
+ } {
715
+ const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
716
+ const state = new EvalState(
717
+ registry,
718
+ options.tracing === true,
719
+ options.shouldRecord ?? null,
720
+ options.regexCache ?? new RegexCache(),
721
+ maxDepth,
722
+ );
723
+ state.dynamicScope.push(...(options.dynamicScope ?? []));
724
+ state.depth = options.depth ?? 0;
725
+ const valid = applyWithOverflowBackstop(
726
+ state,
727
+ target,
728
+ cursor,
729
+ options.pathNode ?? null,
730
+ maxDepth,
731
+ );
732
+ return {
733
+ valid,
734
+ errors: state.errors,
735
+ annotations: state.rootAnnotations,
736
+ dependencies: state.rootDependencies,
737
+ traceRoot: state.traceRoot,
738
+ droppedErrors: state.droppedErrors ?? [],
739
+ allAnnotations: state.allAnnotations ?? [],
740
+ };
741
+ }