@telorun/analyzer 0.56.1 → 0.58.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 (133) hide show
  1. package/dist/analyzer.d.ts +5 -0
  2. package/dist/analyzer.d.ts.map +1 -1
  3. package/dist/analyzer.js +232 -94
  4. package/dist/cel-environment.d.ts.map +1 -1
  5. package/dist/cel-environment.js +6 -3
  6. package/dist/definition-registry.d.ts +5 -0
  7. package/dist/definition-registry.d.ts.map +1 -1
  8. package/dist/definition-registry.js +9 -0
  9. package/dist/index.d.ts +14 -4
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +8 -3
  12. package/dist/invocation-contract.d.ts +21 -13
  13. package/dist/invocation-contract.d.ts.map +1 -1
  14. package/dist/invocation-contract.js +43 -46
  15. package/dist/loaded-types.d.ts +13 -0
  16. package/dist/loaded-types.d.ts.map +1 -1
  17. package/dist/manifest-loader.d.ts +8 -1
  18. package/dist/manifest-loader.d.ts.map +1 -1
  19. package/dist/manifest-loader.js +33 -6
  20. package/dist/manifest-visitor.d.ts +4 -0
  21. package/dist/manifest-visitor.d.ts.map +1 -1
  22. package/dist/manifest-visitor.js +3 -3
  23. package/dist/migrations/driver.d.ts +69 -0
  24. package/dist/migrations/driver.d.ts.map +1 -0
  25. package/dist/migrations/driver.js +200 -0
  26. package/dist/migrations/entries/index.d.ts +3 -0
  27. package/dist/migrations/entries/index.d.ts.map +1 -0
  28. package/dist/migrations/entries/index.js +9 -0
  29. package/dist/migrations/entries/normalize-value-types.json +48 -0
  30. package/dist/migrations/entries/ref-slot-scalar-type.json +20 -0
  31. package/dist/migrations/entry-data.d.ts +25 -0
  32. package/dist/migrations/entry-data.d.ts.map +1 -0
  33. package/dist/migrations/entry-data.js +171 -0
  34. package/dist/migrations/index.d.ts +22 -0
  35. package/dist/migrations/index.d.ts.map +1 -0
  36. package/dist/migrations/index.js +19 -0
  37. package/dist/migrations/match.d.ts +162 -0
  38. package/dist/migrations/match.d.ts.map +1 -0
  39. package/dist/migrations/match.js +295 -0
  40. package/dist/migrations/patch.d.ts +68 -0
  41. package/dist/migrations/patch.d.ts.map +1 -0
  42. package/dist/migrations/patch.js +236 -0
  43. package/dist/migrations/provenance.d.ts +25 -0
  44. package/dist/migrations/provenance.d.ts.map +1 -0
  45. package/dist/migrations/provenance.js +118 -0
  46. package/dist/migrations/registry.d.ts +35 -0
  47. package/dist/migrations/registry.d.ts.map +1 -0
  48. package/dist/migrations/registry.js +35 -0
  49. package/dist/migrations/report.d.ts +28 -0
  50. package/dist/migrations/report.d.ts.map +1 -0
  51. package/dist/migrations/report.js +144 -0
  52. package/dist/migrations/types.d.ts +115 -0
  53. package/dist/migrations/types.d.ts.map +1 -0
  54. package/dist/migrations/types.js +24 -0
  55. package/dist/migrations/yaml-edit.d.ts +21 -0
  56. package/dist/migrations/yaml-edit.d.ts.map +1 -0
  57. package/dist/migrations/yaml-edit.js +245 -0
  58. package/dist/module-file-claims.d.ts +65 -0
  59. package/dist/module-file-claims.d.ts.map +1 -0
  60. package/dist/module-file-claims.js +106 -0
  61. package/dist/parse-loaded-file.d.ts +8 -0
  62. package/dist/parse-loaded-file.d.ts.map +1 -1
  63. package/dist/parse-loaded-file.js +22 -13
  64. package/dist/resolve-schema-type-refs.d.ts +29 -12
  65. package/dist/resolve-schema-type-refs.d.ts.map +1 -1
  66. package/dist/resolve-schema-type-refs.js +89 -16
  67. package/dist/schema-compat.d.ts +35 -9
  68. package/dist/schema-compat.d.ts.map +1 -1
  69. package/dist/schema-compat.js +181 -54
  70. package/dist/schema-region.d.ts +33 -0
  71. package/dist/schema-region.d.ts.map +1 -0
  72. package/dist/schema-region.js +45 -0
  73. package/dist/types.d.ts +51 -0
  74. package/dist/types.d.ts.map +1 -1
  75. package/dist/types.js +6 -0
  76. package/dist/validate-cel-context.d.ts.map +1 -1
  77. package/dist/validate-cel-context.js +29 -3
  78. package/dist/validate-include-placement.d.ts +26 -0
  79. package/dist/validate-include-placement.d.ts.map +1 -0
  80. package/dist/validate-include-placement.js +67 -0
  81. package/dist/validate-step-inputs.d.ts +8 -1
  82. package/dist/validate-step-inputs.d.ts.map +1 -1
  83. package/dist/validate-step-inputs.js +47 -2
  84. package/dist/validate-throws-coverage.d.ts.map +1 -1
  85. package/dist/validate-throws-coverage.js +15 -12
  86. package/dist/validate-value-type-slots.d.ts +43 -0
  87. package/dist/validate-value-type-slots.d.ts.map +1 -0
  88. package/dist/validate-value-type-slots.js +142 -0
  89. package/dist/value-type-keyword.d.ts +60 -0
  90. package/dist/value-type-keyword.d.ts.map +1 -0
  91. package/dist/value-type-keyword.js +119 -0
  92. package/dist/yaml-source-edit.d.ts +56 -0
  93. package/dist/yaml-source-edit.d.ts.map +1 -0
  94. package/dist/yaml-source-edit.js +93 -0
  95. package/package.json +4 -3
  96. package/src/analyzer.ts +276 -129
  97. package/src/cel-environment.ts +6 -3
  98. package/src/definition-registry.ts +10 -0
  99. package/src/index.ts +31 -3
  100. package/src/invocation-contract.ts +43 -48
  101. package/src/loaded-types.ts +13 -0
  102. package/src/manifest-loader.ts +37 -6
  103. package/src/manifest-visitor.ts +11 -3
  104. package/src/migrations/driver.ts +262 -0
  105. package/src/migrations/entries/index.ts +10 -0
  106. package/src/migrations/entries/normalize-value-types.json +48 -0
  107. package/src/migrations/entries/ref-slot-scalar-type.json +20 -0
  108. package/src/migrations/entry-data.ts +207 -0
  109. package/src/migrations/index.ts +22 -0
  110. package/src/migrations/match.ts +382 -0
  111. package/src/migrations/patch.ts +265 -0
  112. package/src/migrations/provenance.ts +167 -0
  113. package/src/migrations/registry.ts +40 -0
  114. package/src/migrations/report.ts +169 -0
  115. package/src/migrations/types.ts +144 -0
  116. package/src/migrations/yaml-edit.ts +260 -0
  117. package/src/module-file-claims.ts +168 -0
  118. package/src/parse-loaded-file.ts +31 -12
  119. package/src/resolve-schema-type-refs.ts +93 -16
  120. package/src/schema-compat.ts +218 -62
  121. package/src/schema-region.ts +46 -0
  122. package/src/types.ts +54 -0
  123. package/src/validate-cel-context.ts +36 -3
  124. package/src/validate-include-placement.ts +70 -0
  125. package/src/validate-step-inputs.ts +56 -1
  126. package/src/validate-throws-coverage.ts +16 -11
  127. package/src/validate-value-type-slots.ts +182 -0
  128. package/src/value-type-keyword.ts +135 -0
  129. package/src/yaml-source-edit.ts +106 -0
  130. package/dist/binary-slot.d.ts +0 -39
  131. package/dist/binary-slot.d.ts.map +0 -1
  132. package/dist/binary-slot.js +0 -58
  133. package/src/binary-slot.ts +0 -71
@@ -1,7 +1,20 @@
1
1
  import AjvModule from "ajv";
2
2
  import addFormats from "ajv-formats";
3
- import { isRefSentinel, isTaggedSentinel, ManifestRootSchema, normalizeRefSlots } from "@telorun/templating";
4
- import { binaryKeyword, isBinarySlot } from "./binary-slot.js";
3
+ import {
4
+ isRefSentinel,
5
+ isTaggedSentinel,
6
+ ManifestRootSchema,
7
+ producedTypeOf,
8
+ } from "@telorun/templating";
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";
5
18
 
6
19
  const Ajv = (AjvModule as any).default ?? AjvModule;
7
20
 
@@ -18,10 +31,11 @@ export function createAjv(): InstanceType<typeof Ajv> {
18
31
  (addFormats as any).default
19
32
  ? (addFormats as any).default(instance)
20
33
  : (addFormats as any)(instance);
21
- // Bytes have no JSON Schema type, so the annotation carries the check. Registered
22
- // here and in the kernel's validators from one definition, so a literal at a byte
23
- // slot is rejected statically and at dispatch by the identical rule.
24
- 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);
25
39
  instance.addSchema(ManifestRootSchema);
26
40
  return instance;
27
41
  }
@@ -34,62 +48,170 @@ export interface CompatibilityResult {
34
48
  issues: string[];
35
49
  }
36
50
 
37
- /** Conservative structural JSON Schema compatibility check.
38
- * Only flags definite mismatches: missing required fields and primitive type conflicts.
39
- * Ambiguous cases (anyOf/oneOf/etc.) are treated as compatible. */
51
+ /**
52
+ * Conservative structural JSON Schema compatibility check is a value shaped
53
+ * like `source` acceptable where `target` is declared?
54
+ *
55
+ * COVARIANT, because the values this compares are consumed by reading: a
56
+ * narrower element satisfies a slot declaring a wider one. Only DEFINITE
57
+ * mismatches are flagged — a missing required field, a primitive type conflict,
58
+ * a disagreeing type argument. Anything ambiguous (`anyOf` / `oneOf` / `allOf`,
59
+ * an absent `type`, an undeclared argument) is treated as compatible, so an
60
+ * unmigrated producer and consumer keep checking exactly as they did.
61
+ *
62
+ * The traversal is written here rather than reused: the function this replaced
63
+ * compared only `type` for the names in `target.required` and descended only
64
+ * into objects, so a stream of arrays of strings and a stream of arrays of
65
+ * integers both read as `array` and passed — leaving argument checking inert on
66
+ * exactly the nested shapes it exists for. What survives from it is its posture.
67
+ *
68
+ * `resolveRef` sees through a named shape. Declaring a shape once and
69
+ * referencing it is the sanctioned way to reuse one, so without it two such
70
+ * arguments present as opaque nodes carrying no information — the same reason
71
+ * {@link withLiveValuesSkipped} takes one.
72
+ */
40
73
  export function checkSchemaCompatibility(
41
74
  source: Record<string, any>,
42
75
  target: Record<string, any>,
76
+ resolveRef?: (ref: string) => Record<string, any> | undefined,
43
77
  ): CompatibilityResult {
44
78
  const issues: string[] = [];
45
- checkObject(source, target, "", issues);
79
+ compare(source, target, "", issues, resolveRef, new Set());
46
80
  return { compatible: issues.length === 0, issues };
47
81
  }
48
82
 
49
- function checkObject(
50
- source: Record<string, any>,
51
- target: Record<string, any>,
83
+ type RefResolver = ((ref: string) => Record<string, any> | undefined) | undefined;
84
+
85
+ function deref(schema: Record<string, any>, resolveRef: RefResolver): Record<string, any> {
86
+ if (!resolveRef || typeof schema.$ref !== "string") return schema;
87
+ return resolveRef(schema.$ref) ?? schema;
88
+ }
89
+
90
+ function compare(
91
+ rawSource: Record<string, any>,
92
+ rawTarget: Record<string, any>,
52
93
  path: string,
53
94
  issues: string[],
95
+ resolveRef: RefResolver,
96
+ seen: Set<string>,
54
97
  ): void {
55
- const targetRequired: string[] = target.required ?? [];
56
- const sourceProps: Record<string, any> = source.properties ?? {};
57
- const targetProps: Record<string, any> = target.properties ?? {};
58
-
59
- for (const field of targetRequired) {
60
- if (!(field in sourceProps)) {
61
- issues.push(`${path}/${field}: required by target but missing from source`);
62
- continue;
98
+ if (!rawSource || !rawTarget || typeof rawSource !== "object" || typeof rawTarget !== "object") {
99
+ return;
100
+ }
101
+ // A recursive shape reached through the same pair of references twice is the
102
+ // same question again; answering it once terminates and loses nothing.
103
+ //
104
+ // The key is the REFERENCE PAIR and deliberately not the path. A path grows on
105
+ // every descent, so a key containing it is new every time and the guard never
106
+ // fires — which is a stack overflow on the first self-referential shape, taking
107
+ // every other diagnostic in the file with it. It also has to be this way to be
108
+ // correct rather than merely terminating: comparing two schemas gives the same
109
+ // answer wherever they are reached from, so the second visit has nothing to add.
110
+ if (typeof rawSource.$ref === "string" && typeof rawTarget.$ref === "string") {
111
+ const key = `${rawSource.$ref}|${rawTarget.$ref}`;
112
+ if (seen.has(key)) return;
113
+ seen.add(key);
114
+ }
115
+ const source = deref(rawSource, resolveRef);
116
+ const target = deref(rawTarget, resolveRef);
117
+
118
+ // Value types first: an `instance` representation has no JSON `type` to
119
+ // compare, so its identity IS the comparison — and its arguments are where the
120
+ // real information lives.
121
+ const sourceType = readValueTypeSlot(source);
122
+ const targetType = readValueTypeSlot(target);
123
+ if (sourceType && targetType) {
124
+ if (sourceType.name !== targetType.name) {
125
+ issues.push(
126
+ `${path || "/"}: value type mismatch — source is '${sourceType.name}', target expects '${targetType.name}'`,
127
+ );
128
+ return;
63
129
  }
64
- const srcProp = sourceProps[field];
65
- const tgtProp = targetProps[field];
66
- if (tgtProp && srcProp) {
67
- checkProperty(srcProp, tgtProp, `${path}/${field}`, issues);
130
+ for (const [argument, targetArg] of Object.entries(targetType.args)) {
131
+ const sourceArg = sourceType.args[argument];
132
+ // An omitted argument is *any*, in BOTH directions. That is what keeps a
133
+ // bare `Telo.Stream` flowing into a typed slot and vice versa, so nothing
134
+ // that does not declare its element is forced to.
135
+ if (sourceArg === undefined) continue;
136
+ compare(
137
+ sourceArg as Record<string, any>,
138
+ targetArg as Record<string, any>,
139
+ `${path}<${argument}>`,
140
+ issues,
141
+ resolveRef,
142
+ seen,
143
+ );
68
144
  }
145
+ return;
69
146
  }
70
- }
71
147
 
72
- function checkProperty(
73
- source: Record<string, any>,
74
- target: Record<string, any>,
75
- path: string,
76
- issues: string[],
77
- ): void {
78
- // Only flag definite primitive type clashes; skip anyOf/oneOf/allOf
148
+ // One side declares a value type and the other does not. A `json`
149
+ // representation refines a base type, so it is compared through that base — a
150
+ // `Telo.TcpPort` into a plain `integer` slot is gradual typing working. An
151
+ // `instance` is not JSON at all, so ANY declared JSON type on the other side is
152
+ // a definite conflict; a side declaring no type at all is still saying nothing
153
+ // and stays compatible.
154
+ if (Boolean(sourceType) !== Boolean(targetType)) {
155
+ const declared = (sourceType ?? targetType)!;
156
+ const other = sourceType ? target : source;
157
+ if (declared.entry && typeof other.type === "string") {
158
+ const base = celBaseOfValueType(declared.entry);
159
+ const asJson = base === undefined ? undefined : declared.entry.base;
160
+ if (asJson !== other.type) {
161
+ issues.push(
162
+ `${path || "/"}: value type mismatch — ${
163
+ sourceType ? "source is" : "target expects"
164
+ } '${declared.name}', ${sourceType ? "target expects" : "source is"} '${other.type}'`,
165
+ );
166
+ return;
167
+ }
168
+ }
169
+ }
170
+
171
+ // Only flag definite primitive type clashes; an absent or union `type` says
172
+ // too little to judge.
79
173
  if (
80
- source.type &&
81
- target.type &&
82
174
  typeof source.type === "string" &&
83
175
  typeof target.type === "string" &&
84
176
  source.type !== target.type
85
177
  ) {
86
178
  issues.push(
87
- `${path}: type mismatch — source is '${source.type}', target expects '${target.type}'`,
179
+ `${path || "/"}: type mismatch — source is '${source.type}', target expects '${target.type}'`,
88
180
  );
89
181
  return;
90
182
  }
91
- if (target.type === "object" && source.type === "object") {
92
- checkObject(source, target, path, issues);
183
+ if (source.anyOf || source.oneOf || source.allOf) return;
184
+ if (target.anyOf || target.oneOf || target.allOf) return;
185
+
186
+ // An array's element, which the old comparison never looked at — so every
187
+ // nested shape passed regardless of what it contained.
188
+ if (target.items && source.items) {
189
+ compare(
190
+ source.items as Record<string, any>,
191
+ target.items as Record<string, any>,
192
+ `${path}[]`,
193
+ issues,
194
+ resolveRef,
195
+ seen,
196
+ );
197
+ }
198
+
199
+ const targetRequired: string[] = Array.isArray(target.required) ? target.required : [];
200
+ const sourceProps: Record<string, any> = source.properties ?? {};
201
+ const targetProps: Record<string, any> = target.properties ?? {};
202
+ for (const field of targetRequired) {
203
+ if (!(field in sourceProps)) {
204
+ // Only when the source describes an object at all: a schema with no
205
+ // `properties` is saying nothing about its shape, not saying it is empty.
206
+ if (source.properties === undefined) continue;
207
+ issues.push(`${path}/${field}: required by target but missing from source`);
208
+ continue;
209
+ }
210
+ const srcProp = sourceProps[field];
211
+ const tgtProp = targetProps[field];
212
+ if (tgtProp && srcProp) {
213
+ compare(srcProp, tgtProp, `${path}/${field}`, issues, resolveRef, seen);
214
+ }
93
215
  }
94
216
  }
95
217
 
@@ -158,13 +280,8 @@ function schemaCompiles(schema: Record<string, any>): boolean {
158
280
  export function validateAgainstSchema(data: unknown, schema: Record<string, any>): SchemaIssue[] {
159
281
  let validate = compiledSchemaValidators.get(schema);
160
282
  if (!validate) {
161
- // Normalize outside the try: a fault in our own ref-slot normalization must
162
- // surface, never be mistaken for the module author's schema being malformed.
163
- // Drop the legacy scalar `type` an older published module may still pin on
164
- // its `x-telo-ref` slots so a resolved reference object validates.
165
- const normalized = normalizeRefSlots(schema) as Record<string, any>;
166
283
  try {
167
- validate = ajv.compile(normalized);
284
+ validate = ajv.compile(schema);
168
285
  } catch (err) {
169
286
  // The normalized schema didn't compile. If the original schema is itself
170
287
  // malformed, that is the module author's error — already surfaced once,
@@ -229,28 +346,36 @@ export function navigateSchemaToExprPath(
229
346
  }
230
347
 
231
348
  /**
232
- * Recognized `x-telo-type` value brands and the CEL primitive each refines.
349
+ * Every `json`-represented value type's CEL brand → the primitive it refines.
350
+ *
233
351
  * A brand is a nominal type the analyzer registers (see cel-environment.ts) so
234
- * structurally-identical values (a `TcpPort` and a `UdpPort` are both integers)
235
- * stay distinct for static wiring checks. Brands carry no runtime effect — the
236
- * value flows as its base type. Add new brands here (e.g. `Url: "string"`).
352
+ * structurally-identical values (a `Telo.TcpPort` and a `Telo.UdpPort` are both
353
+ * integers) stay distinct for static wiring checks. Brands carry no runtime
354
+ * effect — the value flows as its base type.
355
+ *
356
+ * DERIVED from the value-type vocabulary, never hand-written: a new brand is a
357
+ * new entry file, and a table here would be a second place to edit that could
358
+ * silently disagree with the one the runtime reads.
237
359
  */
238
- export const VALUE_BRAND_BASE: Record<string, string> = {
239
- TcpPort: "int",
240
- UdpPort: "int",
241
- };
360
+ export const VALUE_BRAND_BASE: Record<string, string> = valueBrandBases();
242
361
 
243
- /** Read a recognized `x-telo-type` brand off a schema, or undefined. */
362
+ /** Read a `json`-represented value type's brand off a schema, or undefined.
363
+ * An `instance` type is not a brand — it replaces the JSON layer rather than
364
+ * refining it, so it carries its binding's CEL type instead. */
244
365
  export function brandOfSchema(schema: Record<string, any> | undefined): string | undefined {
245
- const brand = schema?.["x-telo-type"];
246
- return typeof brand === "string" && brand in VALUE_BRAND_BASE ? brand : undefined;
366
+ const entry = valueTypeOf(schema);
367
+ return entry && entry.representation === "json" ? entry.name : undefined;
247
368
  }
248
369
 
249
370
  /** Map a JSON Schema type annotation to a CEL type string. */
250
371
  export function jsonSchemaToCelType(schema: Record<string, any> | undefined): string {
251
372
  if (!schema || typeof schema !== "object") return "dyn";
252
- const brand = brandOfSchema(schema);
253
- if (brand) return brand;
373
+ // A declared value type IS the type — for an `instance` representation it is
374
+ // the only thing that says so, since bytes and streams have no JSON Schema
375
+ // type at all. Before the three annotations were unified, a byte slot's
376
+ // expression typed as `dyn` because nothing here consulted `x-telo-binary`.
377
+ const entry = valueTypeOf(schema);
378
+ if (entry) return celTypeOfValueType(entry);
254
379
  if (schema.anyOf || schema.oneOf || schema.allOf) return "dyn";
255
380
  if (Array.isArray(schema.type)) return "dyn";
256
381
  switch (schema.type) {
@@ -289,6 +414,16 @@ export function celTypeSatisfiesJsonSchema(celType: string, schema: Record<strin
289
414
  if (fieldBrand) return fieldBrand === celType;
290
415
  celType = sourceBase;
291
416
  }
417
+ // An `instance` representation has no JSON Schema type to compare against, so
418
+ // an expression carrying its binding's CEL type is accepted on that ground
419
+ // alone. This ADDS a case and never removes one: a mismatch falls through to
420
+ // the rules below rather than being rejected here, so nothing that checks
421
+ // today stops checking, and the `bytes` row still accepts a byte expression at
422
+ // a plain `type: string` slot.
423
+ const slotEntry = valueTypeOf(schema);
424
+ if (slotEntry?.representation === "instance" && celTypeOfValueType(slotEntry) === celType) {
425
+ return true;
426
+ }
292
427
  if (!schema.type && !schema.anyOf && !schema.oneOf && !schema.allOf) return true;
293
428
  if (schema.anyOf || schema.oneOf || schema.allOf) return true;
294
429
  const schemaTypes = Array.isArray(schema.type) ? schema.type : [schema.type];
@@ -376,11 +511,15 @@ function foldedConstraints(schema: Record<string, any>): Record<string, any> {
376
511
 
377
512
  export function celPlaceholderForSchema(rawSchema: Record<string, any>): unknown {
378
513
  const schema = foldedConstraints(rawSchema);
379
- // A byte slot's placeholder must BE bytes: the same keyword validates statically
380
- // and at dispatch, so a CEL leaf standing in for a runtime buffer has to satisfy
381
- // it. This is what keeps the rule single — a literal is rejected because no YAML
382
- // literal is a Uint8Array, while a value arriving by reference passes.
383
- if (isBinarySlot(schema)) return new Uint8Array();
514
+ // An instance-typed slot's placeholder must BE an instance: the same keyword
515
+ // validates statically and at dispatch, so a CEL leaf standing in for a runtime
516
+ // value has to satisfy it. This is what keeps the rule single — a literal is
517
+ // rejected because no YAML literal is a byte buffer, while a value arriving by
518
+ // reference passes. The stand-in comes from the binding table, so a new
519
+ // instance type brings its own rather than adding a branch here; a `live` type
520
+ // declares none, because nothing validates it.
521
+ const placeholder = valueTypePlaceholder(schema);
522
+ if (placeholder !== undefined) return placeholder;
384
523
  if (schema.default !== undefined) return schema.default;
385
524
  // An enum-constrained field needs a placeholder drawn from the enum: the
386
525
  // type-based fallbacks below ("" for a string, 0 for a number) satisfy `type`
@@ -564,6 +703,23 @@ export function substituteCelFields(
564
703
  if (isRefSentinel(data)) {
565
704
  return data;
566
705
  }
706
+ // A tag whose produced type is a CONSTANT of the tag rather than a function of
707
+ // the slot substitutes a placeholder of THAT type: `!include-text` always
708
+ // produces a string and `!include-bytes` always produces bytes. Collapsing
709
+ // them to a slot-shaped placeholder like a CEL expression would make every
710
+ // slot accept both, so a byte embed at a `type: string` field passed
711
+ // `telo check` and failed at resource creation — and the reverse did too.
712
+ // Substituting the real type lets AJV and the `x-telo-type` keyword reject
713
+ // both directions statically, with no new diagnostic code.
714
+ //
715
+ // The engine is what says so. This used to branch on two tag names, which was
716
+ // the only place a tag's produced type was written down and it was written in
717
+ // the consumer — so a future tag producing bytes had to be added to a set here
718
+ // rather than declaring it.
719
+ if (isTaggedSentinel(data)) {
720
+ const produced = producedTypeOf(data.engine);
721
+ if (produced) return celPlaceholderForSchema(produced);
722
+ }
567
723
  if (isTaggedSentinel(data)) {
568
724
  mark();
569
725
  return celPlaceholderForSchema(resolved);
@@ -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
@@ -31,6 +31,36 @@ export type PositionIndex = Map<string, Range>;
31
31
 
32
32
  /** LSP-compatible Diagnostic shape. range is optional because parsed YAML may not carry
33
33
  * position info when only the parsed object (not raw text) is available. */
34
+ /** A mechanically applicable repair, carried unchanged from whatever produced
35
+ * it (a templating engine, a kind-name suggestion) to every consumer: CLI
36
+ * JSON, IDE CodeActions, an agent applying it without re-deriving it from
37
+ * prose.
38
+ *
39
+ * `replacement` is the **whole** value at the diagnostic's `path`, corrected —
40
+ * never a fragment — so applying it needs no knowledge of the language inside.
41
+ * There is deliberately no sub-range: carrying one beside a whole-value
42
+ * replacement gives the field two readings, and the minimal-edit reading
43
+ * (splice `replacement` at `range`) produces garbage because the two measure
44
+ * different strings.
45
+ *
46
+ * One shape rather than one per producer: a `fix` field beside a
47
+ * `suggestedKind` field beside a CEL-specific one would leave every host
48
+ * wiring a separate action path for what is the same gesture. */
49
+ export interface DiagnosticFix {
50
+ readonly replacement: string;
51
+ }
52
+
53
+ /** The `data` stamp diagnostics carry. Loose by design — passes bolt their own
54
+ * keys on — but the fields every consumer reads are declared. */
55
+ export interface DiagnosticData {
56
+ resource?: { kind: string; name: string };
57
+ filePath?: string;
58
+ /** Dotted path of the offending value within its resource. */
59
+ path?: string;
60
+ fix?: DiagnosticFix;
61
+ [key: string]: unknown;
62
+ }
63
+
34
64
  export interface AnalysisDiagnostic {
35
65
  range?: Range;
36
66
  severity?: DiagnosticSeverity;
@@ -42,6 +72,13 @@ export interface AnalysisDiagnostic {
42
72
  data?: unknown;
43
73
  }
44
74
 
75
+ /** Single reader for a diagnostic's fix, so no consumer re-derives the shape
76
+ * by hand-casting `data`. */
77
+ export function diagnosticFix(d: AnalysisDiagnostic): DiagnosticFix | undefined {
78
+ const fix = (d.data as DiagnosticData | undefined)?.fix;
79
+ return fix && typeof fix.replacement === "string" ? fix : undefined;
80
+ }
81
+
45
82
  export interface ManifestSource {
46
83
  supports(url: string): boolean;
47
84
  read(url: string): Promise<{ text: string; source: string }>;
@@ -73,12 +110,29 @@ export interface LoadOptions {
73
110
  * pairs manifests to YAML nodes by index. Folded into the file cache key so a
74
111
  * desugared and a raw load of the same file never collide. */
75
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;
76
126
  }
77
127
 
78
128
  export interface LoaderInitOptions {
79
129
  /** Handlers for CEL stdlib functions (e.g. `sha256`). Analyzer-only callers may
80
130
  * omit this and get throwing stubs; runtime callers (kernel) must supply real impls. */
81
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[];
82
136
  }
83
137
 
84
138
  export interface AnalysisOptions {
@@ -1,5 +1,5 @@
1
1
  export { extractAccessChains, validateChainAgainstSchema } from "@telorun/templating";
2
- import { mergeTypeSchemas } from "@telorun/sdk";
2
+ import { mergeTypeSchemas, parseCanonicalTypeSchemaId } from "@telorun/sdk";
3
3
  import { KERNEL_BUILTINS } from "./builtins.js";
4
4
 
5
5
  export interface ContextResolveOpts {
@@ -89,6 +89,38 @@ export function resolveTypeFieldToSchema(
89
89
 
90
90
  if (typeof value === "object" && value !== null) {
91
91
  const obj = value as Record<string, any>;
92
+ // A canonical, module-scoped id — what `resolveSchemaTypeRefs` normalizes
93
+ // both the reference tag and the legacy `telo://` authority form into.
94
+ //
95
+ // This is the ALIAS-AWARE path, and it is why the canonical form exists:
96
+ // the id names the OWNING MODULE, so two libraries declaring a shape of the
97
+ // same name stay distinct. The bare-name lookup below cannot do that — it
98
+ // matches the first manifest with that `metadata.name` in a flattened list,
99
+ // which is how an alias got silently dropped.
100
+ const canonical = parseCanonicalTypeSchemaId(obj.$ref);
101
+ if (canonical) {
102
+ if (ancestry.has(obj.$ref)) return undefined;
103
+ const owned = allManifests.find(
104
+ (m) =>
105
+ (m.metadata as any)?.name === canonical.typeName &&
106
+ (m.metadata as any)?.module === canonical.moduleName &&
107
+ isTypeKind(m.kind, allManifests) &&
108
+ typeof m.schema === "object" &&
109
+ m.schema !== null,
110
+ );
111
+ if (owned) {
112
+ return applyExtends(
113
+ owned.schema as Record<string, any>,
114
+ owned.extends,
115
+ allManifests,
116
+ new Set(ancestry).add(obj.$ref),
117
+ );
118
+ }
119
+ // A module that is not in this set — a single-file analysis, or an import
120
+ // whose library was not forwarded. Fall through to the bare name, which is
121
+ // what this resolved before the canonical form existed.
122
+ return resolveTypeFieldToSchema(canonical.typeName, allManifests, ancestry);
123
+ }
92
124
  // Inline type resource: { kind: "Type.JsonSchema", schema: {...} }
93
125
  if (obj.schema && typeof obj.schema === "object") {
94
126
  return applyExtends(obj.schema as Record<string, any>, obj.extends, allManifests, ancestry);
@@ -97,8 +129,9 @@ export function resolveTypeFieldToSchema(
97
129
  if (obj.type || obj.properties) {
98
130
  return obj;
99
131
  }
100
- // Named type reference resolved from a `!ref` → { kind, name } resolve the
101
- // named Telo.Type the same way as the bare-string form.
132
+ // Named type reference resolved from a `!ref` → { kind, name }. Reached only
133
+ // where the canonical rewrite could not run (a scope whose aliases are not in
134
+ // hand); resolves by bare name, as it always has.
102
135
  if (typeof obj.name === "string") {
103
136
  return resolveTypeFieldToSchema(obj.name, allManifests, ancestry);
104
137
  }
@@ -0,0 +1,70 @@
1
+ import type { ResourceManifest } from "@telorun/sdk";
2
+ import { INCLUDE_ENGINE_NAMES, walkCelExpressions } from "@telorun/templating";
3
+ import { DiagnosticSeverity, type AnalysisDiagnostic } from "./types.js";
4
+
5
+ const SOURCE = "telo-analyzer";
6
+
7
+ /**
8
+ * Docs that are never instantiated as resources.
9
+ *
10
+ * `Telo.Application` / `Telo.Library` are module declarations, and `Telo.Import`
11
+ * is a dependency edge — none of the three reaches a controller's `create()`.
12
+ * `Telo.Definition` and `Telo.Abstract` are deliberately absent: a definition's
13
+ * template body (`resources:` / `invoke:` / `run:` / `provide:`) DOES become
14
+ * resources, so an embed there resolves normally.
15
+ */
16
+ const NEVER_INSTANTIATED: ReadonlySet<string> = new Set([
17
+ "Telo.Application",
18
+ "Telo.Library",
19
+ "Telo.Import",
20
+ ]);
21
+
22
+ /**
23
+ * An `!include-text` / `!include-bytes` in a doc that is never instantiated is
24
+ * never read.
25
+ *
26
+ * The two tags resolve when the resource owning them is created — deferred so
27
+ * that loading a manifest does not pull payload layers, which is the property
28
+ * the artifact spec protects by giving `telo.yaml` a layer of its own. The cost
29
+ * of that choice is this dead spot: a doc with no `create()` has no moment at
30
+ * which the file would be read, so the value stays an unresolved marker and
31
+ * whatever reads it sees a sentinel object instead of the file's contents.
32
+ *
33
+ * Nothing else would report it. There is no controller to validate against a
34
+ * schema and no runtime consumer to fail, so the manifest ships looking correct
35
+ * — exactly the silent-no-op failure mode that makes a descriptive field worth
36
+ * checking. Hence an error at the one place that can see it.
37
+ *
38
+ * Deliberately narrow: it reports only where the argument is complete ("this
39
+ * doc is never instantiated"). A JSON-Schema region *inside* a definition —
40
+ * `schema:`, `inputType:` — is equally unreadable, but that is a general
41
+ * question about tags in schema metadata rather than one about these two, and
42
+ * no pass answers it for any tag today.
43
+ */
44
+ export function validateIncludePlacement(manifests: ResourceManifest[]): AnalysisDiagnostic[] {
45
+ const out: AnalysisDiagnostic[] = [];
46
+ for (const manifest of manifests) {
47
+ if (!NEVER_INSTANTIATED.has(manifest.kind)) continue;
48
+ const name = (manifest.metadata as { name?: string } | undefined)?.name;
49
+ const filePath = (manifest.metadata as { source?: string } | undefined)?.source;
50
+ walkCelExpressions(manifest, "", (source, path, engineName) => {
51
+ if (!INCLUDE_ENGINE_NAMES.has(engineName)) return;
52
+ out.push({
53
+ severity: DiagnosticSeverity.Error,
54
+ code: "INCLUDE_OUTSIDE_RESOURCE",
55
+ source: SOURCE,
56
+ message:
57
+ `${manifest.kind}${name ? `/${name}` : ""}: \`!${engineName} ${source}\` at '${path}' is ` +
58
+ `never read — a ${manifest.kind} doc is not instantiated, and a file embed is resolved ` +
59
+ `when the resource holding it is created. Move it onto the resource that needs the ` +
60
+ `file, or read the file at runtime with Fs.File.`,
61
+ data: {
62
+ resource: { kind: manifest.kind, name: name ?? "" },
63
+ filePath,
64
+ path,
65
+ },
66
+ });
67
+ });
68
+ }
69
+ return out;
70
+ }