@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,5 +1,10 @@
1
1
  export { extractAccessChains, validateChainAgainstSchema } from "@telorun/templating";
2
- import { mergeTypeSchemas } from "@telorun/sdk";
2
+ import {
3
+ elementSchemaOf,
4
+ isLiveSlot,
5
+ mergeTypeSchemas,
6
+ parseCanonicalTypeSchemaId,
7
+ } from "@telorun/sdk";
3
8
  import { KERNEL_BUILTINS } from "./builtins.js";
4
9
 
5
10
  export interface ContextResolveOpts {
@@ -89,6 +94,38 @@ export function resolveTypeFieldToSchema(
89
94
 
90
95
  if (typeof value === "object" && value !== null) {
91
96
  const obj = value as Record<string, any>;
97
+ // A canonical, module-scoped id — what `resolveSchemaTypeRefs` normalizes
98
+ // both the reference tag and the legacy `telo://` authority form into.
99
+ //
100
+ // This is the ALIAS-AWARE path, and it is why the canonical form exists:
101
+ // the id names the OWNING MODULE, so two libraries declaring a shape of the
102
+ // same name stay distinct. The bare-name lookup below cannot do that — it
103
+ // matches the first manifest with that `metadata.name` in a flattened list,
104
+ // which is how an alias got silently dropped.
105
+ const canonical = parseCanonicalTypeSchemaId(obj.$ref);
106
+ if (canonical) {
107
+ if (ancestry.has(obj.$ref)) return undefined;
108
+ const owned = allManifests.find(
109
+ (m) =>
110
+ (m.metadata as any)?.name === canonical.typeName &&
111
+ (m.metadata as any)?.module === canonical.moduleName &&
112
+ isTypeKind(m.kind, allManifests) &&
113
+ typeof m.schema === "object" &&
114
+ m.schema !== null,
115
+ );
116
+ if (owned) {
117
+ return applyExtends(
118
+ owned.schema as Record<string, any>,
119
+ owned.extends,
120
+ allManifests,
121
+ new Set(ancestry).add(obj.$ref),
122
+ );
123
+ }
124
+ // A module that is not in this set — a single-file analysis, or an import
125
+ // whose library was not forwarded. Fall through to the bare name, which is
126
+ // what this resolved before the canonical form existed.
127
+ return resolveTypeFieldToSchema(canonical.typeName, allManifests, ancestry);
128
+ }
92
129
  // Inline type resource: { kind: "Type.JsonSchema", schema: {...} }
93
130
  if (obj.schema && typeof obj.schema === "object") {
94
131
  return applyExtends(obj.schema as Record<string, any>, obj.extends, allManifests, ancestry);
@@ -97,8 +134,9 @@ export function resolveTypeFieldToSchema(
97
134
  if (obj.type || obj.properties) {
98
135
  return obj;
99
136
  }
100
- // Named type reference resolved from a `!ref` → { kind, name } resolve the
101
- // named Telo.Type the same way as the bare-string form.
137
+ // Named type reference resolved from a `!ref` → { kind, name }. Reached only
138
+ // where the canonical rewrite could not run (a scope whose aliases are not in
139
+ // hand); resolves by bare name, as it always has.
102
140
  if (typeof obj.name === "string") {
103
141
  return resolveTypeFieldToSchema(obj.name, allManifests, ancestry);
104
142
  }
@@ -173,23 +211,73 @@ function schemaAtChain(
173
211
  return cur && typeof cur === "object" ? cur : undefined;
174
212
  }
175
213
 
176
- /** The element schema of a sibling collection expression, when statically known.
177
- * Resolves `inputs.*` chains against the resource's `inputs:` contract and
178
- * returns the array's `items`. Returns undefined for non-chain or untyped
179
- * collections (caller substitutes `dyn`). */
180
- function resolveCollectionElementSchema(
214
+ /**
215
+ * The schema of a sibling collection expression, when statically known.
216
+ *
217
+ * Resolves `inputs.*` chains against the resource's DECLARED contract, falling
218
+ * back to the legacy `inputs:` property map some kinds still carry. Reading the
219
+ * contract is what makes this work at all for a kind that declares `inputType:`
220
+ * — resolving only the property map left `item` untyped in every such kind,
221
+ * silently, which reads as "element typing is permissive here" rather than as a
222
+ * gap. Returns undefined for a non-chain or untyped collection, and the caller
223
+ * substitutes `dyn` rather than inventing an element type.
224
+ */
225
+ function resolveCollectionSchema(
181
226
  manifestRoot: Record<string, any>,
182
227
  field: string,
228
+ allManifests: Record<string, any>[] | undefined,
183
229
  ): Record<string, any> | undefined {
184
230
  const chain = purePathChain(manifestRoot?.[field]);
185
231
  if (!chain || chain[0] !== "inputs") return undefined;
186
- const contract = manifestRoot.inputs;
187
- if (!contract || typeof contract !== "object") return undefined;
188
- const terminal = schemaAtChain(chain.slice(1), { type: "object", properties: contract });
189
- if (terminal && terminal.type === "array" && terminal.items && typeof terminal.items === "object") {
190
- return terminal.items as Record<string, any>;
232
+ const declared = resolveTypeFieldToSchema(manifestRoot.inputType, allManifests ?? []);
233
+ const root =
234
+ declared && typeof declared === "object"
235
+ ? declared
236
+ : manifestRoot.inputs && typeof manifestRoot.inputs === "object"
237
+ ? { type: "object", properties: manifestRoot.inputs }
238
+ : undefined;
239
+ if (!root) return undefined;
240
+ return schemaAtChain(chain.slice(1), root);
241
+ }
242
+
243
+ /**
244
+ * What ITERATING a collection schema yields.
245
+ *
246
+ * An array answers with `items`; anything else answers through the value-type
247
+ * vocabulary, which is where "what is the element of this" is declared. No type
248
+ * is named here on purpose: a future iterable value type is covered by declaring
249
+ * `element` on one of its parameters, with nothing to change in the analyzer.
250
+ */
251
+ function elementOfCollection(
252
+ collection: Record<string, any> | undefined,
253
+ ): Record<string, any> | undefined {
254
+ if (!collection || typeof collection !== "object") return undefined;
255
+ if (collection.type === "array") {
256
+ return collection.items && typeof collection.items === "object"
257
+ ? (collection.items as Record<string, any>)
258
+ : undefined;
191
259
  }
192
- return undefined;
260
+ const element = elementSchemaOf(collection);
261
+ return element && typeof element === "object" ? (element as Record<string, any>) : undefined;
262
+ }
263
+
264
+ /**
265
+ * True when a context binding naming this collection must NOT be bound.
266
+ *
267
+ * A binding that re-exposes the collection can only hand over the value the
268
+ * consumer is already draining, and passing that on is an ordinary
269
+ * pass-through no member-access rule catches — so the drain is silent. `live` is
270
+ * exactly the property that makes a value unsafe to re-expose, and it is already
271
+ * in the vocabulary, so no consumer names a type to decide this.
272
+ */
273
+ function collectionBindingWithheld(
274
+ schema: Record<string, any>,
275
+ manifestRoot: Record<string, any>,
276
+ allManifests: Record<string, any>[] | undefined,
277
+ ): boolean {
278
+ const from = schema?.["x-telo-context-collection-from"] as string | undefined;
279
+ if (!from) return false;
280
+ return isLiveSlot(resolveCollectionSchema(manifestRoot, from, allManifests));
193
281
  }
194
282
 
195
283
  /**
@@ -309,10 +397,20 @@ export function resolveContextAnnotations(
309
397
  // untyped sources fall back to `dyn` so a wrong element type is never invented.
310
398
  const elementFrom = schema["x-telo-context-element-from"] as string | undefined;
311
399
  if (elementFrom) {
312
- const items = resolveCollectionElementSchema(manifestRoot, elementFrom);
400
+ const items = elementOfCollection(resolveCollectionSchema(manifestRoot, elementFrom, allManifests));
313
401
  return items ?? {};
314
402
  }
315
403
 
404
+ // The collection itself, as opposed to its element. Typed from whatever the
405
+ // sibling resolves to, so an array binding keeps the precision it has today
406
+ // instead of degrading to `dyn` now that the slot admits more than one shape.
407
+ // A `live` collection never reaches here — the property is dropped before
408
+ // recursion, so the name is simply not in scope.
409
+ const collectionFrom = schema["x-telo-context-collection-from"] as string | undefined;
410
+ if (collectionFrom) {
411
+ return resolveCollectionSchema(manifestRoot, collectionFrom, allManifests) ?? {};
412
+ }
413
+
316
414
  const fromRoot = schema["x-telo-context-from-root"] as string | undefined;
317
415
  const fromRefKindRaw = schema["x-telo-context-from-ref-kind"] as
318
416
  | string
@@ -412,6 +510,10 @@ export function resolveContextAnnotations(
412
510
  if (schema.properties) {
413
511
  const props: Record<string, any> = {};
414
512
  for (const [k, v] of Object.entries(schema.properties)) {
513
+ // Withholding happens HERE rather than inside the child resolver, because
514
+ // this is the only level that owns the property map — a child can return a
515
+ // schema but cannot remove itself from one.
516
+ if (collectionBindingWithheld(v as Record<string, any>, manifestRoot, allManifests)) continue;
415
517
  props[k] = resolveContextAnnotations(v as Record<string, any>, manifestItem, normalizedOpts);
416
518
  }
417
519
  return { ...schema, properties: props };
@@ -2,7 +2,14 @@ import type { AliasResolver, ModuleScopes } from "./alias-resolver.js";
2
2
  import type { DefinitionRegistry } from "./definition-registry.js";
3
3
  import type { ContractDirection } from "./extends-resolution.js";
4
4
  import { resolveContract } from "./invocation-contract.js";
5
- import { substituteCelFields, validateAgainstSchema } from "./schema-compat.js";
5
+ import {
6
+ checkSchemaCompatibility,
7
+ navigateSchemaToExprPath,
8
+ substituteCelFields,
9
+ validateAgainstSchema,
10
+ } from "./schema-compat.js";
11
+ import { plainChainOf } from "@telorun/templating";
12
+ import { valueTypeOf } from "@telorun/sdk";
6
13
  import {
7
14
  analyzerContractScope,
8
15
  containerOf,
@@ -16,8 +23,12 @@ export interface StepInputIssue {
16
23
  path: string;
17
24
  targetLabel: string;
18
25
  message: string;
26
+ /** Set when the issue is a type-argument disagreement rather than a contract
27
+ * shape violation — the two read differently and deserve their own code. */
28
+ code?: "CEL_TYPE_ARGUMENT_MISMATCH";
19
29
  }
20
30
 
31
+
21
32
  /**
22
33
  * Validate every step's `inputs:` against the invoked target's declared input
23
34
  * contract — the static half of what the kernel enforces at dispatch.
@@ -40,6 +51,10 @@ export function collectStepInputIssues(
40
51
  defs: DefinitionRegistry,
41
52
  aliases: AliasResolver,
42
53
  scopes: ModuleScopes,
54
+ /** The typed `steps.<name>.result` context for this resource. Supplied by the
55
+ * caller because building it is analyzer state; without it the contract check
56
+ * still runs and only the type-argument comparison is skipped. */
57
+ stepContext?: Record<string, any>,
43
58
  ): StepInputIssue[] {
44
59
  const out: StepInputIssue[] = [];
45
60
  const props = defSchema.properties as Record<string, any> | undefined;
@@ -97,6 +112,46 @@ export function collectStepInputIssues(
97
112
  const substituted = substituteCelFields(values, contract.schema, undefined, (p) =>
98
113
  celPaths.add(p),
99
114
  );
115
+ // The type-argument check, at the one site where a produced value's schema
116
+ // meets a consuming slot's. A CEL leaf's placeholder says nothing about
117
+ // what the expression yields, so AJV above is silent here by design — and
118
+ // that silence is exactly where a stream of the wrong element used to
119
+ // flow. The comparison is covariant and gradual: an omitted argument is
120
+ // *any* in both directions, so only a definite conflict is reported.
121
+ if (stepContext) {
122
+ for (const [inputName, inputValue] of Object.entries(values)) {
123
+ const chain = plainChainOf(inputValue);
124
+ // The step context is rooted at the STEP MAP, so a `steps.` prefix is
125
+ // the namespace name and not a property of it. Only that namespace is
126
+ // navigated: `inputs.` and a named binding resolve elsewhere, and
127
+ // guessing at a root this does not hold would compare the wrong schema.
128
+ if (!chain?.startsWith("steps.")) continue;
129
+ const produced = navigateSchemaToExprPath(stepContext, chain.slice("steps.".length));
130
+ const slotSchema = (contract.schema.properties as Record<string, any> | undefined)?.[
131
+ inputName
132
+ ];
133
+ if (!produced || !slotSchema) continue;
134
+ // ONLY a type-argument disagreement, which is what the code says. The
135
+ // comparator is a general structural comparison, so running it on any
136
+ // pair would report a missing required property as "disagreeing type
137
+ // arguments" — and would turn every plain-chain wiring site into a
138
+ // broad new Error-severity check hidden behind an argument-specific
139
+ // name. Both sides must declare a value type for the question to be
140
+ // about arguments at all.
141
+ if (!valueTypeOf(produced) || !valueTypeOf(slotSchema)) continue;
142
+ const { compatible, issues } = checkSchemaCompatibility(produced, slotSchema, (ref) =>
143
+ defs.schemaForId(ref),
144
+ );
145
+ if (compatible) continue;
146
+ out.push({
147
+ path: `${stepPath}.${inputsField}.${inputName}`,
148
+ targetLabel: invokedName ?? invokedKind ?? "the invoked resource",
149
+ message: issues.join("; "),
150
+ code: "CEL_TYPE_ARGUMENT_MISMATCH",
151
+ });
152
+ }
153
+ }
154
+
100
155
  for (const issue of validateAgainstSchema(substituted, contract.schema)) {
101
156
  if (celPaths.has(issue.path)) continue;
102
157
  // A missing-required issue names the property that ISN'T there, so
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Static validation of the `x-telo-type` annotation itself — the strict half of
3
+ * the accessor split, and the `validate-ref-slots.ts` precedent.
4
+ *
5
+ * `readValueTypeSlot` is deliberately lenient: it reports whatever it can read,
6
+ * returning a slot with no `entry` for a name it does not know. That leniency is
7
+ * what lets every surface keep working; this pass is what stops it becoming
8
+ * silence. Before the unification an unrecognized brand simply resolved to
9
+ * `undefined` and the slot quietly lost its identity — the same class of failure
10
+ * `X_TELO_REF_INVALID_USE` exists to prevent for `use` tokens.
11
+ *
12
+ * The vocabulary is CLOSED and `Telo.`-qualified, so there is nothing here to
13
+ * resolve against an alias scope: a name is a built-in or it is a mistake. A
14
+ * SHAPE is a different thing entirely and is named with the reference tag, which
15
+ * carries its own resolution and its own diagnostics — this pass never sees one,
16
+ * because `resolveSchemaTypeRefs` has already turned it into a `$ref`.
17
+ *
18
+ * Scoping follows `X_TELO_REF_UNRESOLVED`: reported only for manifests in the
19
+ * entry's own modules, since a published dependency is not the consumer's to fix.
20
+ *
21
+ * Browser-safe: no Node built-ins.
22
+ */
23
+ import { readValueTypeSlot, VALUE_TYPES, X_TELO_TYPE, type ResourceManifest } from "@telorun/sdk";
24
+ import { distance } from "./levenshtein.js";
25
+ import { isInSchemaRegion } from "./schema-region.js";
26
+
27
+ export interface ValueTypeSlotIssue {
28
+ code: "X_TELO_TYPE_UNKNOWN" | "X_TELO_TYPE_ARGUMENT_UNKNOWN";
29
+ manifest: ResourceManifest;
30
+ /** Dotted path to the annotated schema node, e.g. `schema.properties.body`. */
31
+ path: string;
32
+ message: string;
33
+ /** The whole-value replacement that repairs it, when one is derivable. A
34
+ * misspelled name has a single correct spelling and the annotation's value is
35
+ * that name, so the repair is the primitive `DiagnosticFix` already carries —
36
+ * computing a suggestion and printing it in prose alone leaves the author to
37
+ * retype what the analyzer already knows. Only for the bare-name spelling: the
38
+ * object form's name is nested, and a whole-value replacement there would
39
+ * discard the type arguments beside it. */
40
+ fix?: { replacement: string };
41
+ }
42
+
43
+ /** Schema regions are reached by ANCESTRY, not by root key — see
44
+ * `schema-region.ts`. Walking a manifest's root fields covers only a fraction of
45
+ * the sites an author writes a schema at: an API route's `request.schema.body`
46
+ * sits under `routes`, and a check that never reaches it is a hole in exactly
47
+ * the diagnostic that exists to stop an unknown name degrading silently. */
48
+
49
+ /** The closest declared type name within an edit-distance threshold, or
50
+ * undefined. Mirrors `computeSuggestKind`: case-sensitive, and silent on a tie,
51
+ * because a coin-flip suggestion is worse than none. */
52
+ function suggestValueType(name: string): string | undefined {
53
+ if (!name) return undefined;
54
+ const threshold = Math.min(3, Math.floor(name.length / 3));
55
+ if (threshold < 1) return undefined;
56
+ let best: string | undefined;
57
+ let bestDist = threshold + 1;
58
+ let tied = false;
59
+ for (const candidate of VALUE_TYPES.keys()) {
60
+ const d = distance(name, candidate);
61
+ if (d < bestDist) {
62
+ best = candidate;
63
+ bestDist = d;
64
+ tied = false;
65
+ } else if (d === bestDist) {
66
+ tied = true;
67
+ }
68
+ }
69
+ return !best || bestDist > threshold || tied ? undefined : best;
70
+ }
71
+
72
+ function declaredNames(): string {
73
+ return [...VALUE_TYPES.keys()].join(", ");
74
+ }
75
+
76
+ /** Report the annotation on one schema node. */
77
+ function checkNode(
78
+ node: Record<string, unknown>,
79
+ manifest: ResourceManifest,
80
+ path: string,
81
+ issues: ValueTypeSlotIssue[],
82
+ ): void {
83
+ const slot = readValueTypeSlot(node);
84
+ if (!slot) return;
85
+
86
+ if (!slot.entry) {
87
+ const suggestion = suggestValueType(slot.name);
88
+ // Only the bare-name spelling can carry the repair: it IS the annotation's
89
+ // whole value, which is the only shape `DiagnosticFix` describes.
90
+ const bareName = typeof (node as Record<string, unknown>)[X_TELO_TYPE] === "string";
91
+ issues.push({
92
+ code: "X_TELO_TYPE_UNKNOWN",
93
+ manifest,
94
+ path,
95
+ message:
96
+ `'${slot.name || "(missing name)"}' is not a value type. ` +
97
+ (suggestion ? `Did you mean '${suggestion}'? ` : "") +
98
+ `Declared types: ${declaredNames()}. A value type names how a value is ` +
99
+ `REPRESENTED and is kernel-owned; to name a shape, reference it with !ref.`,
100
+ ...(suggestion && bareName ? { fix: { replacement: suggestion } } : {}),
101
+ });
102
+ return;
103
+ }
104
+
105
+ const declared = new Set(slot.entry.parameters.map((p) => p.name));
106
+ for (const argument of Object.keys(slot.args)) {
107
+ if (declared.has(argument)) continue;
108
+ issues.push({
109
+ code: "X_TELO_TYPE_ARGUMENT_UNKNOWN",
110
+ manifest,
111
+ path,
112
+ message:
113
+ `'${slot.entry.name}' declares no type parameter '${argument}'. ` +
114
+ (declared.size > 0
115
+ ? `Its parameters: ${[...declared].join(", ")}.`
116
+ : `It takes no type parameters.`),
117
+ });
118
+ }
119
+ }
120
+
121
+ /** Walk a schema value, reporting every annotation it carries.
122
+ *
123
+ * Descends through every container rather than through a keyword list: a value
124
+ * type is legal at any schema position — a property, an item, a union branch, a
125
+ * `$defs` entry, a type argument — and enumerating positions is how a check
126
+ * ends up not covering the one an author used. */
127
+ function walk(
128
+ value: unknown,
129
+ manifest: ResourceManifest,
130
+ path: string,
131
+ segments: (string | number)[],
132
+ seen: Set<object>,
133
+ issues: ValueTypeSlotIssue[],
134
+ ): void {
135
+ if (value === null || typeof value !== "object") return;
136
+ if (seen.has(value)) return;
137
+ seen.add(value);
138
+ if (Array.isArray(value)) {
139
+ value.forEach((item, i) =>
140
+ walk(item, manifest, `${path}[${i}]`, [...segments, i], seen, issues),
141
+ );
142
+ return;
143
+ }
144
+ const node = value as Record<string, unknown>;
145
+ // Report only inside a schema region. The walk itself is unbounded — reporting
146
+ // is safe anywhere, unlike a rewrite — but an `x-telo-type` key sitting in a
147
+ // resource's own configuration is not a schema annotation and is not this
148
+ // check's to judge.
149
+ if (isInSchemaRegion([...segments, X_TELO_TYPE])) {
150
+ checkNode(node, manifest, path, issues);
151
+ }
152
+ for (const [key, child] of Object.entries(node)) {
153
+ // The annotation's own value is read by `checkNode`; descending into it
154
+ // would report the type ARGUMENTS as if they were annotated nodes of their
155
+ // own. Their turn comes below, as ordinary schema nodes.
156
+ if (key === X_TELO_TYPE) {
157
+ // Walk the NORMALIZED arguments, so a bare-name argument (`of: Telo.Bytes`)
158
+ // is checked exactly as its expanded form is — the sugar must not be a
159
+ // hole in the check that exists to catch a misspelled name.
160
+ const slot = readValueTypeSlot(node);
161
+ for (const [argName, argValue] of Object.entries(slot?.args ?? {})) {
162
+ walk(
163
+ argValue,
164
+ manifest,
165
+ `${path}.${X_TELO_TYPE}.${argName}`,
166
+ [...segments, X_TELO_TYPE, argName],
167
+ seen,
168
+ issues,
169
+ );
170
+ }
171
+ continue;
172
+ }
173
+ walk(child, manifest, path ? `${path}.${key}` : key, [...segments, key], seen, issues);
174
+ }
175
+ }
176
+
177
+ /** Every `x-telo-type` problem in one manifest, wherever a schema is written. */
178
+ export function validateValueTypeSlots(manifest: ResourceManifest): ValueTypeSlotIssue[] {
179
+ const issues: ValueTypeSlotIssue[] = [];
180
+ walk(manifest, manifest, "", [], new Set<object>(), issues);
181
+ return issues;
182
+ }
@@ -0,0 +1,135 @@
1
+ /** The AJV half of `x-telo-type`, and the single place any Telo keyword is
2
+ * registered on an AJV instance.
3
+ *
4
+ * WHY CODEGEN RATHER THAN A `validate` FUNCTION. The kernel compiles standalone
5
+ * validators and caches them on disk; a function-valued keyword does not survive
6
+ * that serialization, so the check would silently be absent from exactly the
7
+ * validators the runtime uses. Codegen inlines instead.
8
+ *
9
+ * WHY THE CODEGEN LIVES HERE AND THE VOCABULARY DOES NOT. The SDK cannot depend
10
+ * on ajv, and an entry declares a REPRESENTATION rather than a code fragment —
11
+ * which is what lets one JSON file be read by a Rust kernel that has no AJV at
12
+ * all. The split is the whole point: `sdk/value-types/*.json` says *what a value
13
+ * is*, `sdk/nodejs/src/value-type.ts` says *what that means in this runtime*,
14
+ * and this file turns the pair into a check.
15
+ *
16
+ * ONE REGISTRATION SITE. {@link registerTeloKeywords} replaced five drifted
17
+ * ones — the analyzer's `createAjv` and the kernel's `schema-validator`,
18
+ * `resource-context`, `observed-state` and `manifest-schemas` — which registered
19
+ * overlapping lists of twelve, four, one and one. Drift there is not cosmetic: a
20
+ * no-op registration is what keeps `strict` mode quiet about an annotation, and
21
+ * a keyword that emits code is missing entirely from any instance that forgot
22
+ * it, so the same schema validated two ways depending on which AJV saw it. */
23
+
24
+ import * as AjvNS from "ajv";
25
+ import type { KeywordDefinition } from "ajv";
26
+ import {
27
+ VALUE_TYPE_BINDINGS,
28
+ X_TELO_TYPE,
29
+ readValueTypeSlot,
30
+ type ValueTypeEntry,
31
+ } from "@telorun/sdk";
32
+
33
+ // AJV's codegen template tag. The package is consumed in both ESM and CJS interop
34
+ // shapes, so the named export may sit on the namespace or behind `.default` —
35
+ // the same fallback `schema-compat` uses to reach the constructor. Skipping it
36
+ // would leave `codegen` undefined under a loader that does not detect AJV's named
37
+ // CJS re-exports, and the keyword would throw on its first compile.
38
+ type CodegenTag = (s: TemplateStringsArray, ...a: unknown[]) => unknown;
39
+ const ajvExports = (AjvNS as any).default ?? AjvNS;
40
+ const codegen: CodegenTag = ajvExports._ ?? (AjvNS as any)._;
41
+
42
+ /**
43
+ * Annotations that carry no validation at all: analyzer, editor and topology
44
+ * metadata. Registered as no-ops so AJV does not treat them as unknown keywords,
45
+ * and listed in one place so an instance cannot know about half of them.
46
+ *
47
+ * `x-telo-type` is deliberately absent — it is the one that emits code.
48
+ */
49
+ export const ANNOTATION_KEYWORDS = [
50
+ "x-telo-bindings-from",
51
+ "x-telo-catches-for",
52
+ "x-telo-context",
53
+ "x-telo-context-element-from",
54
+ "x-telo-context-from",
55
+ "x-telo-context-from-ref-kind",
56
+ "x-telo-context-from-root",
57
+ "x-telo-context-ref-from",
58
+ "x-telo-error-context",
59
+ "x-telo-eval",
60
+ "x-telo-inline",
61
+ "x-telo-outcome-list",
62
+ "x-telo-provides-zone",
63
+ "x-telo-ref",
64
+ "x-telo-requires-zone",
65
+ "x-telo-schema-from",
66
+ "x-telo-scope",
67
+ "x-telo-step-context",
68
+ "x-telo-topology-role",
69
+ "x-telo-value-schema-from",
70
+ "x-telo-widget",
71
+ ] as const;
72
+
73
+ /**
74
+ * The `x-telo-type` keyword.
75
+ *
76
+ * Three postures, decided by the entry and never by this code:
77
+ *
78
+ * - a `json` representation validates through its own declared schema, so the
79
+ * keyword emits nothing — the name carries nominal identity for static wiring
80
+ * and has no runtime existence at all;
81
+ * - a `live` instance is EXEMPT: its value is never traversed, because iterating
82
+ * a stream to check it is precisely what the exemption is for;
83
+ * - every other instance is ASSERTED against the constructor its binding names.
84
+ *
85
+ * An unknown name emits nothing here. It is a hard diagnostic in the analyzer
86
+ * (`X_TELO_TYPE_UNKNOWN`), which is where a name can be reported against the
87
+ * manifest that wrote it; failing compilation instead would take out every
88
+ * validator in a module for one typo in one slot.
89
+ */
90
+ export function valueTypeKeyword(): KeywordDefinition {
91
+ return {
92
+ keyword: X_TELO_TYPE,
93
+ // Both spellings: a bare name, or the object form carrying type arguments.
94
+ schemaType: ["string", "object"],
95
+ code(cxt: any) {
96
+ const entry: ValueTypeEntry | undefined = readValueTypeSlot({
97
+ [X_TELO_TYPE]: cxt.schema,
98
+ })?.entry;
99
+ if (!entry || entry.representation !== "instance" || entry.live) return;
100
+ const binding = VALUE_TYPE_BINDINGS[entry.binding!];
101
+ if (!binding) return;
102
+ // The constructor reaches generated code through AJV's value scope, which
103
+ // is what keeps this general: `Uint8Array` happens to be a global, but a
104
+ // binding may name a class that is not, and inlining a bare identifier
105
+ // would compile to a reference that does not resolve.
106
+ const ctor = cxt.gen.scopeValue("obj", {
107
+ ref: binding.constructor,
108
+ code: codegen`require("@telorun/sdk").VALUE_TYPE_BINDINGS[${entry.binding!}].constructor`,
109
+ });
110
+ cxt.pass(codegen`${cxt.data} instanceof ${ctor}`);
111
+ },
112
+ error: {
113
+ message: (cxt: any) => {
114
+ const entry = readValueTypeSlot({ [X_TELO_TYPE]: cxt.schema })?.entry;
115
+ return entry?.binding === "bytes"
116
+ ? "must be raw bytes (a Uint8Array) — bytes cannot be written inline in a manifest"
117
+ : `must be a ${entry?.name ?? "declared value type"} — this value is not writable inline in a manifest`;
118
+ },
119
+ },
120
+ } as KeywordDefinition;
121
+ }
122
+
123
+ /**
124
+ * Register every Telo keyword on an AJV instance: the annotations as no-ops and
125
+ * `x-telo-type` as the one that checks.
126
+ *
127
+ * Every AJV instance in the runtime and the analyzer goes through this, so a
128
+ * schema means the same thing wherever it is validated.
129
+ */
130
+ export function registerTeloKeywords(ajv: {
131
+ addKeyword: (keyword: any, definition?: any) => unknown;
132
+ }): void {
133
+ for (const keyword of ANNOTATION_KEYWORDS) ajv.addKeyword(keyword);
134
+ ajv.addKeyword(valueTypeKeyword());
135
+ }