@telorun/analyzer 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
 
@@ -561,16 +633,19 @@ export class StaticAnalyzer {
561
633
  // through the same alias machinery as user-declared Telo.Imports —
562
634
  // honours the library's `exports.kinds` list, no special cases.
563
635
  if (moduleName) {
564
- const exportedKinds: string[] = ((m as any).exports?.kinds as string[] | undefined) ?? [];
636
+ // `Self` resolves the library's own kinds UNGATED a library may reference
637
+ // its own kinds regardless of `exports.kinds`, which gates importers, not
638
+ // internal use. This is what lets a library declare an instance of a kind it
639
+ // does not export (e.g. console's `writeLine`) to enforce a singleton.
565
640
  if (rootModules.has(moduleName)) {
566
- aliases.registerImport("Self", moduleName, exportedKinds);
641
+ aliases.registerImport("Self", moduleName, []);
567
642
  } else {
568
643
  let libResolver = aliasesByModule.get(moduleName);
569
644
  if (!libResolver) {
570
645
  libResolver = new AliasResolver();
571
646
  aliasesByModule.set(moduleName, libResolver);
572
647
  }
573
- libResolver.registerImport("Self", moduleName, exportedKinds);
648
+ libResolver.registerImport("Self", moduleName, []);
574
649
  }
575
650
  }
576
651
  }
@@ -753,6 +828,15 @@ export class StaticAnalyzer {
753
828
  continue;
754
829
  }
755
830
 
831
+ // Forwarded exports (flagged by flattenForAnalyzer) are an imported library's exported
832
+ // instances, already validated in their own module's standalone analysis; their
833
+ // `kind`/CEL are authored in that module's scope (e.g. `Self.X` → that module, not the
834
+ // consumer). Re-validating against the consumer's scope yields false UNDEFINED_KIND /
835
+ // scope-mismatch errors, so skip — they participate here only as resolution targets.
836
+ if ((m.metadata as { forwardedExport?: boolean } | undefined)?.forwardedExport === true) {
837
+ continue;
838
+ }
839
+
756
840
  const resource = { kind: m.kind, name: m.metadata?.name as string };
757
841
 
758
842
  // Resolve kind through alias if needed; direct lookup takes priority so that
@@ -954,6 +1038,7 @@ export class StaticAnalyzer {
954
1038
  // context — which require analyzer state to build — are stashed here.
955
1039
  let celStepContextSchema: Record<string, any> | undefined;
956
1040
  let celInvocationContext: Record<string, any> | undefined;
1041
+ let celErrorScopes: Map<string, Record<string, any>> = new Map();
957
1042
 
958
1043
  visitManifest(
959
1044
  allManifests,
@@ -973,6 +1058,9 @@ export class StaticAnalyzer {
973
1058
  aliases,
974
1059
  )
975
1060
  : undefined;
1061
+ celErrorScopes = collectErrorContextScopes(
1062
+ e.definition?.schema as Record<string, any> | undefined,
1063
+ );
976
1064
  },
977
1065
  onCel: (e) => {
978
1066
  const m = e.source;
@@ -995,6 +1083,22 @@ export class StaticAnalyzer {
995
1083
  };
996
1084
  }
997
1085
 
1086
+ // `error` is only in scope inside an error-bearing branch (e.g. a
1087
+ // `catch:` / `finally:`), so it's merged per-path, not resource-wide.
1088
+ const errorSchema =
1089
+ celErrorScopes.size > 0 ? errorContextForPath(path, celErrorScopes) : undefined;
1090
+ if (errorSchema) {
1091
+ const base =
1092
+ matchedContext ?? { type: "object", properties: {}, additionalProperties: true };
1093
+ matchedContext = {
1094
+ ...base,
1095
+ properties: {
1096
+ ...(base.properties ?? {}),
1097
+ error: errorSchema,
1098
+ },
1099
+ };
1100
+ }
1101
+
998
1102
  let effectiveContext: Record<string, any> | null = null;
999
1103
  if (matchedContext) {
1000
1104
  const manifestItem = matchedScope
@@ -1046,6 +1150,14 @@ export class StaticAnalyzer {
1046
1150
  message: `${m.kind}/${resource.name}: CEL at '${path}': ${f.message}`,
1047
1151
  data: { resource, filePath, path },
1048
1152
  });
1153
+ } else if (f.code === "CEL_NULLABLE_ACCESS") {
1154
+ diagnostics.push({
1155
+ severity: DiagnosticSeverity.Error,
1156
+ code: "CEL_NULLABLE_ACCESS",
1157
+ source: SOURCE,
1158
+ message: `${m.kind}/${resource.name}: CEL at '${path}': ${f.message}`,
1159
+ data: { resource, filePath, path },
1160
+ });
1049
1161
  } else {
1050
1162
  // Unknown code from a future engine — pass the message through,
1051
1163
  // tagged with a generic ENGINE_DIAGNOSTIC code so downstream
@@ -1096,7 +1208,14 @@ export class StaticAnalyzer {
1096
1208
  );
1097
1209
  }
1098
1210
 
1099
- normalize(manifests: ResourceManifest[], registry: AnalysisRegistry): ResourceManifest[] {
1211
+ normalize(
1212
+ manifests: ResourceManifest[],
1213
+ registry: AnalysisRegistry,
1214
+ // Forwarded foreign exports used only as cross-module resolution targets (see
1215
+ // resolveRefSentinels). The kernel passes its analyzer-flattened set so the
1216
+ // entry-only runtime pass can still resolve `!ref Alias.name`.
1217
+ crossModuleTargets?: ResourceManifest[],
1218
+ ): ResourceManifest[] {
1100
1219
  const ctx = registry._context();
1101
1220
  const normalized = normalizeInlineResources(
1102
1221
  manifests,
@@ -1107,7 +1226,13 @@ export class StaticAnalyzer {
1107
1226
  // Resolve !ref sentinels after normalize so both the original and
1108
1227
  // inline-extracted manifests get their refs canonicalized to
1109
1228
  // {kind, name} for the kernel that consumes this output.
1110
- resolveRefSentinels(normalized, ctx.definitions!, ctx.aliases, ctx.aliasesByModule);
1229
+ resolveRefSentinels(
1230
+ normalized,
1231
+ ctx.definitions!,
1232
+ ctx.aliases,
1233
+ ctx.aliasesByModule,
1234
+ crossModuleTargets ?? [],
1235
+ );
1111
1236
  return normalized;
1112
1237
  }
1113
1238
 
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
  },
@@ -338,6 +398,12 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
338
398
  type: "object",
339
399
  properties: {
340
400
  kinds: { type: "array", items: { type: "string" } },
401
+ // `variables` / `secrets` are reserved on the resources.<Alias> value-flow
402
+ // surface, so a library may not export instances under those names.
403
+ resources: {
404
+ type: "array",
405
+ items: { type: "string", not: { enum: ["variables", "secrets"] } },
406
+ },
341
407
  },
342
408
  additionalProperties: true,
343
409
  },
@@ -44,6 +44,10 @@ export function flattenForAnalyzer(graph: LoadedGraph): ResourceManifest[] {
44
44
  if (!targetModule) continue;
45
45
 
46
46
  const stamped = collectModuleManifests(targetModule);
47
+ const libDoc = stamped.find((m) => isModuleKind(m.kind));
48
+ const exportedResources = new Set<string>(
49
+ (libDoc as { exports?: { resources?: string[] } } | undefined)?.exports?.resources ?? [],
50
+ );
47
51
  for (const m of stamped) {
48
52
  if (
49
53
  m.kind === "Telo.Definition" ||
@@ -51,6 +55,22 @@ export function flattenForAnalyzer(graph: LoadedGraph): ResourceManifest[] {
51
55
  m.kind === "Telo.Import"
52
56
  ) {
53
57
  result.push(m);
58
+ } else if (
59
+ !isModuleKind(m.kind) &&
60
+ typeof m.metadata?.name === "string" &&
61
+ exportedResources.has(m.metadata.name as string)
62
+ ) {
63
+ // Forward instances listed in the library's `exports.resources` so the
64
+ // consumer's analyzer can resolve, gate, kind-check, and draw topology edges
65
+ // for cross-module `!ref Alias.name`. The gate IS this forwarding — only
66
+ // exported names cross the boundary. `metadata.forwardedExport` marks them as
67
+ // cross-module resolution targets only (keyed by `metadata.module`), so ref
68
+ // resolution / validation treats them as targets, never as local sources to
69
+ // re-validate or walk.
70
+ result.push({
71
+ ...m,
72
+ metadata: { ...m.metadata, forwardedExport: true } as ResourceManifest["metadata"],
73
+ });
54
74
  }
55
75
  }
56
76
  }
@@ -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