@telorun/analyzer 0.7.0 → 0.8.1

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.
@@ -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;AAGzE,OAAO,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAC1D,OAAO,EAGL,KAAK,WAAW,EACjB,MAAM,sBAAsB,CAAC;AAa9B,OAAO,EAAsB,KAAK,kBAAkB,EAAE,KAAK,eAAe,EAAE,MAAM,YAAY,CAAC;AAoY/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,OAAO,CACL,SAAS,EAAE,gBAAgB,EAAE,EAC7B,OAAO,CAAC,EAAE,eAAe,EACzB,QAAQ,CAAC,EAAE,gBAAgB,GAC1B,kBAAkB,EAAE;IA2TvB,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;IAKxF,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;CAiB5F"}
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;AAa9B,OAAO,EAAsB,KAAK,kBAAkB,EAAE,KAAK,eAAe,EAAE,MAAM,YAAY,CAAC;AAgX/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,OAAO,CACL,SAAS,EAAE,gBAAgB,EAAE,EAC7B,OAAO,CAAC,EAAE,eAAe,EACzB,QAAQ,CAAC,EAAE,gBAAgB,GAC1B,kBAAkB,EAAE;IAmUvB,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;IAKxF,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;CAiB5F"}
package/dist/analyzer.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { defaultRegistry, walkCelExpressions } from "@telorun/templating";
1
2
  import { AliasResolver } from "./alias-resolver.js";
2
3
  import { buildCelEnvironment, buildTypedCelEnvironment, } from "./cel-environment.js";
3
4
  import { DefinitionRegistry } from "./definition-registry.js";
@@ -8,11 +9,10 @@ import { isModuleKind } from "./module-kinds.js";
8
9
  import { normalizeInlineResources } from "./normalize-inline-resources.js";
9
10
  import { celTypeSatisfiesJsonSchema, substituteCelFields, validateAgainstSchema, } from "./schema-compat.js";
10
11
  import { DiagnosticSeverity } from "./types.js";
11
- import { extractAccessChains, getManifestItem, pathMatchesScope, resolveContextAnnotations, resolveTypeFieldToSchema, validateChainAgainstSchema, } from "./validate-cel-context.js";
12
+ import { getManifestItem, pathMatchesScope, resolveContextAnnotations, resolveTypeFieldToSchema, } from "./validate-cel-context.js";
12
13
  import { validateExtends } from "./validate-extends.js";
13
14
  import { validateReferences } from "./validate-references.js";
14
15
  import { validateThrowsCoverage } from "./validate-throws-coverage.js";
15
- const TEMPLATE_REGEX = /\$\{\{\s*([^}]+?)\s*\}\}/g;
16
16
  const SELF_PREFIX = "Self.";
17
17
  /** Resolve an alias-prefixed kind value (e.g. `Self.Encoder` or `Ai.Model`)
18
18
  * to its canonical form. `Self.<Name>` resolves to `<ownModule>.<Name>` —
@@ -36,21 +36,6 @@ function lookupDefinitionTypeField(invokedKind, fieldName, defs, aliases, allMan
36
36
  const value = def[fieldName];
37
37
  return resolveTypeFieldToSchema(value, allManifests);
38
38
  }
39
- function walkCelExpressions(value, path, cb) {
40
- if (typeof value === "string") {
41
- for (const m of value.matchAll(TEMPLATE_REGEX)) {
42
- cb(m[1].trim(), path);
43
- }
44
- }
45
- else if (Array.isArray(value)) {
46
- value.forEach((v, i) => walkCelExpressions(v, `${path}[${i}]`, cb));
47
- }
48
- else if (value !== null && typeof value === "object") {
49
- for (const [k, v] of Object.entries(value)) {
50
- walkCelExpressions(v, path ? `${path}.${k}` : k, cb);
51
- }
52
- }
53
- }
54
39
  const SOURCE = "telo-analyzer";
55
40
  /**
56
41
  * Walk a JSON Schema tree and collect all `x-telo-context` annotations,
@@ -193,28 +178,30 @@ function buildStepContextSchema(manifest, defSchema, allManifests, defs, aliases
193
178
  continue;
194
179
  const s = step;
195
180
  const name = s.name;
196
- if (typeof name === "string") {
197
- const invoke = s[invokeField];
181
+ const invoke = s[invokeField];
182
+ // Only invoke steps register a `steps.<name>.result` entry — control-flow
183
+ // wrappers (try/if/while/switch/throw) don't produce a result and must
184
+ // not shadow real entries with a permissive `additionalProperties: true`,
185
+ // or unknown step references slip through chain validation.
186
+ if (typeof name === "string" && invoke && typeof invoke === "object") {
198
187
  let outputSchema;
199
- if (invoke && typeof invoke === "object") {
200
- const invokedKind = invoke.kind;
201
- const invokedName = invoke.name;
202
- if (invokedName) {
203
- const invokedManifest = allManifests.find((m) => m.metadata?.name === invokedName &&
204
- (!invokedKind || m.kind === invokedKind));
205
- if (invokedManifest) {
206
- outputSchema = resolveTypeFieldToSchema(invokedManifest[outputTypeField], allManifests);
207
- }
208
- }
209
- else {
210
- outputSchema = resolveTypeFieldToSchema(invoke[outputTypeField], allManifests);
211
- }
212
- // Fallback: pull outputType from the kind's Telo.Definition. The
213
- // resource manifest typically doesn't carry outputType; the def does.
214
- if (!outputSchema && invokedKind) {
215
- outputSchema = lookupDefinitionTypeField(invokedKind, outputTypeField, defs, aliases, allManifests);
188
+ const invokedKind = invoke.kind;
189
+ const invokedName = invoke.name;
190
+ if (invokedName) {
191
+ const invokedManifest = allManifests.find((m) => m.metadata?.name === invokedName &&
192
+ (!invokedKind || m.kind === invokedKind));
193
+ if (invokedManifest) {
194
+ outputSchema = resolveTypeFieldToSchema(invokedManifest[outputTypeField], allManifests);
216
195
  }
217
196
  }
197
+ else {
198
+ outputSchema = resolveTypeFieldToSchema(invoke[outputTypeField], allManifests);
199
+ }
200
+ // Fallback: pull outputType from the kind's Telo.Definition. The
201
+ // resource manifest typically doesn't carry outputType; the def does.
202
+ if (!outputSchema && invokedKind) {
203
+ outputSchema = lookupDefinitionTypeField(invokedKind, outputTypeField, defs, aliases, allManifests);
204
+ }
218
205
  stepProperties[name] = {
219
206
  type: "object",
220
207
  properties: {
@@ -512,27 +499,14 @@ export class StaticAnalyzer {
512
499
  const stepContextSchema = mDefinition?.schema
513
500
  ? buildStepContextSchema(m, mDefinition.schema, allManifests, defs, aliases)
514
501
  : undefined;
515
- walkCelExpressions(m, "", (expr, path) => {
516
- let parsed;
517
- try {
518
- parsed = this.celEnv.parse(expr);
519
- }
520
- catch (e) {
521
- diagnostics.push({
522
- severity: DiagnosticSeverity.Error,
523
- code: "CEL_SYNTAX_ERROR",
524
- source: SOURCE,
525
- message: `CEL syntax error at ${path}: ${e instanceof Error ? e.message : String(e)}`,
526
- data: { resource, filePath, path },
527
- });
528
- return;
529
- }
530
- const accessChains = extractAccessChains(parsed.ast);
502
+ walkCelExpressions(m, "", (expr, path, engineName) => {
503
+ // Resolve the effective context for this expression's path. The
504
+ // engine receives a single closed schema and validates member-access
505
+ // chains against it; per-path resolution (step context, x-telo-context,
506
+ // kernel-globals merge) stays on the analyzer side because it depends
507
+ // on analyzer-internal state (definitions, aliases).
531
508
  const contexts = mDefinition?.schema ? extractContextsFromSchema(mDefinition.schema) : [];
532
509
  const invocationContext = m.metadata?.xTeloInvocationContext;
533
- // If no static context but we have step context, inject it
534
- if (contexts.length === 0 && !invocationContext && !stepContextSchema)
535
- return;
536
510
  let matchedContext;
537
511
  let matchedScope;
538
512
  for (const ctx of contexts) {
@@ -544,7 +518,6 @@ export class StaticAnalyzer {
544
518
  }
545
519
  if (!matchedContext)
546
520
  matchedContext = invocationContext;
547
- // Merge step context into the effective context
548
521
  if (stepContextSchema) {
549
522
  const base = matchedContext ?? { type: "object", properties: {}, additionalProperties: true };
550
523
  matchedContext = {
@@ -555,24 +528,49 @@ export class StaticAnalyzer {
555
528
  },
556
529
  };
557
530
  }
558
- if (!matchedContext)
531
+ let effectiveContext = null;
532
+ if (matchedContext) {
533
+ const manifestItem = matchedScope
534
+ ? getManifestItem(path, matchedScope, m)
535
+ : m;
536
+ const resolvedContext = resolveContextAnnotations(matchedContext, manifestItem, allManifests);
537
+ effectiveContext = mergeKernelGlobalsIntoContext(resolvedContext, kernelGlobals);
538
+ }
539
+ const engine = defaultRegistry().get(engineName);
540
+ if (!engine)
559
541
  return;
560
- const manifestItem = matchedScope
561
- ? getManifestItem(path, matchedScope, m)
562
- : m;
563
- const resolvedContext = resolveContextAnnotations(matchedContext, manifestItem, allManifests);
564
- const effectiveContext = mergeKernelGlobalsIntoContext(resolvedContext, kernelGlobals);
565
- for (const chain of accessChains) {
566
- const err = validateChainAgainstSchema(chain, effectiveContext);
567
- if (!err)
568
- continue;
569
- diagnostics.push({
570
- severity: DiagnosticSeverity.Error,
571
- code: "CEL_UNKNOWN_FIELD",
572
- source: SOURCE,
573
- message: `${m.kind}/${resource.name}: CEL at '${path}': ${err}`,
574
- data: { resource, filePath, path },
575
- });
542
+ const findings = engine.analyze(expr, { celEnv: this.celEnv, contextSchema: effectiveContext });
543
+ for (const f of findings) {
544
+ if (f.code === "CEL_SYNTAX_ERROR") {
545
+ diagnostics.push({
546
+ severity: DiagnosticSeverity.Error,
547
+ code: "CEL_SYNTAX_ERROR",
548
+ source: SOURCE,
549
+ message: `CEL syntax error at ${path}: ${f.message}`,
550
+ data: { resource, filePath, path },
551
+ });
552
+ }
553
+ else if (f.code === "CEL_UNKNOWN_FIELD") {
554
+ diagnostics.push({
555
+ severity: DiagnosticSeverity.Error,
556
+ code: "CEL_UNKNOWN_FIELD",
557
+ source: SOURCE,
558
+ message: `${m.kind}/${resource.name}: CEL at '${path}': ${f.message}`,
559
+ data: { resource, filePath, path },
560
+ });
561
+ }
562
+ else {
563
+ // Unknown code from a future engine — pass the message through,
564
+ // tagged with a generic ENGINE_DIAGNOSTIC code so downstream
565
+ // filters can still bucket it.
566
+ diagnostics.push({
567
+ severity: DiagnosticSeverity.Error,
568
+ code: f.code ?? "ENGINE_DIAGNOSTIC",
569
+ source: SOURCE,
570
+ message: `${m.kind}/${resource.name}: !${engineName} at '${path}': ${f.message}`,
571
+ data: { resource, filePath, path },
572
+ });
573
+ }
576
574
  }
577
575
  });
578
576
  }
@@ -1,23 +1,7 @@
1
1
  import { Environment } from "@marcbachmann/cel-js";
2
- import { type ResourceManifest } from "@telorun/sdk";
3
- export interface CelHandlers {
4
- sha256: (s: string) => string;
5
- json: (value: unknown) => string;
6
- }
7
- /** Build a CEL `Environment` with Telo's stdlib of functions. Always registers the
8
- * same function signatures (so `env.check()` succeeds for type-inference) — the
9
- * handlers govern what the function does when called at runtime. Analyzer-only
10
- * callers can omit handlers; runtime callers (kernel) must supply real ones.
11
- *
12
- * Also registers the `Stream` object type, backed by the `Stream` class from
13
- * `@telorun/sdk`. CEL's type-checker rejects values whose constructor isn't
14
- * Object/Map/Array/Set/registered; producers that need to expose an
15
- * `AsyncIterable` through a stream-typed property must wrap the iterable in
16
- * `new Stream(...)` so its constructor is the registered class. The type has
17
- * no fields, so terminal access (passing the value through CEL) succeeds but
18
- * member access raises a CEL error at runtime — matching the analyzer's
19
- * static check on `x-telo-stream`-marked properties. */
20
- export declare function buildCelEnvironment(handlers?: CelHandlers): Environment;
2
+ import type { ResourceManifest } from "@telorun/sdk";
3
+ export { buildCelEnvironment } from "@telorun/templating";
4
+ export type { CelHandlers } from "@telorun/templating";
21
5
  /** Clone `baseEnv` and register typed variable declarations so that
22
6
  * `env.check(expr)` can infer return types for expressions referencing known variables.
23
7
  *
@@ -1 +1 @@
1
- {"version":3,"file":"cel-environment.d.ts","sourceRoot":"","sources":["../src/cel-environment.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AACnD,OAAO,EAAU,KAAK,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAG7D,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,CAAC;IAC9B,IAAI,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,MAAM,CAAC;CAClC;AAcD;;;;;;;;;;;;yDAYyD;AACzD,wBAAgB,mBAAmB,CAAC,QAAQ,GAAE,WAA2B,GAAG,WAAW,CAgBtF;AAED;;;;;;;;;uEASuE;AACvE,wBAAgB,wBAAwB,CACtC,OAAO,EAAE,WAAW,EACpB,QAAQ,EAAE,gBAAgB,EAC1B,kBAAkB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,IAAI,GAC9C,WAAW,CAyCb"}
1
+ {"version":3,"file":"cel-environment.d.ts","sourceRoot":"","sources":["../src/cel-environment.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AACnD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAGrD,OAAO,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAC;AAC1D,YAAY,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAEvD;;;;;;;;;uEASuE;AACvE,wBAAgB,wBAAwB,CACtC,OAAO,EAAE,WAAW,EACpB,QAAQ,EAAE,gBAAgB,EAC1B,kBAAkB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,IAAI,GAC9C,WAAW,CAyCb"}
@@ -1,44 +1,5 @@
1
- import { Environment } from "@marcbachmann/cel-js";
2
- import { Stream } from "@telorun/sdk";
3
1
  import { jsonSchemaToCelType } from "./schema-compat.js";
4
- const stub = (name) => () => {
5
- throw new Error(`${name}() is not available in this environment. ` +
6
- `Construct StaticAnalyzer or Loader with celHandlers to enable it.`);
7
- };
8
- const STUB_HANDLERS = {
9
- sha256: stub("sha256"),
10
- json: stub("json"),
11
- };
12
- /** Build a CEL `Environment` with Telo's stdlib of functions. Always registers the
13
- * same function signatures (so `env.check()` succeeds for type-inference) — the
14
- * handlers govern what the function does when called at runtime. Analyzer-only
15
- * callers can omit handlers; runtime callers (kernel) must supply real ones.
16
- *
17
- * Also registers the `Stream` object type, backed by the `Stream` class from
18
- * `@telorun/sdk`. CEL's type-checker rejects values whose constructor isn't
19
- * Object/Map/Array/Set/registered; producers that need to expose an
20
- * `AsyncIterable` through a stream-typed property must wrap the iterable in
21
- * `new Stream(...)` so its constructor is the registered class. The type has
22
- * no fields, so terminal access (passing the value through CEL) succeeds but
23
- * member access raises a CEL error at runtime — matching the analyzer's
24
- * static check on `x-telo-stream`-marked properties. */
25
- export function buildCelEnvironment(handlers = STUB_HANDLERS) {
26
- return new Environment({ unlistedVariablesAreDyn: true, enableOptionalTypes: true })
27
- .registerFunction("join(list, string): string", (list, sep) => list.map(String).join(sep))
28
- .registerFunction("keys(map): list", (map) => {
29
- if (map instanceof Map)
30
- return [...map.keys()];
31
- return Object.keys(map);
32
- })
33
- .registerFunction("values(map): list", (map) => {
34
- if (map instanceof Map)
35
- return [...map.values()];
36
- return Object.values(map);
37
- })
38
- .registerFunction("sha256(string): string", (s) => handlers.sha256(s))
39
- .registerFunction("json(dyn): string", (value) => handlers.json(value))
40
- .registerType("Stream", Stream);
41
- }
2
+ export { buildCelEnvironment } from "@telorun/templating";
42
3
  /** Clone `baseEnv` and register typed variable declarations so that
43
4
  * `env.check(expr)` can infer return types for expressions referencing known variables.
44
5
  *
@@ -1 +1 @@
1
- {"version":3,"file":"manifest-loader.d.ts","sourceRoot":"","sources":["../src/manifest-loader.ts"],"names":[],"mappings":"AACA,OAAO,EAAmB,KAAK,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAOtE,OAAO,EAEL,KAAK,WAAW,EAChB,KAAK,iBAAiB,EACtB,KAAK,cAAc,EAGpB,MAAM,YAAY,CAAC;AASpB,qBAAa,MAAM;IACjB,OAAO,CAAC,QAAQ,CAAC,WAAW,CAGxB;IAEJ,SAAS,CAAC,OAAO,EAAE,cAAc,EAAE,CAAC;IACpC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAc;gBAEzB,qBAAqB,GAAE,cAAc,EAAE,GAAG,iBAAsB;IAmB5E,QAAQ,CAAC,MAAM,EAAE,cAAc,GAAG,IAAI;IAKtC,OAAO,CAAC,IAAI;IAMN,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAK/C,UAAU,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,gBAAgB,EAAE,CAAC;YAqGnE,eAAe;YAoBf,eAAe;IAgEvB,iBAAiB,CACrB,OAAO,EAAE,MAAM,GACd,OAAO,CAAC;QACT,QAAQ,EAAE,MAAM,CAAC;QACjB,SAAS,EAAE,gBAAgB,EAAE,CAAC;QAC9B,eAAe,EAAE,GAAG,CAAC,MAAM,EAAE,gBAAgB,EAAE,CAAC,CAAC;KAClD,GAAG,IAAI,CAAC;IAiCH,eAAe,CACnB,QAAQ,EAAE,MAAM,EAChB,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,KAAK,KAAK,IAAI,GAC5C,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,gBAAgB,EAAE,CAAC,CAAC;IAsCrC,aAAa,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,EAAE,CAAC;CA+HnE"}
1
+ {"version":3,"file":"manifest-loader.d.ts","sourceRoot":"","sources":["../src/manifest-loader.ts"],"names":[],"mappings":"AACA,OAAO,EAAmB,KAAK,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAQtE,OAAO,EAEL,KAAK,WAAW,EAChB,KAAK,iBAAiB,EACtB,KAAK,cAAc,EAGpB,MAAM,YAAY,CAAC;AASpB,qBAAa,MAAM;IACjB,OAAO,CAAC,QAAQ,CAAC,WAAW,CAGxB;IAEJ,SAAS,CAAC,OAAO,EAAE,cAAc,EAAE,CAAC;IACpC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAc;gBAEzB,qBAAqB,GAAE,cAAc,EAAE,GAAG,iBAAsB;IAmB5E,QAAQ,CAAC,MAAM,EAAE,cAAc,GAAG,IAAI;IAKtC,OAAO,CAAC,IAAI;IAMN,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAK/C,UAAU,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,gBAAgB,EAAE,CAAC;YAqGnE,eAAe;YAoBf,eAAe;IAgEvB,iBAAiB,CACrB,OAAO,EAAE,MAAM,GACd,OAAO,CAAC;QACT,QAAQ,EAAE,MAAM,CAAC;QACjB,SAAS,EAAE,gBAAgB,EAAE,CAAC;QAC9B,eAAe,EAAE,GAAG,CAAC,MAAM,EAAE,gBAAgB,EAAE,CAAC,CAAC;KAClD,GAAG,IAAI,CAAC;IAiCH,eAAe,CACnB,QAAQ,EAAE,MAAM,EAChB,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,KAAK,KAAK,IAAI,GAC5C,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,gBAAgB,EAAE,CAAC,CAAC;IAsCrC,aAAa,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,EAAE,CAAC;CA+HnE"}
@@ -1,4 +1,5 @@
1
1
  import { isCompiledValue } from "@telorun/sdk";
2
+ import { defaultCustomTags } from "@telorun/templating";
2
3
  import { isMap, isPair, isScalar, isSeq, parseAllDocuments } from "yaml";
3
4
  import { HttpSource } from "./sources/http-source.js";
4
5
  import { RegistrySource } from "./sources/registry-source.js";
@@ -53,7 +54,7 @@ export class Loader {
53
54
  if (cached && cached.text === text) {
54
55
  return cloneManifestArray(cached.manifests);
55
56
  }
56
- const parsedDocuments = parseAllDocuments(text);
57
+ const parsedDocuments = parseAllDocuments(text, { customTags: defaultCustomTags() });
57
58
  const rawDocs = parsedDocuments.map((d) => d.toJSON());
58
59
  const offsets = documentLineOffsets(text);
59
60
  const lineOffsets = buildLineOffsets(text);
@@ -154,7 +155,7 @@ export class Loader {
154
155
  }
155
156
  async loadPartialFile(url, ownerModuleName, options) {
156
157
  const { text, source } = await this.pick(url).read(url);
157
- const parsedDocuments = parseAllDocuments(text);
158
+ const parsedDocuments = parseAllDocuments(text, { customTags: defaultCustomTags() });
158
159
  const rawDocs = parsedDocuments.map((d) => d.toJSON());
159
160
  const offsets = documentLineOffsets(text);
160
161
  const lineOffsets = buildLineOffsets(text);
@@ -1,10 +1,16 @@
1
1
  import type { Environment } from "@marcbachmann/cel-js";
2
2
  /**
3
- * Walks a raw YAML document and replaces all "${{ expr }}" strings with
4
- * CompiledValue wrappers. Throws on CEL syntax errors.
5
- * Intended to be called once per document at load time.
6
- * Telo.Definition documents are returned unchanged — their schema fields
7
- * are static metadata and must not be treated as CEL templates.
3
+ * Walks a raw YAML document and replaces all `${{ expr }}` strings (and
4
+ * `!cel`-tagged sentinels) with CompiledValue wrappers. Throws on CEL syntax
5
+ * errors. Intended to be called once per document at load time.
6
+ *
7
+ * Note on Telo.Definition / Telo.Abstract: the walker traverses these too.
8
+ * Their `schema` fields are JSON Schema metadata and don't typically contain
9
+ * `${{ }}` text, so compile is a no-op there. Their `template` fields, on
10
+ * the other hand, *do* carry CEL — Definition-driven templates are expanded
11
+ * by the kernel and rely on the precompiled tree. If a description or
12
+ * example string inside a schema happens to contain `${{ }}`, it will be
13
+ * interpreted as CEL; tag it `!literal` to opt out.
8
14
  */
9
15
  export declare function precompileDoc(doc: unknown, env: Environment): unknown;
10
16
  //# sourceMappingURL=precompile.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"precompile.d.ts","sourceRoot":"","sources":["../src/precompile.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAKxD;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,WAAW,GAAG,OAAO,CAarE"}
1
+ {"version":3,"file":"precompile.d.ts","sourceRoot":"","sources":["../src/precompile.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAIxD;;;;;;;;;;;;GAYG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,WAAW,GAAG,OAAO,CAmCrE"}
@@ -1,13 +1,41 @@
1
- const TEMPLATE_REGEX = /\$\{\{\s*([^}]+?)\s*\}\}/g;
2
- const EXACT_TEMPLATE_REGEX = /^\s*\$\{\{\s*([^}]+?)\s*\}\}\s*$/;
1
+ import { isCompiledValue } from "@telorun/sdk";
2
+ import { compileString, defaultRegistry, isTaggedSentinel } from "@telorun/templating";
3
3
  /**
4
- * Walks a raw YAML document and replaces all "${{ expr }}" strings with
5
- * CompiledValue wrappers. Throws on CEL syntax errors.
6
- * Intended to be called once per document at load time.
7
- * Telo.Definition documents are returned unchanged — their schema fields
8
- * are static metadata and must not be treated as CEL templates.
4
+ * Walks a raw YAML document and replaces all `${{ expr }}` strings (and
5
+ * `!cel`-tagged sentinels) with CompiledValue wrappers. Throws on CEL syntax
6
+ * errors. Intended to be called once per document at load time.
7
+ *
8
+ * Note on Telo.Definition / Telo.Abstract: the walker traverses these too.
9
+ * Their `schema` fields are JSON Schema metadata and don't typically contain
10
+ * `${{ }}` text, so compile is a no-op there. Their `template` fields, on
11
+ * the other hand, *do* carry CEL — Definition-driven templates are expanded
12
+ * by the kernel and rely on the precompiled tree. If a description or
13
+ * example string inside a schema happens to contain `${{ }}`, it will be
14
+ * interpreted as CEL; tag it `!literal` to opt out.
9
15
  */
10
16
  export function precompileDoc(doc, env) {
17
+ // Tagged sentinel: dispatch to the engine. The result is decorated with
18
+ // `__tagged` + `engine` + `source` when it's a CompiledValue so the
19
+ // analyzer's diagnostic walk can identify it on compiled trees too;
20
+ // engines returning plain values (e.g. `literal` → a string) pass through
21
+ // verbatim — the runtime contract is "any scalar value is fine."
22
+ if (isTaggedSentinel(doc)) {
23
+ const engine = defaultRegistry().get(doc.engine);
24
+ if (!engine) {
25
+ throw new Error(`Unknown templating engine: !${doc.engine}`);
26
+ }
27
+ const compiled = engine.compile(doc.source, { celEnv: env });
28
+ if (isCompiledValue(compiled)) {
29
+ return {
30
+ __tagged: true,
31
+ __compiled: true,
32
+ engine: doc.engine,
33
+ source: doc.source,
34
+ call: compiled.call.bind(compiled),
35
+ };
36
+ }
37
+ return compiled;
38
+ }
11
39
  if (typeof doc === "string")
12
40
  return compileString(doc, env);
13
41
  if (Array.isArray(doc))
@@ -23,31 +51,3 @@ export function precompileDoc(doc, env) {
23
51
  }
24
52
  return doc;
25
53
  }
26
- function compileString(s, env) {
27
- if (!s.includes("${{"))
28
- return s;
29
- const exact = s.match(EXACT_TEMPLATE_REGEX);
30
- if (exact) {
31
- const expr = exact[1].trim();
32
- const fn = env.parse(expr);
33
- return { __compiled: true, source: expr, call: (ctx) => fn(ctx) };
34
- }
35
- // Interpolated template — collect literal parts + compiled sub-expressions
36
- const parts = [];
37
- let last = 0;
38
- for (const m of s.matchAll(TEMPLATE_REGEX)) {
39
- if (m.index > last)
40
- parts.push(s.slice(last, m.index));
41
- const expr = m[1].trim();
42
- const fn = env.parse(expr);
43
- parts.push({ __compiled: true, source: expr, call: (ctx) => fn(ctx) });
44
- last = m.index + m[0].length;
45
- }
46
- if (last < s.length)
47
- parts.push(s.slice(last));
48
- return {
49
- __compiled: true,
50
- source: s,
51
- call: (ctx) => parts.map((p) => (typeof p === "string" ? p : String(p.call(ctx) ?? ""))).join(""),
52
- };
53
- }
@@ -1 +1 @@
1
- {"version":3,"file":"schema-compat.d.ts","sourceRoot":"","sources":["../src/schema-compat.ts"],"names":[],"mappings":"AAGA,QAAA,MAAM,GAAG,KAA0C,CAAC;AAEpD;0FAC0F;AAC1F,wBAAgB,SAAS,IAAI,YAAY,CAAC,OAAO,GAAG,CAAC,CAMpD;AAKD,MAAM,WAAW,mBAAmB;IAClC,UAAU,EAAE,OAAO,CAAC;IACpB,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAED;;oEAEoE;AACpE,wBAAgB,wBAAwB,CACtC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAC3B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAC1B,mBAAmB,CAIrB;AAiDD,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,GAAG,GAAG,MAAM,CAelD;AAED,wBAAgB,eAAe,CAAC,MAAM,EAAE,GAAG,EAAE,GAAG,IAAI,GAAG,SAAS,GAAG,MAAM,CAGxE;AAuBD,mFAAmF;AACnF,MAAM,WAAW,WAAW;IAC1B,OAAO,EAAE,MAAM,CAAC;IAChB,iFAAiF;IACjF,IAAI,EAAE,MAAM,CAAC;CACd;AAED,0GAA0G;AAC1G,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,WAAW,EAAE,CAe/F;AAED;qFACqF;AACrF,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAQ7E;AAED;;;;6DAI6D;AAC7D,wBAAgB,wBAAwB,CACtC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAC3B,IAAI,EAAE,MAAM,GACX,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,SAAS,CAsBjC;AAED,8DAA8D;AAC9D,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,SAAS,GAAG,MAAM,CAuBnF;AAED,wFAAwF;AACxF,wBAAgB,0BAA0B,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,OAAO,CAqBhG;AAED,6EAA6E;AAC7E,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,OAAO,CAiB5E;AA2BD;iGACiG;AACjG,wBAAgB,mBAAmB,CACjC,IAAI,EAAE,OAAO,EACb,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAC3B,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAC/B,OAAO,CAwBT"}
1
+ {"version":3,"file":"schema-compat.d.ts","sourceRoot":"","sources":["../src/schema-compat.ts"],"names":[],"mappings":"AAIA,QAAA,MAAM,GAAG,KAA0C,CAAC;AAEpD;0FAC0F;AAC1F,wBAAgB,SAAS,IAAI,YAAY,CAAC,OAAO,GAAG,CAAC,CAMpD;AAKD,MAAM,WAAW,mBAAmB;IAClC,UAAU,EAAE,OAAO,CAAC;IACpB,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAED;;oEAEoE;AACpE,wBAAgB,wBAAwB,CACtC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAC3B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAC1B,mBAAmB,CAIrB;AAiDD,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,GAAG,GAAG,MAAM,CAelD;AAED,wBAAgB,eAAe,CAAC,MAAM,EAAE,GAAG,EAAE,GAAG,IAAI,GAAG,SAAS,GAAG,MAAM,CAGxE;AAuBD,mFAAmF;AACnF,MAAM,WAAW,WAAW;IAC1B,OAAO,EAAE,MAAM,CAAC;IAChB,iFAAiF;IACjF,IAAI,EAAE,MAAM,CAAC;CACd;AAED,0GAA0G;AAC1G,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,WAAW,EAAE,CAe/F;AAED;qFACqF;AACrF,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAQ7E;AAED;;;;6DAI6D;AAC7D,wBAAgB,wBAAwB,CACtC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAC3B,IAAI,EAAE,MAAM,GACX,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,SAAS,CAsBjC;AAED,8DAA8D;AAC9D,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,SAAS,GAAG,MAAM,CAuBnF;AAED,wFAAwF;AACxF,wBAAgB,0BAA0B,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,OAAO,CAqBhG;AAED,6EAA6E;AAC7E,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,OAAO,CAiB5E;AA2BD;iGACiG;AACjG,wBAAgB,mBAAmB,CACjC,IAAI,EAAE,OAAO,EACb,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAC3B,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAC/B,OAAO,CA2BT"}
@@ -1,5 +1,6 @@
1
1
  import AjvModule from "ajv";
2
2
  import addFormats from "ajv-formats";
3
+ import { isTaggedSentinel } from "@telorun/templating";
3
4
  const Ajv = AjvModule.default ?? AjvModule;
4
5
  /** Creates a configured AJV instance (allErrors, strict: false, with formats).
5
6
  * Called once for the module-level instance and once per DefinitionRegistry instance. */
@@ -269,6 +270,9 @@ export function substituteCelFields(data, schema, rootSchema) {
269
270
  if (typeof data === "string" && CEL_PURE_RE.test(data)) {
270
271
  return celPlaceholderForSchema(resolved);
271
272
  }
273
+ if (isTaggedSentinel(data)) {
274
+ return celPlaceholderForSchema(resolved);
275
+ }
272
276
  if (Array.isArray(data)) {
273
277
  const itemSchema = resolveRef((resolved.items ?? {}), root);
274
278
  return data.map((item) => substituteCelFields(item, itemSchema, root));
@@ -1,4 +1,4 @@
1
- import type { ASTNode } from "@marcbachmann/cel-js";
1
+ export { extractAccessChains, validateChainAgainstSchema } from "@telorun/templating";
2
2
  /**
3
3
  * Resolve a type field value (string name, inline type, or raw schema) to a JSON Schema.
4
4
  * - String: look up the named type in allManifests (Type.JsonSchema resources)
@@ -6,21 +6,6 @@ import type { ASTNode } from "@marcbachmann/cel-js";
6
6
  * - Object with `type` or `properties`: raw JSON Schema, return as-is
7
7
  */
8
8
  export declare function resolveTypeFieldToSchema(value: unknown, allManifests: Record<string, any>[]): Record<string, any> | undefined;
9
- /**
10
- * Extract all member-access chains from a CEL AST.
11
- * Returns arrays like ["request", "query", "name"] for `request.query.name`.
12
- * Chains that start with a call or non-identifier root are ignored.
13
- * Bound variables in comprehension macros (filter, map, exists, all, exists_one) are excluded.
14
- */
15
- export declare function extractAccessChains(node: ASTNode): string[][];
16
- /**
17
- * Check whether a member-access chain accesses only fields declared in a JSON Schema.
18
- * Returns an error string if a field is unknown in a schema that declares explicit
19
- * properties without `additionalProperties: true`, or if the chain attempts to
20
- * reach inside an `x-telo-stream: true` property.
21
- * Returns null when the chain is valid or the schema is too open to judge.
22
- */
23
- export declare function validateChainAgainstSchema(chain: string[], schema: Record<string, any>): string | null;
24
9
  /**
25
10
  * Returns true when a CEL expression path (from walkCelExpressions, e.g. "routes[0].inputs.q")
26
11
  * falls within the scope of a context (e.g. "$.routes[*].inputs").
@@ -1 +1 @@
1
- {"version":3,"file":"validate-cel-context.d.ts","sourceRoot":"","sources":["../src/validate-cel-context.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,sBAAsB,CAAC;AAEpD;;;;;GAKG;AACH,wBAAgB,wBAAwB,CACtC,KAAK,EAAE,OAAO,EACd,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,GAClC,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,SAAS,CA6BjC;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,OAAO,GAAG,MAAM,EAAE,EAAE,CAI7D;AAsFD;;;;;;GAMG;AACH,wBAAgB,0BAA0B,CACxC,KAAK,EAAE,MAAM,EAAE,EACf,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAC1B,MAAM,GAAG,IAAI,CAiCf;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAoBzE;AAED;;;;;;;;GAQG;AACH,wBAAgB,yBAAyB,CACvC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAC3B,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EACjC,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,GACnC,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAqDrB;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAC7B,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAC5B,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAQrB"}
1
+ {"version":3,"file":"validate-cel-context.d.ts","sourceRoot":"","sources":["../src/validate-cel-context.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,0BAA0B,EAAE,MAAM,qBAAqB,CAAC;AAEtF;;;;;GAKG;AACH,wBAAgB,wBAAwB,CACtC,KAAK,EAAE,OAAO,EACd,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,GAClC,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,SAAS,CA6BjC;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAoBzE;AAED;;;;;;;;GAQG;AACH,wBAAgB,yBAAyB,CACvC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAC3B,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EACjC,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,GACnC,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAqDrB;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAC7B,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAC5B,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAQrB"}
@@ -1,3 +1,4 @@
1
+ export { extractAccessChains, validateChainAgainstSchema } from "@telorun/templating";
1
2
  /**
2
3
  * Resolve a type field value (string name, inline type, or raw schema) to a JSON Schema.
3
4
  * - String: look up the named type in allManifests (Type.JsonSchema resources)
@@ -29,139 +30,6 @@ export function resolveTypeFieldToSchema(value, allManifests) {
29
30
  }
30
31
  return undefined;
31
32
  }
32
- /**
33
- * Extract all member-access chains from a CEL AST.
34
- * Returns arrays like ["request", "query", "name"] for `request.query.name`.
35
- * Chains that start with a call or non-identifier root are ignored.
36
- * Bound variables in comprehension macros (filter, map, exists, all, exists_one) are excluded.
37
- */
38
- export function extractAccessChains(node) {
39
- const chains = [];
40
- visitNode(node, chains, new Set());
41
- return chains;
42
- }
43
- // CEL comprehension macros that bind a variable: list.filter(x, ...), list.map(x, ...), etc.
44
- const COMPREHENSION_METHODS = new Set(["filter", "map", "exists", "all", "exists_one"]);
45
- function visitNode(node, chains, boundVars) {
46
- const chain = extractChain(node, boundVars);
47
- if (chain !== null) {
48
- chains.push(chain);
49
- return; // don't recurse into parts of an already-collected chain
50
- }
51
- // Comprehension macros bind a variable in their body — handle them specially
52
- // AST shape: { op: "rcall", args: [methodName, receiver, [boundVarId, body, ...]] }
53
- if (node.op === "rcall" &&
54
- Array.isArray(node.args) &&
55
- typeof node.args[0] === "string" &&
56
- COMPREHENSION_METHODS.has(node.args[0])) {
57
- const receiver = node.args[1];
58
- const comprehensionArgs = node.args[2];
59
- if (isASTNode(receiver))
60
- visitNode(receiver, chains, boundVars);
61
- if (Array.isArray(comprehensionArgs) &&
62
- comprehensionArgs.length >= 2 &&
63
- isASTNode(comprehensionArgs[0]) &&
64
- comprehensionArgs[0].op === "id") {
65
- const newBoundVars = new Set(boundVars);
66
- newBoundVars.add(comprehensionArgs[0].args);
67
- for (let i = 1; i < comprehensionArgs.length; i++) {
68
- const arg = comprehensionArgs[i];
69
- if (isASTNode(arg))
70
- visitNode(arg, chains, newBoundVars);
71
- }
72
- }
73
- return;
74
- }
75
- const args = node.args;
76
- if (Array.isArray(args)) {
77
- for (const arg of args) {
78
- if (isASTNode(arg)) {
79
- visitNode(arg, chains, boundVars);
80
- }
81
- else if (Array.isArray(arg)) {
82
- for (const item of arg) {
83
- if (isASTNode(item))
84
- visitNode(item, chains, boundVars);
85
- }
86
- }
87
- }
88
- }
89
- }
90
- function isASTNode(v) {
91
- return v !== null && typeof v === "object" && "op" in v;
92
- }
93
- // Sentinel used in extracted chains to represent an `[index]` access. The
94
- // validator treats it like any other deeper segment, so a chain that reaches
95
- // past an `x-telo-stream`-marked property via `[N]` is rejected the same way
96
- // `.field` access is. The actual index expression is opaque to the chain
97
- // validator (it only checks property names against the schema).
98
- const INDEX_SEGMENT = "[*]";
99
- /** Returns the member-access chain for a node if it is purely an id, ".", or
100
- * "[]" chain; else null. Bracket index access is captured as `INDEX_SEGMENT`
101
- * so it counts as a chain extension for the stream-property opacity rule. */
102
- function extractChain(node, boundVars) {
103
- if (node.op === "id") {
104
- const name = node.args;
105
- if (boundVars.has(name))
106
- return null; // bound by a comprehension macro, not a free access
107
- return [name];
108
- }
109
- if (node.op === ".") {
110
- const [obj, field] = node.args;
111
- const parent = extractChain(obj, boundVars);
112
- if (parent !== null)
113
- return [...parent, field];
114
- }
115
- if (node.op === "[]") {
116
- const [obj] = node.args;
117
- const parent = extractChain(obj, boundVars);
118
- if (parent !== null)
119
- return [...parent, INDEX_SEGMENT];
120
- }
121
- return null;
122
- }
123
- /**
124
- * Check whether a member-access chain accesses only fields declared in a JSON Schema.
125
- * Returns an error string if a field is unknown in a schema that declares explicit
126
- * properties without `additionalProperties: true`, or if the chain attempts to
127
- * reach inside an `x-telo-stream: true` property.
128
- * Returns null when the chain is valid or the schema is too open to judge.
129
- */
130
- export function validateChainAgainstSchema(chain, schema) {
131
- let current = schema;
132
- for (let i = 0; i < chain.length; i++) {
133
- const key = chain[i];
134
- if (!current || typeof current !== "object")
135
- return null;
136
- const props = current.properties;
137
- if (!props)
138
- return null;
139
- if (key in props) {
140
- const propSchema = props[key];
141
- // Stream-typed properties are opaque: pass through by reference, no
142
- // member access inside. Reaching `result.output` is fine; reaching
143
- // `result.output.text` is a wiring mistake — the consumer either iterates
144
- // the stream in a JS.Script step or pipes it through an Encoder.
145
- if (propSchema &&
146
- typeof propSchema === "object" &&
147
- propSchema["x-telo-stream"] === true &&
148
- i < chain.length - 1) {
149
- const path = chain.slice(0, i + 1).join(".");
150
- return `'${path}' yields a stream — pipe it through an Encoder or iterate in a JS.Script step (no member access on stream-typed values)`;
151
- }
152
- // Known property — drill into it even if additionalProperties is true
153
- current = propSchema;
154
- continue;
155
- }
156
- // Unknown property — only flag if schema is closed
157
- if (current.additionalProperties === true)
158
- return null;
159
- const path = chain.slice(0, i + 1).join(".");
160
- const available = Object.keys(props).join(", ");
161
- return `'${path}' is not defined (available: ${available})`;
162
- }
163
- return null;
164
- }
165
33
  /**
166
34
  * Returns true when a CEL expression path (from walkCelExpressions, e.g. "routes[0].inputs.q")
167
35
  * falls within the scope of a context (e.g. "$.routes[*].inputs").
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telorun/analyzer",
3
- "version": "0.7.0",
3
+ "version": "0.8.1",
4
4
  "description": "Telo Analyzer - Static manifest validator for Telo manifests.",
5
5
  "keywords": [
6
6
  "telo",
@@ -41,13 +41,17 @@
41
41
  "ajv-formats": "^3.0.1",
42
42
  "jsonpath-plus": "^10.3.0",
43
43
  "yaml": "^2.8.3",
44
- "@telorun/sdk": "0.7.0"
44
+ "@telorun/sdk": "0.7.0",
45
+ "@telorun/templating": "0.2.1"
45
46
  },
46
47
  "devDependencies": {
47
48
  "@types/node": "^20.0.0",
48
- "typescript": "^5.0.0"
49
+ "typescript": "^5.0.0",
50
+ "vitest": "^2.1.8"
49
51
  },
50
52
  "scripts": {
51
- "build": "tsc -p tsconfig.lib.json"
53
+ "build": "tsc -p tsconfig.lib.json",
54
+ "test": "vitest run",
55
+ "test:watch": "vitest"
52
56
  }
53
57
  }
package/src/analyzer.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { ResourceDefinition, ResourceManifest } from "@telorun/sdk";
2
2
  import type { Environment } from "@marcbachmann/cel-js";
3
+ import { defaultRegistry, walkCelExpressions } from "@telorun/templating";
3
4
  import { AliasResolver } from "./alias-resolver.js";
4
5
  import { AnalysisRegistry } from "./analysis-registry.js";
5
6
  import {
@@ -21,19 +22,15 @@ import {
21
22
  } from "./schema-compat.js";
22
23
  import { DiagnosticSeverity, type AnalysisDiagnostic, type AnalysisOptions } from "./types.js";
23
24
  import {
24
- extractAccessChains,
25
25
  getManifestItem,
26
26
  pathMatchesScope,
27
27
  resolveContextAnnotations,
28
28
  resolveTypeFieldToSchema,
29
- validateChainAgainstSchema,
30
29
  } from "./validate-cel-context.js";
31
30
  import { validateExtends } from "./validate-extends.js";
32
31
  import { validateReferences } from "./validate-references.js";
33
32
  import { validateThrowsCoverage } from "./validate-throws-coverage.js";
34
33
 
35
- const TEMPLATE_REGEX = /\$\{\{\s*([^}]+?)\s*\}\}/g;
36
-
37
34
  const SELF_PREFIX = "Self.";
38
35
 
39
36
  /** Resolve an alias-prefixed kind value (e.g. `Self.Encoder` or `Ai.Model`)
@@ -69,24 +66,6 @@ function lookupDefinitionTypeField(
69
66
  return resolveTypeFieldToSchema(value, allManifests);
70
67
  }
71
68
 
72
- function walkCelExpressions(
73
- value: unknown,
74
- path: string,
75
- cb: (expr: string, path: string) => void,
76
- ): void {
77
- if (typeof value === "string") {
78
- for (const m of value.matchAll(TEMPLATE_REGEX)) {
79
- cb(m[1].trim(), path);
80
- }
81
- } else if (Array.isArray(value)) {
82
- value.forEach((v, i) => walkCelExpressions(v, `${path}[${i}]`, cb));
83
- } else if (value !== null && typeof value === "object") {
84
- for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
85
- walkCelExpressions(v, path ? `${path}.${k}` : k, cb);
86
- }
87
- }
88
- }
89
-
90
69
  const SOURCE = "telo-analyzer";
91
70
 
92
71
  /**
@@ -253,35 +232,37 @@ function buildStepContextSchema(
253
232
  if (!step || typeof step !== "object") continue;
254
233
  const s = step as Record<string, any>;
255
234
  const name = s.name;
256
- if (typeof name === "string") {
257
- const invoke = s[invokeField] as Record<string, any> | undefined;
235
+ const invoke = s[invokeField] as Record<string, any> | undefined;
236
+ // Only invoke steps register a `steps.<name>.result` entry control-flow
237
+ // wrappers (try/if/while/switch/throw) don't produce a result and must
238
+ // not shadow real entries with a permissive `additionalProperties: true`,
239
+ // or unknown step references slip through chain validation.
240
+ if (typeof name === "string" && invoke && typeof invoke === "object") {
258
241
  let outputSchema: Record<string, any> | undefined;
259
- if (invoke && typeof invoke === "object") {
260
- const invokedKind = invoke.kind as string | undefined;
261
- const invokedName = invoke.name as string | undefined;
262
- if (invokedName) {
263
- const invokedManifest = allManifests.find(
264
- (m) =>
265
- (m.metadata as any)?.name === invokedName &&
266
- (!invokedKind || m.kind === invokedKind),
267
- ) as Record<string, any> | undefined;
268
- if (invokedManifest) {
269
- outputSchema = resolveTypeFieldToSchema(invokedManifest[outputTypeField], allManifests);
270
- }
271
- } else {
272
- outputSchema = resolveTypeFieldToSchema(invoke[outputTypeField], allManifests);
273
- }
274
- // Fallback: pull outputType from the kind's Telo.Definition. The
275
- // resource manifest typically doesn't carry outputType; the def does.
276
- if (!outputSchema && invokedKind) {
277
- outputSchema = lookupDefinitionTypeField(
278
- invokedKind,
279
- outputTypeField,
280
- defs,
281
- aliases,
282
- allManifests,
283
- );
242
+ const invokedKind = invoke.kind as string | undefined;
243
+ const invokedName = invoke.name as string | undefined;
244
+ if (invokedName) {
245
+ const invokedManifest = allManifests.find(
246
+ (m) =>
247
+ (m.metadata as any)?.name === invokedName &&
248
+ (!invokedKind || m.kind === invokedKind),
249
+ ) as Record<string, any> | undefined;
250
+ if (invokedManifest) {
251
+ outputSchema = resolveTypeFieldToSchema(invokedManifest[outputTypeField], allManifests);
284
252
  }
253
+ } else {
254
+ outputSchema = resolveTypeFieldToSchema(invoke[outputTypeField], allManifests);
255
+ }
256
+ // Fallback: pull outputType from the kind's Telo.Definition. The
257
+ // resource manifest typically doesn't carry outputType; the def does.
258
+ if (!outputSchema && invokedKind) {
259
+ outputSchema = lookupDefinitionTypeField(
260
+ invokedKind,
261
+ outputTypeField,
262
+ defs,
263
+ aliases,
264
+ allManifests,
265
+ );
285
266
  }
286
267
  stepProperties[name] = {
287
268
  type: "object",
@@ -651,31 +632,17 @@ export class StaticAnalyzer {
651
632
  )
652
633
  : undefined;
653
634
 
654
- walkCelExpressions(m, "", (expr, path) => {
655
- let parsed: ReturnType<typeof this.celEnv.parse> | undefined;
656
- try {
657
- parsed = this.celEnv.parse(expr);
658
- } catch (e) {
659
- diagnostics.push({
660
- severity: DiagnosticSeverity.Error,
661
- code: "CEL_SYNTAX_ERROR",
662
- source: SOURCE,
663
- message: `CEL syntax error at ${path}: ${e instanceof Error ? e.message : String(e)}`,
664
- data: { resource, filePath, path },
665
- });
666
- return;
667
- }
668
-
669
- const accessChains = extractAccessChains(parsed.ast);
670
-
635
+ walkCelExpressions(m, "", (expr, path, engineName) => {
636
+ // Resolve the effective context for this expression's path. The
637
+ // engine receives a single closed schema and validates member-access
638
+ // chains against it; per-path resolution (step context, x-telo-context,
639
+ // kernel-globals merge) stays on the analyzer side because it depends
640
+ // on analyzer-internal state (definitions, aliases).
671
641
  const contexts = mDefinition?.schema ? extractContextsFromSchema(mDefinition.schema) : [];
672
642
  const invocationContext = (m.metadata as any)?.xTeloInvocationContext as
673
643
  | Record<string, any>
674
644
  | undefined;
675
645
 
676
- // If no static context but we have step context, inject it
677
- if (contexts.length === 0 && !invocationContext && !stepContextSchema) return;
678
-
679
646
  let matchedContext: Record<string, any> | undefined;
680
647
  let matchedScope: string | undefined;
681
648
  for (const ctx of contexts) {
@@ -687,7 +654,6 @@ export class StaticAnalyzer {
687
654
  }
688
655
  if (!matchedContext) matchedContext = invocationContext;
689
656
 
690
- // Merge step context into the effective context
691
657
  if (stepContextSchema) {
692
658
  const base = matchedContext ?? { type: "object", properties: {}, additionalProperties: true };
693
659
  matchedContext = {
@@ -699,28 +665,51 @@ export class StaticAnalyzer {
699
665
  };
700
666
  }
701
667
 
702
- if (!matchedContext) return;
703
-
704
- const manifestItem = matchedScope
705
- ? getManifestItem(path, matchedScope, m as Record<string, any>)
706
- : (m as Record<string, any>);
707
- const resolvedContext = resolveContextAnnotations(
708
- matchedContext,
709
- manifestItem,
710
- allManifests as Record<string, any>[],
711
- );
712
- const effectiveContext = mergeKernelGlobalsIntoContext(resolvedContext, kernelGlobals);
713
-
714
- for (const chain of accessChains) {
715
- const err = validateChainAgainstSchema(chain, effectiveContext);
716
- if (!err) continue;
717
- diagnostics.push({
718
- severity: DiagnosticSeverity.Error,
719
- code: "CEL_UNKNOWN_FIELD",
720
- source: SOURCE,
721
- message: `${m.kind}/${resource.name}: CEL at '${path}': ${err}`,
722
- data: { resource, filePath, path },
723
- });
668
+ let effectiveContext: Record<string, any> | null = null;
669
+ if (matchedContext) {
670
+ const manifestItem = matchedScope
671
+ ? getManifestItem(path, matchedScope, m as Record<string, any>)
672
+ : (m as Record<string, any>);
673
+ const resolvedContext = resolveContextAnnotations(
674
+ matchedContext,
675
+ manifestItem,
676
+ allManifests as Record<string, any>[],
677
+ );
678
+ effectiveContext = mergeKernelGlobalsIntoContext(resolvedContext, kernelGlobals);
679
+ }
680
+
681
+ const engine = defaultRegistry().get(engineName);
682
+ if (!engine) return;
683
+ const findings = engine.analyze(expr, { celEnv: this.celEnv, contextSchema: effectiveContext });
684
+ for (const f of findings) {
685
+ if (f.code === "CEL_SYNTAX_ERROR") {
686
+ diagnostics.push({
687
+ severity: DiagnosticSeverity.Error,
688
+ code: "CEL_SYNTAX_ERROR",
689
+ source: SOURCE,
690
+ message: `CEL syntax error at ${path}: ${f.message}`,
691
+ data: { resource, filePath, path },
692
+ });
693
+ } else if (f.code === "CEL_UNKNOWN_FIELD") {
694
+ diagnostics.push({
695
+ severity: DiagnosticSeverity.Error,
696
+ code: "CEL_UNKNOWN_FIELD",
697
+ source: SOURCE,
698
+ message: `${m.kind}/${resource.name}: CEL at '${path}': ${f.message}`,
699
+ data: { resource, filePath, path },
700
+ });
701
+ } else {
702
+ // Unknown code from a future engine — pass the message through,
703
+ // tagged with a generic ENGINE_DIAGNOSTIC code so downstream
704
+ // filters can still bucket it.
705
+ diagnostics.push({
706
+ severity: DiagnosticSeverity.Error,
707
+ code: f.code ?? "ENGINE_DIAGNOSTIC",
708
+ source: SOURCE,
709
+ message: `${m.kind}/${resource.name}: !${engineName} at '${path}': ${f.message}`,
710
+ data: { resource, filePath, path },
711
+ });
712
+ }
724
713
  }
725
714
  });
726
715
  }
@@ -1,54 +1,9 @@
1
1
  import { Environment } from "@marcbachmann/cel-js";
2
- import { Stream, type ResourceManifest } from "@telorun/sdk";
2
+ import type { ResourceManifest } from "@telorun/sdk";
3
3
  import { jsonSchemaToCelType } from "./schema-compat.js";
4
4
 
5
- export interface CelHandlers {
6
- sha256: (s: string) => string;
7
- json: (value: unknown) => string;
8
- }
9
-
10
- const stub = (name: string) => () => {
11
- throw new Error(
12
- `${name}() is not available in this environment. ` +
13
- `Construct StaticAnalyzer or Loader with celHandlers to enable it.`,
14
- );
15
- };
16
-
17
- const STUB_HANDLERS: CelHandlers = {
18
- sha256: stub("sha256"),
19
- json: stub("json"),
20
- };
21
-
22
- /** Build a CEL `Environment` with Telo's stdlib of functions. Always registers the
23
- * same function signatures (so `env.check()` succeeds for type-inference) — the
24
- * handlers govern what the function does when called at runtime. Analyzer-only
25
- * callers can omit handlers; runtime callers (kernel) must supply real ones.
26
- *
27
- * Also registers the `Stream` object type, backed by the `Stream` class from
28
- * `@telorun/sdk`. CEL's type-checker rejects values whose constructor isn't
29
- * Object/Map/Array/Set/registered; producers that need to expose an
30
- * `AsyncIterable` through a stream-typed property must wrap the iterable in
31
- * `new Stream(...)` so its constructor is the registered class. The type has
32
- * no fields, so terminal access (passing the value through CEL) succeeds but
33
- * member access raises a CEL error at runtime — matching the analyzer's
34
- * static check on `x-telo-stream`-marked properties. */
35
- export function buildCelEnvironment(handlers: CelHandlers = STUB_HANDLERS): Environment {
36
- return new Environment({ unlistedVariablesAreDyn: true, enableOptionalTypes: true })
37
- .registerFunction("join(list, string): string", (list: unknown[], sep: string) =>
38
- list.map(String).join(sep),
39
- )
40
- .registerFunction("keys(map): list", (map: unknown) => {
41
- if (map instanceof Map) return [...map.keys()];
42
- return Object.keys(map as Record<string, unknown>);
43
- })
44
- .registerFunction("values(map): list", (map: unknown) => {
45
- if (map instanceof Map) return [...map.values()];
46
- return Object.values(map as Record<string, unknown>);
47
- })
48
- .registerFunction("sha256(string): string", (s: string) => handlers.sha256(s))
49
- .registerFunction("json(dyn): string", (value: unknown) => handlers.json(value))
50
- .registerType("Stream", Stream as unknown as new (...args: unknown[]) => unknown);
51
- }
5
+ export { buildCelEnvironment } from "@telorun/templating";
6
+ export type { CelHandlers } from "@telorun/templating";
52
7
 
53
8
  /** Clone `baseEnv` and register typed variable declarations so that
54
9
  * `env.check(expr)` can infer return types for expressions referencing known variables.
@@ -1,5 +1,6 @@
1
1
  import type { Environment } from "@marcbachmann/cel-js";
2
2
  import { isCompiledValue, type ResourceManifest } from "@telorun/sdk";
3
+ import { defaultCustomTags } from "@telorun/templating";
3
4
  import { isMap, isPair, isScalar, isSeq, parseAllDocuments, type Document } from "yaml";
4
5
  import { HttpSource } from "./sources/http-source.js";
5
6
  import { RegistrySource } from "./sources/registry-source.js";
@@ -74,7 +75,7 @@ export class Loader {
74
75
  return cloneManifestArray(cached.manifests);
75
76
  }
76
77
 
77
- const parsedDocuments = parseAllDocuments(text);
78
+ const parsedDocuments = parseAllDocuments(text, { customTags: defaultCustomTags() });
78
79
  const rawDocs = parsedDocuments.map((d) => d.toJSON());
79
80
  const offsets = documentLineOffsets(text);
80
81
  const lineOffsets = buildLineOffsets(text);
@@ -194,7 +195,7 @@ export class Loader {
194
195
  ): Promise<ResourceManifest[]> {
195
196
  const { text, source } = await this.pick(url).read(url);
196
197
 
197
- const parsedDocuments = parseAllDocuments(text);
198
+ const parsedDocuments = parseAllDocuments(text, { customTags: defaultCustomTags() });
198
199
  const rawDocs = parsedDocuments.map((d) => d.toJSON());
199
200
  const offsets = documentLineOffsets(text);
200
201
  const lineOffsets = buildLineOffsets(text);
package/src/precompile.ts CHANGED
@@ -1,17 +1,43 @@
1
- import type { CompiledValue } from "@telorun/sdk";
2
1
  import type { Environment } from "@marcbachmann/cel-js";
3
-
4
- const TEMPLATE_REGEX = /\$\{\{\s*([^}]+?)\s*\}\}/g;
5
- const EXACT_TEMPLATE_REGEX = /^\s*\$\{\{\s*([^}]+?)\s*\}\}\s*$/;
2
+ import { isCompiledValue } from "@telorun/sdk";
3
+ import { compileString, defaultRegistry, isTaggedSentinel } from "@telorun/templating";
6
4
 
7
5
  /**
8
- * Walks a raw YAML document and replaces all "${{ expr }}" strings with
9
- * CompiledValue wrappers. Throws on CEL syntax errors.
10
- * Intended to be called once per document at load time.
11
- * Telo.Definition documents are returned unchanged — their schema fields
12
- * are static metadata and must not be treated as CEL templates.
6
+ * Walks a raw YAML document and replaces all `${{ expr }}` strings (and
7
+ * `!cel`-tagged sentinels) with CompiledValue wrappers. Throws on CEL syntax
8
+ * errors. Intended to be called once per document at load time.
9
+ *
10
+ * Note on Telo.Definition / Telo.Abstract: the walker traverses these too.
11
+ * Their `schema` fields are JSON Schema metadata and don't typically contain
12
+ * `${{ }}` text, so compile is a no-op there. Their `template` fields, on
13
+ * the other hand, *do* carry CEL — Definition-driven templates are expanded
14
+ * by the kernel and rely on the precompiled tree. If a description or
15
+ * example string inside a schema happens to contain `${{ }}`, it will be
16
+ * interpreted as CEL; tag it `!literal` to opt out.
13
17
  */
14
18
  export function precompileDoc(doc: unknown, env: Environment): unknown {
19
+ // Tagged sentinel: dispatch to the engine. The result is decorated with
20
+ // `__tagged` + `engine` + `source` when it's a CompiledValue so the
21
+ // analyzer's diagnostic walk can identify it on compiled trees too;
22
+ // engines returning plain values (e.g. `literal` → a string) pass through
23
+ // verbatim — the runtime contract is "any scalar value is fine."
24
+ if (isTaggedSentinel(doc)) {
25
+ const engine = defaultRegistry().get(doc.engine);
26
+ if (!engine) {
27
+ throw new Error(`Unknown templating engine: !${doc.engine}`);
28
+ }
29
+ const compiled = engine.compile(doc.source, { celEnv: env });
30
+ if (isCompiledValue(compiled)) {
31
+ return {
32
+ __tagged: true,
33
+ __compiled: true,
34
+ engine: doc.engine,
35
+ source: doc.source,
36
+ call: compiled.call.bind(compiled),
37
+ };
38
+ }
39
+ return compiled;
40
+ }
15
41
  if (typeof doc === "string") return compileString(doc, env);
16
42
  if (Array.isArray(doc)) return doc.map((item) => precompileDoc(item, env));
17
43
  // Only recurse into plain objects. Class instances (ResourceInstance, ScopeHandle, etc.)
@@ -25,33 +51,3 @@ export function precompileDoc(doc: unknown, env: Environment): unknown {
25
51
  }
26
52
  return doc;
27
53
  }
28
-
29
- function compileString(s: string, env: Environment): unknown {
30
- if (!s.includes("${{")) return s;
31
-
32
- const exact = s.match(EXACT_TEMPLATE_REGEX);
33
- if (exact) {
34
- const expr = exact[1].trim();
35
- const fn = env.parse(expr);
36
- return { __compiled: true, source: expr, call: (ctx: Record<string, unknown>) => fn(ctx) } satisfies CompiledValue;
37
- }
38
-
39
- // Interpolated template — collect literal parts + compiled sub-expressions
40
- const parts: Array<string | CompiledValue> = [];
41
- let last = 0;
42
- for (const m of s.matchAll(TEMPLATE_REGEX)) {
43
- if (m.index! > last) parts.push(s.slice(last, m.index));
44
- const expr = m[1].trim();
45
- const fn = env.parse(expr);
46
- parts.push({ __compiled: true, source: expr, call: (ctx: Record<string, unknown>) => fn(ctx) } satisfies CompiledValue);
47
- last = m.index! + m[0].length;
48
- }
49
- if (last < s.length) parts.push(s.slice(last));
50
-
51
- return {
52
- __compiled: true,
53
- source: s,
54
- call: (ctx: Record<string, unknown>) =>
55
- parts.map((p) => (typeof p === "string" ? p : String(p.call(ctx) ?? ""))).join(""),
56
- } satisfies CompiledValue;
57
- }
@@ -1,5 +1,6 @@
1
1
  import AjvModule from "ajv";
2
2
  import addFormats from "ajv-formats";
3
+ import { isTaggedSentinel } from "@telorun/templating";
3
4
 
4
5
  const Ajv = (AjvModule as any).default ?? AjvModule;
5
6
 
@@ -300,6 +301,9 @@ export function substituteCelFields(
300
301
  if (typeof data === "string" && CEL_PURE_RE.test(data)) {
301
302
  return celPlaceholderForSchema(resolved);
302
303
  }
304
+ if (isTaggedSentinel(data)) {
305
+ return celPlaceholderForSchema(resolved);
306
+ }
303
307
  if (Array.isArray(data)) {
304
308
  const itemSchema = resolveRef((resolved.items ?? {}) as Record<string, any>, root);
305
309
  return data.map((item) => substituteCelFields(item, itemSchema, root));
@@ -1,4 +1,4 @@
1
- import type { ASTNode } from "@marcbachmann/cel-js";
1
+ export { extractAccessChains, validateChainAgainstSchema } from "@telorun/templating";
2
2
 
3
3
  /**
4
4
  * Resolve a type field value (string name, inline type, or raw schema) to a JSON Schema.
@@ -40,147 +40,6 @@ export function resolveTypeFieldToSchema(
40
40
  return undefined;
41
41
  }
42
42
 
43
- /**
44
- * Extract all member-access chains from a CEL AST.
45
- * Returns arrays like ["request", "query", "name"] for `request.query.name`.
46
- * Chains that start with a call or non-identifier root are ignored.
47
- * Bound variables in comprehension macros (filter, map, exists, all, exists_one) are excluded.
48
- */
49
- export function extractAccessChains(node: ASTNode): string[][] {
50
- const chains: string[][] = [];
51
- visitNode(node, chains, new Set());
52
- return chains;
53
- }
54
-
55
- // CEL comprehension macros that bind a variable: list.filter(x, ...), list.map(x, ...), etc.
56
- const COMPREHENSION_METHODS = new Set(["filter", "map", "exists", "all", "exists_one"]);
57
-
58
- function visitNode(node: ASTNode, chains: string[][], boundVars: Set<string>): void {
59
- const chain = extractChain(node, boundVars);
60
- if (chain !== null) {
61
- chains.push(chain);
62
- return; // don't recurse into parts of an already-collected chain
63
- }
64
-
65
- // Comprehension macros bind a variable in their body — handle them specially
66
- // AST shape: { op: "rcall", args: [methodName, receiver, [boundVarId, body, ...]] }
67
- if (
68
- node.op === "rcall" &&
69
- Array.isArray(node.args) &&
70
- typeof node.args[0] === "string" &&
71
- COMPREHENSION_METHODS.has(node.args[0])
72
- ) {
73
- const receiver = node.args[1];
74
- const comprehensionArgs = node.args[2];
75
- if (isASTNode(receiver)) visitNode(receiver, chains, boundVars);
76
- if (
77
- Array.isArray(comprehensionArgs) &&
78
- comprehensionArgs.length >= 2 &&
79
- isASTNode(comprehensionArgs[0]) &&
80
- (comprehensionArgs[0] as ASTNode).op === "id"
81
- ) {
82
- const newBoundVars = new Set(boundVars);
83
- newBoundVars.add((comprehensionArgs[0] as ASTNode).args as string);
84
- for (let i = 1; i < comprehensionArgs.length; i++) {
85
- const arg = comprehensionArgs[i];
86
- if (isASTNode(arg)) visitNode(arg as ASTNode, chains, newBoundVars);
87
- }
88
- }
89
- return;
90
- }
91
-
92
- const args = node.args;
93
- if (Array.isArray(args)) {
94
- for (const arg of args) {
95
- if (isASTNode(arg)) {
96
- visitNode(arg, chains, boundVars);
97
- } else if (Array.isArray(arg)) {
98
- for (const item of arg) {
99
- if (isASTNode(item)) visitNode(item, chains, boundVars);
100
- }
101
- }
102
- }
103
- }
104
- }
105
-
106
- function isASTNode(v: unknown): v is ASTNode {
107
- return v !== null && typeof v === "object" && "op" in (v as object);
108
- }
109
-
110
- // Sentinel used in extracted chains to represent an `[index]` access. The
111
- // validator treats it like any other deeper segment, so a chain that reaches
112
- // past an `x-telo-stream`-marked property via `[N]` is rejected the same way
113
- // `.field` access is. The actual index expression is opaque to the chain
114
- // validator (it only checks property names against the schema).
115
- const INDEX_SEGMENT = "[*]";
116
-
117
- /** Returns the member-access chain for a node if it is purely an id, ".", or
118
- * "[]" chain; else null. Bracket index access is captured as `INDEX_SEGMENT`
119
- * so it counts as a chain extension for the stream-property opacity rule. */
120
- function extractChain(node: ASTNode, boundVars: Set<string>): string[] | null {
121
- if (node.op === "id") {
122
- const name = node.args as string;
123
- if (boundVars.has(name)) return null; // bound by a comprehension macro, not a free access
124
- return [name];
125
- }
126
- if (node.op === ".") {
127
- const [obj, field] = node.args as [ASTNode, string];
128
- const parent = extractChain(obj, boundVars);
129
- if (parent !== null) return [...parent, field];
130
- }
131
- if (node.op === "[]") {
132
- const [obj] = node.args as [ASTNode, ASTNode];
133
- const parent = extractChain(obj, boundVars);
134
- if (parent !== null) return [...parent, INDEX_SEGMENT];
135
- }
136
- return null;
137
- }
138
-
139
- /**
140
- * Check whether a member-access chain accesses only fields declared in a JSON Schema.
141
- * Returns an error string if a field is unknown in a schema that declares explicit
142
- * properties without `additionalProperties: true`, or if the chain attempts to
143
- * reach inside an `x-telo-stream: true` property.
144
- * Returns null when the chain is valid or the schema is too open to judge.
145
- */
146
- export function validateChainAgainstSchema(
147
- chain: string[],
148
- schema: Record<string, any>,
149
- ): string | null {
150
- let current: Record<string, any> = schema;
151
- for (let i = 0; i < chain.length; i++) {
152
- const key = chain[i]!;
153
- if (!current || typeof current !== "object") return null;
154
- const props: Record<string, any> | undefined = current.properties;
155
- if (!props) return null;
156
- if (key in props) {
157
- const propSchema = props[key];
158
- // Stream-typed properties are opaque: pass through by reference, no
159
- // member access inside. Reaching `result.output` is fine; reaching
160
- // `result.output.text` is a wiring mistake — the consumer either iterates
161
- // the stream in a JS.Script step or pipes it through an Encoder.
162
- if (
163
- propSchema &&
164
- typeof propSchema === "object" &&
165
- propSchema["x-telo-stream"] === true &&
166
- i < chain.length - 1
167
- ) {
168
- const path = chain.slice(0, i + 1).join(".");
169
- return `'${path}' yields a stream — pipe it through an Encoder or iterate in a JS.Script step (no member access on stream-typed values)`;
170
- }
171
- // Known property — drill into it even if additionalProperties is true
172
- current = propSchema;
173
- continue;
174
- }
175
- // Unknown property — only flag if schema is closed
176
- if (current.additionalProperties === true) return null;
177
- const path = chain.slice(0, i + 1).join(".");
178
- const available = Object.keys(props).join(", ");
179
- return `'${path}' is not defined (available: ${available})`;
180
- }
181
- return null;
182
- }
183
-
184
43
  /**
185
44
  * Returns true when a CEL expression path (from walkCelExpressions, e.g. "routes[0].inputs.q")
186
45
  * falls within the scope of a context (e.g. "$.routes[*].inputs").