@telorun/analyzer 0.7.0 → 0.8.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.
@@ -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;AA8W/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,
@@ -512,27 +497,14 @@ export class StaticAnalyzer {
512
497
  const stepContextSchema = mDefinition?.schema
513
498
  ? buildStepContextSchema(m, mDefinition.schema, allManifests, defs, aliases)
514
499
  : 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);
500
+ walkCelExpressions(m, "", (expr, path, engineName) => {
501
+ // Resolve the effective context for this expression's path. The
502
+ // engine receives a single closed schema and validates member-access
503
+ // chains against it; per-path resolution (step context, x-telo-context,
504
+ // kernel-globals merge) stays on the analyzer side because it depends
505
+ // on analyzer-internal state (definitions, aliases).
531
506
  const contexts = mDefinition?.schema ? extractContextsFromSchema(mDefinition.schema) : [];
532
507
  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
508
  let matchedContext;
537
509
  let matchedScope;
538
510
  for (const ctx of contexts) {
@@ -544,7 +516,6 @@ export class StaticAnalyzer {
544
516
  }
545
517
  if (!matchedContext)
546
518
  matchedContext = invocationContext;
547
- // Merge step context into the effective context
548
519
  if (stepContextSchema) {
549
520
  const base = matchedContext ?? { type: "object", properties: {}, additionalProperties: true };
550
521
  matchedContext = {
@@ -555,24 +526,49 @@ export class StaticAnalyzer {
555
526
  },
556
527
  };
557
528
  }
558
- if (!matchedContext)
529
+ let effectiveContext = null;
530
+ if (matchedContext) {
531
+ const manifestItem = matchedScope
532
+ ? getManifestItem(path, matchedScope, m)
533
+ : m;
534
+ const resolvedContext = resolveContextAnnotations(matchedContext, manifestItem, allManifests);
535
+ effectiveContext = mergeKernelGlobalsIntoContext(resolvedContext, kernelGlobals);
536
+ }
537
+ const engine = defaultRegistry().get(engineName);
538
+ if (!engine)
559
539
  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
- });
540
+ const findings = engine.analyze(expr, { celEnv: this.celEnv, contextSchema: effectiveContext });
541
+ for (const f of findings) {
542
+ if (f.code === "CEL_SYNTAX_ERROR") {
543
+ diagnostics.push({
544
+ severity: DiagnosticSeverity.Error,
545
+ code: "CEL_SYNTAX_ERROR",
546
+ source: SOURCE,
547
+ message: `CEL syntax error at ${path}: ${f.message}`,
548
+ data: { resource, filePath, path },
549
+ });
550
+ }
551
+ else if (f.code === "CEL_UNKNOWN_FIELD") {
552
+ diagnostics.push({
553
+ severity: DiagnosticSeverity.Error,
554
+ code: "CEL_UNKNOWN_FIELD",
555
+ source: SOURCE,
556
+ message: `${m.kind}/${resource.name}: CEL at '${path}': ${f.message}`,
557
+ data: { resource, filePath, path },
558
+ });
559
+ }
560
+ else {
561
+ // Unknown code from a future engine — pass the message through,
562
+ // tagged with a generic ENGINE_DIAGNOSTIC code so downstream
563
+ // filters can still bucket it.
564
+ diagnostics.push({
565
+ severity: DiagnosticSeverity.Error,
566
+ code: f.code ?? "ENGINE_DIAGNOSTIC",
567
+ source: SOURCE,
568
+ message: `${m.kind}/${resource.name}: !${engineName} at '${path}': ${f.message}`,
569
+ data: { resource, filePath, path },
570
+ });
571
+ }
576
572
  }
577
573
  });
578
574
  }
@@ -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.0",
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.0"
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
  /**
@@ -651,31 +630,17 @@ export class StaticAnalyzer {
651
630
  )
652
631
  : undefined;
653
632
 
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
-
633
+ walkCelExpressions(m, "", (expr, path, engineName) => {
634
+ // Resolve the effective context for this expression's path. The
635
+ // engine receives a single closed schema and validates member-access
636
+ // chains against it; per-path resolution (step context, x-telo-context,
637
+ // kernel-globals merge) stays on the analyzer side because it depends
638
+ // on analyzer-internal state (definitions, aliases).
671
639
  const contexts = mDefinition?.schema ? extractContextsFromSchema(mDefinition.schema) : [];
672
640
  const invocationContext = (m.metadata as any)?.xTeloInvocationContext as
673
641
  | Record<string, any>
674
642
  | undefined;
675
643
 
676
- // If no static context but we have step context, inject it
677
- if (contexts.length === 0 && !invocationContext && !stepContextSchema) return;
678
-
679
644
  let matchedContext: Record<string, any> | undefined;
680
645
  let matchedScope: string | undefined;
681
646
  for (const ctx of contexts) {
@@ -687,7 +652,6 @@ export class StaticAnalyzer {
687
652
  }
688
653
  if (!matchedContext) matchedContext = invocationContext;
689
654
 
690
- // Merge step context into the effective context
691
655
  if (stepContextSchema) {
692
656
  const base = matchedContext ?? { type: "object", properties: {}, additionalProperties: true };
693
657
  matchedContext = {
@@ -699,28 +663,51 @@ export class StaticAnalyzer {
699
663
  };
700
664
  }
701
665
 
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
- });
666
+ let effectiveContext: Record<string, any> | null = null;
667
+ if (matchedContext) {
668
+ const manifestItem = matchedScope
669
+ ? getManifestItem(path, matchedScope, m as Record<string, any>)
670
+ : (m as Record<string, any>);
671
+ const resolvedContext = resolveContextAnnotations(
672
+ matchedContext,
673
+ manifestItem,
674
+ allManifests as Record<string, any>[],
675
+ );
676
+ effectiveContext = mergeKernelGlobalsIntoContext(resolvedContext, kernelGlobals);
677
+ }
678
+
679
+ const engine = defaultRegistry().get(engineName);
680
+ if (!engine) return;
681
+ const findings = engine.analyze(expr, { celEnv: this.celEnv, contextSchema: effectiveContext });
682
+ for (const f of findings) {
683
+ if (f.code === "CEL_SYNTAX_ERROR") {
684
+ diagnostics.push({
685
+ severity: DiagnosticSeverity.Error,
686
+ code: "CEL_SYNTAX_ERROR",
687
+ source: SOURCE,
688
+ message: `CEL syntax error at ${path}: ${f.message}`,
689
+ data: { resource, filePath, path },
690
+ });
691
+ } else if (f.code === "CEL_UNKNOWN_FIELD") {
692
+ diagnostics.push({
693
+ severity: DiagnosticSeverity.Error,
694
+ code: "CEL_UNKNOWN_FIELD",
695
+ source: SOURCE,
696
+ message: `${m.kind}/${resource.name}: CEL at '${path}': ${f.message}`,
697
+ data: { resource, filePath, path },
698
+ });
699
+ } else {
700
+ // Unknown code from a future engine — pass the message through,
701
+ // tagged with a generic ENGINE_DIAGNOSTIC code so downstream
702
+ // filters can still bucket it.
703
+ diagnostics.push({
704
+ severity: DiagnosticSeverity.Error,
705
+ code: f.code ?? "ENGINE_DIAGNOSTIC",
706
+ source: SOURCE,
707
+ message: `${m.kind}/${resource.name}: !${engineName} at '${path}': ${f.message}`,
708
+ data: { resource, filePath, path },
709
+ });
710
+ }
724
711
  }
725
712
  });
726
713
  }
@@ -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").