@telorun/analyzer 0.13.0 → 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.
@@ -33,6 +33,7 @@ export declare class AnalysisRegistry {
33
33
  visitManifest(resources: ResourceManifest[], visitor: ManifestVisitor, opts?: {
34
34
  skipKinds?: ReadonlySet<string>;
35
35
  expand?: boolean;
36
+ discoverNestedRefs?: boolean;
36
37
  }): void;
37
38
  /**
38
39
  * Returns the built-in kernel definitions. The underlying DefinitionRegistry already
@@ -1 +1 @@
1
- {"version":3,"file":"analysis-registry.d.ts","sourceRoot":"","sources":["../src/analysis-registry.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAKzE,OAAO,EAAqC,KAAK,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAEhG,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAElD;;;;GAIG;AACH,qBAAa,gBAAgB;IAC3B,OAAO,CAAC,QAAQ,CAAC,IAAI,CAA4B;IACjD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAuB;IAC/C,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAoC;IAEpE,kBAAkB,CAAC,GAAG,EAAE,kBAAkB,GAAG,IAAI;IAIjD,sBAAsB,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI;IAIpE,cAAc,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,IAAI;IAIpE,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS;IAI7C;;;;;;;OAOG;IACH,mBAAmB,CACjB,QAAQ,EAAE,gBAAgB,EAC1B,KAAK,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,IAAI,EAClC,OAAO,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,IAAI,GACnC,IAAI;IAkBP;;;;;;OAMG;IACH,aAAa,CACX,SAAS,EAAE,gBAAgB,EAAE,EAC7B,OAAO,EAAE,eAAe,EACxB,IAAI,CAAC,EAAE;QAAE,SAAS,CAAC,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;QAAC,MAAM,CAAC,EAAE,OAAO,CAAA;KAAE,GAC3D,IAAI;IAQP;;;;OAIG;IACH,kBAAkB,IAAI,kBAAkB,EAAE;IAI1C,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,kBAAkB,GAAG,SAAS;IAM/D,QAAQ,IAAI,MAAM,EAAE;IAIpB;mEAC+D;IAC/D,UAAU,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,EAAE;IAIxC;;;gCAG4B;IAC5B,oBAAoB,IAAI,MAAM,EAAE;IAIhC;wEACoE;IACpE,WAAW,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS;IAIhD;;;;;gEAK4D;IAC5D,qBAAqB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,SAAS;IA+B7D,qFAAqF;IACrF,QAAQ,IAAI,eAAe;CAG5B"}
1
+ {"version":3,"file":"analysis-registry.d.ts","sourceRoot":"","sources":["../src/analysis-registry.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAKzE,OAAO,EAAqC,KAAK,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAEhG,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAElD;;;;GAIG;AACH,qBAAa,gBAAgB;IAC3B,OAAO,CAAC,QAAQ,CAAC,IAAI,CAA4B;IACjD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAuB;IAC/C,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAoC;IAEpE,kBAAkB,CAAC,GAAG,EAAE,kBAAkB,GAAG,IAAI;IAIjD,sBAAsB,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI;IAIpE,cAAc,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,IAAI;IAIpE,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS;IAI7C;;;;;;;OAOG;IACH,mBAAmB,CACjB,QAAQ,EAAE,gBAAgB,EAC1B,KAAK,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,IAAI,EAClC,OAAO,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,IAAI,GACnC,IAAI;IAkBP;;;;;;OAMG;IACH,aAAa,CACX,SAAS,EAAE,gBAAgB,EAAE,EAC7B,OAAO,EAAE,eAAe,EACxB,IAAI,CAAC,EAAE;QAAE,SAAS,CAAC,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;QAAC,MAAM,CAAC,EAAE,OAAO,CAAC;QAAC,kBAAkB,CAAC,EAAE,OAAO,CAAA;KAAE,GACzF,IAAI;IAQP;;;;OAIG;IACH,kBAAkB,IAAI,kBAAkB,EAAE;IAI1C,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,kBAAkB,GAAG,SAAS;IAM/D,QAAQ,IAAI,MAAM,EAAE;IAIpB;mEAC+D;IAC/D,UAAU,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,EAAE;IAIxC;;;gCAG4B;IAC5B,oBAAoB,IAAI,MAAM,EAAE;IAIhC;wEACoE;IACpE,WAAW,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS;IAIhD;;;;;gEAK4D;IAC5D,qBAAqB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,SAAS;IA+B7D,qFAAqF;IACrF,QAAQ,IAAI,eAAe;CAG5B"}
@@ -1 +1 @@
1
- {"version":3,"file":"analyzer.d.ts","sourceRoot":"","sources":["../src/analyzer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAsB,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAIzE,OAAO,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAC1D,OAAO,EAGL,KAAK,WAAW,EACjB,MAAM,sBAAsB,CAAC;AAiB9B,OAAO,EAAsB,KAAK,kBAAkB,EAAE,KAAK,eAAe,EAAE,MAAM,YAAY,CAAC;AA+c/F,MAAM,WAAW,qBAAqB;IACpC,WAAW,CAAC,EAAE,WAAW,CAAC;CAC3B;AAED,qBAAa,cAAc;IACzB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAc;gBAEzB,OAAO,GAAE,qBAA0B;IAI/C;;;;;;;;;;;;;;OAcG;IACH,OAAO,CACL,SAAS,EAAE,gBAAgB,EAAE,EAC7B,OAAO,CAAC,EAAE,eAAe,EACzB,QAAQ,CAAC,EAAE,gBAAgB,GAC1B,kBAAkB,EAAE;IAyjBvB,aAAa,CACX,SAAS,EAAE,gBAAgB,EAAE,EAC7B,OAAO,CAAC,EAAE,eAAe,EACzB,QAAQ,CAAC,EAAE,gBAAgB,GAC1B,kBAAkB,EAAE;IAMvB,SAAS,CAAC,SAAS,EAAE,gBAAgB,EAAE,EAAE,QAAQ,EAAE,gBAAgB,GAAG,gBAAgB,EAAE;IAexF,OAAO,CACL,SAAS,EAAE,gBAAgB,EAAE,EAC7B,QAAQ,EAAE,gBAAgB,GACzB;QAAE,WAAW,EAAE,kBAAkB,EAAE,CAAC;QAAC,KAAK,EAAE,MAAM,EAAE,GAAG,IAAI,CAAC;QAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE;CAsB5F"}
1
+ {"version":3,"file":"analyzer.d.ts","sourceRoot":"","sources":["../src/analyzer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAsB,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAIzE,OAAO,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAC1D,OAAO,EAGL,KAAK,WAAW,EACjB,MAAM,sBAAsB,CAAC;AAiB9B,OAAO,EAAsB,KAAK,kBAAkB,EAAE,KAAK,eAAe,EAAE,MAAM,YAAY,CAAC;AAuhB/F,MAAM,WAAW,qBAAqB;IACpC,WAAW,CAAC,EAAE,WAAW,CAAC;CAC3B;AAED,qBAAa,cAAc;IACzB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAc;gBAEzB,OAAO,GAAE,qBAA0B;IAI/C;;;;;;;;;;;;;;OAcG;IACH,OAAO,CACL,SAAS,EAAE,gBAAgB,EAAE,EAC7B,OAAO,CAAC,EAAE,eAAe,EACzB,QAAQ,CAAC,EAAE,gBAAgB,GAC1B,kBAAkB,EAAE;IAqlBvB,aAAa,CACX,SAAS,EAAE,gBAAgB,EAAE,EAC7B,OAAO,CAAC,EAAE,eAAe,EACzB,QAAQ,CAAC,EAAE,gBAAgB,GAC1B,kBAAkB,EAAE;IAMvB,SAAS,CAAC,SAAS,EAAE,gBAAgB,EAAE,EAAE,QAAQ,EAAE,gBAAgB,GAAG,gBAAgB,EAAE;IAexF,OAAO,CACL,SAAS,EAAE,gBAAgB,EAAE,EAC7B,QAAQ,EAAE,gBAAgB,GACzB;QAAE,WAAW,EAAE,kBAAkB,EAAE,CAAC;QAAC,KAAK,EAAE,MAAM,EAAE,GAAG,IAAI,CAAC;QAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE;CAsB5F"}
package/dist/analyzer.js CHANGED
@@ -307,6 +307,73 @@ function buildStepContextSchema(manifest, defSchema, allManifests, defs, aliases
307
307
  }
308
308
  return undefined;
309
309
  }
310
+ /**
311
+ * Collect every field annotated with `x-telo-error-context` anywhere in a
312
+ * definition schema (resolving local `$ref`s into `$defs`, cycle-safe), mapping
313
+ * the annotated field name to its declared error-shape schema. The field name
314
+ * is matched against CEL paths so the context applies at any nesting depth under
315
+ * that field — e.g. `error` inside a `catch:` nested inside another `try:`. No
316
+ * specific field name (or `Run.Sequence`) is hardcoded; any composer that tags
317
+ * its error-bearing branch fields opts in the same way.
318
+ */
319
+ function collectErrorContextScopes(defSchema) {
320
+ const out = new Map();
321
+ if (!defSchema || typeof defSchema !== "object")
322
+ return out;
323
+ const seen = new Set();
324
+ const walk = (schema) => {
325
+ if (!schema || typeof schema !== "object" || seen.has(schema))
326
+ return;
327
+ seen.add(schema);
328
+ const props = schema.properties;
329
+ if (props) {
330
+ for (const [fieldName, fieldSchema] of Object.entries(props)) {
331
+ if (fieldSchema && typeof fieldSchema === "object") {
332
+ const errCtx = fieldSchema["x-telo-error-context"];
333
+ if (errCtx && typeof errCtx === "object" && !out.has(fieldName)) {
334
+ out.set(fieldName, errCtx);
335
+ }
336
+ }
337
+ walk(resolveLocalRef(fieldSchema, defSchema));
338
+ }
339
+ }
340
+ if (schema.items)
341
+ walk(resolveLocalRef(schema.items, defSchema));
342
+ for (const key of ["oneOf", "anyOf", "allOf"]) {
343
+ const arr = schema[key];
344
+ if (Array.isArray(arr))
345
+ for (const sub of arr)
346
+ walk(resolveLocalRef(sub, defSchema));
347
+ }
348
+ if (schema.$defs && typeof schema.$defs === "object") {
349
+ for (const sub of Object.values(schema.$defs)) {
350
+ walk(sub);
351
+ }
352
+ }
353
+ };
354
+ walk(defSchema);
355
+ return out;
356
+ }
357
+ /**
358
+ * Return the error-context schema for a CEL `path` when the path lies within
359
+ * (any depth under) one of the error-bearing fields, else undefined. A path is
360
+ * "within" field `f` when it contains a segment `f[<index>]`. When multiple
361
+ * error-bearing fields match (e.g. a `finally` nested inside a `catch`), the
362
+ * deepest — the one whose segment appears latest in the path — wins, so the
363
+ * innermost branch's schema governs.
364
+ */
365
+ function errorContextForPath(path, scopes) {
366
+ let best;
367
+ for (const [fieldName, schema] of scopes) {
368
+ const escaped = fieldName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
369
+ for (const match of path.matchAll(new RegExp(`(^|\\.)${escaped}\\[\\d+\\]`, "g"))) {
370
+ if (best === undefined || match.index > best.index) {
371
+ best = { index: match.index, schema };
372
+ }
373
+ }
374
+ }
375
+ return best?.schema;
376
+ }
310
377
  const CEL_PURE_RE = /^\s*\$\{\{[^}]*\}\}\s*$/;
311
378
  const CEL_EXPR_RE = /\$\{\{\s*([^}]+?)\s*\}\}/;
312
379
  /** Recursively walk `data`+`schema` together, type-checking every pure CEL template
@@ -784,6 +851,7 @@ export class StaticAnalyzer {
784
851
  // context — which require analyzer state to build — are stashed here.
785
852
  let celStepContextSchema;
786
853
  let celInvocationContext;
854
+ let celErrorScopes = new Map();
787
855
  visitManifest(allManifests, defs, {
788
856
  onResourceEnter: (e) => {
789
857
  const m = e.source;
@@ -791,6 +859,7 @@ export class StaticAnalyzer {
791
859
  celStepContextSchema = e.definition?.schema
792
860
  ? buildStepContextSchema(m, e.definition.schema, allManifests, defs, aliases)
793
861
  : undefined;
862
+ celErrorScopes = collectErrorContextScopes(e.definition?.schema);
794
863
  },
795
864
  onCel: (e) => {
796
865
  const m = e.source;
@@ -808,6 +877,19 @@ export class StaticAnalyzer {
808
877
  },
809
878
  };
810
879
  }
880
+ // `error` is only in scope inside an error-bearing branch (e.g. a
881
+ // `catch:` / `finally:`), so it's merged per-path, not resource-wide.
882
+ const errorSchema = celErrorScopes.size > 0 ? errorContextForPath(path, celErrorScopes) : undefined;
883
+ if (errorSchema) {
884
+ const base = matchedContext ?? { type: "object", properties: {}, additionalProperties: true };
885
+ matchedContext = {
886
+ ...base,
887
+ properties: {
888
+ ...(base.properties ?? {}),
889
+ error: errorSchema,
890
+ },
891
+ };
892
+ }
811
893
  let effectiveContext = null;
812
894
  if (matchedContext) {
813
895
  const manifestItem = matchedScope
@@ -855,6 +937,15 @@ export class StaticAnalyzer {
855
937
  data: { resource, filePath, path },
856
938
  });
857
939
  }
940
+ else if (f.code === "CEL_NULLABLE_ACCESS") {
941
+ diagnostics.push({
942
+ severity: DiagnosticSeverity.Error,
943
+ code: "CEL_NULLABLE_ACCESS",
944
+ source: SOURCE,
945
+ message: `${m.kind}/${resource.name}: CEL at '${path}': ${f.message}`,
946
+ data: { resource, filePath, path },
947
+ });
948
+ }
858
949
  else {
859
950
  // Unknown code from a future engine — pass the message through,
860
951
  // tagged with a generic ENGINE_DIAGNOSTIC code so downstream
@@ -1 +1 @@
1
- {"version":3,"file":"builtins.d.ts","sourceRoot":"","sources":["../src/builtins.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAEvD,eAAO,MAAM,eAAe,EAAE,kBAAkB,EA0V/C,CAAC"}
1
+ {"version":3,"file":"builtins.d.ts","sourceRoot":"","sources":["../src/builtins.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAEvD,eAAO,MAAM,eAAe,EAAE,kBAAkB,EAsZ/C,CAAC"}
package/dist/builtins.js CHANGED
@@ -232,6 +232,66 @@ export const KERNEL_BUILTINS = [
232
232
  },
233
233
  additionalProperties: true,
234
234
  },
235
+ // Gated reference: run() a Runnable/Service only when the
236
+ // `when` CEL guard holds. Discriminated by the `ref` key. `ref`
237
+ // is a bare name or a resolved `!ref` (`{ kind, name }`).
238
+ {
239
+ type: "object",
240
+ required: ["ref"],
241
+ properties: {
242
+ ref: {
243
+ anyOf: [
244
+ { type: "string", "x-telo-ref": "telo#Runnable" },
245
+ { type: "string", "x-telo-ref": "telo#Service" },
246
+ {
247
+ type: "object",
248
+ required: ["kind", "name"],
249
+ properties: {
250
+ kind: { type: "string" },
251
+ name: { type: "string" },
252
+ },
253
+ additionalProperties: true,
254
+ },
255
+ ],
256
+ },
257
+ when: { type: "string" },
258
+ },
259
+ additionalProperties: false,
260
+ },
261
+ // Inline flat invoke step: invoke an Invocable / Runnable on boot
262
+ // with an optional `name` (for steps.<name>.result plumbing),
263
+ // `when` guard, and `inputs`. Discriminated by the `invoke` key.
264
+ // Control flow (if/while/switch/try) is not available here —
265
+ // reach for Run.Sequence. `invoke` is ref-only and must resolve
266
+ // to a `{ kind, name }` reference (a `!ref` / `{kind,name}`):
267
+ // requiring `name` rejects an inline `{ kind }` definition (no
268
+ // name) at analysis instead of failing at boot with an undefined
269
+ // resource name. The Invocable/Runnable kind set mirrors
270
+ // Run.Sequence invoke steps.
271
+ {
272
+ type: "object",
273
+ required: ["invoke"],
274
+ properties: {
275
+ name: { type: "string" },
276
+ invoke: {
277
+ "x-telo-topology-role": "invoke",
278
+ type: "object",
279
+ required: ["kind", "name"],
280
+ properties: {
281
+ kind: { type: "string" },
282
+ name: { type: "string" },
283
+ },
284
+ additionalProperties: true,
285
+ anyOf: [
286
+ { "x-telo-ref": "telo#Invocable" },
287
+ { "x-telo-ref": "telo#Runnable" },
288
+ ],
289
+ },
290
+ inputs: { type: "object", additionalProperties: true },
291
+ when: { type: "string" },
292
+ },
293
+ additionalProperties: false,
294
+ },
235
295
  ],
236
296
  },
237
297
  },
@@ -65,6 +65,13 @@ export interface RefSiteEvent {
65
65
  inScope: boolean;
66
66
  /** Scope manifests visible to this ref path (non-empty only when `inScope`). */
67
67
  visibleScopeManifests: ResourceManifest[];
68
+ /** True when the site was found by value-tree scanning rather than the field
69
+ * map (only when `discoverNestedRefs` is set) — a ref nested behind a `$ref`
70
+ * the field map doesn't descend (e.g. `Run.Sequence` `steps[].invoke`).
71
+ * Nested sites carry no x-telo-ref constraint (`entry.refs` is empty) and no
72
+ * scope info; `concretePath` still points at the exact location, so consumers
73
+ * can anchor to it. */
74
+ nested?: boolean;
68
75
  }
69
76
  export interface SchemaFromSiteEvent {
70
77
  source: ResourceManifest;
@@ -104,6 +111,14 @@ export interface VisitOptions {
104
111
  * so refs nested behind `x-telo-schema-from` are surfaced. `SchemaFromSite`
105
112
  * events are always emitted from the base map regardless of this flag. */
106
113
  expand?: boolean;
114
+ /** When true, additionally discover refs by scanning each resource's value
115
+ * tree for `!ref` sentinels and `{kind, name}` reference objects — surfacing
116
+ * refs the field map never lists because they sit behind a `$ref` it doesn't
117
+ * descend (notably `Run.Sequence` step `invoke`s). Emitted as `RefSite`s with
118
+ * `nested: true`, deduped against the field-map sites by concrete path.
119
+ * Opt-in: the validators / dependency graph must NOT enable it (those refs
120
+ * are runtime-resolved, not boot dependencies). */
121
+ discoverNestedRefs?: boolean;
107
122
  }
108
123
  export declare function visitManifest(resources: ResourceManifest[], registry: DefinitionRegistry, visitor: ManifestVisitor, options?: VisitOptions): void;
109
124
  //# sourceMappingURL=manifest-visitor.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"manifest-visitor.d.ts","sourceRoot":"","sources":["../src/manifest-visitor.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"manifest-visitor.d.ts","sourceRoot":"","sources":["../src/manifest-visitor.ts"],"names":[],"mappings":"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"}
@@ -1,16 +1,59 @@
1
- import { walkCelExpressions } from "@telorun/templating";
1
+ import { isRefSentinel, isTaggedSentinel, walkCelExpressions } from "@telorun/templating";
2
2
  import { isRefEntry, isSchemaFromEntry, isScopeEntry, resolveFieldEntries, resolveFieldValues, } from "./reference-field-map.js";
3
3
  import { extractContextsFromSchema, pathMatchesScope } from "./validate-cel-context.js";
4
+ /** Synthetic entry for a value-tree-discovered ref — these carry no declared
5
+ * x-telo-ref constraint. */
6
+ const NESTED_REF_ENTRY = { refs: [], isArray: false };
7
+ /** Scans a value tree for ref-shaped values, emitting each with its concrete
8
+ * path. Recognizes `!ref <name>` sentinels and named `{kind, name}` reference
9
+ * objects. Other tagged sentinels (`!cel`, `!literal`) and precompiled nodes
10
+ * are leaves. Path format matches `resolveFieldEntries` / `walkCelExpressions`
11
+ * (`a.b[0].c`).
12
+ *
13
+ * Stops at every `{kind, …}` resource boundary: a named ref is emitted, an
14
+ * inline resource (`{kind}` with no name) is left alone, and **neither is
15
+ * descended into**. A nested resource's own refs belong to its inner topology,
16
+ * not the enclosing node — e.g. an inline `Sql.Exec` step's `connection` is the
17
+ * Exec's dependency, not the surrounding `Run.Sequence`'s. The scan is started
18
+ * per top-level field (not on the resource object) so the resource's own
19
+ * `kind` doesn't trip this boundary. */
20
+ function walkRefValues(value, path, cb) {
21
+ if (isRefSentinel(value)) {
22
+ cb(value, path);
23
+ return;
24
+ }
25
+ if (isTaggedSentinel(value))
26
+ return;
27
+ if (Array.isArray(value)) {
28
+ value.forEach((v, i) => walkRefValues(v, `${path}[${i}]`, cb));
29
+ return;
30
+ }
31
+ if (value === null || typeof value !== "object")
32
+ return;
33
+ if (value.__compiled)
34
+ return;
35
+ const obj = value;
36
+ if (typeof obj.kind === "string") {
37
+ // Resource boundary — emit if it's a named ref, then stop descending.
38
+ if (typeof obj.name === "string")
39
+ cb(value, path);
40
+ return;
41
+ }
42
+ for (const [k, v] of Object.entries(obj)) {
43
+ walkRefValues(v, path ? `${path}.${k}` : k, cb);
44
+ }
45
+ }
4
46
  const scopePrefixOf = (pointer) => pointer.replace(/^\//, "").replace(/\//g, ".");
5
47
  const pathUnderPrefix = (fieldPath, prefix) => fieldPath === prefix ||
6
48
  fieldPath.startsWith(prefix + ".") ||
7
49
  fieldPath.startsWith(prefix + "[");
8
50
  export function visitManifest(resources, registry, visitor, options = {}) {
9
- const { aliases, aliasesByModule, skipKinds, expand } = options;
51
+ const { aliases, aliasesByModule, skipKinds, expand, discoverNestedRefs } = options;
10
52
  const wantsRefs = !!visitor.onRef;
11
53
  const wantsScope = !!visitor.onScope;
12
54
  const wantsSchemaFrom = !!visitor.onSchemaFrom;
13
55
  const wantsCel = !!visitor.onCel;
56
+ const wantsNested = wantsRefs && !!discoverNestedRefs;
14
57
  for (const r of resources) {
15
58
  if (!r.metadata?.name || !r.kind)
16
59
  continue;
@@ -20,6 +63,9 @@ export function visitManifest(resources, registry, visitor, options = {}) {
20
63
  const definition = registry.resolve(r.kind) ??
21
64
  (resolvedKind ? registry.resolve(resolvedKind) : undefined);
22
65
  visitor.onResourceEnter?.({ source: r, definition });
66
+ // Concrete paths emitted from the field map — so the value-tree scan below
67
+ // doesn't re-emit a ref the field map already covered.
68
+ const emittedRefPaths = wantsNested ? new Set() : null;
23
69
  if (wantsRefs || wantsScope || wantsSchemaFrom) {
24
70
  const baseMap = aliases
25
71
  ? registry.getFieldMapForKind(r.kind, aliases)
@@ -69,6 +115,7 @@ export function visitManifest(resources, registry, visitor, options = {}) {
69
115
  for (const { value, path: concretePath } of resolveFieldEntries(r, fieldPath)) {
70
116
  if (!value)
71
117
  continue;
118
+ emittedRefPaths?.add(concretePath);
72
119
  visitor.onRef({
73
120
  source: r,
74
121
  fieldPath,
@@ -90,6 +137,30 @@ export function visitManifest(resources, registry, visitor, options = {}) {
90
137
  }
91
138
  }
92
139
  }
140
+ // Value-tree-driven nested ref discovery — refs the field map can't reach
141
+ // because they sit behind a `$ref` it doesn't descend (e.g. Run.Sequence
142
+ // step `invoke`s). Deduped against the field-map sites by concrete path.
143
+ // Scanned per top-level field so the resource's own `kind` isn't treated as
144
+ // a resource boundary by `walkRefValues`.
145
+ if (wantsNested) {
146
+ const emitNested = (value, path) => {
147
+ if (emittedRefPaths.has(path))
148
+ return;
149
+ visitor.onRef({
150
+ source: r,
151
+ fieldPath: path,
152
+ concretePath: path,
153
+ value,
154
+ entry: NESTED_REF_ENTRY,
155
+ inScope: false,
156
+ visibleScopeManifests: [],
157
+ nested: true,
158
+ });
159
+ };
160
+ for (const [key, value] of Object.entries(r)) {
161
+ walkRefValues(value, key, emitNested);
162
+ }
163
+ }
93
164
  if (wantsCel) {
94
165
  const contexts = definition?.schema ? extractContextsFromSchema(definition.schema) : [];
95
166
  walkCelExpressions(r, "", (expr, path, engineName) => {
@@ -157,6 +157,22 @@ function traverseNode(node, path, map, root, visitedRefs = new Set()) {
157
157
  if (node["x-telo-context"])
158
158
  entry.context = node["x-telo-context"];
159
159
  map.set(path, entry);
160
+ // A node can mix item-level ref branches (a bare string / `{kind, name}`)
161
+ // with object branches that carry their OWN nested refs — e.g. Application
162
+ // `targets`: a bare ref vs inline `{ invoke }` vs gated `{ ref }`. Descend
163
+ // into the variant objects so those nested slots register too (and their
164
+ // `!ref` sentinels resolve). Pure x-telo-ref branches have no properties
165
+ // and contribute nothing here.
166
+ for (const variantKey of ["oneOf", "anyOf", "allOf"]) {
167
+ const variants = node[variantKey];
168
+ if (!Array.isArray(variants))
169
+ continue;
170
+ for (const variant of variants) {
171
+ if (!variant || typeof variant !== "object")
172
+ continue;
173
+ traverseVariant(variant, path, map, root, visitedRefs);
174
+ }
175
+ }
160
176
  return;
161
177
  }
162
178
  // Array — recurse into items
@@ -4,6 +4,10 @@ import type { DefinitionRegistry } from "./definition-registry.js";
4
4
  export interface ThrowsCodeMeta {
5
5
  data?: Record<string, any>;
6
6
  }
7
+ /** Code a non-`InvokeError` failure surfaces as inside a `catch` block. Mirrors
8
+ * `PLAIN_ERROR_CODE` in `@telorun/run`'s `toSequenceError`: any invoke can throw
9
+ * a plain error, which the catch sees as `error.code === "INTERNAL_ERROR"`. */
10
+ export declare const PLAIN_ERROR_CODE = "INTERNAL_ERROR";
7
11
  export interface ThrowsUnion {
8
12
  /** Code → per-code metadata (data schema, etc). Keys are the declared codes. */
9
13
  codes: Map<string, ThrowsCodeMeta>;
@@ -12,6 +16,12 @@ export interface ThrowsUnion {
12
16
  * an unknown kind was encountered, or a cycle short-circuited resolution.
13
17
  * Callers must treat unbounded unions as requiring a catch-all entry. */
14
18
  unbounded: boolean;
19
+ /** True when the block can fail with a non-`InvokeError` (any `invoke:` step).
20
+ * Such a failure surfaces inside an enclosing `catch` as `PLAIN_ERROR_CODE`,
21
+ * so a `throw: { code: "${{ error.code }}" }` rethrow can propagate it. Not
22
+ * injected into `codes` — only seeds `enclosingTryCodes` at a try/catch site,
23
+ * leaving non-rethrow unions untouched. */
24
+ canThrowPlain?: boolean;
15
25
  }
16
26
  export interface ResolveCtx {
17
27
  allManifests: ResourceManifest[];
@@ -1 +1 @@
1
- {"version":3,"file":"resolve-throws-union.d.ts","sourceRoot":"","sources":["../src/resolve-throws-union.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAsB,gBAAgB,EAAE,MAAM,cAAc,CAAC;AACzE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACzD,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;AAEnE,MAAM,WAAW,cAAc;IAC7B,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;CAC5B;AAED,MAAM,WAAW,WAAW;IAC1B,gFAAgF;IAChF,KAAK,EAAE,GAAG,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC;IACnC;;;8EAG0E;IAC1E,SAAS,EAAE,OAAO,CAAC;CACpB;AAED,MAAM,WAAW,UAAU;IACzB,YAAY,EAAE,gBAAgB,EAAE,CAAC;IACjC,IAAI,EAAE,kBAAkB,CAAC;IACzB,OAAO,EAAE,aAAa,CAAC;IACvB,IAAI,EAAE,GAAG,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC;IAC/B,UAAU,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;CACzB;AAED,wBAAgB,gBAAgB,CAC9B,YAAY,EAAE,gBAAgB,EAAE,EAChC,IAAI,EAAE,kBAAkB,EACxB,OAAO,EAAE,aAAa,GACrB,UAAU,CAQZ;AA+BD;;;;8CAI8C;AAC9C,wBAAgB,kBAAkB,CAChC,QAAQ,EAAE,gBAAgB,EAC1B,GAAG,EAAE,UAAU,GACd,WAAW,CA+Cb"}
1
+ {"version":3,"file":"resolve-throws-union.d.ts","sourceRoot":"","sources":["../src/resolve-throws-union.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAsB,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAEzE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACzD,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;AAEnE,MAAM,WAAW,cAAc;IAC7B,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;CAC5B;AAED;;gFAEgF;AAChF,eAAO,MAAM,gBAAgB,mBAAmB,CAAC;AAEjD,MAAM,WAAW,WAAW;IAC1B,gFAAgF;IAChF,KAAK,EAAE,GAAG,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC;IACnC;;;8EAG0E;IAC1E,SAAS,EAAE,OAAO,CAAC;IACnB;;;;gDAI4C;IAC5C,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB;AAED,MAAM,WAAW,UAAU;IACzB,YAAY,EAAE,gBAAgB,EAAE,CAAC;IACjC,IAAI,EAAE,kBAAkB,CAAC;IACzB,OAAO,EAAE,aAAa,CAAC;IACvB,IAAI,EAAE,GAAG,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC;IAC/B,UAAU,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;CACzB;AAED,wBAAgB,gBAAgB,CAC9B,YAAY,EAAE,gBAAgB,EAAE,EAChC,IAAI,EAAE,kBAAkB,EACxB,OAAO,EAAE,aAAa,GACrB,UAAU,CAQZ;AAgCD;;;;8CAI8C;AAC9C,wBAAgB,kBAAkB,CAChC,QAAQ,EAAE,gBAAgB,EAC1B,GAAG,EAAE,UAAU,GACd,WAAW,CA+Cb"}
@@ -1,3 +1,8 @@
1
+ import { isTaggedSentinel } from "@telorun/templating";
2
+ /** Code a non-`InvokeError` failure surfaces as inside a `catch` block. Mirrors
3
+ * `PLAIN_ERROR_CODE` in `@telorun/run`'s `toSequenceError`: any invoke can throw
4
+ * a plain error, which the catch sees as `error.code === "INTERNAL_ERROR"`. */
5
+ export const PLAIN_ERROR_CODE = "INTERNAL_ERROR";
1
6
  export function createResolveCtx(allManifests, defs, aliases) {
2
7
  return {
3
8
  allManifests,
@@ -17,6 +22,8 @@ function unionInto(target, src) {
17
22
  }
18
23
  if (src.unbounded)
19
24
  target.unbounded = true;
25
+ if (src.canThrowPlain)
26
+ target.canThrowPlain = true;
20
27
  }
21
28
  function definitionFor(kind, defs, aliases) {
22
29
  const resolved = aliases.resolveKind(kind);
@@ -115,7 +122,11 @@ function collectStepArrayThrows(steps, invokeField, enclosingTryCodes, ctx) {
115
122
  * composers that reuse those shape conventions work without changes here. */
116
123
  function collectStepThrows(step, invokeField, enclosingTryCodes, ctx) {
117
124
  if (step[invokeField]) {
118
- return resolveStepInvokeThrows(step, invokeField, enclosingTryCodes, ctx);
125
+ // Any invoked resource can throw a non-InvokeError at runtime, which an
126
+ // enclosing catch surfaces as PLAIN_ERROR_CODE — record that possibility.
127
+ const u = cloneUnion(resolveStepInvokeThrows(step, invokeField, enclosingTryCodes, ctx));
128
+ u.canThrowPlain = true;
129
+ return u;
119
130
  }
120
131
  if (step.throw && typeof step.throw === "object") {
121
132
  return resolveThrowStepCode(step.throw, enclosingTryCodes);
@@ -128,6 +139,11 @@ function collectStepThrows(step, invokeField, enclosingTryCodes, ctx) {
128
139
  // out instead. Sequence-specific subtraction — the plan explicitly
129
140
  // anchors this to Run.Sequence's try/catch schema shape.
130
141
  const tryCodes = new Set(tryUnion.codes.keys());
142
+ // A plain (non-InvokeError) failure in the try block reaches the catch as
143
+ // `error.code === PLAIN_ERROR_CODE`, so a `throw: { code: error.code }`
144
+ // rethrow can propagate it — seed the set the catch resolves against.
145
+ if (tryUnion.canThrowPlain)
146
+ tryCodes.add(PLAIN_ERROR_CODE);
131
147
  propagated = collectStepArrayThrows(step.catch, invokeField, tryCodes, ctx);
132
148
  // Unbounded in the try block still signals the caller to expect
133
149
  // arbitrary codes to flow through the catch (e.g. via passthrough).
@@ -179,6 +195,8 @@ function cloneUnion(u) {
179
195
  for (const [c, m] of u.codes)
180
196
  out.codes.set(c, m);
181
197
  out.unbounded = u.unbounded;
198
+ if (u.canThrowPlain)
199
+ out.canThrowPlain = true;
182
200
  return out;
183
201
  }
184
202
  function resolveStepInvokeThrows(step, invokeField, enclosingTryCodes, ctx) {
@@ -226,14 +244,24 @@ function resolveThrowStepCode(throwSpec, enclosingTryCodes) {
226
244
  return resolveCodeExpression(throwSpec.code, enclosingTryCodes);
227
245
  }
228
246
  function resolveCodeExpression(codeInput, enclosingTryCodes) {
229
- if (typeof codeInput !== "string" || codeInput.length === 0) {
230
- return { codes: new Map(), unbounded: true };
247
+ // A `!cel`-tagged sentinel and a `${{ … }}` string must resolve identically
248
+ // normalize both to the inner CEL expression (or a bare literal code).
249
+ let expr;
250
+ if (isTaggedSentinel(codeInput)) {
251
+ if (codeInput.engine !== "cel")
252
+ return { codes: new Map(), unbounded: true };
253
+ expr = codeInput.source.trim();
231
254
  }
232
- const match = codeInput.match(/^\s*\$\{\{\s*([\s\S]+?)\s*\}\}\s*$/);
233
- if (!match) {
234
- return { codes: new Map([[codeInput, {}]]), unbounded: false };
255
+ else if (typeof codeInput === "string" && codeInput.length > 0) {
256
+ const match = codeInput.match(/^\s*\$\{\{\s*([\s\S]+?)\s*\}\}\s*$/);
257
+ if (!match) {
258
+ return { codes: new Map([[codeInput, {}]]), unbounded: false };
259
+ }
260
+ expr = match[1].trim();
261
+ }
262
+ else {
263
+ return { codes: new Map(), unbounded: true };
235
264
  }
236
- const expr = match[1].trim();
237
265
  const litMatch = expr.match(/^'([^']+)'$|^"([^"]+)"$/);
238
266
  if (litMatch) {
239
267
  const code = litMatch[1] ?? litMatch[2];
@@ -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;AAMrD,OAAO,EAAsB,KAAK,kBAAkB,EAAE,KAAK,eAAe,EAAE,MAAM,YAAY,CAAC;AA4C/F;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,kBAAkB,CAChC,SAAS,EAAE,gBAAgB,EAAE,EAC7B,OAAO,EAAE,eAAe,GACvB,kBAAkB,EAAE,CA0YtB"}
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;AAMrD,OAAO,EAAsB,KAAK,kBAAkB,EAAE,KAAK,eAAe,EAAE,MAAM,YAAY,CAAC;AA4C/F;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,kBAAkB,CAChC,SAAS,EAAE,gBAAgB,EAAE,EAC7B,OAAO,EAAE,eAAe,GACvB,kBAAkB,EAAE,CAiZtB"}
@@ -232,6 +232,13 @@ export function validateReferences(resources, context) {
232
232
  // Skip inline resources — Phase 2 normalization hasn't run yet.
233
233
  if (isInlineResource(refVal))
234
234
  return;
235
+ // Polymorphic ref slots (Application `targets`) accept object forms
236
+ // whose references live in nested slots rather than being a `{kind,
237
+ // name}` ref themselves — inline `{ invoke }` and gated `{ ref }`.
238
+ // Those nested refs are validated via their own field-map entries, so
239
+ // skip the item-level structural check here.
240
+ if (typeof refVal.kind !== "string" && ("invoke" in refVal || "ref" in refVal))
241
+ return;
235
242
  // 1. Structural check
236
243
  if (typeof refVal.kind !== "string" || typeof refVal.name !== "string") {
237
244
  diagnostics.push({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telorun/analyzer",
3
- "version": "0.13.0",
3
+ "version": "1.0.0",
4
4
  "description": "Telo Analyzer - Static manifest validator for Telo manifests.",
5
5
  "keywords": [
6
6
  "telo",
@@ -42,7 +42,7 @@
42
42
  "ajv-formats": "^3.0.1",
43
43
  "jsonpath-plus": "^10.3.0",
44
44
  "yaml": "^2.8.3",
45
- "@telorun/templating": "0.3.1"
45
+ "@telorun/templating": "1.0.0"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@types/node": "^20.0.0",
@@ -50,7 +50,7 @@
50
50
  "vitest": "^2.1.8"
51
51
  },
52
52
  "peerDependencies": {
53
- "@telorun/sdk": "0.12.0"
53
+ "@telorun/sdk": "1.0.0"
54
54
  },
55
55
  "scripts": {
56
56
  "build": "tsc -p tsconfig.lib.json",
@@ -73,7 +73,7 @@ export class AnalysisRegistry {
73
73
  visitManifest(
74
74
  resources: ResourceManifest[],
75
75
  visitor: ManifestVisitor,
76
- opts?: { skipKinds?: ReadonlySet<string>; expand?: boolean },
76
+ opts?: { skipKinds?: ReadonlySet<string>; expand?: boolean; discoverNestedRefs?: boolean },
77
77
  ): void {
78
78
  runVisitManifest(resources, this.defs, visitor, {
79
79
  aliases: this.aliases,
package/src/analyzer.ts CHANGED
@@ -379,6 +379,78 @@ function buildStepContextSchema(
379
379
  return undefined;
380
380
  }
381
381
 
382
+ /**
383
+ * Collect every field annotated with `x-telo-error-context` anywhere in a
384
+ * definition schema (resolving local `$ref`s into `$defs`, cycle-safe), mapping
385
+ * the annotated field name to its declared error-shape schema. The field name
386
+ * is matched against CEL paths so the context applies at any nesting depth under
387
+ * that field — e.g. `error` inside a `catch:` nested inside another `try:`. No
388
+ * specific field name (or `Run.Sequence`) is hardcoded; any composer that tags
389
+ * its error-bearing branch fields opts in the same way.
390
+ */
391
+ function collectErrorContextScopes(
392
+ defSchema: Record<string, any> | undefined,
393
+ ): Map<string, Record<string, any>> {
394
+ const out = new Map<string, Record<string, any>>();
395
+ if (!defSchema || typeof defSchema !== "object") return out;
396
+ const seen = new Set<Record<string, any>>();
397
+
398
+ const walk = (schema: Record<string, any> | undefined): void => {
399
+ if (!schema || typeof schema !== "object" || seen.has(schema)) return;
400
+ seen.add(schema);
401
+
402
+ const props = schema.properties as Record<string, any> | undefined;
403
+ if (props) {
404
+ for (const [fieldName, fieldSchema] of Object.entries(props)) {
405
+ if (fieldSchema && typeof fieldSchema === "object") {
406
+ const errCtx = (fieldSchema as Record<string, any>)["x-telo-error-context"];
407
+ if (errCtx && typeof errCtx === "object" && !out.has(fieldName)) {
408
+ out.set(fieldName, errCtx as Record<string, any>);
409
+ }
410
+ }
411
+ walk(resolveLocalRef(fieldSchema as Record<string, any>, defSchema));
412
+ }
413
+ }
414
+ if (schema.items) walk(resolveLocalRef(schema.items as Record<string, any>, defSchema));
415
+ for (const key of ["oneOf", "anyOf", "allOf"] as const) {
416
+ const arr = schema[key];
417
+ if (Array.isArray(arr)) for (const sub of arr) walk(resolveLocalRef(sub, defSchema));
418
+ }
419
+ if (schema.$defs && typeof schema.$defs === "object") {
420
+ for (const sub of Object.values(schema.$defs as Record<string, any>)) {
421
+ walk(sub as Record<string, any>);
422
+ }
423
+ }
424
+ };
425
+
426
+ walk(defSchema);
427
+ return out;
428
+ }
429
+
430
+ /**
431
+ * Return the error-context schema for a CEL `path` when the path lies within
432
+ * (any depth under) one of the error-bearing fields, else undefined. A path is
433
+ * "within" field `f` when it contains a segment `f[<index>]`. When multiple
434
+ * error-bearing fields match (e.g. a `finally` nested inside a `catch`), the
435
+ * deepest — the one whose segment appears latest in the path — wins, so the
436
+ * innermost branch's schema governs.
437
+ */
438
+ function errorContextForPath(
439
+ path: string,
440
+ scopes: Map<string, Record<string, any>>,
441
+ ): Record<string, any> | undefined {
442
+ let best: { index: number; schema: Record<string, any> } | undefined;
443
+ for (const [fieldName, schema] of scopes) {
444
+ const escaped = fieldName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
445
+ for (const match of path.matchAll(new RegExp(`(^|\\.)${escaped}\\[\\d+\\]`, "g"))) {
446
+ if (best === undefined || match.index > best.index) {
447
+ best = { index: match.index, schema };
448
+ }
449
+ }
450
+ }
451
+ return best?.schema;
452
+ }
453
+
382
454
  const CEL_PURE_RE = /^\s*\$\{\{[^}]*\}\}\s*$/;
383
455
  const CEL_EXPR_RE = /\$\{\{\s*([^}]+?)\s*\}\}/;
384
456
 
@@ -954,6 +1026,7 @@ export class StaticAnalyzer {
954
1026
  // context — which require analyzer state to build — are stashed here.
955
1027
  let celStepContextSchema: Record<string, any> | undefined;
956
1028
  let celInvocationContext: Record<string, any> | undefined;
1029
+ let celErrorScopes: Map<string, Record<string, any>> = new Map();
957
1030
 
958
1031
  visitManifest(
959
1032
  allManifests,
@@ -973,6 +1046,9 @@ export class StaticAnalyzer {
973
1046
  aliases,
974
1047
  )
975
1048
  : undefined;
1049
+ celErrorScopes = collectErrorContextScopes(
1050
+ e.definition?.schema as Record<string, any> | undefined,
1051
+ );
976
1052
  },
977
1053
  onCel: (e) => {
978
1054
  const m = e.source;
@@ -995,6 +1071,22 @@ export class StaticAnalyzer {
995
1071
  };
996
1072
  }
997
1073
 
1074
+ // `error` is only in scope inside an error-bearing branch (e.g. a
1075
+ // `catch:` / `finally:`), so it's merged per-path, not resource-wide.
1076
+ const errorSchema =
1077
+ celErrorScopes.size > 0 ? errorContextForPath(path, celErrorScopes) : undefined;
1078
+ if (errorSchema) {
1079
+ const base =
1080
+ matchedContext ?? { type: "object", properties: {}, additionalProperties: true };
1081
+ matchedContext = {
1082
+ ...base,
1083
+ properties: {
1084
+ ...(base.properties ?? {}),
1085
+ error: errorSchema,
1086
+ },
1087
+ };
1088
+ }
1089
+
998
1090
  let effectiveContext: Record<string, any> | null = null;
999
1091
  if (matchedContext) {
1000
1092
  const manifestItem = matchedScope
@@ -1046,6 +1138,14 @@ export class StaticAnalyzer {
1046
1138
  message: `${m.kind}/${resource.name}: CEL at '${path}': ${f.message}`,
1047
1139
  data: { resource, filePath, path },
1048
1140
  });
1141
+ } else if (f.code === "CEL_NULLABLE_ACCESS") {
1142
+ diagnostics.push({
1143
+ severity: DiagnosticSeverity.Error,
1144
+ code: "CEL_NULLABLE_ACCESS",
1145
+ source: SOURCE,
1146
+ message: `${m.kind}/${resource.name}: CEL at '${path}': ${f.message}`,
1147
+ data: { resource, filePath, path },
1148
+ });
1049
1149
  } else {
1050
1150
  // Unknown code from a future engine — pass the message through,
1051
1151
  // tagged with a generic ENGINE_DIAGNOSTIC code so downstream
package/src/builtins.ts CHANGED
@@ -234,6 +234,66 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
234
234
  },
235
235
  additionalProperties: true,
236
236
  },
237
+ // Gated reference: run() a Runnable/Service only when the
238
+ // `when` CEL guard holds. Discriminated by the `ref` key. `ref`
239
+ // is a bare name or a resolved `!ref` (`{ kind, name }`).
240
+ {
241
+ type: "object",
242
+ required: ["ref"],
243
+ properties: {
244
+ ref: {
245
+ anyOf: [
246
+ { type: "string", "x-telo-ref": "telo#Runnable" },
247
+ { type: "string", "x-telo-ref": "telo#Service" },
248
+ {
249
+ type: "object",
250
+ required: ["kind", "name"],
251
+ properties: {
252
+ kind: { type: "string" },
253
+ name: { type: "string" },
254
+ },
255
+ additionalProperties: true,
256
+ },
257
+ ],
258
+ },
259
+ when: { type: "string" },
260
+ },
261
+ additionalProperties: false,
262
+ },
263
+ // Inline flat invoke step: invoke an Invocable / Runnable on boot
264
+ // with an optional `name` (for steps.<name>.result plumbing),
265
+ // `when` guard, and `inputs`. Discriminated by the `invoke` key.
266
+ // Control flow (if/while/switch/try) is not available here —
267
+ // reach for Run.Sequence. `invoke` is ref-only and must resolve
268
+ // to a `{ kind, name }` reference (a `!ref` / `{kind,name}`):
269
+ // requiring `name` rejects an inline `{ kind }` definition (no
270
+ // name) at analysis instead of failing at boot with an undefined
271
+ // resource name. The Invocable/Runnable kind set mirrors
272
+ // Run.Sequence invoke steps.
273
+ {
274
+ type: "object",
275
+ required: ["invoke"],
276
+ properties: {
277
+ name: { type: "string" },
278
+ invoke: {
279
+ "x-telo-topology-role": "invoke",
280
+ type: "object",
281
+ required: ["kind", "name"],
282
+ properties: {
283
+ kind: { type: "string" },
284
+ name: { type: "string" },
285
+ },
286
+ additionalProperties: true,
287
+ anyOf: [
288
+ { "x-telo-ref": "telo#Invocable" },
289
+ { "x-telo-ref": "telo#Runnable" },
290
+ ],
291
+ },
292
+ inputs: { type: "object", additionalProperties: true },
293
+ when: { type: "string" },
294
+ },
295
+ additionalProperties: false,
296
+ },
237
297
  ],
238
298
  },
239
299
  },
@@ -1,5 +1,5 @@
1
1
  import type { ResourceDefinition, ResourceManifest } from "@telorun/sdk";
2
- import { walkCelExpressions } from "@telorun/templating";
2
+ import { isRefSentinel, isTaggedSentinel, walkCelExpressions } from "@telorun/templating";
3
3
  import type { AliasResolver } from "./alias-resolver.js";
4
4
  import type { DefinitionRegistry } from "./definition-registry.js";
5
5
  import {
@@ -80,6 +80,13 @@ export interface RefSiteEvent {
80
80
  inScope: boolean;
81
81
  /** Scope manifests visible to this ref path (non-empty only when `inScope`). */
82
82
  visibleScopeManifests: ResourceManifest[];
83
+ /** True when the site was found by value-tree scanning rather than the field
84
+ * map (only when `discoverNestedRefs` is set) — a ref nested behind a `$ref`
85
+ * the field map doesn't descend (e.g. `Run.Sequence` `steps[].invoke`).
86
+ * Nested sites carry no x-telo-ref constraint (`entry.refs` is empty) and no
87
+ * scope info; `concretePath` still points at the exact location, so consumers
88
+ * can anchor to it. */
89
+ nested?: boolean;
83
90
  }
84
91
 
85
92
  export interface SchemaFromSiteEvent {
@@ -123,6 +130,58 @@ export interface VisitOptions {
123
130
  * so refs nested behind `x-telo-schema-from` are surfaced. `SchemaFromSite`
124
131
  * events are always emitted from the base map regardless of this flag. */
125
132
  expand?: boolean;
133
+ /** When true, additionally discover refs by scanning each resource's value
134
+ * tree for `!ref` sentinels and `{kind, name}` reference objects — surfacing
135
+ * refs the field map never lists because they sit behind a `$ref` it doesn't
136
+ * descend (notably `Run.Sequence` step `invoke`s). Emitted as `RefSite`s with
137
+ * `nested: true`, deduped against the field-map sites by concrete path.
138
+ * Opt-in: the validators / dependency graph must NOT enable it (those refs
139
+ * are runtime-resolved, not boot dependencies). */
140
+ discoverNestedRefs?: boolean;
141
+ }
142
+
143
+ /** Synthetic entry for a value-tree-discovered ref — these carry no declared
144
+ * x-telo-ref constraint. */
145
+ const NESTED_REF_ENTRY: RefFieldEntry = { refs: [], isArray: false };
146
+
147
+ /** Scans a value tree for ref-shaped values, emitting each with its concrete
148
+ * path. Recognizes `!ref <name>` sentinels and named `{kind, name}` reference
149
+ * objects. Other tagged sentinels (`!cel`, `!literal`) and precompiled nodes
150
+ * are leaves. Path format matches `resolveFieldEntries` / `walkCelExpressions`
151
+ * (`a.b[0].c`).
152
+ *
153
+ * Stops at every `{kind, …}` resource boundary: a named ref is emitted, an
154
+ * inline resource (`{kind}` with no name) is left alone, and **neither is
155
+ * descended into**. A nested resource's own refs belong to its inner topology,
156
+ * not the enclosing node — e.g. an inline `Sql.Exec` step's `connection` is the
157
+ * Exec's dependency, not the surrounding `Run.Sequence`'s. The scan is started
158
+ * per top-level field (not on the resource object) so the resource's own
159
+ * `kind` doesn't trip this boundary. */
160
+ function walkRefValues(
161
+ value: unknown,
162
+ path: string,
163
+ cb: (value: unknown, path: string) => void,
164
+ ): void {
165
+ if (isRefSentinel(value)) {
166
+ cb(value, path);
167
+ return;
168
+ }
169
+ if (isTaggedSentinel(value)) return;
170
+ if (Array.isArray(value)) {
171
+ value.forEach((v, i) => walkRefValues(v, `${path}[${i}]`, cb));
172
+ return;
173
+ }
174
+ if (value === null || typeof value !== "object") return;
175
+ if ((value as { __compiled?: unknown }).__compiled) return;
176
+ const obj = value as Record<string, unknown>;
177
+ if (typeof obj.kind === "string") {
178
+ // Resource boundary — emit if it's a named ref, then stop descending.
179
+ if (typeof obj.name === "string") cb(value, path);
180
+ return;
181
+ }
182
+ for (const [k, v] of Object.entries(obj)) {
183
+ walkRefValues(v, path ? `${path}.${k}` : k, cb);
184
+ }
126
185
  }
127
186
 
128
187
  const scopePrefixOf = (pointer: string): string =>
@@ -139,12 +198,13 @@ export function visitManifest(
139
198
  visitor: ManifestVisitor,
140
199
  options: VisitOptions = {},
141
200
  ): void {
142
- const { aliases, aliasesByModule, skipKinds, expand } = options;
201
+ const { aliases, aliasesByModule, skipKinds, expand, discoverNestedRefs } = options;
143
202
 
144
203
  const wantsRefs = !!visitor.onRef;
145
204
  const wantsScope = !!visitor.onScope;
146
205
  const wantsSchemaFrom = !!visitor.onSchemaFrom;
147
206
  const wantsCel = !!visitor.onCel;
207
+ const wantsNested = wantsRefs && !!discoverNestedRefs;
148
208
 
149
209
  for (const r of resources) {
150
210
  if (!r.metadata?.name || !r.kind) continue;
@@ -157,6 +217,10 @@ export function visitManifest(
157
217
 
158
218
  visitor.onResourceEnter?.({ source: r, definition });
159
219
 
220
+ // Concrete paths emitted from the field map — so the value-tree scan below
221
+ // doesn't re-emit a ref the field map already covered.
222
+ const emittedRefPaths = wantsNested ? new Set<string>() : null;
223
+
160
224
  if (wantsRefs || wantsScope || wantsSchemaFrom) {
161
225
  const baseMap = aliases
162
226
  ? registry.getFieldMapForKind(r.kind, aliases)
@@ -208,6 +272,7 @@ export function visitManifest(
208
272
 
209
273
  for (const { value, path: concretePath } of resolveFieldEntries(r, fieldPath)) {
210
274
  if (!value) continue;
275
+ emittedRefPaths?.add(concretePath);
211
276
  visitor.onRef!({
212
277
  source: r,
213
278
  fieldPath,
@@ -230,6 +295,30 @@ export function visitManifest(
230
295
  }
231
296
  }
232
297
 
298
+ // Value-tree-driven nested ref discovery — refs the field map can't reach
299
+ // because they sit behind a `$ref` it doesn't descend (e.g. Run.Sequence
300
+ // step `invoke`s). Deduped against the field-map sites by concrete path.
301
+ // Scanned per top-level field so the resource's own `kind` isn't treated as
302
+ // a resource boundary by `walkRefValues`.
303
+ if (wantsNested) {
304
+ const emitNested = (value: unknown, path: string) => {
305
+ if (emittedRefPaths!.has(path)) return;
306
+ visitor.onRef!({
307
+ source: r,
308
+ fieldPath: path,
309
+ concretePath: path,
310
+ value,
311
+ entry: NESTED_REF_ENTRY,
312
+ inScope: false,
313
+ visibleScopeManifests: [],
314
+ nested: true,
315
+ });
316
+ };
317
+ for (const [key, value] of Object.entries(r as Record<string, unknown>)) {
318
+ walkRefValues(value, key, emitNested);
319
+ }
320
+ }
321
+
233
322
  if (wantsCel) {
234
323
  const contexts = definition?.schema ? extractContextsFromSchema(definition.schema) : [];
235
324
  walkCelExpressions(r, "", (expr, path, engineName) => {
@@ -213,6 +213,20 @@ function traverseNode(
213
213
  const entry: RefFieldEntry = { refs, isArray: path.includes("[]") };
214
214
  if (node["x-telo-context"]) entry.context = node["x-telo-context"] as Record<string, any>;
215
215
  map.set(path, entry);
216
+ // A node can mix item-level ref branches (a bare string / `{kind, name}`)
217
+ // with object branches that carry their OWN nested refs — e.g. Application
218
+ // `targets`: a bare ref vs inline `{ invoke }` vs gated `{ ref }`. Descend
219
+ // into the variant objects so those nested slots register too (and their
220
+ // `!ref` sentinels resolve). Pure x-telo-ref branches have no properties
221
+ // and contribute nothing here.
222
+ for (const variantKey of ["oneOf", "anyOf", "allOf"] as const) {
223
+ const variants = node[variantKey];
224
+ if (!Array.isArray(variants)) continue;
225
+ for (const variant of variants) {
226
+ if (!variant || typeof variant !== "object") continue;
227
+ traverseVariant(variant as Record<string, any>, path, map, root, visitedRefs);
228
+ }
229
+ }
216
230
  return;
217
231
  }
218
232
 
@@ -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]!;
@@ -248,6 +248,13 @@ export function validateReferences(
248
248
  // Skip inline resources — Phase 2 normalization hasn't run yet.
249
249
  if (isInlineResource(refVal)) return;
250
250
 
251
+ // Polymorphic ref slots (Application `targets`) accept object forms
252
+ // whose references live in nested slots rather than being a `{kind,
253
+ // name}` ref themselves — inline `{ invoke }` and gated `{ ref }`.
254
+ // Those nested refs are validated via their own field-map entries, so
255
+ // skip the item-level structural check here.
256
+ if (typeof refVal.kind !== "string" && ("invoke" in refVal || "ref" in refVal)) return;
257
+
251
258
  // 1. Structural check
252
259
  if (typeof refVal.kind !== "string" || typeof refVal.name !== "string") {
253
260
  diagnostics.push({