@telorun/analyzer 0.1.4 → 0.2.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.
- package/README.md +3 -3
- package/dist/analyzer.d.ts +6 -0
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/analyzer.js +45 -25
- package/dist/builtins.d.ts.map +1 -1
- package/dist/builtins.js +52 -24
- package/dist/cel-environment.d.ts +12 -5
- package/dist/cel-environment.d.ts.map +1 -1
- package/dist/cel-environment.js +31 -17
- package/dist/definition-registry.d.ts +5 -5
- package/dist/definition-registry.d.ts.map +1 -1
- package/dist/definition-registry.js +10 -10
- package/dist/dependency-graph.d.ts.map +1 -1
- package/dist/dependency-graph.js +9 -2
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/kernel-globals.d.ts +6 -2
- package/dist/kernel-globals.d.ts.map +1 -1
- package/dist/kernel-globals.js +14 -8
- package/dist/manifest-loader.d.ts +1 -0
- package/dist/manifest-loader.d.ts.map +1 -1
- package/dist/manifest-loader.js +36 -14
- package/dist/module-kinds.d.ts +4 -0
- package/dist/module-kinds.d.ts.map +1 -0
- package/dist/module-kinds.js +4 -0
- package/dist/normalize-inline-resources.d.ts.map +1 -1
- package/dist/normalize-inline-resources.js +6 -1
- package/dist/precompile.d.ts +3 -2
- package/dist/precompile.d.ts.map +1 -1
- package/dist/precompile.js +13 -11
- package/dist/reference-field-map.d.ts +1 -1
- package/dist/resolve-throws-union.d.ts +30 -0
- package/dist/resolve-throws-union.d.ts.map +1 -0
- package/dist/resolve-throws-union.js +252 -0
- package/dist/types.d.ts +3 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/validate-cel-context.js +1 -1
- package/dist/validate-references.d.ts.map +1 -1
- package/dist/validate-references.js +19 -12
- package/dist/validate-throws-coverage.d.ts +8 -0
- package/dist/validate-throws-coverage.d.ts.map +1 -0
- package/dist/validate-throws-coverage.js +461 -0
- package/package.json +2 -2
- package/src/analyzer.ts +60 -26
- package/src/builtins.ts +52 -24
- package/src/cel-environment.ts +40 -17
- package/src/definition-registry.ts +10 -10
- package/src/dependency-graph.ts +9 -2
- package/src/index.ts +2 -0
- package/src/kernel-globals.ts +19 -10
- package/src/manifest-loader.ts +40 -14
- package/src/module-kinds.ts +6 -0
- package/src/normalize-inline-resources.ts +6 -1
- package/src/precompile.ts +14 -11
- package/src/reference-field-map.ts +1 -1
- package/src/resolve-throws-union.ts +345 -0
- package/src/types.ts +3 -0
- package/src/validate-cel-context.ts +1 -1
- package/src/validate-references.ts +19 -12
- package/src/validate-throws-coverage.ts +565 -0
- package/dist/adapters/node-adapter.d.ts +0 -17
- package/dist/adapters/node-adapter.d.ts.map +0 -1
- package/dist/adapters/node-adapter.js +0 -71
|
@@ -6,7 +6,12 @@ import type { AliasResolver } from "./alias-resolver.js";
|
|
|
6
6
|
import type { DefinitionRegistry } from "./definition-registry.js";
|
|
7
7
|
|
|
8
8
|
const SOURCE = "telo-analyzer";
|
|
9
|
-
|
|
9
|
+
/** Kinds skipped by reference validation. Telo.Application and Telo.Library
|
|
10
|
+
* are intentionally not here: Application has `targets` with x-telo-ref that
|
|
11
|
+
* must be validated, and Library has no ref-bearing fields so flows through
|
|
12
|
+
* harmlessly. Telo.Import is also not here for the same reason — its
|
|
13
|
+
* `source` field isn't x-telo-ref, so nothing gets checked. */
|
|
14
|
+
const SYSTEM_KINDS = new Set(["Telo.Definition", "Telo.Abstract"]);
|
|
10
15
|
|
|
11
16
|
/**
|
|
12
17
|
* Checks whether `kind` satisfies the ref constraint in `entry`.
|
|
@@ -27,7 +32,7 @@ function checkKind(
|
|
|
27
32
|
if (!targetKind) return [];
|
|
28
33
|
const targetDef = registry.resolve(targetKind);
|
|
29
34
|
if (!targetDef) return [];
|
|
30
|
-
if (targetDef.kind === "
|
|
35
|
+
if (targetDef.kind === "Telo.Abstract") {
|
|
31
36
|
const implementing = registry.getByExtends(targetKind);
|
|
32
37
|
if (implementing.length === 0) return []; // partial context — no implementations loaded yet
|
|
33
38
|
const implementingKinds = new Set(
|
|
@@ -71,7 +76,7 @@ export function validateReferences(
|
|
|
71
76
|
if (!aliases || !registry) return diagnostics;
|
|
72
77
|
|
|
73
78
|
// Build outer resource lookup by name for resolution check.
|
|
74
|
-
// Exclude system kinds (
|
|
79
|
+
// Exclude system kinds (Telo.Definition) — they are type blueprints, not instances,
|
|
75
80
|
// and their names (e.g. "Server", "Job") would shadow user-defined resource instances.
|
|
76
81
|
const byName = new Map<string, ResourceManifest>();
|
|
77
82
|
for (const r of resources) {
|
|
@@ -86,6 +91,7 @@ export function validateReferences(
|
|
|
86
91
|
|
|
87
92
|
const resourceLabel = `${r.kind}/${r.metadata.name as string}`;
|
|
88
93
|
const resourceData = { kind: r.kind, name: r.metadata.name as string };
|
|
94
|
+
const filePath = (r.metadata as { source?: string } | undefined)?.source;
|
|
89
95
|
|
|
90
96
|
// Collect scope visibility prefixes (JSON Pointer → dot prefix) and their manifests.
|
|
91
97
|
// scope field path → flat array of ResourceManifest declared in that scope.
|
|
@@ -155,7 +161,7 @@ export function validateReferences(
|
|
|
155
161
|
code: "UNRESOLVED_REFERENCE",
|
|
156
162
|
source: SOURCE,
|
|
157
163
|
message: `${resourceLabel}: reference at '${fieldPath}' → resource '${val}' not found`,
|
|
158
|
-
data: { resource: resourceData, path: fieldPath },
|
|
164
|
+
data: { resource: resourceData, filePath, path: fieldPath },
|
|
159
165
|
});
|
|
160
166
|
continue;
|
|
161
167
|
}
|
|
@@ -166,7 +172,7 @@ export function validateReferences(
|
|
|
166
172
|
code: "REFERENCE_KIND_MISMATCH",
|
|
167
173
|
source: SOURCE,
|
|
168
174
|
message: `${resourceLabel}: reference at '${fieldPath}' → ${kindErrors.join("; ")}`,
|
|
169
|
-
data: { resource: resourceData, path: fieldPath },
|
|
175
|
+
data: { resource: resourceData, filePath, path: fieldPath },
|
|
170
176
|
});
|
|
171
177
|
}
|
|
172
178
|
continue;
|
|
@@ -185,7 +191,7 @@ export function validateReferences(
|
|
|
185
191
|
code: "INVALID_REFERENCE",
|
|
186
192
|
source: SOURCE,
|
|
187
193
|
message: `${resourceLabel}: reference at '${fieldPath}' must have string 'kind' and 'name' fields`,
|
|
188
|
-
data: { resource: resourceData, path: fieldPath },
|
|
194
|
+
data: { resource: resourceData, filePath, path: fieldPath },
|
|
189
195
|
});
|
|
190
196
|
continue;
|
|
191
197
|
}
|
|
@@ -198,7 +204,7 @@ export function validateReferences(
|
|
|
198
204
|
code: "REFERENCE_KIND_MISMATCH",
|
|
199
205
|
source: SOURCE,
|
|
200
206
|
message: `${resourceLabel}: reference at '${fieldPath}' → ${kindErrors.join("; ")}`,
|
|
201
|
-
data: { resource: resourceData, path: fieldPath },
|
|
207
|
+
data: { resource: resourceData, filePath, path: fieldPath },
|
|
202
208
|
});
|
|
203
209
|
}
|
|
204
210
|
|
|
@@ -212,7 +218,7 @@ export function validateReferences(
|
|
|
212
218
|
code: "UNRESOLVED_REFERENCE",
|
|
213
219
|
source: SOURCE,
|
|
214
220
|
message: `${resourceLabel}: reference at '${fieldPath}' → resource '${refVal.name}' not found`,
|
|
215
|
-
data: { resource: resourceData, path: fieldPath },
|
|
221
|
+
data: { resource: resourceData, filePath, path: fieldPath },
|
|
216
222
|
});
|
|
217
223
|
}
|
|
218
224
|
}
|
|
@@ -231,6 +237,7 @@ export function validateReferences(
|
|
|
231
237
|
|
|
232
238
|
const resourceLabel = `${r.kind}/${r.metadata.name as string}`;
|
|
233
239
|
const resourceData = { kind: r.kind, name: r.metadata.name as string };
|
|
240
|
+
const filePath = (r.metadata as { source?: string } | undefined)?.source;
|
|
234
241
|
|
|
235
242
|
for (const [fieldPath, entry] of fieldMap) {
|
|
236
243
|
if (!isSchemaFromEntry(entry)) continue;
|
|
@@ -245,7 +252,7 @@ export function validateReferences(
|
|
|
245
252
|
code: "INVALID_SCHEMA_FROM",
|
|
246
253
|
source: SOURCE,
|
|
247
254
|
message: `${resourceLabel}: x-telo-schema-from "${schemaFrom}" must contain at least one "/" to separate anchor from JSON Pointer`,
|
|
248
|
-
data: { resource: resourceData, path: fieldPath },
|
|
255
|
+
data: { resource: resourceData, filePath, path: fieldPath },
|
|
249
256
|
});
|
|
250
257
|
continue;
|
|
251
258
|
}
|
|
@@ -288,7 +295,7 @@ export function validateReferences(
|
|
|
288
295
|
code: "SCHEMA_FROM_MISSING_PATH",
|
|
289
296
|
source: SOURCE,
|
|
290
297
|
message: `${resourceLabel}: x-telo-schema-from at '${fieldPath}' → kind '${refVal.kind}' has no schema`,
|
|
291
|
-
data: { resource: resourceData, path: fieldPath },
|
|
298
|
+
data: { resource: resourceData, filePath, path: fieldPath },
|
|
292
299
|
});
|
|
293
300
|
continue;
|
|
294
301
|
}
|
|
@@ -300,7 +307,7 @@ export function validateReferences(
|
|
|
300
307
|
code: "SCHEMA_FROM_MISSING_PATH",
|
|
301
308
|
source: SOURCE,
|
|
302
309
|
message: `${resourceLabel}: x-telo-schema-from at '${fieldPath}' → kind '${refVal.kind}' has no schema path '${jsonPointer}'`,
|
|
303
|
-
data: { resource: resourceData, path: fieldPath },
|
|
310
|
+
data: { resource: resourceData, filePath, path: fieldPath },
|
|
304
311
|
});
|
|
305
312
|
continue;
|
|
306
313
|
}
|
|
@@ -312,7 +319,7 @@ export function validateReferences(
|
|
|
312
319
|
code: "DEPENDENT_SCHEMA_MISMATCH",
|
|
313
320
|
source: SOURCE,
|
|
314
321
|
message: `${resourceLabel}: '${fieldPath}' does not match schema from '${refVal.kind}${jsonPointer}': ${issue}`,
|
|
315
|
-
data: { resource: resourceData, path: fieldPath },
|
|
322
|
+
data: { resource: resourceData, filePath, path: fieldPath },
|
|
316
323
|
});
|
|
317
324
|
}
|
|
318
325
|
}
|
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
import type { ASTNode, Environment } from "@marcbachmann/cel-js";
|
|
2
|
+
import type { ResourceManifest } from "@telorun/sdk";
|
|
3
|
+
import type { AliasResolver } from "./alias-resolver.js";
|
|
4
|
+
import type { DefinitionRegistry } from "./definition-registry.js";
|
|
5
|
+
import {
|
|
6
|
+
createResolveCtx,
|
|
7
|
+
resolveThrowsUnion,
|
|
8
|
+
type ThrowsCodeMeta,
|
|
9
|
+
type ThrowsUnion,
|
|
10
|
+
} from "./resolve-throws-union.js";
|
|
11
|
+
import { DiagnosticSeverity, type AnalysisDiagnostic } from "./types.js";
|
|
12
|
+
import { extractAccessChains, validateChainAgainstSchema } from "./validate-cel-context.js";
|
|
13
|
+
|
|
14
|
+
const SOURCE = "telo-analyzer";
|
|
15
|
+
const TEMPLATE_REGEX = /\$\{\{\s*([^}]+?)\s*\}\}/g;
|
|
16
|
+
|
|
17
|
+
interface OutcomeEntry {
|
|
18
|
+
when?: string;
|
|
19
|
+
body?: unknown;
|
|
20
|
+
headers?: Record<string, unknown>;
|
|
21
|
+
status?: number;
|
|
22
|
+
schema?: Record<string, unknown>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface ReturnsLocation {
|
|
26
|
+
manifest: ResourceManifest;
|
|
27
|
+
entries: OutcomeEntry[];
|
|
28
|
+
arrayPath: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Walk `definition.schema` and `data` in tandem, invoking `onOutcome` each
|
|
32
|
+
* time an array schema annotated with `x-telo-outcome-list` is encountered.
|
|
33
|
+
* Keeps schema/data in lockstep so callers can resolve sibling fields. */
|
|
34
|
+
function collectOutcomeLists(
|
|
35
|
+
manifest: ResourceManifest,
|
|
36
|
+
schema: Record<string, any> | undefined,
|
|
37
|
+
onReturns: (loc: ReturnsLocation) => void,
|
|
38
|
+
onCatches: (
|
|
39
|
+
arr: OutcomeEntry[],
|
|
40
|
+
arrayPath: string,
|
|
41
|
+
siblingData: Record<string, any>,
|
|
42
|
+
catchesFor: string,
|
|
43
|
+
) => void,
|
|
44
|
+
): void {
|
|
45
|
+
if (!schema) return;
|
|
46
|
+
walkSchemaData(schema, manifest as Record<string, any>, "", {
|
|
47
|
+
manifest,
|
|
48
|
+
onReturns,
|
|
49
|
+
onCatches,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
type WalkCtx = {
|
|
54
|
+
manifest: ResourceManifest;
|
|
55
|
+
onReturns: (loc: ReturnsLocation) => void;
|
|
56
|
+
onCatches: (
|
|
57
|
+
arr: OutcomeEntry[],
|
|
58
|
+
arrayPath: string,
|
|
59
|
+
siblingData: Record<string, any>,
|
|
60
|
+
catchesFor: string,
|
|
61
|
+
) => void;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
function walkSchemaData(
|
|
65
|
+
schema: Record<string, any>,
|
|
66
|
+
data: unknown,
|
|
67
|
+
path: string,
|
|
68
|
+
ctx: WalkCtx,
|
|
69
|
+
): void {
|
|
70
|
+
if (!schema || typeof schema !== "object") return;
|
|
71
|
+
|
|
72
|
+
const outcomeKind = schema["x-telo-outcome-list"] as "returns" | "catches" | undefined;
|
|
73
|
+
if (outcomeKind && Array.isArray(data)) {
|
|
74
|
+
// The sibling data is the parent object (not reachable here without tracking).
|
|
75
|
+
// collectOutcomeListsInObject passes the parent; this branch is a fallback
|
|
76
|
+
// for top-level outcome lists (never occurs in practice).
|
|
77
|
+
if (outcomeKind === "returns") {
|
|
78
|
+
ctx.onReturns({ manifest: ctx.manifest, entries: data as OutcomeEntry[], arrayPath: path });
|
|
79
|
+
}
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (schema.properties && typeof data === "object" && data !== null && !Array.isArray(data)) {
|
|
84
|
+
const dataObj = data as Record<string, unknown>;
|
|
85
|
+
for (const [key, propSchema] of Object.entries(schema.properties as Record<string, any>)) {
|
|
86
|
+
const nextPath = path ? `${path}.${key}` : key;
|
|
87
|
+
const child = dataObj[key];
|
|
88
|
+
const outcome = propSchema["x-telo-outcome-list"] as "returns" | "catches" | undefined;
|
|
89
|
+
if (outcome) {
|
|
90
|
+
const entries = Array.isArray(child) ? (child as OutcomeEntry[]) : [];
|
|
91
|
+
if (outcome === "returns") {
|
|
92
|
+
// Only fire for present arrays — rule 6 (missing returns) is
|
|
93
|
+
// enforced by schema required fields, not here.
|
|
94
|
+
if (Array.isArray(child)) {
|
|
95
|
+
ctx.onReturns({ manifest: ctx.manifest, entries, arrayPath: nextPath });
|
|
96
|
+
}
|
|
97
|
+
} else {
|
|
98
|
+
const catchesFor = propSchema["x-telo-catches-for"] as string | undefined;
|
|
99
|
+
if (catchesFor) {
|
|
100
|
+
// Fire even when absent so the coverage check can flag handlers
|
|
101
|
+
// whose declared union is non-empty but the list is missing.
|
|
102
|
+
ctx.onCatches(entries, nextPath, dataObj, catchesFor);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (child !== undefined) walkSchemaData(propSchema, child, nextPath, ctx);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (schema.items && Array.isArray(data)) {
|
|
112
|
+
for (const [i, item] of data.entries()) {
|
|
113
|
+
walkSchemaData(schema.items, item, `${path}[${i}]`, ctx);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Read a referenced handler's `{kind, name}` from a sibling field. Handles
|
|
119
|
+
* both `"Alias.Kind"` strings and `{ kind, name? }` objects. */
|
|
120
|
+
function resolveHandlerRef(sibling: unknown): { kind: string; name?: string } | null {
|
|
121
|
+
if (!sibling) return null;
|
|
122
|
+
if (typeof sibling === "string") return { kind: sibling };
|
|
123
|
+
if (typeof sibling === "object") {
|
|
124
|
+
const obj = sibling as { kind?: string; name?: string };
|
|
125
|
+
if (typeof obj.kind === "string") {
|
|
126
|
+
return { kind: obj.kind, name: obj.name };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Parse a `when:` CEL expression and extract the set of `error.code` literals
|
|
133
|
+
* it covers. Recognised forms (per the plan's "coverage-proving" list):
|
|
134
|
+
* - `error.code == 'FOO'`
|
|
135
|
+
* - a disjunction of the above (`||`)
|
|
136
|
+
* - `error.code in ['FOO', 'BAR']`
|
|
137
|
+
* Parenthesised nestings of `||` over equality/in are flattened.
|
|
138
|
+
* Any non-matching sub-expression forfeits coverage for the whole `when:`. */
|
|
139
|
+
function extractCoveredCodes(
|
|
140
|
+
whenExpr: string,
|
|
141
|
+
env: Environment,
|
|
142
|
+
): { proven: boolean; codes: Set<string> } {
|
|
143
|
+
const match = whenExpr.match(/\$\{\{\s*([^}]+?)\s*\}\}/);
|
|
144
|
+
if (!match) return { proven: false, codes: new Set() };
|
|
145
|
+
let ast: ASTNode;
|
|
146
|
+
try {
|
|
147
|
+
ast = env.parse(match[1].trim()).ast;
|
|
148
|
+
} catch {
|
|
149
|
+
return { proven: false, codes: new Set() };
|
|
150
|
+
}
|
|
151
|
+
const codes = new Set<string>();
|
|
152
|
+
const proven = extractFromNode(ast, codes);
|
|
153
|
+
return { proven, codes };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function extractFromNode(node: ASTNode, codes: Set<string>): boolean {
|
|
157
|
+
if (node.op === "||") {
|
|
158
|
+
const [l, r] = node.args as [ASTNode, ASTNode];
|
|
159
|
+
return extractFromNode(l, codes) && extractFromNode(r, codes);
|
|
160
|
+
}
|
|
161
|
+
if (node.op === "==") {
|
|
162
|
+
const [l, r] = node.args as [ASTNode, ASTNode];
|
|
163
|
+
const lit = readErrorCodeEq(l, r) ?? readErrorCodeEq(r, l);
|
|
164
|
+
if (lit === null) return false;
|
|
165
|
+
codes.add(lit);
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
if (node.op === "in") {
|
|
169
|
+
const [l, r] = node.args as [ASTNode, ASTNode];
|
|
170
|
+
if (!isErrorCodeRef(l) || r.op !== "list") return false;
|
|
171
|
+
for (const item of r.args as ASTNode[]) {
|
|
172
|
+
if (item.op !== "value" || typeof item.args !== "string") return false;
|
|
173
|
+
codes.add(item.args);
|
|
174
|
+
}
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function readErrorCodeEq(ref: ASTNode, lit: ASTNode): string | null {
|
|
181
|
+
if (!isErrorCodeRef(ref)) return null;
|
|
182
|
+
if (lit.op !== "value" || typeof lit.args !== "string") return null;
|
|
183
|
+
return lit.args;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function isErrorCodeRef(node: ASTNode): boolean {
|
|
187
|
+
if (node.op !== ".") return false;
|
|
188
|
+
const [obj, field] = node.args as [ASTNode, string];
|
|
189
|
+
if (field !== "code") return false;
|
|
190
|
+
return obj.op === "id" && obj.args === "error";
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Rule 7: within an outcome list, a no-`when:` entry must be the last entry. */
|
|
194
|
+
function checkCatchAllPlacement(
|
|
195
|
+
entries: OutcomeEntry[],
|
|
196
|
+
resource: { kind: string; name: string },
|
|
197
|
+
channel: "returns" | "catches",
|
|
198
|
+
filePath: string | undefined,
|
|
199
|
+
arrayPath: string,
|
|
200
|
+
): AnalysisDiagnostic[] {
|
|
201
|
+
const diagnostics: AnalysisDiagnostic[] = [];
|
|
202
|
+
for (let i = 0; i < entries.length - 1; i++) {
|
|
203
|
+
const e = entries[i];
|
|
204
|
+
if (!e?.when) {
|
|
205
|
+
diagnostics.push({
|
|
206
|
+
severity: DiagnosticSeverity.Error,
|
|
207
|
+
code: "CATCHALL_NOT_LAST",
|
|
208
|
+
source: SOURCE,
|
|
209
|
+
message: `${channel}: catch-all entry (no \`when:\`) at index ${i} must be last — entries after it are unreachable.`,
|
|
210
|
+
data: { resource, filePath, path: `${arrayPath}[${i}]` },
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return diagnostics;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Rule 1 + Rule 4: check declared-union coverage and reject undeclared codes
|
|
218
|
+
* in coverage-proving `when:` clauses. Phase 2 accepts inherit/passthrough
|
|
219
|
+
* handler unions too — when the resolved union is unbounded, a catch-all is
|
|
220
|
+
* required (rule 4 extension). */
|
|
221
|
+
function checkCatchesCoverage(
|
|
222
|
+
entries: OutcomeEntry[],
|
|
223
|
+
union: ThrowsUnion,
|
|
224
|
+
resource: { kind: string; name: string },
|
|
225
|
+
filePath: string | undefined,
|
|
226
|
+
arrayPath: string,
|
|
227
|
+
env: Environment,
|
|
228
|
+
): AnalysisDiagnostic[] {
|
|
229
|
+
const diagnostics: AnalysisDiagnostic[] = [];
|
|
230
|
+
const declaredCodes = new Set(union.codes.keys());
|
|
231
|
+
const covered = new Set<string>();
|
|
232
|
+
let hasCatchAll = false;
|
|
233
|
+
|
|
234
|
+
for (let i = 0; i < entries.length; i++) {
|
|
235
|
+
const e = entries[i];
|
|
236
|
+
if (!e) continue;
|
|
237
|
+
if (!e.when) {
|
|
238
|
+
hasCatchAll = true;
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
const { proven, codes } = extractCoveredCodes(e.when, env);
|
|
242
|
+
if (proven) {
|
|
243
|
+
for (const c of codes) {
|
|
244
|
+
if (!declaredCodes.has(c)) {
|
|
245
|
+
diagnostics.push({
|
|
246
|
+
severity: DiagnosticSeverity.Error,
|
|
247
|
+
code: "UNDECLARED_THROW_CODE",
|
|
248
|
+
source: SOURCE,
|
|
249
|
+
message: `catches[${i}] references code '${c}' which is not in the handler's declared throw union {${[...declaredCodes].sort().join(", ") || "∅"}}${union.unbounded ? " (union is unbounded — a catch-all is required)" : ""}.`,
|
|
250
|
+
data: { resource, filePath, path: `${arrayPath}[${i}].when` },
|
|
251
|
+
});
|
|
252
|
+
} else {
|
|
253
|
+
covered.add(c);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Unbounded union (passthrough or transitive): authors can't enumerate the
|
|
260
|
+
// codes, so a catch-all is mandatory.
|
|
261
|
+
if (union.unbounded && !hasCatchAll) {
|
|
262
|
+
diagnostics.push({
|
|
263
|
+
severity: DiagnosticSeverity.Error,
|
|
264
|
+
code: "UNBOUNDED_UNION_NEEDS_CATCHALL",
|
|
265
|
+
source: SOURCE,
|
|
266
|
+
message: `The handler's throw union is unbounded (inherit/passthrough resolution couldn't enumerate all codes). The catches: list must include a catch-all entry (no \`when:\`).`,
|
|
267
|
+
data: { resource, filePath, path: arrayPath },
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (!hasCatchAll) {
|
|
272
|
+
for (const code of declaredCodes) {
|
|
273
|
+
if (!covered.has(code)) {
|
|
274
|
+
diagnostics.push({
|
|
275
|
+
severity: DiagnosticSeverity.Error,
|
|
276
|
+
code: "UNCOVERED_THROW_CODE",
|
|
277
|
+
source: SOURCE,
|
|
278
|
+
message: `Code '${code}' is declared by the handler but not covered by any catches: entry (no matching \`when:\` and no catch-all).`,
|
|
279
|
+
data: { resource, filePath, path: arrayPath },
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return diagnostics;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Rule 2: for each `error.data.<field>` chain in a catches entry's expressions,
|
|
289
|
+
* type-check against the data schema declared for the matched code(s). When the
|
|
290
|
+
* matching `when:` disjunctively covers multiple codes, use the intersection
|
|
291
|
+
* of their data schemas so only fields present on every code narrow through. */
|
|
292
|
+
function checkTypedErrorData(
|
|
293
|
+
entries: OutcomeEntry[],
|
|
294
|
+
union: ThrowsUnion,
|
|
295
|
+
resource: { kind: string; name: string },
|
|
296
|
+
filePath: string | undefined,
|
|
297
|
+
arrayPath: string,
|
|
298
|
+
env: Environment,
|
|
299
|
+
): AnalysisDiagnostic[] {
|
|
300
|
+
const diagnostics: AnalysisDiagnostic[] = [];
|
|
301
|
+
// If the union is unbounded we can't narrow data schemas reliably — skip
|
|
302
|
+
// typed-data checks for those entries. The catch-all path still provides
|
|
303
|
+
// runtime access to error.data as an opaque value.
|
|
304
|
+
if (union.unbounded || union.codes.size === 0) return diagnostics;
|
|
305
|
+
|
|
306
|
+
const dataByCode: Record<string, Record<string, any> | undefined> = {};
|
|
307
|
+
for (const [code, meta] of union.codes) {
|
|
308
|
+
dataByCode[code] = (meta as ThrowsCodeMeta).data;
|
|
309
|
+
}
|
|
310
|
+
const allCodes = Object.keys(dataByCode);
|
|
311
|
+
|
|
312
|
+
for (let i = 0; i < entries.length; i++) {
|
|
313
|
+
const e = entries[i];
|
|
314
|
+
if (!e) continue;
|
|
315
|
+
const covered = e.when
|
|
316
|
+
? extractCoveredCodes(e.when, env)
|
|
317
|
+
: { proven: false, codes: new Set<string>() };
|
|
318
|
+
// Codes applicable to this entry:
|
|
319
|
+
// - coverage-proving `when:` → exactly those codes
|
|
320
|
+
// - catch-all (no `when:`) or non-proven expr → all declared codes
|
|
321
|
+
const applicable = covered.proven
|
|
322
|
+
? [...covered.codes].filter((c) => c in dataByCode)
|
|
323
|
+
: allCodes;
|
|
324
|
+
if (applicable.length === 0) continue;
|
|
325
|
+
const schemas = applicable.map((c) => dataByCode[c]).filter(Boolean) as Record<string, any>[];
|
|
326
|
+
if (schemas.length === 0) continue;
|
|
327
|
+
const dataSchema = intersectDataSchemas(schemas);
|
|
328
|
+
// Walk CEL expressions inside this entry's body / headers — only
|
|
329
|
+
// string-valued fields can contain CEL templates.
|
|
330
|
+
collectCelStrings(e.body, `${arrayPath}[${i}].body`).forEach((entry) => {
|
|
331
|
+
diagnostics.push(
|
|
332
|
+
...checkCelChainAgainstDataSchema(entry, dataSchema, resource, filePath, env),
|
|
333
|
+
);
|
|
334
|
+
});
|
|
335
|
+
if (e.headers) {
|
|
336
|
+
collectCelStrings(e.headers, `${arrayPath}[${i}].headers`).forEach((entry) => {
|
|
337
|
+
diagnostics.push(
|
|
338
|
+
...checkCelChainAgainstDataSchema(entry, dataSchema, resource, filePath, env),
|
|
339
|
+
);
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return diagnostics;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Intersection of JSON Schemas (object type, explicit properties only). Only
|
|
347
|
+
* retains properties present in every input; picks the most-specific of the
|
|
348
|
+
* sub-schemas when they agree on `type`, else widens to `{}`. */
|
|
349
|
+
function intersectDataSchemas(schemas: Record<string, any>[]): Record<string, any> {
|
|
350
|
+
if (schemas.length === 1) return schemas[0];
|
|
351
|
+
const commonProps: Record<string, Record<string, any>> = {};
|
|
352
|
+
const firstProps = (schemas[0].properties ?? {}) as Record<string, Record<string, any>>;
|
|
353
|
+
for (const propName of Object.keys(firstProps)) {
|
|
354
|
+
const sub = schemas.map((s) => (s.properties ?? {})[propName]);
|
|
355
|
+
if (sub.some((p) => p === undefined)) continue;
|
|
356
|
+
commonProps[propName] = intersectPropertySchemas(sub);
|
|
357
|
+
}
|
|
358
|
+
return {
|
|
359
|
+
type: "object",
|
|
360
|
+
properties: commonProps,
|
|
361
|
+
additionalProperties: false,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function intersectPropertySchemas(schemas: Record<string, any>[]): Record<string, any> {
|
|
366
|
+
const types = new Set(schemas.map((s) => s?.type).filter(Boolean));
|
|
367
|
+
if (types.size === 1) {
|
|
368
|
+
const type = [...types][0];
|
|
369
|
+
if (type === "object") return intersectDataSchemas(schemas);
|
|
370
|
+
return { type };
|
|
371
|
+
}
|
|
372
|
+
return {};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
interface CelString {
|
|
376
|
+
expr: string;
|
|
377
|
+
path: string;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function collectCelStrings(value: unknown, path: string): CelString[] {
|
|
381
|
+
const out: CelString[] = [];
|
|
382
|
+
if (typeof value === "string") {
|
|
383
|
+
for (const m of value.matchAll(TEMPLATE_REGEX)) {
|
|
384
|
+
out.push({ expr: m[1].trim(), path });
|
|
385
|
+
}
|
|
386
|
+
return out;
|
|
387
|
+
}
|
|
388
|
+
if (Array.isArray(value)) {
|
|
389
|
+
for (const [i, v] of value.entries()) {
|
|
390
|
+
out.push(...collectCelStrings(v, `${path}[${i}]`));
|
|
391
|
+
}
|
|
392
|
+
return out;
|
|
393
|
+
}
|
|
394
|
+
if (value !== null && typeof value === "object") {
|
|
395
|
+
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
396
|
+
out.push(...collectCelStrings(v, path ? `${path}.${k}` : k));
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return out;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function checkCelChainAgainstDataSchema(
|
|
403
|
+
entry: CelString,
|
|
404
|
+
dataSchema: Record<string, any>,
|
|
405
|
+
resource: { kind: string; name: string },
|
|
406
|
+
filePath: string | undefined,
|
|
407
|
+
env: Environment,
|
|
408
|
+
): AnalysisDiagnostic[] {
|
|
409
|
+
let ast: ASTNode;
|
|
410
|
+
try {
|
|
411
|
+
ast = env.parse(entry.expr).ast;
|
|
412
|
+
} catch {
|
|
413
|
+
return [];
|
|
414
|
+
}
|
|
415
|
+
const chains = extractAccessChains(ast);
|
|
416
|
+
const diagnostics: AnalysisDiagnostic[] = [];
|
|
417
|
+
for (const chain of chains) {
|
|
418
|
+
// Only interested in chains that start with error.data.*
|
|
419
|
+
if (chain[0] !== "error" || chain[1] !== "data" || chain.length <= 2) continue;
|
|
420
|
+
const subChain = chain.slice(2); // strip "error", "data"
|
|
421
|
+
const err = validateChainAgainstSchema(subChain, dataSchema);
|
|
422
|
+
if (err) {
|
|
423
|
+
diagnostics.push({
|
|
424
|
+
severity: DiagnosticSeverity.Error,
|
|
425
|
+
code: "CEL_UNKNOWN_FIELD",
|
|
426
|
+
source: SOURCE,
|
|
427
|
+
message: `${resource.kind}/${resource.name}: CEL at '${entry.path}': error.data.${err}`,
|
|
428
|
+
data: { resource, filePath, path: entry.path },
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
return diagnostics;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** Rule 8 extension: `inherit: true` only makes sense on a definition whose
|
|
436
|
+
* schema contains at least one `x-telo-step-context` array — the annotation
|
|
437
|
+
* that drives the resolver's generic step traversal. A definition with
|
|
438
|
+
* `inherit: true` and no such array has no invocables to inherit from. */
|
|
439
|
+
function validateThrowsDeclarations(manifests: ResourceManifest[]): AnalysisDiagnostic[] {
|
|
440
|
+
const diagnostics: AnalysisDiagnostic[] = [];
|
|
441
|
+
for (const m of manifests) {
|
|
442
|
+
if (m.kind !== "Telo.Definition") continue;
|
|
443
|
+
const throws = (m as Record<string, any>).throws;
|
|
444
|
+
if (!throws) continue;
|
|
445
|
+
const name = (m.metadata?.name as string | undefined) ?? "<unnamed>";
|
|
446
|
+
const filePath = (m.metadata as { source?: string } | undefined)?.source;
|
|
447
|
+
if (throws.inherit === true) {
|
|
448
|
+
const schema = (m as Record<string, any>).schema as Record<string, any> | undefined;
|
|
449
|
+
if (!schemaHasStepContext(schema)) {
|
|
450
|
+
diagnostics.push({
|
|
451
|
+
severity: DiagnosticSeverity.Error,
|
|
452
|
+
code: "INHERIT_WITHOUT_STEP_CONTEXT",
|
|
453
|
+
source: SOURCE,
|
|
454
|
+
message: `Telo.Definition '${name}' declares throws.inherit: true but its schema has no field annotated with x-telo-step-context. inherit is only meaningful on definitions that drive invocables via step arrays.`,
|
|
455
|
+
data: { resource: { kind: m.kind, name }, filePath, path: "throws.inherit" },
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
return diagnostics;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function schemaHasStepContext(schema: Record<string, any> | undefined): boolean {
|
|
464
|
+
if (!schema || typeof schema !== "object") return false;
|
|
465
|
+
if ("x-telo-step-context" in schema) return true;
|
|
466
|
+
const props = schema.properties;
|
|
467
|
+
if (props && typeof props === "object") {
|
|
468
|
+
for (const v of Object.values(props as Record<string, any>)) {
|
|
469
|
+
if (schemaHasStepContext(v)) return true;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
if (schema.items && schemaHasStepContext(schema.items)) return true;
|
|
473
|
+
for (const key of ["oneOf", "anyOf", "allOf"] as const) {
|
|
474
|
+
const arr = schema[key];
|
|
475
|
+
if (Array.isArray(arr)) {
|
|
476
|
+
for (const sub of arr) if (schemaHasStepContext(sub)) return true;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
if (schema.$defs && typeof schema.$defs === "object") {
|
|
480
|
+
for (const v of Object.values(schema.$defs as Record<string, any>)) {
|
|
481
|
+
if (schemaHasStepContext(v)) return true;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
return false;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** Entry point — invoked once per analyze() run. */
|
|
488
|
+
export function validateThrowsCoverage(
|
|
489
|
+
manifests: ResourceManifest[],
|
|
490
|
+
defs: DefinitionRegistry,
|
|
491
|
+
aliases: AliasResolver,
|
|
492
|
+
env: Environment,
|
|
493
|
+
): AnalysisDiagnostic[] {
|
|
494
|
+
const diagnostics: AnalysisDiagnostic[] = [];
|
|
495
|
+
diagnostics.push(...validateThrowsDeclarations(manifests));
|
|
496
|
+
|
|
497
|
+
const resolveCtx = createResolveCtx(manifests, defs, aliases);
|
|
498
|
+
|
|
499
|
+
for (const manifest of manifests) {
|
|
500
|
+
if (!manifest.kind || !manifest.metadata?.name) continue;
|
|
501
|
+
if (manifest.kind === "Telo.Definition" || manifest.kind === "Telo.Abstract") continue;
|
|
502
|
+
const resolvedKind = aliases.resolveKind(manifest.kind);
|
|
503
|
+
const definition =
|
|
504
|
+
defs.resolve(manifest.kind) ?? (resolvedKind ? defs.resolve(resolvedKind) : undefined);
|
|
505
|
+
if (!definition?.schema) continue;
|
|
506
|
+
const resource = { kind: manifest.kind, name: manifest.metadata.name as string };
|
|
507
|
+
const filePath = (manifest.metadata as { source?: string } | undefined)?.source;
|
|
508
|
+
|
|
509
|
+
collectOutcomeLists(
|
|
510
|
+
manifest,
|
|
511
|
+
definition.schema,
|
|
512
|
+
(ret) => {
|
|
513
|
+
diagnostics.push(
|
|
514
|
+
...checkCatchAllPlacement(ret.entries, resource, "returns", filePath, ret.arrayPath),
|
|
515
|
+
);
|
|
516
|
+
},
|
|
517
|
+
(entries, arrayPath, siblingData, catchesFor) => {
|
|
518
|
+
diagnostics.push(
|
|
519
|
+
...checkCatchAllPlacement(entries, resource, "catches", filePath, arrayPath),
|
|
520
|
+
);
|
|
521
|
+
const handlerRef = resolveHandlerRef(siblingData[catchesFor]);
|
|
522
|
+
const union = handlerRefUnion(handlerRef, manifests, resolveCtx);
|
|
523
|
+
diagnostics.push(
|
|
524
|
+
...checkCatchesCoverage(entries, union, resource, filePath, arrayPath, env),
|
|
525
|
+
);
|
|
526
|
+
diagnostics.push(
|
|
527
|
+
...checkTypedErrorData(entries, union, resource, filePath, arrayPath, env),
|
|
528
|
+
);
|
|
529
|
+
},
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
return diagnostics;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** Resolve a handler ref's effective throw union. Prefers the named manifest
|
|
536
|
+
* (so `inherit: true` handlers expose their transitive union); falls back to
|
|
537
|
+
* the definition's own codes when no name is given. */
|
|
538
|
+
function handlerRefUnion(
|
|
539
|
+
handlerRef: { kind: string; name?: string } | null,
|
|
540
|
+
manifests: ResourceManifest[],
|
|
541
|
+
ctx: ReturnType<typeof createResolveCtx>,
|
|
542
|
+
): ThrowsUnion {
|
|
543
|
+
if (!handlerRef) return { codes: new Map(), unbounded: false };
|
|
544
|
+
if (handlerRef.name) {
|
|
545
|
+
const resolvedKind = ctx.aliases.resolveKind(handlerRef.kind);
|
|
546
|
+
const targetManifest = manifests.find(
|
|
547
|
+
(m) =>
|
|
548
|
+
m.metadata?.name === handlerRef.name &&
|
|
549
|
+
(m.kind === handlerRef.kind ||
|
|
550
|
+
m.kind === resolvedKind ||
|
|
551
|
+
ctx.aliases.resolveKind(m.kind) === handlerRef.kind),
|
|
552
|
+
);
|
|
553
|
+
if (targetManifest) return resolveThrowsUnion(targetManifest, ctx);
|
|
554
|
+
}
|
|
555
|
+
const resolved = ctx.aliases.resolveKind(handlerRef.kind);
|
|
556
|
+
const def =
|
|
557
|
+
ctx.defs.resolve(handlerRef.kind) ?? (resolved ? ctx.defs.resolve(resolved) : undefined);
|
|
558
|
+
if (!def?.throws) return { codes: new Map(), unbounded: false };
|
|
559
|
+
const codes = new Map<string, ThrowsCodeMeta>();
|
|
560
|
+
for (const [c, meta] of Object.entries(def.throws.codes ?? {})) {
|
|
561
|
+
codes.set(c, { data: (meta as { data?: Record<string, any> }).data });
|
|
562
|
+
}
|
|
563
|
+
const unbounded = def.throws.passthrough === true || def.throws.inherit === true;
|
|
564
|
+
return { codes, unbounded };
|
|
565
|
+
}
|