@telorun/analyzer 0.12.1 → 1.0.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.
Files changed (51) hide show
  1. package/dist/analysis-registry.d.ts +13 -0
  2. package/dist/analysis-registry.d.ts.map +1 -1
  3. package/dist/analysis-registry.js +15 -0
  4. package/dist/analyzer.d.ts.map +1 -1
  5. package/dist/analyzer.js +154 -83
  6. package/dist/builtins.d.ts.map +1 -1
  7. package/dist/builtins.js +85 -0
  8. package/dist/cel-environment.d.ts +1 -1
  9. package/dist/cel-environment.d.ts.map +1 -1
  10. package/dist/cel-environment.js +40 -2
  11. package/dist/dependency-graph.d.ts.map +1 -1
  12. package/dist/dependency-graph.js +41 -62
  13. package/dist/index.d.ts +2 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +1 -0
  16. package/dist/kernel-globals.d.ts +1 -1
  17. package/dist/kernel-globals.d.ts.map +1 -1
  18. package/dist/kernel-globals.js +19 -1
  19. package/dist/manifest-visitor.d.ts +124 -0
  20. package/dist/manifest-visitor.d.ts.map +1 -0
  21. package/dist/manifest-visitor.js +181 -0
  22. package/dist/reference-field-map.js +16 -0
  23. package/dist/resolve-throws-union.d.ts +10 -0
  24. package/dist/resolve-throws-union.d.ts.map +1 -1
  25. package/dist/resolve-throws-union.js +35 -7
  26. package/dist/schema-compat.d.ts +10 -0
  27. package/dist/schema-compat.d.ts.map +1 -1
  28. package/dist/schema-compat.js +32 -0
  29. package/dist/validate-cel-context.d.ts +14 -0
  30. package/dist/validate-cel-context.d.ts.map +1 -1
  31. package/dist/validate-cel-context.js +38 -0
  32. package/dist/validate-references.d.ts.map +1 -1
  33. package/dist/validate-references.js +124 -160
  34. package/dist/validate-unused-declarations.d.ts +25 -0
  35. package/dist/validate-unused-declarations.d.ts.map +1 -0
  36. package/dist/validate-unused-declarations.js +91 -0
  37. package/package.json +3 -3
  38. package/src/analysis-registry.ts +20 -0
  39. package/src/analyzer.ts +256 -168
  40. package/src/builtins.ts +85 -0
  41. package/src/cel-environment.ts +42 -1
  42. package/src/dependency-graph.ts +37 -52
  43. package/src/index.ts +11 -0
  44. package/src/kernel-globals.ts +22 -1
  45. package/src/manifest-visitor.ts +340 -0
  46. package/src/reference-field-map.ts +14 -0
  47. package/src/resolve-throws-union.ts +36 -8
  48. package/src/schema-compat.ts +32 -0
  49. package/src/validate-cel-context.ts +50 -0
  50. package/src/validate-references.ts +175 -211
  51. package/src/validate-unused-declarations.ts +95 -0
@@ -1,4 +1,5 @@
1
1
  import type { ResourceDefinition, ResourceManifest } from "@telorun/sdk";
2
+ import { isTaggedSentinel } from "@telorun/templating";
2
3
  import type { AliasResolver } from "./alias-resolver.js";
3
4
  import type { DefinitionRegistry } from "./definition-registry.js";
4
5
 
@@ -6,6 +7,11 @@ export interface ThrowsCodeMeta {
6
7
  data?: Record<string, any>;
7
8
  }
8
9
 
10
+ /** Code a non-`InvokeError` failure surfaces as inside a `catch` block. Mirrors
11
+ * `PLAIN_ERROR_CODE` in `@telorun/run`'s `toSequenceError`: any invoke can throw
12
+ * a plain error, which the catch sees as `error.code === "INTERNAL_ERROR"`. */
13
+ export const PLAIN_ERROR_CODE = "INTERNAL_ERROR";
14
+
9
15
  export interface ThrowsUnion {
10
16
  /** Code → per-code metadata (data schema, etc). Keys are the declared codes. */
11
17
  codes: Map<string, ThrowsCodeMeta>;
@@ -14,6 +20,12 @@ export interface ThrowsUnion {
14
20
  * an unknown kind was encountered, or a cycle short-circuited resolution.
15
21
  * Callers must treat unbounded unions as requiring a catch-all entry. */
16
22
  unbounded: boolean;
23
+ /** True when the block can fail with a non-`InvokeError` (any `invoke:` step).
24
+ * Such a failure surfaces inside an enclosing `catch` as `PLAIN_ERROR_CODE`,
25
+ * so a `throw: { code: "${{ error.code }}" }` rethrow can propagate it. Not
26
+ * injected into `codes` — only seeds `enclosingTryCodes` at a try/catch site,
27
+ * leaving non-rethrow unions untouched. */
28
+ canThrowPlain?: boolean;
17
29
  }
18
30
 
19
31
  export interface ResolveCtx {
@@ -47,6 +59,7 @@ function unionInto(target: ThrowsUnion, src: ThrowsUnion): void {
47
59
  if (!target.codes.has(code)) target.codes.set(code, meta);
48
60
  }
49
61
  if (src.unbounded) target.unbounded = true;
62
+ if (src.canThrowPlain) target.canThrowPlain = true;
50
63
  }
51
64
 
52
65
  function definitionFor(
@@ -173,7 +186,11 @@ function collectStepThrows(
173
186
  ctx: ResolveCtx,
174
187
  ): ThrowsUnion {
175
188
  if (step[invokeField]) {
176
- return resolveStepInvokeThrows(step, invokeField, enclosingTryCodes, ctx);
189
+ // Any invoked resource can throw a non-InvokeError at runtime, which an
190
+ // enclosing catch surfaces as PLAIN_ERROR_CODE — record that possibility.
191
+ const u = cloneUnion(resolveStepInvokeThrows(step, invokeField, enclosingTryCodes, ctx));
192
+ u.canThrowPlain = true;
193
+ return u;
177
194
  }
178
195
 
179
196
  if (step.throw && typeof step.throw === "object") {
@@ -188,6 +205,10 @@ function collectStepThrows(
188
205
  // out instead. Sequence-specific subtraction — the plan explicitly
189
206
  // anchors this to Run.Sequence's try/catch schema shape.
190
207
  const tryCodes = new Set(tryUnion.codes.keys());
208
+ // A plain (non-InvokeError) failure in the try block reaches the catch as
209
+ // `error.code === PLAIN_ERROR_CODE`, so a `throw: { code: error.code }`
210
+ // rethrow can propagate it — seed the set the catch resolves against.
211
+ if (tryUnion.canThrowPlain) tryCodes.add(PLAIN_ERROR_CODE);
191
212
  propagated = collectStepArrayThrows(step.catch, invokeField, tryCodes, ctx);
192
213
  // Unbounded in the try block still signals the caller to expect
193
214
  // arbitrary codes to flow through the catch (e.g. via passthrough).
@@ -247,6 +268,7 @@ function cloneUnion(u: ThrowsUnion): ThrowsUnion {
247
268
  const out = emptyUnion();
248
269
  for (const [c, m] of u.codes) out.codes.set(c, m);
249
270
  out.unbounded = u.unbounded;
271
+ if (u.canThrowPlain) out.canThrowPlain = true;
250
272
  return out;
251
273
  }
252
274
 
@@ -316,16 +338,22 @@ function resolveCodeExpression(
316
338
  codeInput: unknown,
317
339
  enclosingTryCodes: Set<string> | undefined,
318
340
  ): ThrowsUnion {
319
- if (typeof codeInput !== "string" || codeInput.length === 0) {
341
+ // A `!cel`-tagged sentinel and a `${{ … }}` string must resolve identically
342
+ // normalize both to the inner CEL expression (or a bare literal code).
343
+ let expr: string;
344
+ if (isTaggedSentinel(codeInput)) {
345
+ if (codeInput.engine !== "cel") return { codes: new Map(), unbounded: true };
346
+ expr = codeInput.source.trim();
347
+ } else if (typeof codeInput === "string" && codeInput.length > 0) {
348
+ const match = codeInput.match(/^\s*\$\{\{\s*([\s\S]+?)\s*\}\}\s*$/);
349
+ if (!match) {
350
+ return { codes: new Map([[codeInput, {}]]), unbounded: false };
351
+ }
352
+ expr = match[1].trim();
353
+ } else {
320
354
  return { codes: new Map(), unbounded: true };
321
355
  }
322
356
 
323
- const match = codeInput.match(/^\s*\$\{\{\s*([\s\S]+?)\s*\}\}\s*$/);
324
- if (!match) {
325
- return { codes: new Map([[codeInput, {}]]), unbounded: false };
326
- }
327
-
328
- const expr = match[1].trim();
329
357
  const litMatch = expr.match(/^'([^']+)'$|^"([^"]+)"$/);
330
358
  if (litMatch) {
331
359
  const code = litMatch[1] ?? litMatch[2]!;
@@ -204,9 +204,29 @@ export function navigateSchemaToExprPath(
204
204
  return current;
205
205
  }
206
206
 
207
+ /**
208
+ * Recognized `x-telo-type` value brands and the CEL primitive each refines.
209
+ * A brand is a nominal type the analyzer registers (see cel-environment.ts) so
210
+ * structurally-identical values (a `TcpPort` and a `UdpPort` are both integers)
211
+ * stay distinct for static wiring checks. Brands carry no runtime effect — the
212
+ * value flows as its base type. Add new brands here (e.g. `Url: "string"`).
213
+ */
214
+ export const VALUE_BRAND_BASE: Record<string, string> = {
215
+ TcpPort: "int",
216
+ UdpPort: "int",
217
+ };
218
+
219
+ /** Read a recognized `x-telo-type` brand off a schema, or undefined. */
220
+ export function brandOfSchema(schema: Record<string, any> | undefined): string | undefined {
221
+ const brand = schema?.["x-telo-type"];
222
+ return typeof brand === "string" && brand in VALUE_BRAND_BASE ? brand : undefined;
223
+ }
224
+
207
225
  /** Map a JSON Schema type annotation to a CEL type string. */
208
226
  export function jsonSchemaToCelType(schema: Record<string, any> | undefined): string {
209
227
  if (!schema || typeof schema !== "object") return "dyn";
228
+ const brand = brandOfSchema(schema);
229
+ if (brand) return brand;
210
230
  if (schema.anyOf || schema.oneOf || schema.allOf) return "dyn";
211
231
  if (Array.isArray(schema.type)) return "dyn";
212
232
  switch (schema.type) {
@@ -233,6 +253,18 @@ export function jsonSchemaToCelType(schema: Record<string, any> | undefined): st
233
253
  /** Check whether a CEL return type is compatible with a JSON Schema type constraint. */
234
254
  export function celTypeSatisfiesJsonSchema(celType: string, schema: Record<string, any>): boolean {
235
255
  if (celType === "dyn") return true;
256
+ // Nominal value brands: when the expression's type is a recognized brand,
257
+ // a branded consuming field must match exactly (a UdpPort wired into a
258
+ // TcpPort-branded field is the error we want). An unbranded field accepts
259
+ // the brand as its base type — gradual typing, so a TcpPort flows freely
260
+ // into a plain integer field. (A plain integer into a branded field is also
261
+ // allowed: only a *conflicting* brand is rejected.)
262
+ const sourceBase = VALUE_BRAND_BASE[celType];
263
+ if (sourceBase) {
264
+ const fieldBrand = brandOfSchema(schema);
265
+ if (fieldBrand) return fieldBrand === celType;
266
+ celType = sourceBase;
267
+ }
236
268
  if (!schema.type && !schema.anyOf && !schema.oneOf && !schema.allOf) return true;
237
269
  if (schema.anyOf || schema.oneOf || schema.allOf) return true;
238
270
  const schemaTypes = Array.isArray(schema.type) ? schema.type : [schema.type];
@@ -266,3 +266,53 @@ function navigatePath(obj: unknown, segments: string[]): unknown {
266
266
  }
267
267
  return cur;
268
268
  }
269
+
270
+ /**
271
+ * Walk a JSON Schema tree and collect all `x-telo-context` annotations,
272
+ * returning them as `{ scope, schema }` pairs using JSONPath-style scopes —
273
+ * the same format the analyzer uses for CEL context validation.
274
+ *
275
+ * Result is sorted by scope specificity (longer scope first) so that the
276
+ * per-expression resolver's first-match-wins logic picks the most-specific
277
+ * context. Without this, a broader ancestor scope (e.g. `$.resources[*]`)
278
+ * could shadow a narrower descendant scope whose activation differs.
279
+ */
280
+ export function extractContextsFromSchema(
281
+ schema: Record<string, any>,
282
+ path = "$",
283
+ ): Array<{ scope: string; schema: Record<string, any> }> {
284
+ const all = collectContexts(schema, path);
285
+ return all.sort((a, b) => b.scope.length - a.scope.length);
286
+ }
287
+
288
+ function collectContexts(
289
+ schema: Record<string, any>,
290
+ path: string,
291
+ ): Array<{ scope: string; schema: Record<string, any> }> {
292
+ if (!schema || typeof schema !== "object") return [];
293
+ const results: Array<{ scope: string; schema: Record<string, any> }> = [];
294
+
295
+ if (schema["x-telo-context"]) {
296
+ results.push({ scope: path, schema: schema["x-telo-context"] });
297
+ }
298
+
299
+ if (schema.properties) {
300
+ for (const [key, value] of Object.entries(schema.properties as Record<string, any>)) {
301
+ results.push(...collectContexts(value, `${path}.${key}`));
302
+ }
303
+ }
304
+
305
+ if (schema.items && typeof schema.items === "object") {
306
+ results.push(...collectContexts(schema.items, `${path}[*]`));
307
+ }
308
+
309
+ for (const key of ["oneOf", "anyOf", "allOf"] as const) {
310
+ if (Array.isArray(schema[key])) {
311
+ for (const subschema of schema[key]) {
312
+ results.push(...collectContexts(subschema, path));
313
+ }
314
+ }
315
+ }
316
+
317
+ return results;
318
+ }