@telorun/analyzer 0.1.1 → 0.1.2
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/dist/adapters/http-adapter.js +2 -2
- package/dist/adapters/registry-adapter.d.ts +4 -0
- package/dist/adapters/registry-adapter.d.ts.map +1 -1
- package/dist/adapters/registry-adapter.js +26 -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 +102 -4
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/manifest-loader.d.ts +4 -2
- package/dist/manifest-loader.d.ts.map +1 -1
- package/dist/manifest-loader.js +77 -4
- package/dist/schema-compat.d.ts.map +1 -1
- package/dist/schema-compat.js +39 -14
- package/dist/types.d.ts +10 -0
- package/dist/types.d.ts.map +1 -1
- 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/package.json +2 -2
- package/src/adapters/http-adapter.ts +2 -2
- package/src/adapters/registry-adapter.ts +29 -7
- package/src/analysis-registry.ts +10 -0
- package/src/analyzer.ts +133 -5
- package/src/index.ts +1 -3
- package/src/manifest-loader.ts +91 -5
- package/src/schema-compat.ts +40 -18
- package/src/types.ts +11 -0
- package/src/validate-cel-context.ts +150 -15
package/src/schema-compat.ts
CHANGED
|
@@ -81,7 +81,19 @@ function checkProperty(
|
|
|
81
81
|
|
|
82
82
|
export function formatSingleError(err: any): string {
|
|
83
83
|
const p = err.instancePath || "/";
|
|
84
|
-
|
|
84
|
+
const params = err.params ?? {};
|
|
85
|
+
switch (err.keyword) {
|
|
86
|
+
case "additionalProperties":
|
|
87
|
+
return `${p} must NOT have additional properties ('${params.additionalProperty}' is not allowed)`;
|
|
88
|
+
case "required":
|
|
89
|
+
return `${p} is missing required property '${params.missingProperty}'`;
|
|
90
|
+
case "enum":
|
|
91
|
+
return `${p} ${err.message ?? "is invalid"} (${(params.allowedValues as unknown[])?.join(" | ")})`;
|
|
92
|
+
case "type":
|
|
93
|
+
return `${p} must be ${params.type} (got ${typeof err.data})`;
|
|
94
|
+
default:
|
|
95
|
+
return `${p} ${err.message ?? "is invalid"}`;
|
|
96
|
+
}
|
|
85
97
|
}
|
|
86
98
|
|
|
87
99
|
export function formatAjvErrors(errors: any[] | null | undefined): string {
|
|
@@ -182,13 +194,20 @@ export function jsonSchemaToCelType(schema: Record<string, any> | undefined): st
|
|
|
182
194
|
if (schema.anyOf || schema.oneOf || schema.allOf) return "dyn";
|
|
183
195
|
if (Array.isArray(schema.type)) return "dyn";
|
|
184
196
|
switch (schema.type) {
|
|
185
|
-
case "integer":
|
|
186
|
-
|
|
187
|
-
case "
|
|
188
|
-
|
|
189
|
-
case "
|
|
190
|
-
|
|
191
|
-
case "
|
|
197
|
+
case "integer":
|
|
198
|
+
return "int";
|
|
199
|
+
case "number":
|
|
200
|
+
return "double";
|
|
201
|
+
case "string":
|
|
202
|
+
return "string";
|
|
203
|
+
case "boolean":
|
|
204
|
+
return "bool";
|
|
205
|
+
case "array":
|
|
206
|
+
return "list";
|
|
207
|
+
case "object":
|
|
208
|
+
return "map";
|
|
209
|
+
case "null":
|
|
210
|
+
return "null_type";
|
|
192
211
|
}
|
|
193
212
|
if (schema.properties) return "map";
|
|
194
213
|
if (schema.items) return "list";
|
|
@@ -196,10 +215,7 @@ export function jsonSchemaToCelType(schema: Record<string, any> | undefined): st
|
|
|
196
215
|
}
|
|
197
216
|
|
|
198
217
|
/** Check whether a CEL return type is compatible with a JSON Schema type constraint. */
|
|
199
|
-
export function celTypeSatisfiesJsonSchema(
|
|
200
|
-
celType: string,
|
|
201
|
-
schema: Record<string, any>,
|
|
202
|
-
): boolean {
|
|
218
|
+
export function celTypeSatisfiesJsonSchema(celType: string, schema: Record<string, any>): boolean {
|
|
203
219
|
if (celType === "dyn") return true;
|
|
204
220
|
if (!schema.type && !schema.anyOf && !schema.oneOf && !schema.allOf) return true;
|
|
205
221
|
if (schema.anyOf || schema.oneOf || schema.allOf) return true;
|
|
@@ -227,12 +243,18 @@ export function celPlaceholderForSchema(schema: Record<string, any>): unknown {
|
|
|
227
243
|
if (schema.default !== undefined) return schema.default;
|
|
228
244
|
switch (schema.type) {
|
|
229
245
|
case "integer":
|
|
230
|
-
case "number":
|
|
231
|
-
|
|
232
|
-
case "
|
|
233
|
-
|
|
234
|
-
case "
|
|
235
|
-
|
|
246
|
+
case "number":
|
|
247
|
+
return schema.minimum ?? 0;
|
|
248
|
+
case "string":
|
|
249
|
+
return "";
|
|
250
|
+
case "boolean":
|
|
251
|
+
return false;
|
|
252
|
+
case "array":
|
|
253
|
+
return [];
|
|
254
|
+
case "object":
|
|
255
|
+
return {};
|
|
256
|
+
default:
|
|
257
|
+
return null;
|
|
236
258
|
}
|
|
237
259
|
}
|
|
238
260
|
|
package/src/types.ts
CHANGED
|
@@ -52,6 +52,17 @@ export interface LoadOptions {
|
|
|
52
52
|
compile?: boolean;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
export interface LoaderInitOptions {
|
|
56
|
+
/** Adapters inserted with highest priority before built-ins. */
|
|
57
|
+
extraAdapters?: ManifestAdapter[];
|
|
58
|
+
/** Include built-in HttpAdapter. Defaults to true. */
|
|
59
|
+
includeHttpAdapter?: boolean;
|
|
60
|
+
/** Include built-in RegistryAdapter. Defaults to true. */
|
|
61
|
+
includeRegistryAdapter?: boolean;
|
|
62
|
+
/** Base URL used by built-in RegistryAdapter when enabled. */
|
|
63
|
+
registryUrl?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
55
66
|
export interface AnalysisOptions {
|
|
56
67
|
strictContexts?: boolean;
|
|
57
68
|
}
|
|
@@ -1,5 +1,45 @@
|
|
|
1
1
|
import type { ASTNode } from "@marcbachmann/cel-js";
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* Resolve a type field value (string name, inline type, or raw schema) to a JSON Schema.
|
|
5
|
+
* - String: look up the named type in allManifests (Type.JsonSchema resources)
|
|
6
|
+
* - Object with `kind` + `schema`: inline type definition → return the `schema`
|
|
7
|
+
* - Object with `type` or `properties`: raw JSON Schema, return as-is
|
|
8
|
+
*/
|
|
9
|
+
export function resolveTypeFieldToSchema(
|
|
10
|
+
value: unknown,
|
|
11
|
+
allManifests: Record<string, any>[],
|
|
12
|
+
): Record<string, any> | undefined {
|
|
13
|
+
if (!value) return undefined;
|
|
14
|
+
|
|
15
|
+
if (typeof value === "string") {
|
|
16
|
+
// Named type reference — find a Kernel.Type resource by name
|
|
17
|
+
const typeManifest = allManifests.find(
|
|
18
|
+
(m) =>
|
|
19
|
+
(m.metadata as any)?.name === value &&
|
|
20
|
+
typeof m.kind === "string" &&
|
|
21
|
+
/\bType\b/.test(m.kind) &&
|
|
22
|
+
typeof m.schema === "object" &&
|
|
23
|
+
m.schema !== null,
|
|
24
|
+
);
|
|
25
|
+
return typeManifest?.schema as Record<string, any> | undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (typeof value === "object" && value !== null) {
|
|
29
|
+
const obj = value as Record<string, any>;
|
|
30
|
+
// Inline type resource: { kind: "Type.JsonSchema", schema: {...} }
|
|
31
|
+
if (obj.schema && typeof obj.schema === "object") {
|
|
32
|
+
return obj.schema as Record<string, any>;
|
|
33
|
+
}
|
|
34
|
+
// Raw JSON Schema (has type or properties)
|
|
35
|
+
if (obj.type || obj.properties) {
|
|
36
|
+
return obj;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
3
43
|
/**
|
|
4
44
|
* Extract all member-access chains from a CEL AST.
|
|
5
45
|
* Returns arrays like ["request", "query", "name"] for `request.query.name`.
|
|
@@ -96,35 +136,35 @@ export function validateChainAgainstSchema(
|
|
|
96
136
|
for (let i = 0; i < chain.length; i++) {
|
|
97
137
|
const key = chain[i]!;
|
|
98
138
|
if (!current || typeof current !== "object") return null;
|
|
99
|
-
// Open schema: no properties declared or explicitly allows additional properties
|
|
100
139
|
const props: Record<string, any> | undefined = current.properties;
|
|
101
140
|
if (!props) return null;
|
|
102
|
-
if (
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
return `'${path}' is not defined (available: ${available})`;
|
|
141
|
+
if (key in props) {
|
|
142
|
+
// Known property — drill into it even if additionalProperties is true
|
|
143
|
+
current = props[key];
|
|
144
|
+
continue;
|
|
107
145
|
}
|
|
108
|
-
|
|
146
|
+
// Unknown property — only flag if schema is closed
|
|
147
|
+
if (current.additionalProperties === true) return null;
|
|
148
|
+
const path = chain.slice(0, i + 1).join(".");
|
|
149
|
+
const available = Object.keys(props).join(", ");
|
|
150
|
+
return `'${path}' is not defined (available: ${available})`;
|
|
109
151
|
}
|
|
110
152
|
return null;
|
|
111
153
|
}
|
|
112
154
|
|
|
113
155
|
/**
|
|
114
|
-
* Returns true when a CEL expression path (from walkCelExpressions, e.g. "routes[0].
|
|
115
|
-
* falls within the
|
|
156
|
+
* Returns true when a CEL expression path (from walkCelExpressions, e.g. "routes[0].inputs.q")
|
|
157
|
+
* falls within the scope of a context (e.g. "$.routes[*].inputs").
|
|
116
158
|
*
|
|
117
|
-
* The
|
|
118
|
-
*
|
|
159
|
+
* The scope is matched directly (no sibling sharing): a context at "$.routes[*].inputs" only
|
|
160
|
+
* applies to expressions whose path starts with "routes[N].inputs", not to other sibling fields.
|
|
119
161
|
*/
|
|
120
162
|
export function pathMatchesScope(exprPath: string, scope: string): boolean {
|
|
121
163
|
const stripped = scope.startsWith("$.") ? scope.slice(2) : scope;
|
|
122
|
-
|
|
123
|
-
if (lastDot <= 0) return false;
|
|
124
|
-
const container = stripped.slice(0, lastDot); // e.g. "routes[*]"
|
|
164
|
+
if (!stripped) return false;
|
|
125
165
|
|
|
126
166
|
// Split on wildcard array segments; each [*] must match a concrete [N] in exprPath
|
|
127
|
-
const parts =
|
|
167
|
+
const parts = stripped.split("[*]");
|
|
128
168
|
let remaining = exprPath;
|
|
129
169
|
for (let i = 0; i < parts.length; i++) {
|
|
130
170
|
const part = parts[i]!;
|
|
@@ -140,3 +180,98 @@ export function pathMatchesScope(exprPath: string, scope: string): boolean {
|
|
|
140
180
|
// Expression must end here or continue into a child path
|
|
141
181
|
return remaining === "" || remaining[0] === "." || remaining[0] === "[";
|
|
142
182
|
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Resolves `x-telo-context-from` annotations in a context schema using the concrete
|
|
186
|
+
* manifest item. Navigates the manifest item at the given slash-separated path and merges
|
|
187
|
+
* the result as named properties into the annotated node (locking additionalProperties: false).
|
|
188
|
+
*
|
|
189
|
+
* Example: `x-telo-context-from: "request/schema"` on the `request` context node replaces
|
|
190
|
+
* the open `request` schema with a closed schema whose properties are the keys of
|
|
191
|
+
* `manifestItem.request.schema` (e.g. `query`, `body`, `params`, `headers`).
|
|
192
|
+
*/
|
|
193
|
+
export function resolveContextAnnotations(
|
|
194
|
+
schema: Record<string, any>,
|
|
195
|
+
manifestItem: Record<string, any>,
|
|
196
|
+
allManifests?: Record<string, any>[],
|
|
197
|
+
): Record<string, any> {
|
|
198
|
+
if (!schema || typeof schema !== "object") return schema;
|
|
199
|
+
|
|
200
|
+
const from = schema["x-telo-context-from"] as string | undefined;
|
|
201
|
+
if (from) {
|
|
202
|
+
const resolved = navigatePath(manifestItem, from.split("/")) as Record<string, any> | undefined;
|
|
203
|
+
// `resolved` is a map of property names → sub-schemas (e.g. { query: {...}, body: {...} })
|
|
204
|
+
return {
|
|
205
|
+
...schema,
|
|
206
|
+
properties: { ...(schema.properties ?? {}), ...(resolved ?? {}) },
|
|
207
|
+
additionalProperties: false,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const refFrom = schema["x-telo-context-ref-from"] as string | undefined;
|
|
212
|
+
if (refFrom && allManifests) {
|
|
213
|
+
const slashIdx = refFrom.indexOf("/");
|
|
214
|
+
const refProp = slashIdx === -1 ? refFrom : refFrom.slice(0, slashIdx);
|
|
215
|
+
const subpath = slashIdx === -1 ? undefined : refFrom.slice(slashIdx + 1);
|
|
216
|
+
const ref = manifestItem[refProp] as Record<string, any> | undefined;
|
|
217
|
+
if (
|
|
218
|
+
ref &&
|
|
219
|
+
typeof ref === "object" &&
|
|
220
|
+
typeof ref.kind === "string" &&
|
|
221
|
+
typeof ref.name === "string" &&
|
|
222
|
+
subpath
|
|
223
|
+
) {
|
|
224
|
+
const refManifest = allManifests.find(
|
|
225
|
+
(m) => m.kind === ref.kind && (m.metadata as any)?.name === ref.name,
|
|
226
|
+
) as Record<string, any> | undefined;
|
|
227
|
+
if (refManifest) {
|
|
228
|
+
const resolved = resolveTypeFieldToSchema(
|
|
229
|
+
navigatePath(refManifest, subpath.split("/")) as unknown,
|
|
230
|
+
allManifests,
|
|
231
|
+
);
|
|
232
|
+
if (resolved && typeof resolved === "object") {
|
|
233
|
+
return resolved;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
// Fallback: open schema (no false errors when outputType is not declared)
|
|
238
|
+
return { ...schema, additionalProperties: true };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (schema.properties) {
|
|
242
|
+
const props: Record<string, any> = {};
|
|
243
|
+
for (const [k, v] of Object.entries(schema.properties)) {
|
|
244
|
+
props[k] = resolveContextAnnotations(v as Record<string, any>, manifestItem, allManifests);
|
|
245
|
+
}
|
|
246
|
+
return { ...schema, properties: props };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return schema;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Extracts the concrete manifest array item for a given expression path + scope.
|
|
254
|
+
* e.g. exprPath="routes[0].inputs.q", scope="$.routes[*].inputs" → manifest.routes[0]
|
|
255
|
+
*/
|
|
256
|
+
export function getManifestItem(
|
|
257
|
+
exprPath: string,
|
|
258
|
+
scope: string,
|
|
259
|
+
manifest: Record<string, any>,
|
|
260
|
+
): Record<string, any> {
|
|
261
|
+
const stripped = scope.startsWith("$.") ? scope.slice(2) : scope;
|
|
262
|
+
const wildcardIdx = stripped.indexOf("[*]");
|
|
263
|
+
if (wildcardIdx === -1) return manifest;
|
|
264
|
+
const arrayProp = stripped.slice(0, wildcardIdx); // e.g. "routes"
|
|
265
|
+
const m = exprPath.match(new RegExp(`^${arrayProp}\\[(\\d+)\\]`));
|
|
266
|
+
if (!m) return manifest;
|
|
267
|
+
return (manifest as any)[arrayProp]?.[Number(m[1])] ?? manifest;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function navigatePath(obj: unknown, segments: string[]): unknown {
|
|
271
|
+
let cur = obj;
|
|
272
|
+
for (const seg of segments) {
|
|
273
|
+
if (cur === null || typeof cur !== "object") return undefined;
|
|
274
|
+
cur = (cur as Record<string, unknown>)[seg];
|
|
275
|
+
}
|
|
276
|
+
return cur;
|
|
277
|
+
}
|