@telorun/cel 0.0.0-stage → 0.108.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (223) hide show
  1. package/LICENSE +17 -0
  2. package/README.md +281 -2
  3. package/dist/activation.d.ts +27 -0
  4. package/dist/activation.d.ts.map +1 -0
  5. package/dist/activation.js +24 -0
  6. package/dist/backend-runtime.d.ts +184 -0
  7. package/dist/backend-runtime.d.ts.map +1 -0
  8. package/dist/backend-runtime.js +425 -0
  9. package/dist/bounded-cache.d.ts +21 -0
  10. package/dist/bounded-cache.d.ts.map +1 -0
  11. package/dist/bounded-cache.js +42 -0
  12. package/dist/catalog-runtime.d.ts +59 -0
  13. package/dist/catalog-runtime.d.ts.map +1 -0
  14. package/dist/catalog-runtime.js +786 -0
  15. package/dist/cel-expression.d.ts +32 -0
  16. package/dist/cel-expression.d.ts.map +1 -0
  17. package/dist/cel-expression.js +29 -0
  18. package/dist/cel-map-value.d.ts +47 -0
  19. package/dist/cel-map-value.d.ts.map +1 -0
  20. package/dist/cel-map-value.js +85 -0
  21. package/dist/cel-program.d.ts +44 -0
  22. package/dist/cel-program.d.ts.map +1 -0
  23. package/dist/cel-program.js +72 -0
  24. package/dist/cel-type.d.ts +131 -0
  25. package/dist/cel-type.d.ts.map +1 -0
  26. package/dist/cel-type.js +293 -0
  27. package/dist/cel-value.d.ts +166 -0
  28. package/dist/cel-value.d.ts.map +1 -0
  29. package/dist/cel-value.js +225 -0
  30. package/dist/check-diagnostic.d.ts +54 -0
  31. package/dist/check-diagnostic.d.ts.map +1 -0
  32. package/dist/check-diagnostic.js +66 -0
  33. package/dist/checker.d.ts +86 -0
  34. package/dist/checker.d.ts.map +1 -0
  35. package/dist/checker.js +806 -0
  36. package/dist/closure-backend.d.ts +21 -0
  37. package/dist/closure-backend.d.ts.map +1 -0
  38. package/dist/closure-backend.js +487 -0
  39. package/dist/comprehension-bindings.d.ts +33 -0
  40. package/dist/comprehension-bindings.d.ts.map +1 -0
  41. package/dist/comprehension-bindings.js +50 -0
  42. package/dist/comprehension-runtime.d.ts +44 -0
  43. package/dist/comprehension-runtime.d.ts.map +1 -0
  44. package/dist/comprehension-runtime.js +137 -0
  45. package/dist/declared-chain.d.ts +35 -0
  46. package/dist/declared-chain.d.ts.map +1 -0
  47. package/dist/declared-chain.js +36 -0
  48. package/dist/duration-value.d.ts +70 -0
  49. package/dist/duration-value.d.ts.map +1 -0
  50. package/dist/duration-value.js +149 -0
  51. package/dist/emitted-module.d.ts +207 -0
  52. package/dist/emitted-module.d.ts.map +1 -0
  53. package/dist/emitted-module.js +359 -0
  54. package/dist/engine-version.d.ts +3 -0
  55. package/dist/engine-version.d.ts.map +1 -0
  56. package/dist/engine-version.js +8 -0
  57. package/dist/environment-digest.d.ts +44 -0
  58. package/dist/environment-digest.d.ts.map +1 -0
  59. package/dist/environment-digest.js +98 -0
  60. package/dist/environment.d.ts +291 -0
  61. package/dist/environment.d.ts.map +1 -0
  62. package/dist/environment.js +474 -0
  63. package/dist/function-catalog.d.ts +66 -0
  64. package/dist/function-catalog.d.ts.map +1 -0
  65. package/dist/function-catalog.js +77 -0
  66. package/dist/function-registry.d.ts +118 -0
  67. package/dist/function-registry.d.ts.map +1 -0
  68. package/dist/function-registry.js +292 -0
  69. package/dist/index.d.ts +93 -0
  70. package/dist/index.d.ts.map +1 -0
  71. package/dist/index.js +65 -0
  72. package/dist/integer-arithmetic.d.ts +27 -0
  73. package/dist/integer-arithmetic.d.ts.map +1 -0
  74. package/dist/integer-arithmetic.js +58 -0
  75. package/dist/js-emitter.d.ts +133 -0
  76. package/dist/js-emitter.d.ts.map +1 -0
  77. package/dist/js-emitter.js +568 -0
  78. package/dist/json-schema-type.d.ts +182 -0
  79. package/dist/json-schema-type.d.ts.map +1 -0
  80. package/dist/json-schema-type.js +487 -0
  81. package/dist/json-text-scan.d.ts +28 -0
  82. package/dist/json-text-scan.d.ts.map +1 -0
  83. package/dist/json-text-scan.js +159 -0
  84. package/dist/lexer.d.ts +103 -0
  85. package/dist/lexer.d.ts.map +1 -0
  86. package/dist/lexer.js +458 -0
  87. package/dist/macro-check.d.ts +41 -0
  88. package/dist/macro-check.d.ts.map +1 -0
  89. package/dist/macro-check.js +162 -0
  90. package/dist/macro-shape.d.ts +24 -0
  91. package/dist/macro-shape.d.ts.map +1 -0
  92. package/dist/macro-shape.js +55 -0
  93. package/dist/member-read.d.ts +89 -0
  94. package/dist/member-read.d.ts.map +1 -0
  95. package/dist/member-read.js +166 -0
  96. package/dist/namespace-resolution.d.ts +35 -0
  97. package/dist/namespace-resolution.d.ts.map +1 -0
  98. package/dist/namespace-resolution.js +160 -0
  99. package/dist/nominal-type.d.ts +63 -0
  100. package/dist/nominal-type.d.ts.map +1 -0
  101. package/dist/nominal-type.js +98 -0
  102. package/dist/nullable-access.d.ts +38 -0
  103. package/dist/nullable-access.d.ts.map +1 -0
  104. package/dist/nullable-access.js +93 -0
  105. package/dist/parse-limits.d.ts +26 -0
  106. package/dist/parse-limits.d.ts.map +1 -0
  107. package/dist/parse-limits.js +21 -0
  108. package/dist/parser.d.ts +48 -0
  109. package/dist/parser.d.ts.map +1 -0
  110. package/dist/parser.js +503 -0
  111. package/dist/qualified-calls.d.ts +22 -0
  112. package/dist/qualified-calls.d.ts.map +1 -0
  113. package/dist/qualified-calls.js +27 -0
  114. package/dist/regular-expression.d.ts +48 -0
  115. package/dist/regular-expression.d.ts.map +1 -0
  116. package/dist/regular-expression.js +77 -0
  117. package/dist/reserved-words.d.ts +52 -0
  118. package/dist/reserved-words.d.ts.map +1 -0
  119. package/dist/reserved-words.js +77 -0
  120. package/dist/resolved-call.d.ts +46 -0
  121. package/dist/resolved-call.d.ts.map +1 -0
  122. package/dist/resolved-call.js +15 -0
  123. package/dist/root-references.d.ts +23 -0
  124. package/dist/root-references.d.ts.map +1 -0
  125. package/dist/root-references.js +120 -0
  126. package/dist/runtime-library.d.ts +67 -0
  127. package/dist/runtime-library.d.ts.map +1 -0
  128. package/dist/runtime-library.js +554 -0
  129. package/dist/serializer.d.ts +24 -0
  130. package/dist/serializer.d.ts.map +1 -0
  131. package/dist/serializer.js +256 -0
  132. package/dist/sha256.d.ts +20 -0
  133. package/dist/sha256.d.ts.map +1 -0
  134. package/dist/sha256.js +103 -0
  135. package/dist/signature.d.ts +72 -0
  136. package/dist/signature.d.ts.map +1 -0
  137. package/dist/signature.js +61 -0
  138. package/dist/signatures/function-catalog.json +788 -0
  139. package/dist/signatures/standard-library.json +229 -0
  140. package/dist/standard-library.d.ts +41 -0
  141. package/dist/standard-library.d.ts.map +1 -0
  142. package/dist/standard-library.js +85 -0
  143. package/dist/syntax-diagnostic.d.ts +61 -0
  144. package/dist/syntax-diagnostic.d.ts.map +1 -0
  145. package/dist/syntax-diagnostic.js +28 -0
  146. package/dist/syntax-tree.d.ts +160 -0
  147. package/dist/syntax-tree.d.ts.map +1 -0
  148. package/dist/syntax-tree.js +58 -0
  149. package/dist/timestamp-value.d.ts +62 -0
  150. package/dist/timestamp-value.d.ts.map +1 -0
  151. package/dist/timestamp-value.js +238 -0
  152. package/dist/tree-equality.d.ts +15 -0
  153. package/dist/tree-equality.d.ts.map +1 -0
  154. package/dist/tree-equality.js +105 -0
  155. package/dist/type-expression.d.ts +42 -0
  156. package/dist/type-expression.d.ts.map +1 -0
  157. package/dist/type-expression.js +154 -0
  158. package/dist/value-equality.d.ts +38 -0
  159. package/dist/value-equality.d.ts.map +1 -0
  160. package/dist/value-equality.js +196 -0
  161. package/dist/value-text.d.ts +25 -0
  162. package/dist/value-text.d.ts.map +1 -0
  163. package/dist/value-text.js +44 -0
  164. package/dist/zoned-calendar.d.ts +61 -0
  165. package/dist/zoned-calendar.d.ts.map +1 -0
  166. package/dist/zoned-calendar.js +143 -0
  167. package/package.json +56 -3
  168. package/src/activation.ts +32 -0
  169. package/src/backend-runtime.ts +604 -0
  170. package/src/bounded-cache.ts +45 -0
  171. package/src/catalog-runtime.ts +921 -0
  172. package/src/cel-expression.ts +53 -0
  173. package/src/cel-map-value.ts +97 -0
  174. package/src/cel-program.ts +103 -0
  175. package/src/cel-type.ts +359 -0
  176. package/src/cel-value.ts +361 -0
  177. package/src/check-diagnostic.ts +104 -0
  178. package/src/checker.ts +1045 -0
  179. package/src/closure-backend.ts +547 -0
  180. package/src/comprehension-bindings.ts +66 -0
  181. package/src/comprehension-runtime.ts +157 -0
  182. package/src/declared-chain.ts +45 -0
  183. package/src/duration-value.ts +160 -0
  184. package/src/emitted-module.ts +494 -0
  185. package/src/engine-version.ts +9 -0
  186. package/src/environment-digest.ts +111 -0
  187. package/src/environment.ts +761 -0
  188. package/src/function-catalog.ts +140 -0
  189. package/src/function-registry.ts +341 -0
  190. package/src/index.ts +407 -0
  191. package/src/integer-arithmetic.ts +64 -0
  192. package/src/js-emitter.ts +721 -0
  193. package/src/json-schema-type.ts +664 -0
  194. package/src/json-text-scan.ts +163 -0
  195. package/src/lexer.ts +562 -0
  196. package/src/macro-check.ts +197 -0
  197. package/src/macro-shape.ts +66 -0
  198. package/src/member-read.ts +167 -0
  199. package/src/namespace-resolution.ts +167 -0
  200. package/src/nominal-type.ts +149 -0
  201. package/src/nullable-access.ts +95 -0
  202. package/src/parse-limits.ts +36 -0
  203. package/src/parser.ts +554 -0
  204. package/src/qualified-calls.ts +39 -0
  205. package/src/regular-expression.ts +101 -0
  206. package/src/reserved-words.ts +94 -0
  207. package/src/resolved-call.ts +47 -0
  208. package/src/root-references.ts +126 -0
  209. package/src/runtime-library.ts +639 -0
  210. package/src/serializer.ts +262 -0
  211. package/src/sha256.ts +112 -0
  212. package/src/signature.ts +127 -0
  213. package/src/signatures/function-catalog.json +788 -0
  214. package/src/signatures/standard-library.json +235 -0
  215. package/src/standard-library.ts +149 -0
  216. package/src/syntax-diagnostic.ts +72 -0
  217. package/src/syntax-tree.ts +229 -0
  218. package/src/timestamp-value.ts +310 -0
  219. package/src/tree-equality.ts +130 -0
  220. package/src/type-expression.ts +182 -0
  221. package/src/value-equality.ts +201 -0
  222. package/src/value-text.ts +45 -0
  223. package/src/zoned-calendar.ts +178 -0
@@ -0,0 +1,664 @@
1
+ /**
2
+ * JSON Schema as a CEL type — the checker's native input, to full depth.
3
+ *
4
+ * A host knows the shape of what it binds as a schema, not as a CEL type, and the
5
+ * conversion is where a checker either keeps that knowledge or throws it away. This
6
+ * one keeps it: nested objects become records of records, `items` becomes the element
7
+ * type, `additionalProperties` becomes the value type of a map, a union stays a
8
+ * **union** instead of collapsing to `dyn`, and a reference is followed. A typo two
9
+ * levels into a schema-typed variable is then a type error with a range, where an
10
+ * engine typing an object as a flat field map can only shrug at anything below the
11
+ * first level.
12
+ *
13
+ * **Nothing a schema says may fall through to `dyn` in silence.** That is the rule the
14
+ * rest of this file implements, and it is a stronger statement than "every keyword is
15
+ * read": a keyword this reader has no rule for is **reported**, by JSON Pointer, beside
16
+ * the type produced in its place. Completeness rests on
17
+ * `TYPE_CONSTRAINING_KEYWORDS` — an engine-owned, closed list of the keywords that can
18
+ * change what CEL type a node has. A node carrying one this reader does not read is
19
+ * reported; a node carrying none of them says nothing about its type and is `dyn`
20
+ * legitimately, unreported.
21
+ *
22
+ * **It reports rather than refuses.** A node this reader cannot type is usually a third
23
+ * party's data — a schema shipped inside something a host merely loaded. Throwing at
24
+ * registration would turn someone else's schema into a crash; typing it `dyn` quietly is
25
+ * the hole itself. A report lets the one consumer that knows where the schema was
26
+ * written anchor a diagnostic at that line.
27
+ *
28
+ * **References split at the document boundary, and the split is the point.**
29
+ *
30
+ * - A **document-local** reference (`#/$defs/…`, `#/definitions/…`) is resolved here,
31
+ * against the document in hand. The engine's input is therefore a node **plus the
32
+ * document it belongs to**, the document travelling with the node as the descent moves
33
+ * between documents.
34
+ * - A reference that **leaves** the document is answered by the host's own resolver —
35
+ * the one this file already consults at every node — whose answer is a registered type
36
+ * name with arguments, **or the document to read in place of this node**. Which
37
+ * document a reference outside this one resolves against, and how a relative reference
38
+ * is rebased, is the host's half; there is no copy of it here and no second seam.
39
+ *
40
+ * A reference **re-entered** on the descent — a recursive schema — is the one deliberate
41
+ * `dyn`, and it is declared as such (`recursive`) rather than reported: the outer reading
42
+ * is what the consumer gets and the descent terminates.
43
+ *
44
+ * **Conversion is memoized by node identity within a document**, because the same schema
45
+ * object is handed over on every keystroke in an editor and a deep schema is not cheap to
46
+ * walk twice. A document a reference resolves to is memoized the same way, so a shape
47
+ * referenced a hundred times converts once and the cost stays linear in the nodes
48
+ * actually reached — so a node reached twice is also reported once, at the pointer it was
49
+ * first reached by. The host's resolver stays a function rather than a handed-over map of
50
+ * every shape it knows, which is what keeps one registration from costing the size of a
51
+ * host's whole workspace.
52
+ */
53
+
54
+ import type { CelType, RecordType, UnionType } from "./cel-type.js";
55
+ import {
56
+ assignable,
57
+ BOOL,
58
+ DOUBLE,
59
+ DYN,
60
+ INT,
61
+ isDyn,
62
+ listOf,
63
+ mapOf,
64
+ NULL,
65
+ STRING,
66
+ typesEqual,
67
+ unionOf,
68
+ } from "./cel-type.js";
69
+
70
+ /** The keywords this reads. Anything else in a schema is, here, absent. */
71
+ export interface JsonSchemaNode {
72
+ readonly type?: string | readonly string[];
73
+ readonly properties?: Readonly<Record<string, JsonSchemaNode>>;
74
+ readonly additionalProperties?: boolean | JsonSchemaNode;
75
+ readonly items?: JsonSchemaNode | readonly JsonSchemaNode[];
76
+ readonly anyOf?: readonly JsonSchemaNode[];
77
+ readonly oneOf?: readonly JsonSchemaNode[];
78
+ readonly allOf?: readonly JsonSchemaNode[];
79
+ readonly $ref?: string;
80
+ readonly enum?: readonly unknown[];
81
+ readonly const?: unknown;
82
+ readonly required?: readonly string[];
83
+ readonly [keyword: string]: unknown;
84
+ }
85
+
86
+ /**
87
+ * A node and the document it belongs to.
88
+ *
89
+ * A `#/…` reference inside `node` resolves against `root`; where the node **is** the
90
+ * document — a host registering a whole schema, which is the common case — `root` is
91
+ * absent and the node stands as its own document.
92
+ */
93
+ export interface JsonSchemaDocument {
94
+ readonly node: JsonSchemaNode;
95
+ readonly root?: JsonSchemaNode;
96
+ }
97
+
98
+ /**
99
+ * What the host says a schema node is, before its structure is read: a registered type
100
+ * name with its type arguments, the document to read in place of this node, or nothing.
101
+ *
102
+ * The second form is how a reference leaving the document is answered. The host is given
103
+ * the node **and the document it belongs to**, because that is what identifies a
104
+ * reference's base — and rebasing is the host's rule, not this engine's.
105
+ */
106
+ export type SchemaTypeAnswer =
107
+ | { readonly name: string; readonly args?: readonly string[] }
108
+ | { readonly document: JsonSchemaDocument };
109
+
110
+ export type SchemaTypeResolver = (document: JsonSchemaDocument) => SchemaTypeAnswer | undefined;
111
+
112
+ /** How a named type a resolver answers becomes a type. */
113
+ export type NamedTypeLookup = (name: string, args: readonly string[]) => CelType | undefined;
114
+
115
+ export interface SchemaConversion {
116
+ readonly resolveSchemaType?: SchemaTypeResolver;
117
+ readonly lookupNamedType?: NamedTypeLookup;
118
+ }
119
+
120
+ /**
121
+ * Why a node could not be judged. Closed, and each member is a fact about the schema:
122
+ *
123
+ * - `keyword-not-read` — the node carries a type-constraining keyword this reader has no
124
+ * rule for.
125
+ * - `shape-not-read` — a keyword this reader does read, in a form it does not: a tuple
126
+ * `items`, a `type` naming no JSON type.
127
+ * - `reference-unresolved` — a reference that leaves the document and that the host's
128
+ * resolver did not answer, or a document-local pointer naming nothing.
129
+ * - `intersection-empty` — two things the node says about its own type cannot both hold.
130
+ * - `named-type-unregistered` — the host's resolver named a type and nothing is registered
131
+ * under that name: the host disagreeing with itself, which must not be silent either.
132
+ */
133
+ export const UNJUDGED_REASONS = [
134
+ "keyword-not-read",
135
+ "shape-not-read",
136
+ "reference-unresolved",
137
+ "intersection-empty",
138
+ "named-type-unregistered",
139
+ ] as const;
140
+
141
+ export type UnjudgedReason = (typeof UNJUDGED_REASONS)[number];
142
+
143
+ export interface UnjudgedSchemaNode {
144
+ /** JSON Pointer to the node, within the document it was found in. */
145
+ readonly pointer: string;
146
+ /**
147
+ * The JSON Pointer, in the document the conversion was given, of the reference that
148
+ * first led out of it. Absent when the node is in that document, so a consumer anchors
149
+ * a diagnostic at `throughReference ?? pointer`.
150
+ */
151
+ readonly throughReference?: string;
152
+ /** The keywords at that node this reader could not read; none, where no keyword is the cause. */
153
+ readonly keywords: readonly string[];
154
+ /** The type name the host's resolver answered, where that is the cause. */
155
+ readonly typeName?: string;
156
+ readonly reason: UnjudgedReason;
157
+ }
158
+
159
+ /** A reference re-entered on the descent: a recursive schema, deliberately `dyn`. */
160
+ export interface RecursiveSchemaReference {
161
+ /** JSON Pointer to the node re-entered, within the document it was found in. */
162
+ readonly pointer: string;
163
+ readonly throughReference?: string;
164
+ /** The reference that led back, where a reference did. */
165
+ readonly reference?: string;
166
+ }
167
+
168
+ export interface SchemaTypeResult {
169
+ readonly type: CelType;
170
+ /** Every node this reader could not judge. Empty is the whole schema judged. */
171
+ readonly unjudged: readonly UnjudgedSchemaNode[];
172
+ readonly recursive: readonly RecursiveSchemaReference[];
173
+ }
174
+
175
+ /**
176
+ * Every keyword that can change what CEL type a node has — the closed list completeness
177
+ * is measured against.
178
+ *
179
+ * A keyword that constrains a **value** rather than its type is deliberately absent:
180
+ * `required` (presence), `contains` (that some element matches, which fixes no element
181
+ * type), `propertyNames` (a JSON object's keys are strings whatever it says), `format`,
182
+ * `pattern` and every numeric, string and array bound. None of them can move a type, so
183
+ * reporting them would be noise a consumer learns to ignore.
184
+ *
185
+ * **The blind spot, written down:** this list is the engine's own, so a keyword that
186
+ * constrains a type and is on no list at all is invisible — it falls through as "a node
187
+ * carrying nothing", which `tests/schema-keyword-coverage.test.ts` pins as `dyn` and
188
+ * unreported. Nothing here can cover that class, because nothing under this package knows
189
+ * JSON Schema's vocabulary; what covers it is that the list and the reader are held to
190
+ * each other in both directions, so adding the keyword is a one-line change that cannot
191
+ * be half done.
192
+ */
193
+ export const TYPE_CONSTRAINING_KEYWORDS: readonly string[] = [
194
+ "$dynamicRef",
195
+ "$recursiveRef",
196
+ "$ref",
197
+ "additionalItems",
198
+ "additionalProperties",
199
+ "allOf",
200
+ "anyOf",
201
+ "const",
202
+ "dependentSchemas",
203
+ "else",
204
+ "enum",
205
+ "if",
206
+ "items",
207
+ "not",
208
+ "oneOf",
209
+ "patternProperties",
210
+ "prefixItems",
211
+ "properties",
212
+ "then",
213
+ "type",
214
+ "unevaluatedItems",
215
+ "unevaluatedProperties",
216
+ ];
217
+
218
+ /** The keywords of that list this reader reads. Every other one on it is reported. */
219
+ export const TYPE_KEYWORDS_READ: readonly string[] = [
220
+ "$ref",
221
+ "additionalProperties",
222
+ "allOf",
223
+ "anyOf",
224
+ "const",
225
+ "enum",
226
+ "items",
227
+ "oneOf",
228
+ "properties",
229
+ "type",
230
+ ];
231
+
232
+ const KEYWORDS_NOT_READ = TYPE_CONSTRAINING_KEYWORDS.filter(
233
+ (keyword) => !TYPE_KEYWORDS_READ.includes(keyword),
234
+ );
235
+
236
+ const SCALARS: Readonly<Record<string, CelType>> = {
237
+ string: STRING,
238
+ integer: INT,
239
+ number: DOUBLE,
240
+ boolean: BOOL,
241
+ null: NULL,
242
+ };
243
+
244
+ /** The type a schema declares, read to full depth, beside what it could not judge. */
245
+ export function schemaType(
246
+ document: JsonSchemaDocument,
247
+ conversion: SchemaConversion = {},
248
+ ): SchemaTypeResult {
249
+ const reader = new SchemaReader(conversion);
250
+ const type = reader.read(document.node, document.root ?? document.node, "", undefined, undefined);
251
+ return { type, unjudged: reader.unjudged, recursive: reader.recursive };
252
+ }
253
+
254
+ /** One part of what a node says about its own type, and the keyword that said it. */
255
+ interface TypePart {
256
+ readonly keyword: string;
257
+ readonly type: CelType;
258
+ }
259
+
260
+ /** What has been read of one document: its memo, and what is being read right now. */
261
+ interface DocumentState {
262
+ readonly done: Map<JsonSchemaNode, CelType>;
263
+ readonly active: Set<JsonSchemaNode>;
264
+ }
265
+
266
+ class SchemaReader {
267
+ readonly unjudged: UnjudgedSchemaNode[] = [];
268
+ readonly recursive: RecursiveSchemaReference[] = [];
269
+ /** Keyed by document, so one node read under two documents is read against each. */
270
+ private readonly documents = new Map<JsonSchemaNode, DocumentState>();
271
+
272
+ constructor(private readonly conversion: SchemaConversion) {}
273
+
274
+ read(
275
+ node: JsonSchemaNode,
276
+ root: JsonSchemaNode,
277
+ pointer: string,
278
+ through: string | undefined,
279
+ arrivedBy: string | undefined,
280
+ ): CelType {
281
+ // Not an object: nothing a node said, so nothing to judge.
282
+ if (typeof node !== "object" || node === null) return DYN;
283
+ const state = this.documentState(root);
284
+ const held = state.done.get(node);
285
+ if (held) return held;
286
+ if (state.active.has(node)) {
287
+ this.recursive.push({
288
+ pointer,
289
+ ...(through === undefined ? {} : { throughReference: through }),
290
+ ...(arrivedBy === undefined ? {} : { reference: arrivedBy }),
291
+ });
292
+ return DYN;
293
+ }
294
+ state.active.add(node);
295
+ let type: CelType;
296
+ try {
297
+ type = this.readNode(node, root, pointer, through);
298
+ } finally {
299
+ state.active.delete(node);
300
+ }
301
+ state.done.set(node, type);
302
+ return type;
303
+ }
304
+
305
+ private documentState(root: JsonSchemaNode): DocumentState {
306
+ const held = this.documents.get(root);
307
+ if (held) return held;
308
+ const state: DocumentState = { done: new Map(), active: new Set() };
309
+ this.documents.set(root, state);
310
+ return state;
311
+ }
312
+
313
+ private readNode(
314
+ node: JsonSchemaNode,
315
+ root: JsonSchemaNode,
316
+ pointer: string,
317
+ through: string | undefined,
318
+ ): CelType {
319
+ const answer = this.conversion.resolveSchemaType?.({ node, root });
320
+ if (answer && "document" in answer) {
321
+ // The host answered a document: the descent crosses into it, and a pointer from
322
+ // here on is within that document. The reference that first left the conversion's
323
+ // own document is what a consumer anchors at, so the first crossing is the one kept.
324
+ const crossed = answer.document;
325
+ return this.read(crossed.node, crossed.root ?? crossed.node, "", through ?? pointer, undefined);
326
+ }
327
+ if (answer) {
328
+ const type = this.conversion.lookupNamedType?.(answer.name, answer.args ?? []);
329
+ if (type) return type;
330
+ // The host named a type nothing is registered under — its own disagreement with itself.
331
+ // Falling through to the structural rules is the right READING, but doing it quietly
332
+ // is the same silence this report exists to end, so it is reported beside that reading.
333
+ this.report(pointer, through, [], "named-type-unregistered", answer.name);
334
+ }
335
+ this.reportUnreadKeywords(node, pointer, through);
336
+ return this.meet(this.parts(node, root, pointer, through), pointer, through);
337
+ }
338
+
339
+ /** Everything the node says about its own type, each with the keyword that said it. */
340
+ private parts(
341
+ node: JsonSchemaNode,
342
+ root: JsonSchemaNode,
343
+ pointer: string,
344
+ through: string | undefined,
345
+ ): readonly TypePart[] {
346
+ const parts: TypePart[] = [];
347
+ if (typeof node.$ref === "string") {
348
+ parts.push({ keyword: "$ref", type: this.reference(node.$ref, root, pointer, through) });
349
+ }
350
+ if (node.allOf && node.allOf.length > 0) {
351
+ const branches = node.allOf.map((branch, at) => ({
352
+ keyword: "allOf",
353
+ type: this.read(branch, root, `${pointer}/allOf/${at}`, through, undefined),
354
+ }));
355
+ parts.push({ keyword: "allOf", type: this.meet(branches, pointer, through) });
356
+ }
357
+ for (const keyword of ["anyOf", "oneOf"] as const) {
358
+ const branches = node[keyword];
359
+ if (!branches || branches.length === 0) continue;
360
+ parts.push({
361
+ keyword,
362
+ type: unionOf(
363
+ branches.map((branch, at) => this.read(branch, root, `${pointer}/${keyword}/${at}`, through, undefined)),
364
+ ),
365
+ });
366
+ }
367
+ const structural = this.structure(node, root, pointer, through);
368
+ if (structural) parts.push(structural);
369
+ return parts;
370
+ }
371
+
372
+ /** What `type`, `properties`, `items` or a constant value say, in that order. */
373
+ private structure(
374
+ node: JsonSchemaNode,
375
+ root: JsonSchemaNode,
376
+ pointer: string,
377
+ through: string | undefined,
378
+ ): TypePart | undefined {
379
+ if (Array.isArray(node.type)) {
380
+ return {
381
+ keyword: "type",
382
+ type: unionOf(node.type.map((name) => this.ofType(name, node, root, pointer, through))),
383
+ };
384
+ }
385
+ if (typeof node.type === "string") {
386
+ return { keyword: "type", type: this.ofType(node.type, node, root, pointer, through) };
387
+ }
388
+ if (node.properties || node.additionalProperties !== undefined) {
389
+ return { keyword: "properties", type: this.record(node, root, pointer, through) };
390
+ }
391
+ if (node.items !== undefined) {
392
+ return { keyword: "items", type: this.array(node, root, pointer, through) };
393
+ }
394
+ // A constant or an enumeration with no `type` still says what JSON type its values
395
+ // hold, which is the difference between `string` and an unjudged node.
396
+ if ("const" in node) return { keyword: "const", type: jsonValueType(node.const) };
397
+ if (node.enum && node.enum.length > 0) {
398
+ return { keyword: "enum", type: unionOf(node.enum.map(jsonValueType)) };
399
+ }
400
+ return undefined;
401
+ }
402
+
403
+ private ofType(
404
+ name: string,
405
+ node: JsonSchemaNode,
406
+ root: JsonSchemaNode,
407
+ pointer: string,
408
+ through: string | undefined,
409
+ ): CelType {
410
+ const scalar = SCALARS[name];
411
+ if (scalar) return scalar;
412
+ if (name === "object") return this.record(node, root, pointer, through);
413
+ if (name === "array") return this.array(node, root, pointer, through);
414
+ this.report(pointer, through, ["type"], "shape-not-read");
415
+ return DYN;
416
+ }
417
+
418
+ private record(
419
+ node: JsonSchemaNode,
420
+ root: JsonSchemaNode,
421
+ pointer: string,
422
+ through: string | undefined,
423
+ ): CelType {
424
+ const properties = node.properties;
425
+ const additional = node.additionalProperties;
426
+ const additionalType =
427
+ additional === undefined || typeof additional === "boolean"
428
+ ? undefined
429
+ : this.read(additional, root, `${pointer}/additionalProperties`, through, undefined);
430
+ if (!properties) {
431
+ // No declared property: a schema that says what any value is, is a map of it; one
432
+ // that admits anything is a map of anything; one that admits nothing holds no field.
433
+ if (additionalType) return mapOf(STRING, additionalType);
434
+ if (additional === false) return { kind: "record", fields: new Map(), open: false };
435
+ return mapOf(STRING, DYN);
436
+ }
437
+ const fields = new Map<string, CelType>();
438
+ for (const [name, property] of Object.entries(properties)) {
439
+ fields.set(name, this.read(property, root, `${pointer}/properties/${escapeSegment(name)}`, through, undefined));
440
+ }
441
+ return { kind: "record", fields, open: additional === true };
442
+ }
443
+
444
+ private array(
445
+ node: JsonSchemaNode,
446
+ root: JsonSchemaNode,
447
+ pointer: string,
448
+ through: string | undefined,
449
+ ): CelType {
450
+ const items = node.items;
451
+ if (Array.isArray(items)) {
452
+ // A tuple: each position has its own type, which a CEL list type cannot hold.
453
+ this.report(pointer, through, ["items"], "shape-not-read");
454
+ return listOf(DYN);
455
+ }
456
+ if (!items) return listOf(DYN);
457
+ return listOf(this.read(items as JsonSchemaNode, root, `${pointer}/items`, through, undefined));
458
+ }
459
+
460
+ /** A document-local reference, resolved here; anything else was the host's to answer. */
461
+ private reference(
462
+ reference: string,
463
+ root: JsonSchemaNode,
464
+ pointer: string,
465
+ through: string | undefined,
466
+ ): CelType {
467
+ if (!reference.startsWith("#")) {
468
+ // The host's resolver was asked at this node and did not answer it.
469
+ this.report(pointer, through, ["$ref"], "reference-unresolved");
470
+ return DYN;
471
+ }
472
+ const target = nodeAtPointer(root, reference.slice(1));
473
+ if (!target) {
474
+ this.report(pointer, through, ["$ref"], "reference-unresolved");
475
+ return DYN;
476
+ }
477
+ return this.read(target, root, reference.slice(1), through, reference);
478
+ }
479
+
480
+ /**
481
+ * The one type every part admits: records merge field-wise, a narrower scalar wins, and
482
+ * two parts that cannot both hold are reported rather than quietly resolved one way.
483
+ */
484
+ private meet(
485
+ parts: readonly TypePart[],
486
+ pointer: string,
487
+ through: string | undefined,
488
+ ): CelType {
489
+ if (parts.length === 0) return DYN;
490
+ let met = parts[0]!;
491
+ for (const part of parts.slice(1)) {
492
+ const both = intersectTypes(met.type, part.type);
493
+ if (!both) {
494
+ this.report(pointer, through, [met.keyword, part.keyword], "intersection-empty");
495
+ return DYN;
496
+ }
497
+ met = { keyword: part.keyword, type: both };
498
+ }
499
+ return met.type;
500
+ }
501
+
502
+ private reportUnreadKeywords(
503
+ node: JsonSchemaNode,
504
+ pointer: string,
505
+ through: string | undefined,
506
+ ): void {
507
+ const present = KEYWORDS_NOT_READ.filter((keyword) => keyword in node);
508
+ if (present.length > 0) this.report(pointer, through, present, "keyword-not-read");
509
+ }
510
+
511
+ private report(
512
+ pointer: string,
513
+ through: string | undefined,
514
+ keywords: readonly string[],
515
+ reason: UnjudgedReason,
516
+ typeName?: string,
517
+ ): void {
518
+ this.unjudged.push({
519
+ pointer,
520
+ ...(through === undefined ? {} : { throughReference: through }),
521
+ keywords: [...new Set(keywords)],
522
+ ...(typeName === undefined ? {} : { typeName }),
523
+ reason,
524
+ });
525
+ }
526
+ }
527
+
528
+ /** The JSON type a constant or an enumerated value holds. */
529
+ function jsonValueType(value: unknown): CelType {
530
+ if (value === null) return NULL;
531
+ switch (typeof value) {
532
+ case "boolean":
533
+ return BOOL;
534
+ case "string":
535
+ return STRING;
536
+ case "number":
537
+ return Number.isInteger(value) ? INT : DOUBLE;
538
+ case "object":
539
+ return Array.isArray(value) ? listOf(DYN) : mapOf(STRING, DYN);
540
+ default:
541
+ return DYN;
542
+ }
543
+ }
544
+
545
+ /**
546
+ * The one type both admit, or nothing when they cannot both hold.
547
+ *
548
+ * `undefined` is "no value satisfies both" — an unsatisfiable schema, which the caller
549
+ * reports rather than resolving in one direction and hiding the contradiction.
550
+ */
551
+ function intersectTypes(left: CelType, right: CelType): CelType | undefined {
552
+ if (isDyn(left)) return right;
553
+ if (isDyn(right)) return left;
554
+ if (typesEqual(left, right)) return left;
555
+ if (left.kind === "union" || right.kind === "union") {
556
+ const union = (left.kind === "union" ? left : right) as UnionType;
557
+ const other = left.kind === "union" ? right : left;
558
+ const met = union.members
559
+ .map((member) => intersectTypes(member, other))
560
+ .filter((member): member is CelType => member !== undefined);
561
+ return met.length === 0 ? undefined : unionOf(met);
562
+ }
563
+ if (left.kind === "record" && right.kind === "record") return intersectRecords(left, right);
564
+ if (left.kind === "record" && right.kind === "map") return intersectRecordWithMap(left, right.value);
565
+ if (right.kind === "record" && left.kind === "map") return intersectRecordWithMap(right, left.value);
566
+ if (left.kind === "map" && right.kind === "map") {
567
+ const key = intersectTypes(left.key, right.key);
568
+ const value = intersectTypes(left.value, right.value);
569
+ return key && value ? mapOf(key, value) : undefined;
570
+ }
571
+ if (left.kind === "list" && right.kind === "list") {
572
+ const element = intersectTypes(left.element, right.element);
573
+ return element ? listOf(element) : undefined;
574
+ }
575
+ if (left.kind === "optional" && right.kind === "optional") {
576
+ const value = intersectTypes(left.value, right.value);
577
+ return value ? { kind: "optional", value } : undefined;
578
+ }
579
+ // Whichever stands where the other is wanted is the narrower of the two.
580
+ if (assignable(left, right)) return left;
581
+ if (assignable(right, left)) return right;
582
+ return undefined;
583
+ }
584
+
585
+ /**
586
+ * Two records meet as the union of their fields — which is what composing a shape out of
587
+ * two partial ones means, and what makes a field of each readable and a third an error.
588
+ * Closed beats open: a reader of the composition may rely on either half's refusal.
589
+ */
590
+ function intersectRecords(left: RecordType, right: RecordType): CelType | undefined {
591
+ const fields = new Map(left.fields);
592
+ for (const [name, type] of right.fields) {
593
+ const held = fields.get(name);
594
+ if (held === undefined) {
595
+ fields.set(name, type);
596
+ continue;
597
+ }
598
+ const both = intersectTypes(held, type);
599
+ if (!both) return undefined;
600
+ fields.set(name, both);
601
+ }
602
+ return {
603
+ kind: "record",
604
+ fields,
605
+ open: left.open && right.open,
606
+ ...(left.name === undefined ? {} : { name: left.name }),
607
+ };
608
+ }
609
+
610
+ /** A record against a map's value type: every field must also be a value of the map. */
611
+ function intersectRecordWithMap(record: RecordType, value: CelType): CelType | undefined {
612
+ const fields = new Map<string, CelType>();
613
+ for (const [name, type] of record.fields) {
614
+ const both = intersectTypes(type, value);
615
+ if (!both) return undefined;
616
+ fields.set(name, both);
617
+ }
618
+ return { ...record, fields };
619
+ }
620
+
621
+ /** A JSON Pointer segment, escaped as RFC 6901 writes it. */
622
+ function escapeSegment(segment: string): string {
623
+ return segment.replace(/~/g, "~0").replace(/\//g, "~1");
624
+ }
625
+
626
+ /** The node a JSON Pointer names within a document, or nothing where it names none. */
627
+ function nodeAtPointer(root: JsonSchemaNode, pointer: string): JsonSchemaNode | undefined {
628
+ if (pointer === "") return root;
629
+ if (!pointer.startsWith("/")) return undefined;
630
+ let at: unknown = root;
631
+ for (const segment of pointer.slice(1).split("/")) {
632
+ const name = segment.replace(/~1/g, "/").replace(/~0/g, "~");
633
+ if (typeof at !== "object" || at === null) return undefined;
634
+ at = Array.isArray(at) ? at[Number(name)] : (at as Record<string, unknown>)[name];
635
+ if (at === undefined) return undefined;
636
+ }
637
+ return typeof at === "object" && at !== null ? (at as JsonSchemaNode) : undefined;
638
+ }
639
+
640
+ /** A field map, each field a type expression or a nested field map. */
641
+ export type FieldDeclaration = string | { readonly fields: Readonly<Record<string, FieldDeclaration>> };
642
+
643
+ /**
644
+ * An object type written as a field map rather than as a schema.
645
+ *
646
+ * It is how a host states a shape it holds no schema for — and how a shallow typing,
647
+ * where every field is `map` or `list`, is still expressible: a consumer that must
648
+ * reproduce an older engine's verdicts exactly needs to be able to say "this much and
649
+ * no more", and a converter that only ever goes deep would force every such consumer
650
+ * to become stricter on the same day it changes engines.
651
+ */
652
+ export function fieldMapType(
653
+ fields: Readonly<Record<string, FieldDeclaration>>,
654
+ readType: (text: string) => CelType,
655
+ ): CelType {
656
+ const read = new Map<string, CelType>();
657
+ for (const [name, declaration] of Object.entries(fields)) {
658
+ read.set(
659
+ name,
660
+ typeof declaration === "string" ? readType(declaration) : fieldMapType(declaration.fields, readType),
661
+ );
662
+ }
663
+ return { kind: "record", fields: read, open: false };
664
+ }