@telorun/analyzer 0.1.1 → 0.1.3

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 (52) hide show
  1. package/README.md +233 -0
  2. package/dist/adapters/http-adapter.d.ts +1 -1
  3. package/dist/adapters/http-adapter.d.ts.map +1 -1
  4. package/dist/adapters/http-adapter.js +4 -3
  5. package/dist/adapters/node-adapter.d.ts +1 -1
  6. package/dist/adapters/node-adapter.d.ts.map +1 -1
  7. package/dist/adapters/node-adapter.js +2 -1
  8. package/dist/adapters/registry-adapter.d.ts +5 -1
  9. package/dist/adapters/registry-adapter.d.ts.map +1 -1
  10. package/dist/adapters/registry-adapter.js +27 -7
  11. package/dist/analysis-registry.d.ts +2 -0
  12. package/dist/analysis-registry.d.ts.map +1 -1
  13. package/dist/analysis-registry.js +8 -0
  14. package/dist/analyzer.d.ts.map +1 -1
  15. package/dist/analyzer.js +107 -4
  16. package/dist/cel-environment.d.ts +5 -2
  17. package/dist/cel-environment.d.ts.map +1 -1
  18. package/dist/cel-environment.js +5 -3
  19. package/dist/index.d.ts +2 -2
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +1 -1
  22. package/dist/kernel-globals.d.ts +34 -0
  23. package/dist/kernel-globals.d.ts.map +1 -0
  24. package/dist/kernel-globals.js +94 -0
  25. package/dist/manifest-loader.d.ts +6 -3
  26. package/dist/manifest-loader.d.ts.map +1 -1
  27. package/dist/manifest-loader.js +110 -5
  28. package/dist/schema-compat.d.ts +1 -1
  29. package/dist/schema-compat.d.ts.map +1 -1
  30. package/dist/schema-compat.js +82 -28
  31. package/dist/types.d.ts +12 -0
  32. package/dist/types.d.ts.map +1 -1
  33. package/dist/types.js +2 -0
  34. package/dist/validate-cel-context.d.ts +26 -4
  35. package/dist/validate-cel-context.d.ts.map +1 -1
  36. package/dist/validate-cel-context.js +123 -15
  37. package/dist/validate-references.d.ts.map +1 -1
  38. package/dist/validate-references.js +13 -1
  39. package/package.json +21 -2
  40. package/src/adapters/http-adapter.ts +4 -4
  41. package/src/adapters/node-adapter.ts +2 -2
  42. package/src/adapters/registry-adapter.ts +30 -8
  43. package/src/analysis-registry.ts +10 -0
  44. package/src/analyzer.ts +139 -5
  45. package/src/cel-environment.ts +5 -3
  46. package/src/index.ts +2 -4
  47. package/src/kernel-globals.ts +110 -0
  48. package/src/manifest-loader.ts +131 -7
  49. package/src/schema-compat.ts +87 -31
  50. package/src/types.ts +14 -0
  51. package/src/validate-cel-context.ts +150 -15
  52. package/src/validate-references.ts +13 -1
@@ -1,3 +1,34 @@
1
+ /**
2
+ * Resolve a type field value (string name, inline type, or raw schema) to a JSON Schema.
3
+ * - String: look up the named type in allManifests (Type.JsonSchema resources)
4
+ * - Object with `kind` + `schema`: inline type definition → return the `schema`
5
+ * - Object with `type` or `properties`: raw JSON Schema, return as-is
6
+ */
7
+ export function resolveTypeFieldToSchema(value, allManifests) {
8
+ if (!value)
9
+ return undefined;
10
+ if (typeof value === "string") {
11
+ // Named type reference — find a Kernel.Type resource by name
12
+ const typeManifest = allManifests.find((m) => m.metadata?.name === value &&
13
+ typeof m.kind === "string" &&
14
+ /\bType\b/.test(m.kind) &&
15
+ typeof m.schema === "object" &&
16
+ m.schema !== null);
17
+ return typeManifest?.schema;
18
+ }
19
+ if (typeof value === "object" && value !== null) {
20
+ const obj = value;
21
+ // Inline type resource: { kind: "Type.JsonSchema", schema: {...} }
22
+ if (obj.schema && typeof obj.schema === "object") {
23
+ return obj.schema;
24
+ }
25
+ // Raw JSON Schema (has type or properties)
26
+ if (obj.type || obj.properties) {
27
+ return obj;
28
+ }
29
+ }
30
+ return undefined;
31
+ }
1
32
  /**
2
33
  * Extract all member-access chains from a CEL AST.
3
34
  * Returns arrays like ["request", "query", "name"] for `request.query.name`.
@@ -87,36 +118,36 @@ export function validateChainAgainstSchema(chain, schema) {
87
118
  const key = chain[i];
88
119
  if (!current || typeof current !== "object")
89
120
  return null;
90
- // Open schema: no properties declared or explicitly allows additional properties
91
121
  const props = current.properties;
92
122
  if (!props)
93
123
  return null;
124
+ if (key in props) {
125
+ // Known property — drill into it even if additionalProperties is true
126
+ current = props[key];
127
+ continue;
128
+ }
129
+ // Unknown property — only flag if schema is closed
94
130
  if (current.additionalProperties === true)
95
131
  return null;
96
- if (!(key in props)) {
97
- const path = chain.slice(0, i + 1).join(".");
98
- const available = Object.keys(props).join(", ");
99
- return `'${path}' is not defined (available: ${available})`;
100
- }
101
- current = props[key];
132
+ const path = chain.slice(0, i + 1).join(".");
133
+ const available = Object.keys(props).join(", ");
134
+ return `'${path}' is not defined (available: ${available})`;
102
135
  }
103
136
  return null;
104
137
  }
105
138
  /**
106
- * Returns true when a CEL expression path (from walkCelExpressions, e.g. "routes[0].handler.inputs.name")
107
- * falls within the container region of a context scope (e.g. "$.routes[*].handler").
139
+ * Returns true when a CEL expression path (from walkCelExpressions, e.g. "routes[0].inputs.q")
140
+ * falls within the scope of a context (e.g. "$.routes[*].inputs").
108
141
  *
109
- * The container is derived by stripping the last dot-separated segment from the scope, so that
110
- * sibling fields within the same parent (e.g. routes[*].response) also match.
142
+ * The scope is matched directly (no sibling sharing): a context at "$.routes[*].inputs" only
143
+ * applies to expressions whose path starts with "routes[N].inputs", not to other sibling fields.
111
144
  */
112
145
  export function pathMatchesScope(exprPath, scope) {
113
146
  const stripped = scope.startsWith("$.") ? scope.slice(2) : scope;
114
- const lastDot = stripped.lastIndexOf(".");
115
- if (lastDot <= 0)
147
+ if (!stripped)
116
148
  return false;
117
- const container = stripped.slice(0, lastDot); // e.g. "routes[*]"
118
149
  // Split on wildcard array segments; each [*] must match a concrete [N] in exprPath
119
- const parts = container.split("[*]");
150
+ const parts = stripped.split("[*]");
120
151
  let remaining = exprPath;
121
152
  for (let i = 0; i < parts.length; i++) {
122
153
  const part = parts[i];
@@ -134,3 +165,80 @@ export function pathMatchesScope(exprPath, scope) {
134
165
  // Expression must end here or continue into a child path
135
166
  return remaining === "" || remaining[0] === "." || remaining[0] === "[";
136
167
  }
168
+ /**
169
+ * Resolves `x-telo-context-from` annotations in a context schema using the concrete
170
+ * manifest item. Navigates the manifest item at the given slash-separated path and merges
171
+ * the result as named properties into the annotated node (locking additionalProperties: false).
172
+ *
173
+ * Example: `x-telo-context-from: "request/schema"` on the `request` context node replaces
174
+ * the open `request` schema with a closed schema whose properties are the keys of
175
+ * `manifestItem.request.schema` (e.g. `query`, `body`, `params`, `headers`).
176
+ */
177
+ export function resolveContextAnnotations(schema, manifestItem, allManifests) {
178
+ if (!schema || typeof schema !== "object")
179
+ return schema;
180
+ const from = schema["x-telo-context-from"];
181
+ if (from) {
182
+ const resolved = navigatePath(manifestItem, from.split("/"));
183
+ // `resolved` is a map of property names → sub-schemas (e.g. { query: {...}, body: {...} })
184
+ return {
185
+ ...schema,
186
+ properties: { ...(schema.properties ?? {}), ...(resolved ?? {}) },
187
+ additionalProperties: false,
188
+ };
189
+ }
190
+ const refFrom = schema["x-telo-context-ref-from"];
191
+ if (refFrom && allManifests) {
192
+ const slashIdx = refFrom.indexOf("/");
193
+ const refProp = slashIdx === -1 ? refFrom : refFrom.slice(0, slashIdx);
194
+ const subpath = slashIdx === -1 ? undefined : refFrom.slice(slashIdx + 1);
195
+ const ref = manifestItem[refProp];
196
+ if (ref &&
197
+ typeof ref === "object" &&
198
+ typeof ref.kind === "string" &&
199
+ typeof ref.name === "string" &&
200
+ subpath) {
201
+ const refManifest = allManifests.find((m) => m.kind === ref.kind && m.metadata?.name === ref.name);
202
+ if (refManifest) {
203
+ const resolved = resolveTypeFieldToSchema(navigatePath(refManifest, subpath.split("/")), allManifests);
204
+ if (resolved && typeof resolved === "object") {
205
+ return resolved;
206
+ }
207
+ }
208
+ }
209
+ // Fallback: open schema (no false errors when outputType is not declared)
210
+ return { ...schema, additionalProperties: true };
211
+ }
212
+ if (schema.properties) {
213
+ const props = {};
214
+ for (const [k, v] of Object.entries(schema.properties)) {
215
+ props[k] = resolveContextAnnotations(v, manifestItem, allManifests);
216
+ }
217
+ return { ...schema, properties: props };
218
+ }
219
+ return schema;
220
+ }
221
+ /**
222
+ * Extracts the concrete manifest array item for a given expression path + scope.
223
+ * e.g. exprPath="routes[0].inputs.q", scope="$.routes[*].inputs" → manifest.routes[0]
224
+ */
225
+ export function getManifestItem(exprPath, scope, manifest) {
226
+ const stripped = scope.startsWith("$.") ? scope.slice(2) : scope;
227
+ const wildcardIdx = stripped.indexOf("[*]");
228
+ if (wildcardIdx === -1)
229
+ return manifest;
230
+ const arrayProp = stripped.slice(0, wildcardIdx); // e.g. "routes"
231
+ const m = exprPath.match(new RegExp(`^${arrayProp}\\[(\\d+)\\]`));
232
+ if (!m)
233
+ return manifest;
234
+ return manifest[arrayProp]?.[Number(m[1])] ?? manifest;
235
+ }
236
+ function navigatePath(obj, segments) {
237
+ let cur = obj;
238
+ for (const seg of segments) {
239
+ if (cur === null || typeof cur !== "object")
240
+ return undefined;
241
+ cur = cur[seg];
242
+ }
243
+ return cur;
244
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"validate-references.d.ts","sourceRoot":"","sources":["../src/validate-references.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAGrD,OAAO,EAAsB,KAAK,kBAAkB,EAAE,KAAK,eAAe,EAAE,MAAM,YAAY,CAAC;AA6C/F;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,kBAAkB,CAChC,SAAS,EAAE,gBAAgB,EAAE,EAC7B,OAAO,EAAE,eAAe,GACvB,kBAAkB,EAAE,CAoPtB"}
1
+ {"version":3,"file":"validate-references.d.ts","sourceRoot":"","sources":["../src/validate-references.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAGrD,OAAO,EAAsB,KAAK,kBAAkB,EAAE,KAAK,eAAe,EAAE,MAAM,YAAY,CAAC;AA6C/F;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,kBAAkB,CAChC,SAAS,EAAE,gBAAgB,EAAE,EAC7B,OAAO,EAAE,eAAe,GACvB,kBAAkB,EAAE,CAgQtB"}
@@ -111,9 +111,21 @@ export function validateReferences(resources, context) {
111
111
  if (!val)
112
112
  continue;
113
113
  // Name-only reference (plain string) — look up by name to validate.
114
+ // Qualified references use "Kind.Name" format (e.g. "Http.Api.PaymentApi");
115
+ // extract the resource name from the last dot segment.
114
116
  if (typeof val === "string") {
115
- const target = byName.get(val) ?? visibleScopeManifests.find((m) => m.metadata?.name === val);
117
+ const lastDot = val.lastIndexOf(".");
118
+ const refName = lastDot > 0 ? val.slice(lastDot + 1) : val;
119
+ const refKindPrefix = lastDot > 0 ? val.slice(0, lastDot) : undefined;
120
+ const target = byName.get(refName) ?? visibleScopeManifests.find((m) => m.metadata?.name === refName);
116
121
  if (!target) {
122
+ // Cross-module reference: "Alias.ResourceName" (single dot, bare alias prefix).
123
+ // The resource lives in the imported module's scope and can't be validated here.
124
+ // Multi-dot prefixes like "Alias.Kind.Name" are local resources with qualified
125
+ // kinds — those must be validated.
126
+ if (refKindPrefix && !refKindPrefix.includes(".") && aliases.hasAlias(refKindPrefix)) {
127
+ continue;
128
+ }
117
129
  diagnostics.push({
118
130
  severity: DiagnosticSeverity.Error,
119
131
  code: "UNRESOLVED_REFERENCE",
package/package.json CHANGED
@@ -1,6 +1,25 @@
1
1
  {
2
2
  "name": "@telorun/analyzer",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
+ "description": "Telo Analyzer - Static manifest validator for Telo manifests.",
5
+ "keywords": [
6
+ "telo",
7
+ "analyzer",
8
+ "validator",
9
+ "manifest",
10
+ "yaml"
11
+ ],
12
+ "author": "Bartosz Pasiński <bartosz.pasinski@codenet.pl>",
13
+ "license": "SEE LICENSE IN LICENSE",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/telorun/telo.git",
17
+ "directory": "analyzer/nodejs"
18
+ },
19
+ "homepage": "https://github.com/telorun/telo#readme",
20
+ "bugs": {
21
+ "url": "https://github.com/telorun/telo/issues"
22
+ },
4
23
  "type": "module",
5
24
  "main": "./dist/index.js",
6
25
  "exports": {
@@ -22,7 +41,7 @@
22
41
  "ajv-formats": "^3.0.1",
23
42
  "yaml": "^2.8.3",
24
43
  "jsonpath-plus": "^10.3.0",
25
- "@telorun/sdk": "0.2.6"
44
+ "@telorun/sdk": "0.2.8"
26
45
  },
27
46
  "devDependencies": {
28
47
  "@types/node": "^20.0.0",
@@ -1,4 +1,4 @@
1
- import type { ManifestAdapter } from "../types.js";
1
+ import { DEFAULT_MANIFEST_FILENAME, type ManifestAdapter } from "../types.js";
2
2
 
3
3
  export class HttpAdapter implements ManifestAdapter {
4
4
  supports(url: string): boolean {
@@ -6,7 +6,7 @@ export class HttpAdapter implements ManifestAdapter {
6
6
  }
7
7
 
8
8
  async read(url: string): Promise<{ text: string; source: string }> {
9
- const fetchUrl = url.includes(".yaml") ? url : `${url}/module.yaml`;
9
+ const fetchUrl = url.includes(".yaml") ? url : `${url}/${DEFAULT_MANIFEST_FILENAME}`;
10
10
  const response = await fetch(fetchUrl);
11
11
  if (!response.ok) {
12
12
  throw new Error(
@@ -17,7 +17,7 @@ export class HttpAdapter implements ManifestAdapter {
17
17
  }
18
18
 
19
19
  resolveRelative(base: string, relative: string): string {
20
- const baseWithSlash = base.endsWith("/") ? base : `${base}/`;
21
- return new URL(relative, baseWithSlash).href;
20
+ const baseDir = base.endsWith("/") ? base : base.slice(0, base.lastIndexOf("/") + 1);
21
+ return new URL(relative, baseDir).href;
22
22
  }
23
23
  }
@@ -1,6 +1,6 @@
1
1
  import * as fs from "fs/promises";
2
2
  import * as path from "path";
3
- import type { ManifestAdapter } from "../types.js";
3
+ import { DEFAULT_MANIFEST_FILENAME, type ManifestAdapter } from "../types.js";
4
4
 
5
5
  /** Node.js fs-based ManifestAdapter for local files. Not browser-compatible. */
6
6
  export class NodeAdapter implements ManifestAdapter {
@@ -20,7 +20,7 @@ export class NodeAdapter implements ManifestAdapter {
20
20
  const filePath = url.startsWith("file://") ? new URL(url).pathname : url;
21
21
  const stat = await fs.stat(filePath).catch(() => null);
22
22
  const resolvedPath =
23
- stat?.isDirectory() ? path.join(filePath, "module.yaml") : filePath;
23
+ stat?.isDirectory() ? path.join(filePath, DEFAULT_MANIFEST_FILENAME) : filePath;
24
24
  const text = await fs.readFile(resolvedPath, "utf8");
25
25
  return { text, source: resolvedPath };
26
26
  }
@@ -1,8 +1,10 @@
1
- import type { ManifestAdapter } from "../types.js";
1
+ import { DEFAULT_MANIFEST_FILENAME, type ManifestAdapter } from "../types.js";
2
2
 
3
- const REGISTRY_BASE = "https://registry.telo.run";
3
+ const DEFAULT_REGISTRY_URL = "https://registry.telo.run";
4
4
 
5
5
  export class RegistryAdapter implements ManifestAdapter {
6
+ constructor(private registryUrl = DEFAULT_REGISTRY_URL) {}
7
+
6
8
  supports(url: string): boolean {
7
9
  return (
8
10
  !url.startsWith("http://") &&
@@ -26,18 +28,38 @@ export class RegistryAdapter implements ManifestAdapter {
26
28
  }
27
29
 
28
30
  resolveRelative(base: string, relative: string): string {
29
- const baseUrl = this.supports(base)
30
- ? this.toRegistryUrl(base).replace("/module.yaml", "")
31
- : base;
31
+ const baseUrl = this.supports(base) ? this.toRegistryModuleBase(base) : base;
32
32
  const baseWithSlash = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
33
33
  return new URL(relative, baseWithSlash).href;
34
34
  }
35
35
 
36
+ private toRegistryModuleBase(moduleRef: string): string {
37
+ const parsed = this.parseModuleRef(moduleRef);
38
+ const normalizedBase = this.registryUrl.replace(/\/+$/, "");
39
+ return `${normalizedBase}/${parsed.modulePath}/${parsed.version}`;
40
+ }
41
+
36
42
  private toRegistryUrl(moduleRef: string): string {
43
+ return `${this.toRegistryModuleBase(moduleRef)}/${DEFAULT_MANIFEST_FILENAME}`;
44
+ }
45
+
46
+ private parseModuleRef(moduleRef: string): { modulePath: string; version: string } {
37
47
  const atIdx = moduleRef.lastIndexOf("@");
48
+ if (atIdx <= 0 || atIdx === moduleRef.length - 1) {
49
+ throw new Error(`Invalid module reference '${moduleRef}', expected namespace/name@version`);
50
+ }
51
+
38
52
  const modulePath = moduleRef.slice(0, atIdx);
39
- const version = moduleRef.slice(atIdx + 1);
40
- const versionSegment = version.startsWith("v") ? version.substring(1) : version;
41
- return `${REGISTRY_BASE}/${modulePath}/${versionSegment}/module.yaml`;
53
+ if (!modulePath.includes("/")) {
54
+ throw new Error(`Invalid module reference '${moduleRef}', expected namespace/name@version`);
55
+ }
56
+
57
+ const rawVersion = moduleRef.slice(atIdx + 1);
58
+ const version = rawVersion.startsWith("v") ? rawVersion.substring(1) : rawVersion;
59
+ if (!version) {
60
+ throw new Error(`Invalid module reference '${moduleRef}', expected namespace/name@version`);
61
+ }
62
+
63
+ return { modulePath, version };
42
64
  }
43
65
  }
@@ -61,6 +61,16 @@ export class AnalysisRegistry {
61
61
  return KERNEL_BUILTINS;
62
62
  }
63
63
 
64
+ resolveDefinition(kind: string): ResourceDefinition | undefined {
65
+ const ctx = this._context();
66
+ const resolved = ctx.aliases?.resolveKind(kind);
67
+ return ctx.definitions?.resolve(kind) ?? (resolved ? ctx.definitions?.resolve(resolved) : undefined);
68
+ }
69
+
70
+ allKinds(): string[] {
71
+ return this._context().definitions?.kinds() ?? [];
72
+ }
73
+
64
74
  /** @internal Bridge for StaticAnalyzer — do not use outside the analyzer package. */
65
75
  _context(): AnalysisContext {
66
76
  return { aliases: this.aliases, definitions: this.defs };
package/src/analyzer.ts CHANGED
@@ -4,17 +4,21 @@ import { AnalysisRegistry } from "./analysis-registry.js";
4
4
  import { buildTypedCelEnvironment, celEnvironment } from "./cel-environment.js";
5
5
  import { DefinitionRegistry } from "./definition-registry.js";
6
6
  import { buildDependencyGraph, formatCycle } from "./dependency-graph.js";
7
+ import { buildKernelGlobalsSchema, mergeKernelGlobalsIntoContext } from "./kernel-globals.js";
7
8
  import { normalizeInlineResources } from "./normalize-inline-resources.js";
8
9
  import {
9
- type SchemaIssue,
10
10
  celTypeSatisfiesJsonSchema,
11
11
  substituteCelFields,
12
12
  validateAgainstSchema,
13
+ type SchemaIssue,
13
14
  } from "./schema-compat.js";
14
15
  import { DiagnosticSeverity, type AnalysisDiagnostic, type AnalysisOptions } from "./types.js";
15
16
  import {
16
17
  extractAccessChains,
18
+ getManifestItem,
17
19
  pathMatchesScope,
20
+ resolveContextAnnotations,
21
+ resolveTypeFieldToSchema,
18
22
  validateChainAgainstSchema,
19
23
  } from "./validate-cel-context.js";
20
24
  import { validateReferences } from "./validate-references.js";
@@ -78,6 +82,87 @@ function extractContextsFromSchema(
78
82
  return results;
79
83
  }
80
84
 
85
+ /**
86
+ * Build a `steps` context schema from `x-telo-step-context` annotation.
87
+ * Walks each step in the manifest array, resolves the invoked resource's outputType,
88
+ * and builds `steps.<name>.result` context entries.
89
+ */
90
+ function buildStepContextSchema(
91
+ manifest: Record<string, any>,
92
+ defSchema: Record<string, any>,
93
+ allManifests: Record<string, any>[],
94
+ ): Record<string, any> | undefined {
95
+ const props = defSchema.properties as Record<string, any> | undefined;
96
+ if (!props) return undefined;
97
+
98
+ for (const [fieldName, fieldSchema] of Object.entries(props)) {
99
+ const stepCtx = fieldSchema["x-telo-step-context"] as Record<string, string> | undefined;
100
+ if (!stepCtx) continue;
101
+
102
+ const invokeField = stepCtx.invoke;
103
+ const outputTypeField = stepCtx.outputType;
104
+ if (!invokeField || !outputTypeField) continue;
105
+
106
+ const steps = manifest[fieldName];
107
+ if (!Array.isArray(steps)) continue;
108
+
109
+ const stepProperties: Record<string, any> = {};
110
+ const collectSteps = (items: unknown[]) => {
111
+ for (const step of items) {
112
+ if (!step || typeof step !== "object") continue;
113
+ const s = step as Record<string, any>;
114
+ const name = s.name;
115
+ if (typeof name === "string") {
116
+ const invoke = s[invokeField] as Record<string, any> | undefined;
117
+ let outputSchema: Record<string, any> | undefined;
118
+ if (invoke && typeof invoke === "object") {
119
+ const invokedKind = invoke.kind as string | undefined;
120
+ const invokedName = invoke.name as string | undefined;
121
+ if (invokedName) {
122
+ const invokedManifest = allManifests.find(
123
+ (m) =>
124
+ (m.metadata as any)?.name === invokedName &&
125
+ (!invokedKind || m.kind === invokedKind),
126
+ ) as Record<string, any> | undefined;
127
+ if (invokedManifest) {
128
+ outputSchema = resolveTypeFieldToSchema(invokedManifest[outputTypeField], allManifests);
129
+ }
130
+ } else {
131
+ outputSchema = resolveTypeFieldToSchema(invoke[outputTypeField], allManifests);
132
+ }
133
+ }
134
+ stepProperties[name] = {
135
+ type: "object",
136
+ properties: {
137
+ result: outputSchema ?? { type: "object", additionalProperties: true },
138
+ },
139
+ };
140
+ }
141
+ // Recurse into nested step arrays (then, else, do, catch, finally, try, default, cases)
142
+ for (const nested of ["then", "else", "do", "catch", "finally", "try", "default"]) {
143
+ if (Array.isArray(s[nested])) collectSteps(s[nested]);
144
+ }
145
+ // cases is an object map of arrays
146
+ if (s.cases && typeof s.cases === "object") {
147
+ for (const arr of Object.values(s.cases)) {
148
+ if (Array.isArray(arr)) collectSteps(arr);
149
+ }
150
+ }
151
+ }
152
+ };
153
+ collectSteps(steps);
154
+
155
+ if (Object.keys(stepProperties).length > 0) {
156
+ return {
157
+ type: "object",
158
+ properties: stepProperties,
159
+ };
160
+ }
161
+ }
162
+
163
+ return undefined;
164
+ }
165
+
81
166
  const CEL_PURE_RE = /^\s*\$\{\{[^}]*\}\}\s*$/;
82
167
  const CEL_EXPR_RE = /\$\{\{\s*([^}]+?)\s*\}\}/;
83
168
 
@@ -132,7 +217,14 @@ function collectCelTypeIssues(
132
217
  const itemSchema = (schema.items ?? {}) as Record<string, any>;
133
218
  for (let i = 0; i < data.length; i++) {
134
219
  issues.push(
135
- ...collectCelTypeIssues(data[i], itemSchema, `${path}[${i}]`, definition, manifest, baseEnv),
220
+ ...collectCelTypeIssues(
221
+ data[i],
222
+ itemSchema,
223
+ `${path}[${i}]`,
224
+ definition,
225
+ manifest,
226
+ baseEnv,
227
+ ),
136
228
  );
137
229
  }
138
230
  } else if (data !== null && typeof data === "object") {
@@ -226,6 +318,10 @@ export class StaticAnalyzer {
226
318
  }
227
319
  }
228
320
 
321
+ // Build typed kernel globals schema so x-telo-context chain validation
322
+ // recognises variables, secrets, resources, env automatically
323
+ const kernelGlobals = buildKernelGlobalsSchema(allManifests);
324
+
229
325
  // Validate each non-definition, non-system resource
230
326
  for (const m of allManifests) {
231
327
  if (!m.kind || !m.metadata?.name) {
@@ -310,6 +406,15 @@ export class StaticAnalyzer {
310
406
  const mDefinition =
311
407
  defs.resolve(m.kind) ?? (resolvedKind ? defs.resolve(resolvedKind) : undefined);
312
408
 
409
+ // Pre-compute step context for manifests with x-telo-step-context
410
+ const stepContextSchema = mDefinition?.schema
411
+ ? buildStepContextSchema(
412
+ m as Record<string, any>,
413
+ mDefinition.schema as Record<string, any>,
414
+ allManifests as Record<string, any>[],
415
+ )
416
+ : undefined;
417
+
313
418
  walkCelExpressions(m, "", (expr, path) => {
314
419
  let parsed: ReturnType<typeof celEnvironment.parse> | undefined;
315
420
  try {
@@ -325,24 +430,53 @@ export class StaticAnalyzer {
325
430
  return;
326
431
  }
327
432
 
433
+ const accessChains = extractAccessChains(parsed.ast);
434
+
328
435
  const contexts = mDefinition?.schema ? extractContextsFromSchema(mDefinition.schema) : [];
329
436
  const invocationContext = (m.metadata as any)?.xTeloInvocationContext as
330
437
  | Record<string, any>
331
438
  | undefined;
332
- if (contexts.length === 0 && !invocationContext) return;
439
+
440
+ // If no static context but we have step context, inject it
441
+ if (contexts.length === 0 && !invocationContext && !stepContextSchema) return;
333
442
 
334
443
  let matchedContext: Record<string, any> | undefined;
444
+ let matchedScope: string | undefined;
335
445
  for (const ctx of contexts) {
336
446
  if (pathMatchesScope(path, ctx.scope)) {
337
447
  matchedContext = ctx.schema;
448
+ matchedScope = ctx.scope;
338
449
  break;
339
450
  }
340
451
  }
341
452
  if (!matchedContext) matchedContext = invocationContext;
453
+
454
+ // Merge step context into the effective context
455
+ if (stepContextSchema) {
456
+ const base = matchedContext ?? { type: "object", properties: {}, additionalProperties: true };
457
+ matchedContext = {
458
+ ...base,
459
+ properties: {
460
+ ...(base.properties ?? {}),
461
+ steps: stepContextSchema,
462
+ },
463
+ };
464
+ }
465
+
342
466
  if (!matchedContext) return;
343
467
 
344
- for (const chain of extractAccessChains(parsed.ast)) {
345
- const err = validateChainAgainstSchema(chain, matchedContext);
468
+ const manifestItem = matchedScope
469
+ ? getManifestItem(path, matchedScope, m as Record<string, any>)
470
+ : (m as Record<string, any>);
471
+ const resolvedContext = resolveContextAnnotations(
472
+ matchedContext,
473
+ manifestItem,
474
+ allManifests as Record<string, any>[],
475
+ );
476
+ const effectiveContext = mergeKernelGlobalsIntoContext(resolvedContext, kernelGlobals);
477
+
478
+ for (const chain of accessChains) {
479
+ const err = validateChainAgainstSchema(chain, effectiveContext);
346
480
  if (!err) continue;
347
481
  diagnostics.push({
348
482
  severity: DiagnosticSeverity.Error,
@@ -20,8 +20,11 @@ export const celEnvironment = new Environment({ unlistedVariablesAreDyn: true })
20
20
  *
21
21
  * - `variables`: typed from the manifest's `variables` field if it is a schema map
22
22
  * (only `Kernel.Module` resources carry this); otherwise registered as `map` (dyn).
23
- * - `secrets`, `resources`, `imports`, `env`: always `map` (dyn — output schemas unknown).
24
- * - `extraContextSchema`: additional variables from an `x-telo-context` annotation. */
23
+ * - `secrets`, `resources`, `env`: always `map` (dyn — output schemas unknown).
24
+ * - `extraContextSchema`: additional variables from an `x-telo-context` annotation.
25
+ *
26
+ * NOTE: The set of kernel globals registered here must match `KERNEL_GLOBAL_NAMES`
27
+ * in kernel-globals.ts, which is used for chain-access validation. */
25
28
  export function buildTypedCelEnvironment(
26
29
  manifest: ResourceManifest,
27
30
  extraContextSchema?: Record<string, any> | null,
@@ -50,7 +53,6 @@ export function buildTypedCelEnvironment(
50
53
 
51
54
  env.registerVariable("secrets", "map");
52
55
  env.registerVariable("resources", "map");
53
- env.registerVariable("imports", "map");
54
56
  env.registerVariable("env", "map");
55
57
 
56
58
  if (extraContextSchema?.properties) {
package/src/index.ts CHANGED
@@ -4,12 +4,10 @@ export { RegistryAdapter } from "./adapters/registry-adapter.js";
4
4
  export { AnalysisRegistry } from "./analysis-registry.js";
5
5
  export { StaticAnalyzer } from "./analyzer.js";
6
6
  export { Loader } from "./manifest-loader.js";
7
- export { DiagnosticSeverity } from "./types.js";
7
+ export { DEFAULT_MANIFEST_FILENAME, DiagnosticSeverity } from "./types.js";
8
8
  export type {
9
9
  AnalysisDiagnostic,
10
- AnalysisOptions,
11
- LoadOptions,
12
- ManifestAdapter,
10
+ AnalysisOptions, LoaderInitOptions, LoadOptions, ManifestAdapter,
13
11
  Position,
14
12
  PositionIndex,
15
13
  Range