@telorun/analyzer 0.10.0 → 0.11.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.
@@ -1 +1 @@
1
- {"version":3,"file":"analyzer.d.ts","sourceRoot":"","sources":["../src/analyzer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAsB,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAIzE,OAAO,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAC1D,OAAO,EAGL,KAAK,WAAW,EACjB,MAAM,sBAAsB,CAAC;AAa9B,OAAO,EAAsB,KAAK,kBAAkB,EAAE,KAAK,eAAe,EAAE,MAAM,YAAY,CAAC;AAgX/F,MAAM,WAAW,qBAAqB;IACpC,WAAW,CAAC,EAAE,WAAW,CAAC;CAC3B;AAED,qBAAa,cAAc;IACzB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAc;gBAEzB,OAAO,GAAE,qBAA0B;IAI/C,OAAO,CACL,SAAS,EAAE,gBAAgB,EAAE,EAC7B,OAAO,CAAC,EAAE,eAAe,EACzB,QAAQ,CAAC,EAAE,gBAAgB,GAC1B,kBAAkB,EAAE;IAqUvB,aAAa,CACX,SAAS,EAAE,gBAAgB,EAAE,EAC7B,OAAO,CAAC,EAAE,eAAe,EACzB,QAAQ,CAAC,EAAE,gBAAgB,GAC1B,kBAAkB,EAAE;IAMvB,SAAS,CAAC,SAAS,EAAE,gBAAgB,EAAE,EAAE,QAAQ,EAAE,gBAAgB,GAAG,gBAAgB,EAAE;IAUxF,OAAO,CACL,SAAS,EAAE,gBAAgB,EAAE,EAC7B,QAAQ,EAAE,gBAAgB,GACzB;QAAE,WAAW,EAAE,kBAAkB,EAAE,CAAC;QAAC,KAAK,EAAE,MAAM,EAAE,GAAG,IAAI,CAAC;QAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE;CAsB5F"}
1
+ {"version":3,"file":"analyzer.d.ts","sourceRoot":"","sources":["../src/analyzer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAsB,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAIzE,OAAO,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAC1D,OAAO,EAGL,KAAK,WAAW,EACjB,MAAM,sBAAsB,CAAC;AAa9B,OAAO,EAAsB,KAAK,kBAAkB,EAAE,KAAK,eAAe,EAAE,MAAM,YAAY,CAAC;AA8c/F,MAAM,WAAW,qBAAqB;IACpC,WAAW,CAAC,EAAE,WAAW,CAAC;CAC3B;AAED,qBAAa,cAAc;IACzB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAc;gBAEzB,OAAO,GAAE,qBAA0B;IAI/C,OAAO,CACL,SAAS,EAAE,gBAAgB,EAAE,EAC7B,OAAO,CAAC,EAAE,eAAe,EACzB,QAAQ,CAAC,EAAE,gBAAgB,GAC1B,kBAAkB,EAAE;IA6avB,aAAa,CACX,SAAS,EAAE,gBAAgB,EAAE,EAC7B,OAAO,CAAC,EAAE,eAAe,EACzB,QAAQ,CAAC,EAAE,gBAAgB,GAC1B,kBAAkB,EAAE;IAMvB,SAAS,CAAC,SAAS,EAAE,gBAAgB,EAAE,EAAE,QAAQ,EAAE,gBAAgB,GAAG,gBAAgB,EAAE;IAUxF,OAAO,CACL,SAAS,EAAE,gBAAgB,EAAE,EAC7B,QAAQ,EAAE,gBAAgB,GACzB;QAAE,WAAW,EAAE,kBAAkB,EAAE,CAAC;QAAC,KAAK,EAAE,MAAM,EAAE,GAAG,IAAI,CAAC;QAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE;CAsB5F"}
package/dist/analyzer.js CHANGED
@@ -37,12 +37,89 @@ function lookupDefinitionTypeField(invokedKind, fieldName, defs, aliases, allMan
37
37
  return resolveTypeFieldToSchema(value, allManifests);
38
38
  }
39
39
  const SOURCE = "telo-analyzer";
40
+ /** Build a closed JSON Schema for the `self` CEL variable available inside a
41
+ * `Telo.Definition` template body. Mirrors the runtime template controller's
42
+ * `const self = { ...resource, name: resource.metadata.name };` — every
43
+ * property the user declared in `schema:` plus synthetic `name` / `kind` and
44
+ * the metadata sub-object (kept open since metadata legitimately carries
45
+ * arbitrary user-added fields). */
46
+ function buildSelfSchema(definition) {
47
+ const userSchema = (definition.schema ?? {});
48
+ const userProps = (userSchema.properties ?? {});
49
+ const userRequired = Array.isArray(userSchema.required) ? userSchema.required : [];
50
+ return {
51
+ type: "object",
52
+ additionalProperties: false,
53
+ properties: {
54
+ ...userProps,
55
+ name: { type: "string" },
56
+ kind: { type: "string" },
57
+ metadata: {
58
+ type: "object",
59
+ additionalProperties: true,
60
+ properties: { name: { type: "string" } },
61
+ },
62
+ },
63
+ required: [...userRequired, "name", "kind"],
64
+ };
65
+ }
66
+ /** Build the JSON Schema for the `inputs` CEL variable available inside an
67
+ * invocable template body. Three-layer fallback mirroring the runtime's
68
+ * caller-supplied inputs:
69
+ * 1. The definition's own `inputType:` field (preferred).
70
+ * 2. The `extends:`-declared abstract's `inputType:` (so a concrete
71
+ * definition inheriting a contract gets typed inputs without
72
+ * redeclaring them).
73
+ * 3. Undefined — caller signals opaque `map<string, dyn>` upstream. */
74
+ function lookupTemplateInputsSchema(definition, defs, aliases, allManifests) {
75
+ const own = resolveTypeFieldToSchema(definition.inputType, allManifests);
76
+ if (own)
77
+ return own;
78
+ const ext = definition.extends;
79
+ if (typeof ext === "string" && ext.length > 0) {
80
+ const canonical = aliases.resolveKind(ext) ?? ext;
81
+ const abstractDef = defs.resolve(canonical);
82
+ if (abstractDef) {
83
+ const inherited = resolveTypeFieldToSchema(abstractDef.inputType, allManifests);
84
+ if (inherited)
85
+ return inherited;
86
+ }
87
+ }
88
+ return undefined;
89
+ }
90
+ /** Returns a "resolver-facing" view of the manifest where the fields used as
91
+ * navigation roots by Telo.Definition's `x-telo-context-from-root` annotations
92
+ * have been pre-augmented:
93
+ * - `schema` → augmented `self` schema (synthetic `name`/`kind`/metadata).
94
+ * - `inputType` → resolved with extends fallback when the field isn't
95
+ * declared directly on the definition.
96
+ *
97
+ * For non-definition manifests the original object is returned. */
98
+ function manifestRootForResolver(m, defs, aliases, allManifests) {
99
+ if (m.kind !== "Telo.Definition")
100
+ return m;
101
+ const inputs = lookupTemplateInputsSchema(m, defs, aliases, allManifests);
102
+ return {
103
+ ...m,
104
+ schema: buildSelfSchema(m),
105
+ ...(inputs ? { inputType: inputs } : {}),
106
+ };
107
+ }
40
108
  /**
41
109
  * Walk a JSON Schema tree and collect all `x-telo-context` annotations,
42
110
  * returning them as `{ scope, schema }` pairs using JSONPath-style scopes —
43
111
  * the same format the analyzer uses for CEL context validation.
112
+ *
113
+ * Result is sorted by scope specificity (longer scope first) so that the
114
+ * per-expression resolver's first-match-wins logic picks the most-specific
115
+ * context. Without this, a broader ancestor scope (e.g. `$.resources[*]`)
116
+ * could shadow a narrower descendant scope whose activation differs.
44
117
  */
45
118
  function extractContextsFromSchema(schema, path = "$") {
119
+ const all = collectContexts(schema, path);
120
+ return all.sort((a, b) => b.scope.length - a.scope.length);
121
+ }
122
+ function collectContexts(schema, path) {
46
123
  if (!schema || typeof schema !== "object")
47
124
  return [];
48
125
  const results = [];
@@ -51,16 +128,16 @@ function extractContextsFromSchema(schema, path = "$") {
51
128
  }
52
129
  if (schema.properties) {
53
130
  for (const [key, value] of Object.entries(schema.properties)) {
54
- results.push(...extractContextsFromSchema(value, `${path}.${key}`));
131
+ results.push(...collectContexts(value, `${path}.${key}`));
55
132
  }
56
133
  }
57
134
  if (schema.items && typeof schema.items === "object") {
58
- results.push(...extractContextsFromSchema(schema.items, `${path}[*]`));
135
+ results.push(...collectContexts(schema.items, `${path}[*]`));
59
136
  }
60
137
  for (const key of ["oneOf", "anyOf", "allOf"]) {
61
138
  if (Array.isArray(schema[key])) {
62
139
  for (const subschema of schema[key]) {
63
- results.push(...extractContextsFromSchema(subschema, path));
140
+ results.push(...collectContexts(subschema, path));
64
141
  }
65
142
  }
66
143
  }
@@ -436,7 +513,11 @@ export class StaticAnalyzer {
436
513
  });
437
514
  continue;
438
515
  }
439
- if (m.kind === "Telo.Definition" || m.kind === "Telo.Abstract") {
516
+ // Abstracts carry only inputType / outputType schema fields and no template
517
+ // body — nothing for the per-resource walk to validate. Definitions are now
518
+ // walked: their template bodies (`resources` / `invoke` / `run` / `provide`)
519
+ // contain CEL that must be checked against `self` / `inputs` / `result`.
520
+ if (m.kind === "Telo.Abstract") {
440
521
  continue;
441
522
  }
442
523
  const resource = { kind: m.kind, name: m.metadata?.name };
@@ -489,6 +570,76 @@ export class StaticAnalyzer {
489
570
  }
490
571
  // (Invocation context compatibility check is handled via x-telo-context in the CEL pass below)
491
572
  }
573
+ // Template-body structural validations: check that template entry-points produce
574
+ // values matching the contract of their dispatch target and (for `provide:`)
575
+ // the abstract this definition `extends`. CEL fields inside the templated
576
+ // values are replaced with type-appropriate placeholders before AJV runs —
577
+ // same pattern as the per-resource schema validation above.
578
+ for (const m of allManifests) {
579
+ if (m.kind !== "Telo.Definition")
580
+ continue;
581
+ const filePath = m.metadata?.source;
582
+ const name = m.metadata?.name;
583
+ if (!name)
584
+ continue;
585
+ const resource = { kind: m.kind, name };
586
+ const md = m;
587
+ const emitTargetMismatch = (targetKind, valueSchema, value, path) => {
588
+ const substituted = substituteCelFields(value, valueSchema);
589
+ const issues = validateAgainstSchema(substituted, valueSchema);
590
+ for (const issue of issues) {
591
+ diagnostics.push({
592
+ severity: DiagnosticSeverity.Error,
593
+ code: "TEMPLATE_TARGET_MISMATCH",
594
+ source: SOURCE,
595
+ message: `${m.kind}/${name}: ${path} does not satisfy ${targetKind}'s contract: ${issue.message}`,
596
+ data: { resource, filePath, path: issue.path ? `${path}.${issue.path}` : path },
597
+ });
598
+ }
599
+ };
600
+ // Resolve the dispatch target's kind, if statically known. Object-form
601
+ // `invoke: { kind, name }` and `provide: { kind, name }` carry it; the
602
+ // string-form `invoke: "name"` does not (the matching resource entry would
603
+ // need to be located by expanded name — out of scope here).
604
+ const invoke = md.invoke;
605
+ const provide = md.provide;
606
+ let dispatchKind;
607
+ if (invoke && typeof invoke === "object" && !Array.isArray(invoke) && typeof invoke.kind === "string") {
608
+ dispatchKind = invoke.kind;
609
+ }
610
+ else if (provide &&
611
+ typeof provide === "object" &&
612
+ !Array.isArray(provide) &&
613
+ typeof provide.kind === "string") {
614
+ dispatchKind = provide.kind;
615
+ }
616
+ // Top-level `inputs:` (sibling of `invoke:` / `provide:`) carries the
617
+ // values passed to the dispatch target's invoke(). Validate against the
618
+ // target's declared `inputType` when both sides have one.
619
+ if (dispatchKind && md.inputs && typeof md.inputs === "object") {
620
+ const targetSchema = lookupDefinitionTypeField(dispatchKind, "inputType", defs, aliases, allManifests);
621
+ if (targetSchema) {
622
+ emitTargetMismatch(dispatchKind, targetSchema, md.inputs, "inputs");
623
+ }
624
+ }
625
+ // Top-level `result:` (a sibling, only meaningful with `provide:`) is a
626
+ // post-call mapping that must satisfy the abstract this definition
627
+ // `extends` (`outputType`). The target's outputType lives on `provide.kind`
628
+ // and is what `result` is typed against *inside* CEL — separate role.
629
+ if (provide &&
630
+ typeof provide === "object" &&
631
+ !Array.isArray(provide) &&
632
+ md.result &&
633
+ typeof md.result === "object") {
634
+ const extendsValue = md.extends;
635
+ if (typeof extendsValue === "string" && extendsValue.length > 0) {
636
+ const abstractSchema = lookupDefinitionTypeField(extendsValue, "outputType", defs, aliases, allManifests);
637
+ if (abstractSchema) {
638
+ emitTargetMismatch(extendsValue, abstractSchema, md.result, "result");
639
+ }
640
+ }
641
+ }
642
+ }
492
643
  // Validate CEL syntax and context variable access in all manifests
493
644
  for (const m of allManifests) {
494
645
  const resource = { kind: m.kind, name: m.metadata?.name };
@@ -533,7 +684,13 @@ export class StaticAnalyzer {
533
684
  const manifestItem = matchedScope
534
685
  ? getManifestItem(path, matchedScope, m)
535
686
  : m;
536
- const resolvedContext = resolveContextAnnotations(matchedContext, manifestItem, allManifests);
687
+ const rootForResolver = manifestRootForResolver(m, defs, aliases, allManifests);
688
+ const resolvedContext = resolveContextAnnotations(matchedContext, manifestItem, {
689
+ manifestRoot: rootForResolver,
690
+ defs,
691
+ aliases,
692
+ allManifests: allManifests,
693
+ });
537
694
  effectiveContext = mergeKernelGlobalsIntoContext(resolvedContext, kernelGlobals);
538
695
  }
539
696
  const engine = defaultRegistry().get(engineName);
@@ -1 +1 @@
1
- {"version":3,"file":"builtins.d.ts","sourceRoot":"","sources":["../src/builtins.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAEvD,eAAO,MAAM,eAAe,EAAE,kBAAkB,EAsJ/C,CAAC"}
1
+ {"version":3,"file":"builtins.d.ts","sourceRoot":"","sources":["../src/builtins.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAEvD,eAAO,MAAM,eAAe,EAAE,kBAAkB,EAwQ/C,CAAC"}
package/dist/builtins.js CHANGED
@@ -38,7 +38,121 @@ export const KERNEL_BUILTINS = [
38
38
  kind: "Telo.Definition",
39
39
  metadata: { name: "Definition", module: "Telo" },
40
40
  capability: "Telo.Template",
41
- schema: { type: "object" },
41
+ // Top-level shape stays open (`additionalProperties: true`) so this change
42
+ // attaches x-telo-context annotations to known template-body fields without
43
+ // tightening the Telo.Definition shape itself. The annotations drive
44
+ // static CEL validation of expressions inside `resources:` / `invoke:` /
45
+ // `run:` / `provide:` / top-level `inputs:` / top-level `result:` against
46
+ // `self` (typed from `schema:`) and `inputs` (typed from `inputType:`,
47
+ // falling back to the extends-declared abstract).
48
+ //
49
+ // `inputs:` and `result:` live as top-level siblings of `invoke:` / `provide:`,
50
+ // matching how Run.Sequence steps factor dispatch from data. The dispatch
51
+ // entry-point (`invoke` / `provide` / `run`) determines how `inputs`/`result`
52
+ // are interpreted at runtime. See analyzer/nodejs/plans/template-internal-cel-validation.md.
53
+ schema: {
54
+ type: "object",
55
+ additionalProperties: true,
56
+ properties: {
57
+ resources: {
58
+ type: "array",
59
+ items: {
60
+ type: "object",
61
+ additionalProperties: true,
62
+ "x-telo-context": {
63
+ type: "object",
64
+ additionalProperties: false,
65
+ properties: {
66
+ self: { "x-telo-context-from-root": "schema" },
67
+ inputs: { "x-telo-context-from-root": "inputType" },
68
+ },
69
+ },
70
+ },
71
+ },
72
+ invoke: {
73
+ oneOf: [
74
+ {
75
+ type: "string",
76
+ "x-telo-context": {
77
+ type: "object",
78
+ additionalProperties: false,
79
+ properties: {
80
+ self: { "x-telo-context-from-root": "schema" },
81
+ },
82
+ },
83
+ },
84
+ {
85
+ type: "object",
86
+ additionalProperties: true,
87
+ properties: {
88
+ kind: { type: "string" },
89
+ name: {
90
+ type: "string",
91
+ "x-telo-context": {
92
+ type: "object",
93
+ additionalProperties: false,
94
+ properties: {
95
+ self: { "x-telo-context-from-root": "schema" },
96
+ },
97
+ },
98
+ },
99
+ },
100
+ },
101
+ ],
102
+ },
103
+ provide: {
104
+ type: "object",
105
+ additionalProperties: true,
106
+ properties: {
107
+ kind: { type: "string" },
108
+ name: {
109
+ type: "string",
110
+ "x-telo-context": {
111
+ type: "object",
112
+ additionalProperties: false,
113
+ properties: {
114
+ self: { "x-telo-context-from-root": "schema" },
115
+ },
116
+ },
117
+ },
118
+ },
119
+ },
120
+ run: {
121
+ type: "string",
122
+ "x-telo-context": {
123
+ type: "object",
124
+ additionalProperties: false,
125
+ properties: {
126
+ self: { "x-telo-context-from-root": "schema" },
127
+ },
128
+ },
129
+ },
130
+ inputs: {
131
+ type: "object",
132
+ additionalProperties: true,
133
+ "x-telo-context": {
134
+ type: "object",
135
+ additionalProperties: false,
136
+ properties: {
137
+ self: { "x-telo-context-from-root": "schema" },
138
+ inputs: { "x-telo-context-from-root": "inputType" },
139
+ },
140
+ },
141
+ },
142
+ result: {
143
+ type: "object",
144
+ additionalProperties: true,
145
+ "x-telo-context": {
146
+ type: "object",
147
+ additionalProperties: false,
148
+ properties: {
149
+ self: { "x-telo-context-from-root": "schema" },
150
+ result: { "x-telo-context-from-ref-kind": "provide/kind#outputType" },
151
+ },
152
+ },
153
+ },
154
+ },
155
+ },
42
156
  },
43
157
  {
44
158
  kind: "Telo.Definition",
@@ -1,4 +1,19 @@
1
1
  export { extractAccessChains, validateChainAgainstSchema } from "@telorun/templating";
2
+ export interface ContextResolveOpts {
3
+ /** When provided, used to resolve `x-telo-context-from-root` annotations against the
4
+ * root manifest. When omitted, defaults to `manifestItem`. */
5
+ manifestRoot?: Record<string, any>;
6
+ /** When provided alongside `aliases`, used to resolve `x-telo-context-from-ref-kind`
7
+ * annotations: read a kind name from a path on `manifestRoot` and return the
8
+ * declared definition's `<field>` schema. */
9
+ defs?: {
10
+ resolve(kind: string): Record<string, any> | undefined;
11
+ };
12
+ aliases?: {
13
+ resolveKind(kind: string): string | undefined;
14
+ };
15
+ allManifests?: Record<string, any>[];
16
+ }
2
17
  /**
3
18
  * Resolve a type field value (string name, inline type, or raw schema) to a JSON Schema.
4
19
  * - String: look up the named type in allManifests (Type.JsonSchema resources)
@@ -15,15 +30,49 @@ export declare function resolveTypeFieldToSchema(value: unknown, allManifests: R
15
30
  */
16
31
  export declare function pathMatchesScope(exprPath: string, scope: string): boolean;
17
32
  /**
18
- * Resolves `x-telo-context-from` annotations in a context schema using the concrete
19
- * manifest item. Navigates the manifest item at the given slash-separated path and merges
20
- * the result as named properties into the annotated node (locking additionalProperties: false).
33
+ * Resolves `x-telo-context-*` annotations in a context schema using the concrete
34
+ * manifest item (per-scope) and the manifest root.
21
35
  *
22
- * Example: `x-telo-context-from: "request/schema"` on the `request` context node replaces
23
- * the open `request` schema with a closed schema whose properties are the keys of
24
- * `manifestItem.request.schema` (e.g. `query`, `body`, `params`, `headers`).
36
+ * Annotation forms:
37
+ *
38
+ * - `x-telo-context-from`: navigates `manifestItem.<path>` and treats the resolved
39
+ * value as a **property map** (keys → sub-schemas) that is merged into the
40
+ * annotated node's properties. Used for HTTP-style scopes where the navigated
41
+ * value is itself a map of variable names.
42
+ *
43
+ * Example: `x-telo-context-from: "request/schema"` reads `manifestItem.request.schema`
44
+ * (= `{ query: {...}, body: {...}, … }`) and merges those keys as named properties
45
+ * of the context node.
46
+ *
47
+ * - `x-telo-context-from-root`: navigates `manifestRoot.<path>` and **replaces** the
48
+ * annotated node's schema with the resolved value. Used on individual property
49
+ * schemas (e.g. `properties.self`) where the resolved value is a single variable's
50
+ * full schema, not a property map.
51
+ *
52
+ * Example: `properties.self.x-telo-context-from-root: "schema"` reads
53
+ * `manifestRoot.schema` and uses it as the schema of the `self` CEL variable.
54
+ *
55
+ * - `x-telo-context-from-ref-kind`: reads a kind name from `manifestRoot.<refPath>`,
56
+ * resolves it via the definition registry, and returns that kind's `<field>` schema
57
+ * (e.g. `outputType`/`inputType`). Used to type `result` against the dispatch
58
+ * target's declared output shape.
59
+ *
60
+ * Syntax: `<refPath>#<field>` — slashes traverse the manifest tree.
61
+ *
62
+ * Example: `x-telo-context-from-ref-kind: "provide/kind#outputType"` reads
63
+ * `manifestRoot.provide.kind` as a kind name, looks up the kind's Telo.Definition,
64
+ * and returns the `outputType` schema.
65
+ *
66
+ * - `x-telo-context-ref-from`: existing form — reads `{kind, name}` object from
67
+ * `manifestItem.<path>`, looks up the named manifest, returns its `<subpath>` field.
68
+ *
69
+ * **Fallback chain.** When both `x-telo-context-from-root` and
70
+ * `x-telo-context-from-ref-kind` are present on the same node, the resolver tries
71
+ * `from-root` first; if that produces no usable schema, it falls back to `from-ref-kind`.
72
+ * This lets a definition declare typing from its own field with a sibling-kind fallback
73
+ * (e.g. `inputType` direct → `extends`-declared abstract's `inputType`).
25
74
  */
26
- export declare function resolveContextAnnotations(schema: Record<string, any>, manifestItem: Record<string, any>, allManifests?: Record<string, any>[]): Record<string, any>;
75
+ export declare function resolveContextAnnotations(schema: Record<string, any>, manifestItem: Record<string, any>, opts?: ContextResolveOpts | Record<string, any>[]): Record<string, any>;
27
76
  /**
28
77
  * Extracts the concrete manifest array item for a given expression path + scope.
29
78
  * e.g. exprPath="routes[0].inputs.q", scope="$.routes[*].inputs" → manifest.routes[0]
@@ -1 +1 @@
1
- {"version":3,"file":"validate-cel-context.d.ts","sourceRoot":"","sources":["../src/validate-cel-context.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,0BAA0B,EAAE,MAAM,qBAAqB,CAAC;AAEtF;;;;;GAKG;AACH,wBAAgB,wBAAwB,CACtC,KAAK,EAAE,OAAO,EACd,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,GAClC,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,SAAS,CA6BjC;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAoBzE;AAED;;;;;;;;GAQG;AACH,wBAAgB,yBAAyB,CACvC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAC3B,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EACjC,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,GACnC,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAqDrB;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAC7B,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAC5B,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAQrB"}
1
+ {"version":3,"file":"validate-cel-context.d.ts","sourceRoot":"","sources":["../src/validate-cel-context.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,0BAA0B,EAAE,MAAM,qBAAqB,CAAC;AAEtF,MAAM,WAAW,kBAAkB;IACjC;mEAC+D;IAC/D,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;IACnC;;kDAE8C;IAC9C,IAAI,CAAC,EAAE;QACL,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,SAAS,CAAC;KACxD,CAAC;IACF,OAAO,CAAC,EAAE;QACR,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAAC;KAC/C,CAAC;IACF,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,CAAC;CACtC;AAED;;;;;GAKG;AACH,wBAAgB,wBAAwB,CACtC,KAAK,EAAE,OAAO,EACd,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,GAClC,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,SAAS,CA6BjC;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAoBzE;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0CG;AACH,wBAAgB,yBAAyB,CACvC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAC3B,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EACjC,IAAI,CAAC,EAAE,kBAAkB,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,GAChD,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CA6FrB;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAC7B,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAC5B,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAQrB"}
@@ -61,17 +61,56 @@ export function pathMatchesScope(exprPath, scope) {
61
61
  return remaining === "" || remaining[0] === "." || remaining[0] === "[";
62
62
  }
63
63
  /**
64
- * Resolves `x-telo-context-from` annotations in a context schema using the concrete
65
- * manifest item. Navigates the manifest item at the given slash-separated path and merges
66
- * the result as named properties into the annotated node (locking additionalProperties: false).
64
+ * Resolves `x-telo-context-*` annotations in a context schema using the concrete
65
+ * manifest item (per-scope) and the manifest root.
67
66
  *
68
- * Example: `x-telo-context-from: "request/schema"` on the `request` context node replaces
69
- * the open `request` schema with a closed schema whose properties are the keys of
70
- * `manifestItem.request.schema` (e.g. `query`, `body`, `params`, `headers`).
67
+ * Annotation forms:
68
+ *
69
+ * - `x-telo-context-from`: navigates `manifestItem.<path>` and treats the resolved
70
+ * value as a **property map** (keys → sub-schemas) that is merged into the
71
+ * annotated node's properties. Used for HTTP-style scopes where the navigated
72
+ * value is itself a map of variable names.
73
+ *
74
+ * Example: `x-telo-context-from: "request/schema"` reads `manifestItem.request.schema`
75
+ * (= `{ query: {...}, body: {...}, … }`) and merges those keys as named properties
76
+ * of the context node.
77
+ *
78
+ * - `x-telo-context-from-root`: navigates `manifestRoot.<path>` and **replaces** the
79
+ * annotated node's schema with the resolved value. Used on individual property
80
+ * schemas (e.g. `properties.self`) where the resolved value is a single variable's
81
+ * full schema, not a property map.
82
+ *
83
+ * Example: `properties.self.x-telo-context-from-root: "schema"` reads
84
+ * `manifestRoot.schema` and uses it as the schema of the `self` CEL variable.
85
+ *
86
+ * - `x-telo-context-from-ref-kind`: reads a kind name from `manifestRoot.<refPath>`,
87
+ * resolves it via the definition registry, and returns that kind's `<field>` schema
88
+ * (e.g. `outputType`/`inputType`). Used to type `result` against the dispatch
89
+ * target's declared output shape.
90
+ *
91
+ * Syntax: `<refPath>#<field>` — slashes traverse the manifest tree.
92
+ *
93
+ * Example: `x-telo-context-from-ref-kind: "provide/kind#outputType"` reads
94
+ * `manifestRoot.provide.kind` as a kind name, looks up the kind's Telo.Definition,
95
+ * and returns the `outputType` schema.
96
+ *
97
+ * - `x-telo-context-ref-from`: existing form — reads `{kind, name}` object from
98
+ * `manifestItem.<path>`, looks up the named manifest, returns its `<subpath>` field.
99
+ *
100
+ * **Fallback chain.** When both `x-telo-context-from-root` and
101
+ * `x-telo-context-from-ref-kind` are present on the same node, the resolver tries
102
+ * `from-root` first; if that produces no usable schema, it falls back to `from-ref-kind`.
103
+ * This lets a definition declare typing from its own field with a sibling-kind fallback
104
+ * (e.g. `inputType` direct → `extends`-declared abstract's `inputType`).
71
105
  */
72
- export function resolveContextAnnotations(schema, manifestItem, allManifests) {
106
+ export function resolveContextAnnotations(schema, manifestItem, opts) {
73
107
  if (!schema || typeof schema !== "object")
74
108
  return schema;
109
+ // Back-compat: third positional arg used to be `allManifests: Record<string, any>[]`.
110
+ const normalizedOpts = Array.isArray(opts)
111
+ ? { allManifests: opts }
112
+ : (opts ?? {});
113
+ const { manifestRoot = manifestItem, defs, aliases, allManifests } = normalizedOpts;
75
114
  const from = schema["x-telo-context-from"];
76
115
  if (from) {
77
116
  const resolved = navigatePath(manifestItem, from.split("/"));
@@ -82,6 +121,37 @@ export function resolveContextAnnotations(schema, manifestItem, allManifests) {
82
121
  additionalProperties: false,
83
122
  };
84
123
  }
124
+ const fromRoot = schema["x-telo-context-from-root"];
125
+ const fromRefKind = schema["x-telo-context-from-ref-kind"];
126
+ if (fromRoot || fromRefKind) {
127
+ if (fromRoot) {
128
+ const resolved = navigatePath(manifestRoot, fromRoot.split("/"));
129
+ if (resolved && typeof resolved === "object" && !Array.isArray(resolved)) {
130
+ return resolved;
131
+ }
132
+ }
133
+ if (fromRefKind && defs) {
134
+ const hashIdx = fromRefKind.indexOf("#");
135
+ if (hashIdx > 0) {
136
+ const refPath = fromRefKind.slice(0, hashIdx);
137
+ const field = fromRefKind.slice(hashIdx + 1);
138
+ const kindValue = navigatePath(manifestRoot, refPath.split("/"));
139
+ if (typeof kindValue === "string" && kindValue.length > 0) {
140
+ const canonical = aliases?.resolveKind(kindValue) ?? kindValue;
141
+ const def = defs.resolve(canonical);
142
+ const typeField = def
143
+ ? def[field]
144
+ : undefined;
145
+ const resolved = resolveTypeFieldToSchema(typeField, allManifests ?? []);
146
+ if (resolved && typeof resolved === "object") {
147
+ return resolved;
148
+ }
149
+ }
150
+ }
151
+ }
152
+ // Open fallback so unresolved types never produce false-positive CEL diagnostics.
153
+ return { type: "object", additionalProperties: true };
154
+ }
85
155
  const refFrom = schema["x-telo-context-ref-from"];
86
156
  if (refFrom && allManifests) {
87
157
  const slashIdx = refFrom.indexOf("/");
@@ -107,7 +177,7 @@ export function resolveContextAnnotations(schema, manifestItem, allManifests) {
107
177
  if (schema.properties) {
108
178
  const props = {};
109
179
  for (const [k, v] of Object.entries(schema.properties)) {
110
- props[k] = resolveContextAnnotations(v, manifestItem, allManifests);
180
+ props[k] = resolveContextAnnotations(v, manifestItem, normalizedOpts);
111
181
  }
112
182
  return { ...schema, properties: props };
113
183
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telorun/analyzer",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Telo Analyzer - Static manifest validator for Telo manifests.",
5
5
  "keywords": [
6
6
  "telo",
@@ -41,8 +41,8 @@
41
41
  "ajv-formats": "^3.0.1",
42
42
  "jsonpath-plus": "^10.3.0",
43
43
  "yaml": "^2.8.3",
44
- "@telorun/sdk": "0.10.0",
45
- "@telorun/templating": "0.2.2"
44
+ "@telorun/sdk": "0.11.1",
45
+ "@telorun/templating": "0.2.3"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@types/node": "^20.0.0",
package/src/analyzer.ts CHANGED
@@ -68,14 +68,108 @@ function lookupDefinitionTypeField(
68
68
 
69
69
  const SOURCE = "telo-analyzer";
70
70
 
71
+ /** Build a closed JSON Schema for the `self` CEL variable available inside a
72
+ * `Telo.Definition` template body. Mirrors the runtime template controller's
73
+ * `const self = { ...resource, name: resource.metadata.name };` — every
74
+ * property the user declared in `schema:` plus synthetic `name` / `kind` and
75
+ * the metadata sub-object (kept open since metadata legitimately carries
76
+ * arbitrary user-added fields). */
77
+ function buildSelfSchema(definition: Record<string, any>): Record<string, any> {
78
+ const userSchema = (definition.schema ?? {}) as Record<string, any>;
79
+ const userProps = (userSchema.properties ?? {}) as Record<string, any>;
80
+ const userRequired = Array.isArray(userSchema.required) ? userSchema.required : [];
81
+ return {
82
+ type: "object",
83
+ additionalProperties: false,
84
+ properties: {
85
+ ...userProps,
86
+ name: { type: "string" },
87
+ kind: { type: "string" },
88
+ metadata: {
89
+ type: "object",
90
+ additionalProperties: true,
91
+ properties: { name: { type: "string" } },
92
+ },
93
+ },
94
+ required: [...userRequired, "name", "kind"],
95
+ };
96
+ }
97
+
98
+ /** Build the JSON Schema for the `inputs` CEL variable available inside an
99
+ * invocable template body. Three-layer fallback mirroring the runtime's
100
+ * caller-supplied inputs:
101
+ * 1. The definition's own `inputType:` field (preferred).
102
+ * 2. The `extends:`-declared abstract's `inputType:` (so a concrete
103
+ * definition inheriting a contract gets typed inputs without
104
+ * redeclaring them).
105
+ * 3. Undefined — caller signals opaque `map<string, dyn>` upstream. */
106
+ function lookupTemplateInputsSchema(
107
+ definition: Record<string, any>,
108
+ defs: DefinitionRegistry,
109
+ aliases: AliasResolver,
110
+ allManifests: Record<string, any>[],
111
+ ): Record<string, any> | undefined {
112
+ const own = resolveTypeFieldToSchema(definition.inputType, allManifests);
113
+ if (own) return own;
114
+ const ext = definition.extends as string | undefined;
115
+ if (typeof ext === "string" && ext.length > 0) {
116
+ const canonical = aliases.resolveKind(ext) ?? ext;
117
+ const abstractDef = defs.resolve(canonical);
118
+ if (abstractDef) {
119
+ const inherited = resolveTypeFieldToSchema(
120
+ (abstractDef as unknown as Record<string, unknown>).inputType,
121
+ allManifests,
122
+ );
123
+ if (inherited) return inherited;
124
+ }
125
+ }
126
+ return undefined;
127
+ }
128
+
129
+ /** Returns a "resolver-facing" view of the manifest where the fields used as
130
+ * navigation roots by Telo.Definition's `x-telo-context-from-root` annotations
131
+ * have been pre-augmented:
132
+ * - `schema` → augmented `self` schema (synthetic `name`/`kind`/metadata).
133
+ * - `inputType` → resolved with extends fallback when the field isn't
134
+ * declared directly on the definition.
135
+ *
136
+ * For non-definition manifests the original object is returned. */
137
+ function manifestRootForResolver(
138
+ m: Record<string, any>,
139
+ defs: DefinitionRegistry,
140
+ aliases: AliasResolver,
141
+ allManifests: Record<string, any>[],
142
+ ): Record<string, any> {
143
+ if (m.kind !== "Telo.Definition") return m;
144
+ const inputs = lookupTemplateInputsSchema(m, defs, aliases, allManifests);
145
+ return {
146
+ ...m,
147
+ schema: buildSelfSchema(m),
148
+ ...(inputs ? { inputType: inputs } : {}),
149
+ };
150
+ }
151
+
71
152
  /**
72
153
  * Walk a JSON Schema tree and collect all `x-telo-context` annotations,
73
154
  * returning them as `{ scope, schema }` pairs using JSONPath-style scopes —
74
155
  * the same format the analyzer uses for CEL context validation.
156
+ *
157
+ * Result is sorted by scope specificity (longer scope first) so that the
158
+ * per-expression resolver's first-match-wins logic picks the most-specific
159
+ * context. Without this, a broader ancestor scope (e.g. `$.resources[*]`)
160
+ * could shadow a narrower descendant scope whose activation differs.
75
161
  */
76
162
  function extractContextsFromSchema(
77
163
  schema: Record<string, any>,
78
164
  path = "$",
165
+ ): Array<{ scope: string; schema: Record<string, any> }> {
166
+ const all = collectContexts(schema, path);
167
+ return all.sort((a, b) => b.scope.length - a.scope.length);
168
+ }
169
+
170
+ function collectContexts(
171
+ schema: Record<string, any>,
172
+ path: string,
79
173
  ): Array<{ scope: string; schema: Record<string, any> }> {
80
174
  if (!schema || typeof schema !== "object") return [];
81
175
  const results: Array<{ scope: string; schema: Record<string, any> }> = [];
@@ -86,18 +180,18 @@ function extractContextsFromSchema(
86
180
 
87
181
  if (schema.properties) {
88
182
  for (const [key, value] of Object.entries(schema.properties as Record<string, any>)) {
89
- results.push(...extractContextsFromSchema(value, `${path}.${key}`));
183
+ results.push(...collectContexts(value, `${path}.${key}`));
90
184
  }
91
185
  }
92
186
 
93
187
  if (schema.items && typeof schema.items === "object") {
94
- results.push(...extractContextsFromSchema(schema.items, `${path}[*]`));
188
+ results.push(...collectContexts(schema.items, `${path}[*]`));
95
189
  }
96
190
 
97
191
  for (const key of ["oneOf", "anyOf", "allOf"] as const) {
98
192
  if (Array.isArray(schema[key])) {
99
193
  for (const subschema of schema[key]) {
100
- results.push(...extractContextsFromSchema(subschema, path));
194
+ results.push(...collectContexts(subschema, path));
101
195
  }
102
196
  }
103
197
  }
@@ -552,7 +646,11 @@ export class StaticAnalyzer {
552
646
  });
553
647
  continue;
554
648
  }
555
- if (m.kind === "Telo.Definition" || m.kind === "Telo.Abstract") {
649
+ // Abstracts carry only inputType / outputType schema fields and no template
650
+ // body — nothing for the per-resource walk to validate. Definitions are now
651
+ // walked: their template bodies (`resources` / `invoke` / `run` / `provide`)
652
+ // contain CEL that must be checked against `self` / `inputs` / `result`.
653
+ if (m.kind === "Telo.Abstract") {
556
654
  continue;
557
655
  }
558
656
 
@@ -612,6 +710,99 @@ export class StaticAnalyzer {
612
710
  // (Invocation context compatibility check is handled via x-telo-context in the CEL pass below)
613
711
  }
614
712
 
713
+ // Template-body structural validations: check that template entry-points produce
714
+ // values matching the contract of their dispatch target and (for `provide:`)
715
+ // the abstract this definition `extends`. CEL fields inside the templated
716
+ // values are replaced with type-appropriate placeholders before AJV runs —
717
+ // same pattern as the per-resource schema validation above.
718
+ for (const m of allManifests) {
719
+ if (m.kind !== "Telo.Definition") continue;
720
+ const filePath = (m.metadata as { source?: string } | undefined)?.source;
721
+ const name = (m.metadata as any)?.name as string | undefined;
722
+ if (!name) continue;
723
+ const resource = { kind: m.kind, name };
724
+ const md = m as Record<string, any>;
725
+
726
+ const emitTargetMismatch = (
727
+ targetKind: string,
728
+ valueSchema: Record<string, any>,
729
+ value: unknown,
730
+ path: string,
731
+ ) => {
732
+ const substituted = substituteCelFields(value, valueSchema);
733
+ const issues = validateAgainstSchema(substituted, valueSchema);
734
+ for (const issue of issues) {
735
+ diagnostics.push({
736
+ severity: DiagnosticSeverity.Error,
737
+ code: "TEMPLATE_TARGET_MISMATCH",
738
+ source: SOURCE,
739
+ message: `${m.kind}/${name}: ${path} does not satisfy ${targetKind}'s contract: ${issue.message}`,
740
+ data: { resource, filePath, path: issue.path ? `${path}.${issue.path}` : path },
741
+ });
742
+ }
743
+ };
744
+
745
+ // Resolve the dispatch target's kind, if statically known. Object-form
746
+ // `invoke: { kind, name }` and `provide: { kind, name }` carry it; the
747
+ // string-form `invoke: "name"` does not (the matching resource entry would
748
+ // need to be located by expanded name — out of scope here).
749
+ const invoke = md.invoke;
750
+ const provide = md.provide;
751
+ let dispatchKind: string | undefined;
752
+ if (invoke && typeof invoke === "object" && !Array.isArray(invoke) && typeof invoke.kind === "string") {
753
+ dispatchKind = invoke.kind;
754
+ } else if (
755
+ provide &&
756
+ typeof provide === "object" &&
757
+ !Array.isArray(provide) &&
758
+ typeof provide.kind === "string"
759
+ ) {
760
+ dispatchKind = provide.kind;
761
+ }
762
+
763
+ // Top-level `inputs:` (sibling of `invoke:` / `provide:`) carries the
764
+ // values passed to the dispatch target's invoke(). Validate against the
765
+ // target's declared `inputType` when both sides have one.
766
+ if (dispatchKind && md.inputs && typeof md.inputs === "object") {
767
+ const targetSchema = lookupDefinitionTypeField(
768
+ dispatchKind,
769
+ "inputType",
770
+ defs,
771
+ aliases,
772
+ allManifests as Record<string, any>[],
773
+ );
774
+ if (targetSchema) {
775
+ emitTargetMismatch(dispatchKind, targetSchema, md.inputs, "inputs");
776
+ }
777
+ }
778
+
779
+ // Top-level `result:` (a sibling, only meaningful with `provide:`) is a
780
+ // post-call mapping that must satisfy the abstract this definition
781
+ // `extends` (`outputType`). The target's outputType lives on `provide.kind`
782
+ // and is what `result` is typed against *inside* CEL — separate role.
783
+ if (
784
+ provide &&
785
+ typeof provide === "object" &&
786
+ !Array.isArray(provide) &&
787
+ md.result &&
788
+ typeof md.result === "object"
789
+ ) {
790
+ const extendsValue = md.extends as string | undefined;
791
+ if (typeof extendsValue === "string" && extendsValue.length > 0) {
792
+ const abstractSchema = lookupDefinitionTypeField(
793
+ extendsValue,
794
+ "outputType",
795
+ defs,
796
+ aliases,
797
+ allManifests as Record<string, any>[],
798
+ );
799
+ if (abstractSchema) {
800
+ emitTargetMismatch(extendsValue, abstractSchema, md.result, "result");
801
+ }
802
+ }
803
+ }
804
+ }
805
+
615
806
  // Validate CEL syntax and context variable access in all manifests
616
807
  for (const m of allManifests) {
617
808
  const resource = { kind: m.kind, name: m.metadata?.name as string };
@@ -670,11 +861,18 @@ export class StaticAnalyzer {
670
861
  const manifestItem = matchedScope
671
862
  ? getManifestItem(path, matchedScope, m as Record<string, any>)
672
863
  : (m as Record<string, any>);
673
- const resolvedContext = resolveContextAnnotations(
674
- matchedContext,
675
- manifestItem,
864
+ const rootForResolver = manifestRootForResolver(
865
+ m as Record<string, any>,
866
+ defs,
867
+ aliases,
676
868
  allManifests as Record<string, any>[],
677
869
  );
870
+ const resolvedContext = resolveContextAnnotations(matchedContext, manifestItem, {
871
+ manifestRoot: rootForResolver,
872
+ defs,
873
+ aliases,
874
+ allManifests: allManifests as Record<string, any>[],
875
+ });
678
876
  effectiveContext = mergeKernelGlobalsIntoContext(resolvedContext, kernelGlobals);
679
877
  }
680
878
 
package/src/builtins.ts CHANGED
@@ -40,7 +40,121 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
40
40
  kind: "Telo.Definition",
41
41
  metadata: { name: "Definition", module: "Telo" },
42
42
  capability: "Telo.Template",
43
- schema: { type: "object" },
43
+ // Top-level shape stays open (`additionalProperties: true`) so this change
44
+ // attaches x-telo-context annotations to known template-body fields without
45
+ // tightening the Telo.Definition shape itself. The annotations drive
46
+ // static CEL validation of expressions inside `resources:` / `invoke:` /
47
+ // `run:` / `provide:` / top-level `inputs:` / top-level `result:` against
48
+ // `self` (typed from `schema:`) and `inputs` (typed from `inputType:`,
49
+ // falling back to the extends-declared abstract).
50
+ //
51
+ // `inputs:` and `result:` live as top-level siblings of `invoke:` / `provide:`,
52
+ // matching how Run.Sequence steps factor dispatch from data. The dispatch
53
+ // entry-point (`invoke` / `provide` / `run`) determines how `inputs`/`result`
54
+ // are interpreted at runtime. See analyzer/nodejs/plans/template-internal-cel-validation.md.
55
+ schema: {
56
+ type: "object",
57
+ additionalProperties: true,
58
+ properties: {
59
+ resources: {
60
+ type: "array",
61
+ items: {
62
+ type: "object",
63
+ additionalProperties: true,
64
+ "x-telo-context": {
65
+ type: "object",
66
+ additionalProperties: false,
67
+ properties: {
68
+ self: { "x-telo-context-from-root": "schema" },
69
+ inputs: { "x-telo-context-from-root": "inputType" },
70
+ },
71
+ },
72
+ },
73
+ },
74
+ invoke: {
75
+ oneOf: [
76
+ {
77
+ type: "string",
78
+ "x-telo-context": {
79
+ type: "object",
80
+ additionalProperties: false,
81
+ properties: {
82
+ self: { "x-telo-context-from-root": "schema" },
83
+ },
84
+ },
85
+ },
86
+ {
87
+ type: "object",
88
+ additionalProperties: true,
89
+ properties: {
90
+ kind: { type: "string" },
91
+ name: {
92
+ type: "string",
93
+ "x-telo-context": {
94
+ type: "object",
95
+ additionalProperties: false,
96
+ properties: {
97
+ self: { "x-telo-context-from-root": "schema" },
98
+ },
99
+ },
100
+ },
101
+ },
102
+ },
103
+ ],
104
+ },
105
+ provide: {
106
+ type: "object",
107
+ additionalProperties: true,
108
+ properties: {
109
+ kind: { type: "string" },
110
+ name: {
111
+ type: "string",
112
+ "x-telo-context": {
113
+ type: "object",
114
+ additionalProperties: false,
115
+ properties: {
116
+ self: { "x-telo-context-from-root": "schema" },
117
+ },
118
+ },
119
+ },
120
+ },
121
+ },
122
+ run: {
123
+ type: "string",
124
+ "x-telo-context": {
125
+ type: "object",
126
+ additionalProperties: false,
127
+ properties: {
128
+ self: { "x-telo-context-from-root": "schema" },
129
+ },
130
+ },
131
+ },
132
+ inputs: {
133
+ type: "object",
134
+ additionalProperties: true,
135
+ "x-telo-context": {
136
+ type: "object",
137
+ additionalProperties: false,
138
+ properties: {
139
+ self: { "x-telo-context-from-root": "schema" },
140
+ inputs: { "x-telo-context-from-root": "inputType" },
141
+ },
142
+ },
143
+ },
144
+ result: {
145
+ type: "object",
146
+ additionalProperties: true,
147
+ "x-telo-context": {
148
+ type: "object",
149
+ additionalProperties: false,
150
+ properties: {
151
+ self: { "x-telo-context-from-root": "schema" },
152
+ result: { "x-telo-context-from-ref-kind": "provide/kind#outputType" },
153
+ },
154
+ },
155
+ },
156
+ },
157
+ },
44
158
  },
45
159
  {
46
160
  kind: "Telo.Definition",
@@ -1,5 +1,21 @@
1
1
  export { extractAccessChains, validateChainAgainstSchema } from "@telorun/templating";
2
2
 
3
+ export interface ContextResolveOpts {
4
+ /** When provided, used to resolve `x-telo-context-from-root` annotations against the
5
+ * root manifest. When omitted, defaults to `manifestItem`. */
6
+ manifestRoot?: Record<string, any>;
7
+ /** When provided alongside `aliases`, used to resolve `x-telo-context-from-ref-kind`
8
+ * annotations: read a kind name from a path on `manifestRoot` and return the
9
+ * declared definition's `<field>` schema. */
10
+ defs?: {
11
+ resolve(kind: string): Record<string, any> | undefined;
12
+ };
13
+ aliases?: {
14
+ resolveKind(kind: string): string | undefined;
15
+ };
16
+ allManifests?: Record<string, any>[];
17
+ }
18
+
3
19
  /**
4
20
  * Resolve a type field value (string name, inline type, or raw schema) to a JSON Schema.
5
21
  * - String: look up the named type in allManifests (Type.JsonSchema resources)
@@ -70,21 +86,61 @@ export function pathMatchesScope(exprPath: string, scope: string): boolean {
70
86
  }
71
87
 
72
88
  /**
73
- * Resolves `x-telo-context-from` annotations in a context schema using the concrete
74
- * manifest item. Navigates the manifest item at the given slash-separated path and merges
75
- * the result as named properties into the annotated node (locking additionalProperties: false).
89
+ * Resolves `x-telo-context-*` annotations in a context schema using the concrete
90
+ * manifest item (per-scope) and the manifest root.
91
+ *
92
+ * Annotation forms:
93
+ *
94
+ * - `x-telo-context-from`: navigates `manifestItem.<path>` and treats the resolved
95
+ * value as a **property map** (keys → sub-schemas) that is merged into the
96
+ * annotated node's properties. Used for HTTP-style scopes where the navigated
97
+ * value is itself a map of variable names.
98
+ *
99
+ * Example: `x-telo-context-from: "request/schema"` reads `manifestItem.request.schema`
100
+ * (= `{ query: {...}, body: {...}, … }`) and merges those keys as named properties
101
+ * of the context node.
102
+ *
103
+ * - `x-telo-context-from-root`: navigates `manifestRoot.<path>` and **replaces** the
104
+ * annotated node's schema with the resolved value. Used on individual property
105
+ * schemas (e.g. `properties.self`) where the resolved value is a single variable's
106
+ * full schema, not a property map.
107
+ *
108
+ * Example: `properties.self.x-telo-context-from-root: "schema"` reads
109
+ * `manifestRoot.schema` and uses it as the schema of the `self` CEL variable.
110
+ *
111
+ * - `x-telo-context-from-ref-kind`: reads a kind name from `manifestRoot.<refPath>`,
112
+ * resolves it via the definition registry, and returns that kind's `<field>` schema
113
+ * (e.g. `outputType`/`inputType`). Used to type `result` against the dispatch
114
+ * target's declared output shape.
115
+ *
116
+ * Syntax: `<refPath>#<field>` — slashes traverse the manifest tree.
117
+ *
118
+ * Example: `x-telo-context-from-ref-kind: "provide/kind#outputType"` reads
119
+ * `manifestRoot.provide.kind` as a kind name, looks up the kind's Telo.Definition,
120
+ * and returns the `outputType` schema.
76
121
  *
77
- * Example: `x-telo-context-from: "request/schema"` on the `request` context node replaces
78
- * the open `request` schema with a closed schema whose properties are the keys of
79
- * `manifestItem.request.schema` (e.g. `query`, `body`, `params`, `headers`).
122
+ * - `x-telo-context-ref-from`: existing form reads `{kind, name}` object from
123
+ * `manifestItem.<path>`, looks up the named manifest, returns its `<subpath>` field.
124
+ *
125
+ * **Fallback chain.** When both `x-telo-context-from-root` and
126
+ * `x-telo-context-from-ref-kind` are present on the same node, the resolver tries
127
+ * `from-root` first; if that produces no usable schema, it falls back to `from-ref-kind`.
128
+ * This lets a definition declare typing from its own field with a sibling-kind fallback
129
+ * (e.g. `inputType` direct → `extends`-declared abstract's `inputType`).
80
130
  */
81
131
  export function resolveContextAnnotations(
82
132
  schema: Record<string, any>,
83
133
  manifestItem: Record<string, any>,
84
- allManifests?: Record<string, any>[],
134
+ opts?: ContextResolveOpts | Record<string, any>[],
85
135
  ): Record<string, any> {
86
136
  if (!schema || typeof schema !== "object") return schema;
87
137
 
138
+ // Back-compat: third positional arg used to be `allManifests: Record<string, any>[]`.
139
+ const normalizedOpts: ContextResolveOpts = Array.isArray(opts)
140
+ ? { allManifests: opts }
141
+ : (opts ?? {});
142
+ const { manifestRoot = manifestItem, defs, aliases, allManifests } = normalizedOpts;
143
+
88
144
  const from = schema["x-telo-context-from"] as string | undefined;
89
145
  if (from) {
90
146
  const resolved = navigatePath(manifestItem, from.split("/")) as Record<string, any> | undefined;
@@ -96,6 +152,40 @@ export function resolveContextAnnotations(
96
152
  };
97
153
  }
98
154
 
155
+ const fromRoot = schema["x-telo-context-from-root"] as string | undefined;
156
+ const fromRefKind = schema["x-telo-context-from-ref-kind"] as string | undefined;
157
+ if (fromRoot || fromRefKind) {
158
+ if (fromRoot) {
159
+ const resolved = navigatePath(manifestRoot, fromRoot.split("/")) as
160
+ | Record<string, any>
161
+ | undefined;
162
+ if (resolved && typeof resolved === "object" && !Array.isArray(resolved)) {
163
+ return resolved;
164
+ }
165
+ }
166
+ if (fromRefKind && defs) {
167
+ const hashIdx = fromRefKind.indexOf("#");
168
+ if (hashIdx > 0) {
169
+ const refPath = fromRefKind.slice(0, hashIdx);
170
+ const field = fromRefKind.slice(hashIdx + 1);
171
+ const kindValue = navigatePath(manifestRoot, refPath.split("/"));
172
+ if (typeof kindValue === "string" && kindValue.length > 0) {
173
+ const canonical = aliases?.resolveKind(kindValue) ?? kindValue;
174
+ const def = defs.resolve(canonical);
175
+ const typeField = def
176
+ ? (def as Record<string, unknown>)[field]
177
+ : undefined;
178
+ const resolved = resolveTypeFieldToSchema(typeField, allManifests ?? []);
179
+ if (resolved && typeof resolved === "object") {
180
+ return resolved;
181
+ }
182
+ }
183
+ }
184
+ }
185
+ // Open fallback so unresolved types never produce false-positive CEL diagnostics.
186
+ return { type: "object", additionalProperties: true };
187
+ }
188
+
99
189
  const refFrom = schema["x-telo-context-ref-from"] as string | undefined;
100
190
  if (refFrom && allManifests) {
101
191
  const slashIdx = refFrom.indexOf("/");
@@ -129,7 +219,7 @@ export function resolveContextAnnotations(
129
219
  if (schema.properties) {
130
220
  const props: Record<string, any> = {};
131
221
  for (const [k, v] of Object.entries(schema.properties)) {
132
- props[k] = resolveContextAnnotations(v as Record<string, any>, manifestItem, allManifests);
222
+ props[k] = resolveContextAnnotations(v as Record<string, any>, manifestItem, normalizedOpts);
133
223
  }
134
224
  return { ...schema, properties: props };
135
225
  }