@telorun/analyzer 0.13.0 → 0.14.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 (41) hide show
  1. package/dist/adapters/http-adapter.d.ts +10 -0
  2. package/dist/adapters/http-adapter.d.ts.map +1 -0
  3. package/dist/adapters/http-adapter.js +18 -0
  4. package/dist/adapters/node-adapter.d.ts +17 -0
  5. package/dist/adapters/node-adapter.d.ts.map +1 -0
  6. package/dist/adapters/node-adapter.js +71 -0
  7. package/dist/adapters/registry-adapter.d.ts +15 -0
  8. package/dist/adapters/registry-adapter.d.ts.map +1 -0
  9. package/dist/adapters/registry-adapter.js +53 -0
  10. package/dist/alias-resolver.d.ts +6 -0
  11. package/dist/alias-resolver.d.ts.map +1 -1
  12. package/dist/alias-resolver.js +8 -0
  13. package/dist/analysis-registry.d.ts +1 -0
  14. package/dist/analysis-registry.d.ts.map +1 -1
  15. package/dist/analyzer.d.ts +1 -1
  16. package/dist/analyzer.d.ts.map +1 -1
  17. package/dist/analyzer.js +113 -5
  18. package/dist/builtins.d.ts.map +1 -1
  19. package/dist/builtins.js +61 -0
  20. package/dist/flatten-for-analyzer.d.ts.map +1 -1
  21. package/dist/flatten-for-analyzer.js +13 -0
  22. package/dist/manifest-visitor.d.ts +15 -0
  23. package/dist/manifest-visitor.d.ts.map +1 -1
  24. package/dist/manifest-visitor.js +73 -2
  25. package/dist/reference-field-map.js +16 -0
  26. package/dist/resolve-ref-sentinels.d.ts +15 -17
  27. package/dist/resolve-ref-sentinels.d.ts.map +1 -1
  28. package/dist/resolve-ref-sentinels.js +94 -40
  29. package/dist/resolve-throws-union.d.ts +10 -0
  30. package/dist/resolve-throws-union.d.ts.map +1 -1
  31. package/dist/resolve-throws-union.js +35 -7
  32. package/dist/validate-references.d.ts.map +1 -1
  33. package/dist/validate-references.js +105 -7
  34. package/package.json +3 -3
  35. package/src/analysis-registry.ts +1 -1
  36. package/src/analyzer.ts +100 -0
  37. package/src/builtins.ts +60 -0
  38. package/src/manifest-visitor.ts +91 -2
  39. package/src/reference-field-map.ts +14 -0
  40. package/src/resolve-throws-union.ts +36 -8
  41. package/src/validate-references.ts +7 -0
@@ -4,6 +4,7 @@ import { isInlineResource, resolveFieldEntries, resolveFieldValues } from "./ref
4
4
  import { navigateJsonPointer } from "./schema-compat.js";
5
5
  import { REF_VALIDATION_SKIP_KINDS as SYSTEM_KINDS } from "./system-kinds.js";
6
6
  import { DiagnosticSeverity } from "./types.js";
7
+ import { isModuleKind } from "./module-kinds.js";
7
8
  const SOURCE = "telo-analyzer";
8
9
  /**
9
10
  * Checks whether `kind` satisfies the ref constraint in `entry`.
@@ -95,10 +96,41 @@ export function validateReferences(resources, context) {
95
96
  // source, different sourceLine) keep separate fingerprints and still
96
97
  // trip the diagnostic. `analyze()` enforces that every non-system
97
98
  // manifest carries both positional fields — no defensive guard needed.
99
+ // Forwarded foreign exports (an imported library's exported instances, carrying a
100
+ // metadata.module that isn't a root module) are resolution TARGETS only: excluded from
101
+ // duplicate detection and local name resolution, and never walked as ref sources.
102
+ const rootModules = new Set();
103
+ for (const r of resources) {
104
+ if (isModuleKind(r.kind) && typeof r.metadata?.name === "string") {
105
+ rootModules.add(r.metadata.name);
106
+ }
107
+ }
108
+ const moduleOf = (r) => r.metadata?.module;
109
+ const isForeign = (r) => {
110
+ const mod = moduleOf(r);
111
+ return typeof mod === "string" && !rootModules.has(mod);
112
+ };
113
+ const byModuleName = new Map();
114
+ for (const r of resources) {
115
+ if (!r.metadata?.name || SYSTEM_KINDS.has(r.kind) || !isForeign(r))
116
+ continue;
117
+ byModuleName.set(`${moduleOf(r)}\0${r.metadata.name}`, r);
118
+ }
119
+ /** Whether the analysis set actually carries the named module's exported instances.
120
+ * False in partial single-file analysis (the import subtree wasn't loaded) — used to
121
+ * skip cross-module resolution checks rather than emit false `UNRESOLVED_REFERENCE`. */
122
+ const moduleLoaded = (module) => {
123
+ const prefix = `${module}\0`;
124
+ for (const key of byModuleName.keys())
125
+ if (key.startsWith(prefix))
126
+ return true;
127
+ return false;
128
+ };
129
+ const localResources = resources.filter((r) => !isForeign(r));
98
130
  const byNameAll = new Map();
99
131
  const seen = new Set();
100
132
  for (const r of resources) {
101
- if (!r.metadata?.name || SYSTEM_KINDS.has(r.kind) || r.kind === "Telo.Import")
133
+ if (!r.metadata?.name || SYSTEM_KINDS.has(r.kind) || r.kind === "Telo.Import" || isForeign(r))
102
134
  continue;
103
135
  const name = r.metadata.name;
104
136
  // `analyze()` guarantees both fields are present on non-system manifests.
@@ -140,6 +172,32 @@ export function validateReferences(resources, context) {
140
172
  });
141
173
  }
142
174
  }
175
+ // A resource name must contain no dot. The `!ref` resolver splits the tag's source on
176
+ // the first dot to separate an import alias from the resource name, so a dotted name
177
+ // would mis-resolve into a cross-module lookup. This is the load-bearing invariant of
178
+ // the reference grammar, so it is enforced here rather than left to the (unenforced)
179
+ // casing convention.
180
+ for (const [name, list] of byNameAll) {
181
+ if (!name.includes("."))
182
+ continue;
183
+ for (const r of list) {
184
+ const m = r.metadata;
185
+ const range = typeof m?.sourceLine === "number"
186
+ ? {
187
+ start: { line: m.sourceLine, character: 0 },
188
+ end: { line: m.sourceLine, character: Number.MAX_SAFE_INTEGER },
189
+ }
190
+ : undefined;
191
+ diagnostics.push({
192
+ severity: DiagnosticSeverity.Error,
193
+ code: "INVALID_RESOURCE_NAME",
194
+ source: SOURCE,
195
+ message: `${r.kind}/${name}: resource name must not contain '.' — in a '!ref' the '.' separates an import alias from the resource name`,
196
+ ...(range ? { range } : {}),
197
+ data: { resource: { kind: r.kind, name }, filePath: m?.source, path: "metadata.name" },
198
+ });
199
+ }
200
+ }
143
201
  // Single-resource map for the resolution / scope lookups below — when a
144
202
  // collision exists, falling back to the first occurrence keeps the rest
145
203
  // of the pass behaving the same as before the duplicate diagnostic was
@@ -152,7 +210,7 @@ export function validateReferences(resources, context) {
152
210
  // resolved against the schema-from-expanded field map, with its source
153
211
  // enclosure (`inScope`) and the scope manifests visible to it — so this
154
212
  // handler only validates, it does not re-walk.
155
- visitManifest(resources, registry, {
213
+ visitManifest(localResources, registry, {
156
214
  onRef: (e) => {
157
215
  const r = e.source;
158
216
  const resourceLabel = `${r.kind}/${r.metadata.name}`;
@@ -166,13 +224,35 @@ export function validateReferences(resources, context) {
166
224
  // string/inline ambiguity at the source.
167
225
  if (isRefSentinel(val)) {
168
226
  const refName = val.source;
169
- const target = byName.get(refName) ?? visibleScopeManifests.find((m) => m.metadata?.name === refName);
227
+ const dot = refName.indexOf(".");
228
+ const aliasPrefix = dot > 0 ? refName.slice(0, dot) : undefined;
229
+ // Cross-module sentinel left unresolved by Phase 2.5 — it qualifies an import
230
+ // alias. If that module's exports are loaded in this analysis, the miss is real
231
+ // (name not in exports.resources, or a typo); if not (partial single-file
232
+ // analysis), skip rather than emit a false UNRESOLVED_REFERENCE.
233
+ if (aliasPrefix && aliasPrefix !== "Self" && aliases.hasAlias(aliasPrefix)) {
234
+ const module = aliases.moduleForAlias(aliasPrefix);
235
+ if (module && !moduleLoaded(module))
236
+ return;
237
+ diagnostics.push({
238
+ severity: DiagnosticSeverity.Error,
239
+ code: "UNRESOLVED_REFERENCE",
240
+ source: SOURCE,
241
+ message: `${resourceLabel}: reference at '${concretePath}' → '${refName}' is not exported by module '${module ?? aliasPrefix}' (add it to exports.resources)`,
242
+ data: { resource: resourceData, filePath, path: concretePath },
243
+ });
244
+ return;
245
+ }
246
+ // Local reference (bare name or explicit `Self.`-qualified).
247
+ const localName = aliasPrefix === "Self" ? refName.slice(dot + 1) : refName;
248
+ const target = byName.get(localName) ??
249
+ visibleScopeManifests.find((m) => m.metadata?.name === localName);
170
250
  if (!target) {
171
251
  diagnostics.push({
172
252
  severity: DiagnosticSeverity.Error,
173
253
  code: "UNRESOLVED_REFERENCE",
174
254
  source: SOURCE,
175
- message: `${resourceLabel}: reference at '${concretePath}' → resource '${refName}' not found`,
255
+ message: `${resourceLabel}: reference at '${concretePath}' → resource '${localName}' not found`,
176
256
  data: { resource: resourceData, filePath, path: concretePath },
177
257
  });
178
258
  return;
@@ -232,6 +312,13 @@ export function validateReferences(resources, context) {
232
312
  // Skip inline resources — Phase 2 normalization hasn't run yet.
233
313
  if (isInlineResource(refVal))
234
314
  return;
315
+ // Polymorphic ref slots (Application `targets`) accept object forms
316
+ // whose references live in nested slots rather than being a `{kind,
317
+ // name}` ref themselves — inline `{ invoke }` and gated `{ ref }`.
318
+ // Those nested refs are validated via their own field-map entries, so
319
+ // skip the item-level structural check here.
320
+ if (typeof refVal.kind !== "string" && ("invoke" in refVal || "ref" in refVal))
321
+ return;
235
322
  // 1. Structural check
236
323
  if (typeof refVal.kind !== "string" || typeof refVal.name !== "string") {
237
324
  diagnostics.push({
@@ -255,8 +342,19 @@ export function validateReferences(resources, context) {
255
342
  });
256
343
  }
257
344
  // 3. Resolution check — resource with this name must exist.
258
- const exists = byName.has(refVal.name) ||
259
- visibleScopeManifests.some((m) => m.metadata?.name === refVal.name);
345
+ let exists;
346
+ if (typeof refVal.alias === "string" && refVal.alias !== "Self") {
347
+ // Cross-module ref resolved by Phase 2.5. Validate against the forwarded
348
+ // exports when loaded; in partial context (module not loaded) assume resolvable.
349
+ const module = aliases.moduleForAlias(refVal.alias);
350
+ exists =
351
+ !module || !moduleLoaded(module) || byModuleName.has(`${module}\0${refVal.name}`);
352
+ }
353
+ else {
354
+ exists =
355
+ byName.has(refVal.name) ||
356
+ visibleScopeManifests.some((m) => m.metadata?.name === refVal.name);
357
+ }
260
358
  if (!exists) {
261
359
  diagnostics.push({
262
360
  severity: DiagnosticSeverity.Error,
@@ -273,7 +371,7 @@ export function validateReferences(resources, context) {
273
371
  // concrete kind, navigate the JSON Pointer into that kind's definition schema, and
274
372
  // validate the field value against the resulting sub-schema. Driven off the base map
275
373
  // (un-expanded) so each schema-from slot is seen as its own site.
276
- visitManifest(resources, registry, {
374
+ visitManifest(localResources, registry, {
277
375
  onSchemaFrom: (e) => {
278
376
  const r = e.source;
279
377
  const fieldPath = e.fieldPath;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telorun/analyzer",
3
- "version": "0.13.0",
3
+ "version": "0.14.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": "0.4.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": "0.13.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]!;