@telorun/analyzer 0.57.0 → 0.59.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 (116) hide show
  1. package/dist/analyzer.d.ts.map +1 -1
  2. package/dist/analyzer.js +91 -9
  3. package/dist/cel-environment.d.ts.map +1 -1
  4. package/dist/cel-environment.js +6 -3
  5. package/dist/definition-registry.d.ts +5 -0
  6. package/dist/definition-registry.d.ts.map +1 -1
  7. package/dist/definition-registry.js +9 -0
  8. package/dist/index.d.ts +11 -3
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +6 -2
  11. package/dist/invocation-contract.d.ts +58 -13
  12. package/dist/invocation-contract.d.ts.map +1 -1
  13. package/dist/invocation-contract.js +123 -46
  14. package/dist/loaded-types.d.ts +13 -0
  15. package/dist/loaded-types.d.ts.map +1 -1
  16. package/dist/manifest-loader.d.ts +8 -1
  17. package/dist/manifest-loader.d.ts.map +1 -1
  18. package/dist/manifest-loader.js +33 -6
  19. package/dist/migrations/driver.d.ts +69 -0
  20. package/dist/migrations/driver.d.ts.map +1 -0
  21. package/dist/migrations/driver.js +200 -0
  22. package/dist/migrations/entries/index.d.ts +3 -0
  23. package/dist/migrations/entries/index.d.ts.map +1 -0
  24. package/dist/migrations/entries/index.js +9 -0
  25. package/dist/migrations/entries/normalize-value-types.json +48 -0
  26. package/dist/migrations/entries/ref-slot-scalar-type.json +20 -0
  27. package/dist/migrations/entry-data.d.ts +25 -0
  28. package/dist/migrations/entry-data.d.ts.map +1 -0
  29. package/dist/migrations/entry-data.js +171 -0
  30. package/dist/migrations/index.d.ts +22 -0
  31. package/dist/migrations/index.d.ts.map +1 -0
  32. package/dist/migrations/index.js +19 -0
  33. package/dist/migrations/match.d.ts +162 -0
  34. package/dist/migrations/match.d.ts.map +1 -0
  35. package/dist/migrations/match.js +295 -0
  36. package/dist/migrations/patch.d.ts +68 -0
  37. package/dist/migrations/patch.d.ts.map +1 -0
  38. package/dist/migrations/patch.js +236 -0
  39. package/dist/migrations/provenance.d.ts +25 -0
  40. package/dist/migrations/provenance.d.ts.map +1 -0
  41. package/dist/migrations/provenance.js +118 -0
  42. package/dist/migrations/registry.d.ts +35 -0
  43. package/dist/migrations/registry.d.ts.map +1 -0
  44. package/dist/migrations/registry.js +35 -0
  45. package/dist/migrations/report.d.ts +28 -0
  46. package/dist/migrations/report.d.ts.map +1 -0
  47. package/dist/migrations/report.js +144 -0
  48. package/dist/migrations/types.d.ts +115 -0
  49. package/dist/migrations/types.d.ts.map +1 -0
  50. package/dist/migrations/types.js +24 -0
  51. package/dist/migrations/yaml-edit.d.ts +21 -0
  52. package/dist/migrations/yaml-edit.d.ts.map +1 -0
  53. package/dist/migrations/yaml-edit.js +245 -0
  54. package/dist/parse-loaded-file.d.ts +8 -0
  55. package/dist/parse-loaded-file.d.ts.map +1 -1
  56. package/dist/parse-loaded-file.js +22 -13
  57. package/dist/resolve-schema-type-refs.d.ts +29 -12
  58. package/dist/resolve-schema-type-refs.d.ts.map +1 -1
  59. package/dist/resolve-schema-type-refs.js +89 -16
  60. package/dist/schema-compat.d.ts +53 -9
  61. package/dist/schema-compat.d.ts.map +1 -1
  62. package/dist/schema-compat.js +245 -66
  63. package/dist/schema-region.d.ts +33 -0
  64. package/dist/schema-region.d.ts.map +1 -0
  65. package/dist/schema-region.js +45 -0
  66. package/dist/types.d.ts +17 -0
  67. package/dist/types.d.ts.map +1 -1
  68. package/dist/validate-cel-context.d.ts.map +1 -1
  69. package/dist/validate-cel-context.js +97 -15
  70. package/dist/validate-step-inputs.d.ts +8 -1
  71. package/dist/validate-step-inputs.d.ts.map +1 -1
  72. package/dist/validate-step-inputs.js +47 -2
  73. package/dist/validate-value-type-slots.d.ts +43 -0
  74. package/dist/validate-value-type-slots.d.ts.map +1 -0
  75. package/dist/validate-value-type-slots.js +142 -0
  76. package/dist/value-type-keyword.d.ts +60 -0
  77. package/dist/value-type-keyword.d.ts.map +1 -0
  78. package/dist/value-type-keyword.js +119 -0
  79. package/dist/yaml-source-edit.d.ts +56 -0
  80. package/dist/yaml-source-edit.d.ts.map +1 -0
  81. package/dist/yaml-source-edit.js +93 -0
  82. package/package.json +3 -3
  83. package/src/analyzer.ts +99 -6
  84. package/src/cel-environment.ts +6 -3
  85. package/src/definition-registry.ts +10 -0
  86. package/src/index.ts +34 -3
  87. package/src/invocation-contract.ts +134 -48
  88. package/src/loaded-types.ts +13 -0
  89. package/src/manifest-loader.ts +37 -6
  90. package/src/migrations/driver.ts +262 -0
  91. package/src/migrations/entries/index.ts +10 -0
  92. package/src/migrations/entries/normalize-value-types.json +48 -0
  93. package/src/migrations/entries/ref-slot-scalar-type.json +20 -0
  94. package/src/migrations/entry-data.ts +207 -0
  95. package/src/migrations/index.ts +22 -0
  96. package/src/migrations/match.ts +382 -0
  97. package/src/migrations/patch.ts +265 -0
  98. package/src/migrations/provenance.ts +167 -0
  99. package/src/migrations/registry.ts +40 -0
  100. package/src/migrations/report.ts +169 -0
  101. package/src/migrations/types.ts +144 -0
  102. package/src/migrations/yaml-edit.ts +260 -0
  103. package/src/parse-loaded-file.ts +31 -12
  104. package/src/resolve-schema-type-refs.ts +93 -16
  105. package/src/schema-compat.ts +277 -75
  106. package/src/schema-region.ts +46 -0
  107. package/src/types.ts +17 -0
  108. package/src/validate-cel-context.ts +117 -15
  109. package/src/validate-step-inputs.ts +56 -1
  110. package/src/validate-value-type-slots.ts +182 -0
  111. package/src/value-type-keyword.ts +135 -0
  112. package/src/yaml-source-edit.ts +106 -0
  113. package/dist/binary-slot.d.ts +0 -39
  114. package/dist/binary-slot.d.ts.map +0 -1
  115. package/dist/binary-slot.js +0 -58
  116. package/src/binary-slot.ts +0 -71
@@ -1,14 +1,20 @@
1
1
  import AjvModule from "ajv";
2
2
  import addFormats from "ajv-formats";
3
3
  import {
4
- INCLUDE_BYTES_ENGINE,
5
- INCLUDE_ENGINE_NAMES,
6
4
  isRefSentinel,
7
5
  isTaggedSentinel,
8
6
  ManifestRootSchema,
9
- normalizeRefSlots,
7
+ producedTypeOf,
10
8
  } from "@telorun/templating";
11
- import { binaryKeyword, isBinarySlot } from "./binary-slot.js";
9
+ import {
10
+ celBaseOfValueType,
11
+ celTypeOfValueType,
12
+ readValueTypeSlot,
13
+ valueBrandBases,
14
+ valueTypeOf,
15
+ valueTypePlaceholder,
16
+ } from "@telorun/sdk";
17
+ import { registerTeloKeywords } from "./value-type-keyword.js";
12
18
 
13
19
  const Ajv = (AjvModule as any).default ?? AjvModule;
14
20
 
@@ -25,10 +31,11 @@ export function createAjv(): InstanceType<typeof Ajv> {
25
31
  (addFormats as any).default
26
32
  ? (addFormats as any).default(instance)
27
33
  : (addFormats as any)(instance);
28
- // Bytes have no JSON Schema type, so the annotation carries the check. Registered
29
- // here and in the kernel's validators from one definition, so a literal at a byte
30
- // slot is rejected statically and at dispatch by the identical rule.
31
- instance.addKeyword(binaryKeyword());
34
+ // One registration site for every Telo keyword the annotations as no-ops and
35
+ // `x-telo-type` as the one that checks. Registered here and in the kernel's
36
+ // validators from one definition, so a literal at an instance-typed slot is
37
+ // rejected statically and at dispatch by the identical rule.
38
+ registerTeloKeywords(instance);
32
39
  instance.addSchema(ManifestRootSchema);
33
40
  return instance;
34
41
  }
@@ -41,62 +48,210 @@ export interface CompatibilityResult {
41
48
  issues: string[];
42
49
  }
43
50
 
44
- /** Conservative structural JSON Schema compatibility check.
45
- * Only flags definite mismatches: missing required fields and primitive type conflicts.
46
- * Ambiguous cases (anyOf/oneOf/etc.) are treated as compatible. */
51
+ /** The alternatives a union node declares, or undefined when it is not one.
52
+ * `anyOf` and `oneOf` are one question here which branches could accept this
53
+ * value and their difference (exactly-one vs at-least-one) is a validation
54
+ * rule, not a compatibility one. */
55
+ function unionBranches(schema: Record<string, any>): Record<string, any>[] | undefined {
56
+ const branches = schema.anyOf ?? schema.oneOf;
57
+ if (!Array.isArray(branches) || branches.length === 0) return undefined;
58
+ return branches.filter((b) => b && typeof b === "object") as Record<string, any>[];
59
+ }
60
+
61
+ /**
62
+ * Conservative structural JSON Schema compatibility check — is a value shaped
63
+ * like `source` acceptable where `target` is declared?
64
+ *
65
+ * COVARIANT, because the values this compares are consumed by reading: a
66
+ * narrower element satisfies a slot declaring a wider one. Only DEFINITE
67
+ * mismatches are flagged — a missing required field, a primitive type conflict,
68
+ * a disagreeing type argument. Anything ambiguous (`anyOf` / `oneOf` / `allOf`,
69
+ * an absent `type`, an undeclared argument) is treated as compatible, so an
70
+ * unmigrated producer and consumer keep checking exactly as they did.
71
+ *
72
+ * The traversal is written here rather than reused: the function this replaced
73
+ * compared only `type` for the names in `target.required` and descended only
74
+ * into objects, so a stream of arrays of strings and a stream of arrays of
75
+ * integers both read as `array` and passed — leaving argument checking inert on
76
+ * exactly the nested shapes it exists for. What survives from it is its posture.
77
+ *
78
+ * `resolveRef` sees through a named shape. Declaring a shape once and
79
+ * referencing it is the sanctioned way to reuse one, so without it two such
80
+ * arguments present as opaque nodes carrying no information — the same reason
81
+ * {@link withLiveValuesSkipped} takes one.
82
+ */
47
83
  export function checkSchemaCompatibility(
48
84
  source: Record<string, any>,
49
85
  target: Record<string, any>,
86
+ resolveRef?: (ref: string) => Record<string, any> | undefined,
50
87
  ): CompatibilityResult {
51
88
  const issues: string[] = [];
52
- checkObject(source, target, "", issues);
89
+ compare(source, target, "", issues, resolveRef, new Set());
53
90
  return { compatible: issues.length === 0, issues };
54
91
  }
55
92
 
56
- function checkObject(
57
- source: Record<string, any>,
58
- target: Record<string, any>,
93
+ type RefResolver = ((ref: string) => Record<string, any> | undefined) | undefined;
94
+
95
+ function deref(schema: Record<string, any>, resolveRef: RefResolver): Record<string, any> {
96
+ if (!resolveRef || typeof schema.$ref !== "string") return schema;
97
+ return resolveRef(schema.$ref) ?? schema;
98
+ }
99
+
100
+ function compare(
101
+ rawSource: Record<string, any>,
102
+ rawTarget: Record<string, any>,
59
103
  path: string,
60
104
  issues: string[],
105
+ resolveRef: RefResolver,
106
+ seen: Set<string>,
61
107
  ): void {
62
- const targetRequired: string[] = target.required ?? [];
63
- const sourceProps: Record<string, any> = source.properties ?? {};
64
- const targetProps: Record<string, any> = target.properties ?? {};
108
+ if (!rawSource || !rawTarget || typeof rawSource !== "object" || typeof rawTarget !== "object") {
109
+ return;
110
+ }
111
+ // A recursive shape reached through the same pair of references twice is the
112
+ // same question again; answering it once terminates and loses nothing.
113
+ //
114
+ // The key is the REFERENCE PAIR and deliberately not the path. A path grows on
115
+ // every descent, so a key containing it is new every time and the guard never
116
+ // fires — which is a stack overflow on the first self-referential shape, taking
117
+ // every other diagnostic in the file with it. It also has to be this way to be
118
+ // correct rather than merely terminating: comparing two schemas gives the same
119
+ // answer wherever they are reached from, so the second visit has nothing to add.
120
+ if (typeof rawSource.$ref === "string" && typeof rawTarget.$ref === "string") {
121
+ const key = `${rawSource.$ref}|${rawTarget.$ref}`;
122
+ if (seen.has(key)) return;
123
+ seen.add(key);
124
+ }
125
+ const source = deref(rawSource, resolveRef);
126
+ const target = deref(rawTarget, resolveRef);
127
+
128
+ // A union is ALTERNATIVES, so it is compared by distributing over branches on
129
+ // both sides: a definite conflict is one where no source-branch/target-branch
130
+ // pair agrees. Returning silently the moment either side was a union — which
131
+ // is what this did — switched the whole comparison off for any slot that
132
+ // accepts more than one shape, and those are exactly the slots where a value
133
+ // type carries the only information distinguishing the branches.
134
+ //
135
+ // `allOf` is a conjunction rather than a choice, so it keeps the old posture:
136
+ // it says too little to judge and stays compatible.
137
+ if (source.allOf || target.allOf) return;
138
+ const sourceBranches = unionBranches(source);
139
+ const targetBranches = unionBranches(target);
140
+ if (sourceBranches || targetBranches) {
141
+ const lefts = sourceBranches ?? [source];
142
+ const rights = targetBranches ?? [target];
143
+ const reasons: string[] = [];
144
+ for (const left of lefts) {
145
+ for (const right of rights) {
146
+ const probe: string[] = [];
147
+ // A fresh `seen` per probe: a pair rejected on one branch must not mark
148
+ // a reference pair visited for the next, which would silently pass it.
149
+ compare(left, right, path, probe, resolveRef, new Set(seen));
150
+ if (probe.length === 0) return;
151
+ reasons.push(...probe);
152
+ }
153
+ }
154
+ issues.push(
155
+ `${path || "/"}: no alternative matches — ${[...new Set(reasons)].join("; ")}`,
156
+ );
157
+ return;
158
+ }
65
159
 
66
- for (const field of targetRequired) {
67
- if (!(field in sourceProps)) {
68
- issues.push(`${path}/${field}: required by target but missing from source`);
69
- continue;
160
+ // Value types first: an `instance` representation has no JSON `type` to
161
+ // compare, so its identity IS the comparison — and its arguments are where the
162
+ // real information lives.
163
+ const sourceType = readValueTypeSlot(source);
164
+ const targetType = readValueTypeSlot(target);
165
+ if (sourceType && targetType) {
166
+ if (sourceType.name !== targetType.name) {
167
+ issues.push(
168
+ `${path || "/"}: value type mismatch — source is '${sourceType.name}', target expects '${targetType.name}'`,
169
+ );
170
+ return;
70
171
  }
71
- const srcProp = sourceProps[field];
72
- const tgtProp = targetProps[field];
73
- if (tgtProp && srcProp) {
74
- checkProperty(srcProp, tgtProp, `${path}/${field}`, issues);
172
+ for (const [argument, targetArg] of Object.entries(targetType.args)) {
173
+ const sourceArg = sourceType.args[argument];
174
+ // An omitted argument is *any*, in BOTH directions. That is what keeps a
175
+ // bare `Telo.Stream` flowing into a typed slot and vice versa, so nothing
176
+ // that does not declare its element is forced to.
177
+ if (sourceArg === undefined) continue;
178
+ compare(
179
+ sourceArg as Record<string, any>,
180
+ targetArg as Record<string, any>,
181
+ `${path}<${argument}>`,
182
+ issues,
183
+ resolveRef,
184
+ seen,
185
+ );
75
186
  }
187
+ return;
76
188
  }
77
- }
78
189
 
79
- function checkProperty(
80
- source: Record<string, any>,
81
- target: Record<string, any>,
82
- path: string,
83
- issues: string[],
84
- ): void {
85
- // Only flag definite primitive type clashes; skip anyOf/oneOf/allOf
190
+ // One side declares a value type and the other does not. A `json`
191
+ // representation refines a base type, so it is compared through that base — a
192
+ // `Telo.TcpPort` into a plain `integer` slot is gradual typing working. An
193
+ // `instance` is not JSON at all, so ANY declared JSON type on the other side is
194
+ // a definite conflict; a side declaring no type at all is still saying nothing
195
+ // and stays compatible.
196
+ if (Boolean(sourceType) !== Boolean(targetType)) {
197
+ const declared = (sourceType ?? targetType)!;
198
+ const other = sourceType ? target : source;
199
+ if (declared.entry && typeof other.type === "string") {
200
+ const base = celBaseOfValueType(declared.entry);
201
+ const asJson = base === undefined ? undefined : declared.entry.base;
202
+ if (asJson !== other.type) {
203
+ issues.push(
204
+ `${path || "/"}: value type mismatch — ${
205
+ sourceType ? "source is" : "target expects"
206
+ } '${declared.name}', ${sourceType ? "target expects" : "source is"} '${other.type}'`,
207
+ );
208
+ return;
209
+ }
210
+ }
211
+ }
212
+
213
+ // Only flag definite primitive type clashes; an absent or union `type` says
214
+ // too little to judge.
86
215
  if (
87
- source.type &&
88
- target.type &&
89
216
  typeof source.type === "string" &&
90
217
  typeof target.type === "string" &&
91
218
  source.type !== target.type
92
219
  ) {
93
220
  issues.push(
94
- `${path}: type mismatch — source is '${source.type}', target expects '${target.type}'`,
221
+ `${path || "/"}: type mismatch — source is '${source.type}', target expects '${target.type}'`,
95
222
  );
96
223
  return;
97
224
  }
98
- if (target.type === "object" && source.type === "object") {
99
- checkObject(source, target, path, issues);
225
+
226
+ // An array's element, which the old comparison never looked at — so every
227
+ // nested shape passed regardless of what it contained.
228
+ if (target.items && source.items) {
229
+ compare(
230
+ source.items as Record<string, any>,
231
+ target.items as Record<string, any>,
232
+ `${path}[]`,
233
+ issues,
234
+ resolveRef,
235
+ seen,
236
+ );
237
+ }
238
+
239
+ const targetRequired: string[] = Array.isArray(target.required) ? target.required : [];
240
+ const sourceProps: Record<string, any> = source.properties ?? {};
241
+ const targetProps: Record<string, any> = target.properties ?? {};
242
+ for (const field of targetRequired) {
243
+ if (!(field in sourceProps)) {
244
+ // Only when the source describes an object at all: a schema with no
245
+ // `properties` is saying nothing about its shape, not saying it is empty.
246
+ if (source.properties === undefined) continue;
247
+ issues.push(`${path}/${field}: required by target but missing from source`);
248
+ continue;
249
+ }
250
+ const srcProp = sourceProps[field];
251
+ const tgtProp = targetProps[field];
252
+ if (tgtProp && srcProp) {
253
+ compare(srcProp, tgtProp, `${path}/${field}`, issues, resolveRef, seen);
254
+ }
100
255
  }
101
256
  }
102
257
 
@@ -165,13 +320,8 @@ function schemaCompiles(schema: Record<string, any>): boolean {
165
320
  export function validateAgainstSchema(data: unknown, schema: Record<string, any>): SchemaIssue[] {
166
321
  let validate = compiledSchemaValidators.get(schema);
167
322
  if (!validate) {
168
- // Normalize outside the try: a fault in our own ref-slot normalization must
169
- // surface, never be mistaken for the module author's schema being malformed.
170
- // Drop the legacy scalar `type` an older published module may still pin on
171
- // its `x-telo-ref` slots so a resolved reference object validates.
172
- const normalized = normalizeRefSlots(schema) as Record<string, any>;
173
323
  try {
174
- validate = ajv.compile(normalized);
324
+ validate = ajv.compile(schema);
175
325
  } catch (err) {
176
326
  // The normalized schema didn't compile. If the original schema is itself
177
327
  // malformed, that is the module author's error — already surfaced once,
@@ -236,28 +386,36 @@ export function navigateSchemaToExprPath(
236
386
  }
237
387
 
238
388
  /**
239
- * Recognized `x-telo-type` value brands and the CEL primitive each refines.
389
+ * Every `json`-represented value type's CEL brand → the primitive it refines.
390
+ *
240
391
  * A brand is a nominal type the analyzer registers (see cel-environment.ts) so
241
- * structurally-identical values (a `TcpPort` and a `UdpPort` are both integers)
242
- * stay distinct for static wiring checks. Brands carry no runtime effect — the
243
- * value flows as its base type. Add new brands here (e.g. `Url: "string"`).
392
+ * structurally-identical values (a `Telo.TcpPort` and a `Telo.UdpPort` are both
393
+ * integers) stay distinct for static wiring checks. Brands carry no runtime
394
+ * effect — the value flows as its base type.
395
+ *
396
+ * DERIVED from the value-type vocabulary, never hand-written: a new brand is a
397
+ * new entry file, and a table here would be a second place to edit that could
398
+ * silently disagree with the one the runtime reads.
244
399
  */
245
- export const VALUE_BRAND_BASE: Record<string, string> = {
246
- TcpPort: "int",
247
- UdpPort: "int",
248
- };
400
+ export const VALUE_BRAND_BASE: Record<string, string> = valueBrandBases();
249
401
 
250
- /** Read a recognized `x-telo-type` brand off a schema, or undefined. */
402
+ /** Read a `json`-represented value type's brand off a schema, or undefined.
403
+ * An `instance` type is not a brand — it replaces the JSON layer rather than
404
+ * refining it, so it carries its binding's CEL type instead. */
251
405
  export function brandOfSchema(schema: Record<string, any> | undefined): string | undefined {
252
- const brand = schema?.["x-telo-type"];
253
- return typeof brand === "string" && brand in VALUE_BRAND_BASE ? brand : undefined;
406
+ const entry = valueTypeOf(schema);
407
+ return entry && entry.representation === "json" ? entry.name : undefined;
254
408
  }
255
409
 
256
410
  /** Map a JSON Schema type annotation to a CEL type string. */
257
411
  export function jsonSchemaToCelType(schema: Record<string, any> | undefined): string {
258
412
  if (!schema || typeof schema !== "object") return "dyn";
259
- const brand = brandOfSchema(schema);
260
- if (brand) return brand;
413
+ // A declared value type IS the type — for an `instance` representation it is
414
+ // the only thing that says so, since bytes and streams have no JSON Schema
415
+ // type at all. Before the three annotations were unified, a byte slot's
416
+ // expression typed as `dyn` because nothing here consulted `x-telo-binary`.
417
+ const entry = valueTypeOf(schema);
418
+ if (entry) return celTypeOfValueType(entry);
261
419
  if (schema.anyOf || schema.oneOf || schema.allOf) return "dyn";
262
420
  if (Array.isArray(schema.type)) return "dyn";
263
421
  switch (schema.type) {
@@ -296,8 +454,25 @@ export function celTypeSatisfiesJsonSchema(celType: string, schema: Record<strin
296
454
  if (fieldBrand) return fieldBrand === celType;
297
455
  celType = sourceBase;
298
456
  }
457
+ // An `instance` representation has no JSON Schema type to compare against, so
458
+ // an expression carrying its binding's CEL type is accepted on that ground
459
+ // alone. This ADDS a case and never removes one: a mismatch falls through to
460
+ // the rules below rather than being rejected here, so nothing that checks
461
+ // today stops checking, and the `bytes` row still accepts a byte expression at
462
+ // a plain `type: string` slot.
463
+ const slotEntry = valueTypeOf(schema);
464
+ if (slotEntry?.representation === "instance" && celTypeOfValueType(slotEntry) === celType) {
465
+ return true;
466
+ }
299
467
  if (!schema.type && !schema.anyOf && !schema.oneOf && !schema.allOf) return true;
300
- if (schema.anyOf || schema.oneOf || schema.allOf) return true;
468
+ // `allOf` is a conjunction and says too little to judge from a single CEL
469
+ // type. A union is a CHOICE, so it is satisfied by satisfying any branch —
470
+ // distributed for the same reason `compare` does it: accepting every union
471
+ // outright turns the check off for exactly the slots that admit more than one
472
+ // shape, and those are the ones where the branches carry the information.
473
+ if (schema.allOf) return true;
474
+ const branches = unionBranches(schema);
475
+ if (branches) return branches.some((branch) => celTypeSatisfiesJsonSchema(celType, branch));
301
476
  const schemaTypes = Array.isArray(schema.type) ? schema.type : [schema.type];
302
477
  const accepted: Record<string, string[]> = {
303
478
  int: ["integer", "number"],
@@ -383,11 +558,15 @@ function foldedConstraints(schema: Record<string, any>): Record<string, any> {
383
558
 
384
559
  export function celPlaceholderForSchema(rawSchema: Record<string, any>): unknown {
385
560
  const schema = foldedConstraints(rawSchema);
386
- // A byte slot's placeholder must BE bytes: the same keyword validates statically
387
- // and at dispatch, so a CEL leaf standing in for a runtime buffer has to satisfy
388
- // it. This is what keeps the rule single — a literal is rejected because no YAML
389
- // literal is a Uint8Array, while a value arriving by reference passes.
390
- if (isBinarySlot(schema)) return new Uint8Array();
561
+ // An instance-typed slot's placeholder must BE an instance: the same keyword
562
+ // validates statically and at dispatch, so a CEL leaf standing in for a runtime
563
+ // value has to satisfy it. This is what keeps the rule single — a literal is
564
+ // rejected because no YAML literal is a byte buffer, while a value arriving by
565
+ // reference passes. The stand-in comes from the binding table, so a new
566
+ // instance type brings its own rather than adding a branch here; a `live` type
567
+ // declares none, because nothing validates it.
568
+ const placeholder = valueTypePlaceholder(schema);
569
+ if (placeholder !== undefined) return placeholder;
391
570
  if (schema.default !== undefined) return schema.default;
392
571
  // An enum-constrained field needs a placeholder drawn from the enum: the
393
572
  // type-based fallbacks below ("" for a string, 0 for a number) satisfy `type`
@@ -396,6 +575,23 @@ export function celPlaceholderForSchema(rawSchema: Record<string, any>): unknown
396
575
  // member chosen is irrelevant — only its acceptability to AJV matters, since
397
576
  // the real value is checked at runtime once the expression resolves.
398
577
  if (Array.isArray(schema.enum) && schema.enum.length > 0) return schema.enum[0];
578
+ // A UNION with no `type` of its own. Without this, a whole-field CEL leaf at
579
+ // such a slot gets `null`, which every branch then rejects — so a field
580
+ // declared `anyOf: [array, boolean]` could not be written as an expression at
581
+ // all, while one whose union happens to contain a `live` branch escaped by
582
+ // accident (nothing validates a live value, so `null` passed). The first
583
+ // branch that yields a placeholder wins: the same conservative posture
584
+ // `selectUnionBranch` takes, and enough for AJV, whose question is only
585
+ // whether SOME branch accepts the stand-in.
586
+ if (schema.type === undefined) {
587
+ const branches = unionBranches(schema);
588
+ if (branches) {
589
+ for (const branch of branches) {
590
+ const candidate = celPlaceholderForSchema(branch);
591
+ if (candidate !== null) return candidate;
592
+ }
593
+ }
594
+ }
399
595
  switch (schema.type) {
400
596
  case "integer":
401
597
  case "number":
@@ -471,7 +667,7 @@ export function resolveRef(schema: Record<string, any>, root: Record<string, any
471
667
  * — an ambiguous union is one the analyzer should not resolve on the author's
472
668
  * behalf.
473
669
  */
474
- function selectUnionBranch(
670
+ export function selectUnionBranch(
475
671
  schema: Record<string, any>,
476
672
  data: unknown,
477
673
  root: Record<string, any>,
@@ -571,16 +767,22 @@ export function substituteCelFields(
571
767
  if (isRefSentinel(data)) {
572
768
  return data;
573
769
  }
574
- // A file embed's type is a CONSTANT of the tag, not a function of the slot:
575
- // `!include-text` always produces a string and `!include-bytes` always
576
- // produces bytes. Collapsing them to a slot-shaped placeholder like a CEL
577
- // expression would make every slot accept both, so a byte embed at a
578
- // `type: string` field passed `telo check` and failed at resource creation
579
- // and the reverse (text at an `x-telo-binary` slot) did too. Substituting the
580
- // real type lets AJV and the `x-telo-binary` keyword reject both directions
581
- // statically, with no new diagnostic code.
582
- if (isTaggedSentinel(data) && INCLUDE_ENGINE_NAMES.has(data.engine)) {
583
- return data.engine === INCLUDE_BYTES_ENGINE ? new Uint8Array() : "";
770
+ // A tag whose produced type is a CONSTANT of the tag rather than a function of
771
+ // the slot substitutes a placeholder of THAT type: `!include-text` always
772
+ // produces a string and `!include-bytes` always produces bytes. Collapsing
773
+ // them to a slot-shaped placeholder like a CEL expression would make every
774
+ // slot accept both, so a byte embed at a `type: string` field passed
775
+ // `telo check` and failed at resource creation and the reverse did too.
776
+ // Substituting the real type lets AJV and the `x-telo-type` keyword reject
777
+ // both directions statically, with no new diagnostic code.
778
+ //
779
+ // The engine is what says so. This used to branch on two tag names, which was
780
+ // the only place a tag's produced type was written down and it was written in
781
+ // the consumer — so a future tag producing bytes had to be added to a set here
782
+ // rather than declaring it.
783
+ if (isTaggedSentinel(data)) {
784
+ const produced = producedTypeOf(data.engine);
785
+ if (produced) return celPlaceholderForSchema(produced);
584
786
  }
585
787
  if (isTaggedSentinel(data)) {
586
788
  mark();
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Where author-written JSON Schema lives in a manifest — one definition, read by
3
+ * every surface that has to bound itself to schema.
4
+ *
5
+ * The keys are the KERNEL's own schema-valued manifest keys, which no resource
6
+ * kind owns. That is what makes the rule generic: a surface using it learns no
7
+ * resource kind, and a module that invents a schema-bearing field of its own
8
+ * reaches it through one of these or not at all — against the topology-driven
9
+ * constraint, an enumeration of the standard library's kinds would be both
10
+ * incomplete and knowledge the analyzer must not hold.
11
+ *
12
+ * A schema fragment is NOT confined to kind documents. An inline `inputType:` /
13
+ * `outputType:` sits on any kind that declares one, an API route carries
14
+ * `request.schema.body`, a `Telo.JsonSchema` carries `schema`. So a check that
15
+ * walks a manifest's ROOT keys covers a fraction of the sites an author writes —
16
+ * which is a silent hole in exactly the checks that exist to stop a silent
17
+ * degrade. Containment is by ANCESTRY instead: a node is in a schema region when
18
+ * some key on the path to it is one of these.
19
+ *
20
+ * Browser-safe: no Node built-ins.
21
+ */
22
+
23
+ /** The kernel's schema-valued manifest keys. */
24
+ export const SCHEMA_REGION_KEYS: readonly string[] = [
25
+ "schema",
26
+ "status",
27
+ "inputType",
28
+ "outputType",
29
+ "itemType",
30
+ ];
31
+
32
+ /**
33
+ * True when `path` reaches into a schema region — some ANCESTOR segment is a
34
+ * schema-valued key.
35
+ *
36
+ * Ancestors only, so a rule keyed on a region key itself still means "inside a
37
+ * schema" rather than "is one". `path` is the walk's own segment list; numeric
38
+ * segments (array indices) never equal a key name, so they need no special case.
39
+ */
40
+ export function isInSchemaRegion(path: readonly (string | number)[]): boolean {
41
+ for (let i = 0; i < path.length - 1; i++) {
42
+ const segment = path[i];
43
+ if (typeof segment === "string" && SCHEMA_REGION_KEYS.includes(segment)) return true;
44
+ }
45
+ return false;
46
+ }
package/src/types.ts CHANGED
@@ -110,12 +110,29 @@ export interface LoadOptions {
110
110
  * pairs manifests to YAML nodes by index. Folded into the file cache key so a
111
111
  * desugared and a raw load of the same file never collide. */
112
112
  desugarImports?: boolean;
113
+ /** When true, the loader's migration phase rewrites legacy spellings in each
114
+ * parsed document before anything else reads the tree. On for every resolved
115
+ * consumer — the kernel's analysis and runtime loads, `telo check`, the
116
+ * analyzer — so one rewrite serves the definition registry, the runtime and
117
+ * the editor's analysis alike.
118
+ *
119
+ * **Off for a round-trip view.** The editor pairs manifests to YAML nodes by
120
+ * index and writes the pair back on save; migrating one half of that pair
121
+ * would silently change the author's file. `telo migrate` is likewise a raw
122
+ * consumer — it rewrites the YAML itself and must see the legacy spelling to
123
+ * find it. Folded into the file cache key so a migrated and a raw load of
124
+ * the same file never collide. */
125
+ migrate?: boolean;
113
126
  }
114
127
 
115
128
  export interface LoaderInitOptions {
116
129
  /** Handlers for CEL stdlib functions (e.g. `sha256`). Analyzer-only callers may
117
130
  * omit this and get throwing stubs; runtime callers (kernel) must supply real impls. */
118
131
  celHandlers?: import("./cel-environment.js").CelHandlers;
132
+ /** Migration set for `LoadOptions.migrate` loads. Defaults to the analyzer's
133
+ * own `CORE_MIGRATIONS`. A host supplies its own once module-shipped entries
134
+ * are aggregated alongside the core ones. */
135
+ migrations?: readonly import("./migrations/types.js").MigrationEntry[];
119
136
  }
120
137
 
121
138
  export interface AnalysisOptions {