@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.
- package/README.md +233 -0
- package/dist/adapters/http-adapter.d.ts +1 -1
- package/dist/adapters/http-adapter.d.ts.map +1 -1
- package/dist/adapters/http-adapter.js +4 -3
- package/dist/adapters/node-adapter.d.ts +1 -1
- package/dist/adapters/node-adapter.d.ts.map +1 -1
- package/dist/adapters/node-adapter.js +2 -1
- package/dist/adapters/registry-adapter.d.ts +5 -1
- package/dist/adapters/registry-adapter.d.ts.map +1 -1
- package/dist/adapters/registry-adapter.js +27 -7
- package/dist/analysis-registry.d.ts +2 -0
- package/dist/analysis-registry.d.ts.map +1 -1
- package/dist/analysis-registry.js +8 -0
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/analyzer.js +107 -4
- package/dist/cel-environment.d.ts +5 -2
- package/dist/cel-environment.d.ts.map +1 -1
- package/dist/cel-environment.js +5 -3
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/kernel-globals.d.ts +34 -0
- package/dist/kernel-globals.d.ts.map +1 -0
- package/dist/kernel-globals.js +94 -0
- package/dist/manifest-loader.d.ts +6 -3
- package/dist/manifest-loader.d.ts.map +1 -1
- package/dist/manifest-loader.js +110 -5
- package/dist/schema-compat.d.ts +1 -1
- package/dist/schema-compat.d.ts.map +1 -1
- package/dist/schema-compat.js +82 -28
- package/dist/types.d.ts +12 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +2 -0
- package/dist/validate-cel-context.d.ts +26 -4
- package/dist/validate-cel-context.d.ts.map +1 -1
- package/dist/validate-cel-context.js +123 -15
- package/dist/validate-references.d.ts.map +1 -1
- package/dist/validate-references.js +13 -1
- package/package.json +21 -2
- package/src/adapters/http-adapter.ts +4 -4
- package/src/adapters/node-adapter.ts +2 -2
- package/src/adapters/registry-adapter.ts +30 -8
- package/src/analysis-registry.ts +10 -0
- package/src/analyzer.ts +139 -5
- package/src/cel-environment.ts +5 -3
- package/src/index.ts +2 -4
- package/src/kernel-globals.ts +110 -0
- package/src/manifest-loader.ts +131 -7
- package/src/schema-compat.ts +87 -31
- package/src/types.ts +14 -0
- package/src/validate-cel-context.ts +150 -15
- 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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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].
|
|
107
|
-
* falls within the
|
|
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
|
|
110
|
-
*
|
|
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
|
-
|
|
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 =
|
|
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,
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
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}
|
|
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
|
|
21
|
-
return new URL(relative,
|
|
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
|
|
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,
|
|
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
|
|
1
|
+
import { DEFAULT_MANIFEST_FILENAME, type ManifestAdapter } from "../types.js";
|
|
2
2
|
|
|
3
|
-
const
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
}
|
package/src/analysis-registry.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
-
|
|
345
|
-
|
|
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,
|
package/src/cel-environment.ts
CHANGED
|
@@ -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`, `
|
|
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
|