@telorun/analyzer 0.28.0 → 0.29.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;AAKzE,OAAO,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAC1D,OAAO,EAIL,KAAK,WAAW,EACjB,MAAM,sBAAsB,CAAC;AAmB9B,OAAO,EAAsB,KAAK,kBAAkB,EAAE,KAAK,eAAe,EAAE,MAAM,YAAY,CAAC;AA4hB/F,MAAM,WAAW,qBAAqB;IACpC,WAAW,CAAC,EAAE,WAAW,CAAC;CAC3B;AAED,qBAAa,cAAc;IACzB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAc;gBAEzB,OAAO,GAAE,qBAA0B;IAI/C;;;;;;;;;;;;;;OAcG;IACH,OAAO,CACL,SAAS,EAAE,gBAAgB,EAAE,EAC7B,OAAO,CAAC,EAAE,eAAe,EACzB,QAAQ,CAAC,EAAE,gBAAgB,GAC1B,kBAAkB,EAAE;IAgvBvB,aAAa,CACX,SAAS,EAAE,gBAAgB,EAAE,EAC7B,OAAO,CAAC,EAAE,eAAe,EACzB,QAAQ,CAAC,EAAE,gBAAgB,GAC1B,kBAAkB,EAAE;IAMvB,SAAS,CACP,SAAS,EAAE,gBAAgB,EAAE,EAC7B,QAAQ,EAAE,gBAAgB,EAI1B,kBAAkB,CAAC,EAAE,gBAAgB,EAAE,GACtC,gBAAgB,EAAE;IAmBrB,OAAO,CACL,SAAS,EAAE,gBAAgB,EAAE,EAC7B,QAAQ,EAAE,gBAAgB,GACzB;QAAE,WAAW,EAAE,kBAAkB,EAAE,CAAC;QAAC,KAAK,EAAE,MAAM,EAAE,GAAG,IAAI,CAAC;QAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE;CAsB5F"}
1
+ {"version":3,"file":"analyzer.d.ts","sourceRoot":"","sources":["../src/analyzer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAsB,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAKzE,OAAO,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAC1D,OAAO,EAIL,KAAK,WAAW,EACjB,MAAM,sBAAsB,CAAC;AAmB9B,OAAO,EAAsB,KAAK,kBAAkB,EAAE,KAAK,eAAe,EAAE,MAAM,YAAY,CAAC;AAwjB/F,MAAM,WAAW,qBAAqB;IACpC,WAAW,CAAC,EAAE,WAAW,CAAC;CAC3B;AAED,qBAAa,cAAc;IACzB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAc;gBAEzB,OAAO,GAAE,qBAA0B;IAI/C;;;;;;;;;;;;;;OAcG;IACH,OAAO,CACL,SAAS,EAAE,gBAAgB,EAAE,EAC7B,OAAO,CAAC,EAAE,eAAe,EACzB,QAAQ,CAAC,EAAE,gBAAgB,GAC1B,kBAAkB,EAAE;IAkyBvB,aAAa,CACX,SAAS,EAAE,gBAAgB,EAAE,EAC7B,OAAO,CAAC,EAAE,eAAe,EACzB,QAAQ,CAAC,EAAE,gBAAgB,GAC1B,kBAAkB,EAAE;IAMvB,SAAS,CACP,SAAS,EAAE,gBAAgB,EAAE,EAC7B,QAAQ,EAAE,gBAAgB,EAI1B,kBAAkB,CAAC,EAAE,gBAAgB,EAAE,GACtC,gBAAgB,EAAE;IAmBrB,OAAO,CACL,SAAS,EAAE,gBAAgB,EAAE,EAC7B,QAAQ,EAAE,gBAAgB,GACzB;QAAE,WAAW,EAAE,kBAAkB,EAAE,CAAC;QAAC,KAAK,EAAE,MAAM,EAAE,GAAG,IAAI,CAAC;QAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE;CAsB5F"}
package/dist/analyzer.js CHANGED
@@ -16,7 +16,8 @@ import { validateSchemaTypeRefs } from "./validate-schema-type-refs.js";
16
16
  import { rewriteSyntheticOrigins } from "./rewrite-synthetic-origins.js";
17
17
  import { celTypeSatisfiesJsonSchema, substituteCelFields, validateAgainstSchema, } from "./schema-compat.js";
18
18
  import { DiagnosticSeverity } from "./types.js";
19
- import { extractContextsFromSchema, getManifestItem, pathMatchesScope, resolveContextAnnotations, resolveTypeFieldToSchema, } from "./validate-cel-context.js";
19
+ import { extractCelRegionScopes, extractContextsFromSchema, getManifestItem, pathMatchesScope, resolveContextAnnotations, resolveTypeFieldToSchema, } from "./validate-cel-context.js";
20
+ import { buildEvalPaths, evalPathsCover } from "./eval-paths.js";
20
21
  import { validateExtends } from "./validate-extends.js";
21
22
  import { validateNestedInlineResources } from "./validate-nested-inline.js";
22
23
  import { validateProviderCoherence } from "./validate-provider-coherence.js";
@@ -320,6 +321,29 @@ function buildStepContextSchema(manifest, defSchema, allManifests, defs, aliases
320
321
  * specific field name (or `Run.Sequence`) is hardcoded; any composer that tags
321
322
  * its error-bearing branch fields opts in the same way.
322
323
  */
324
+ /**
325
+ * True when a `walkCelExpressions` path (`with[0].handler.inputs.x`) crosses an
326
+ * inline nested resource — an `{ kind: … }` object below the host root — before
327
+ * reaching the leaf. Such CEL belongs to the nested resource's kind (validated
328
+ * when that resource is analyzed), not the host's schema, so the
329
+ * non-eval-field check must not attribute it to the host.
330
+ */
331
+ function pathCrossesNestedResource(root, path) {
332
+ const segments = path.match(/[^.[\]]+/g) ?? [];
333
+ let node = root;
334
+ for (let i = 0; i < segments.length - 1; i++) {
335
+ node = Array.isArray(node)
336
+ ? node[Number(segments[i])]
337
+ : node?.[segments[i]];
338
+ if (node !== null &&
339
+ typeof node === "object" &&
340
+ !Array.isArray(node) &&
341
+ typeof node.kind === "string") {
342
+ return true;
343
+ }
344
+ }
345
+ return false;
346
+ }
323
347
  function collectErrorContextScopes(defSchema) {
324
348
  const out = new Map();
325
349
  if (!defSchema || typeof defSchema !== "object")
@@ -995,6 +1019,13 @@ export class StaticAnalyzer {
995
1019
  let celStepContextSchema;
996
1020
  let celInvocationContext;
997
1021
  let celErrorScopes = new Map();
1022
+ // Region coverage for the "CEL in a non-eval field" check: the union of
1023
+ // `x-telo-eval` paths (own + capability) and `x-telo-context` /
1024
+ // `x-telo-step-context` / `x-telo-error-context` scopes. A `!cel` outside
1025
+ // every region is read as a literal — the runtime never evaluates it.
1026
+ let celEvalPaths = [];
1027
+ let celRegionScopes = [];
1028
+ let celRuleApplies = false;
998
1029
  visitManifest(allManifests, defs, {
999
1030
  onResourceEnter: (e) => {
1000
1031
  const m = e.source;
@@ -1003,12 +1034,52 @@ export class StaticAnalyzer {
1003
1034
  ? buildStepContextSchema(m, e.definition.schema, allManifests, defs, aliases)
1004
1035
  : undefined;
1005
1036
  celErrorScopes = collectErrorContextScopes(e.definition?.schema);
1037
+ // The non-eval-field check only applies to runtime resource instances:
1038
+ // structural / templating kinds (capability `Telo.Template`, or no
1039
+ // definition) carry CEL the kernel evaluates by other rules.
1040
+ const capability = e.definition?.capability;
1041
+ celRuleApplies =
1042
+ !!e.definition?.schema && capability !== undefined && capability !== "Telo.Template";
1043
+ if (celRuleApplies) {
1044
+ const ownSchema = e.definition.schema;
1045
+ const own = buildEvalPaths(ownSchema);
1046
+ const capabilityDef = capability ? defs.resolve(capability) : undefined;
1047
+ const parent = capabilityDef?.schema
1048
+ ? buildEvalPaths(capabilityDef.schema)
1049
+ : { compile: [], runtime: [] };
1050
+ celEvalPaths = [...own.compile, ...own.runtime, ...parent.compile, ...parent.runtime];
1051
+ celRegionScopes = extractCelRegionScopes(ownSchema);
1052
+ }
1053
+ else {
1054
+ celEvalPaths = [];
1055
+ celRegionScopes = [];
1056
+ }
1006
1057
  },
1007
1058
  onCel: (e) => {
1008
1059
  const m = e.source;
1009
1060
  const resource = { kind: m.kind, name: m.metadata?.name };
1010
1061
  const filePath = m.metadata?.source;
1011
1062
  const { expr, path, engineName, matchedScope } = e;
1063
+ // A `!cel` (or `${{ }}`) in a field with no `x-telo-eval` / `x-telo-context`
1064
+ // is never evaluated — the runtime reads it as a literal (e.g. a
1065
+ // `concurrency` `!cel` that silently degraded to a sparse `[null, …]`).
1066
+ // Flag it rather than letting it pass as valid CEL. Inline resources
1067
+ // (resource-wide invocation context) carry CEL the kernel evaluates.
1068
+ if (celRuleApplies &&
1069
+ engineName === "cel" &&
1070
+ celInvocationContext === undefined &&
1071
+ !evalPathsCover(celEvalPaths, path) &&
1072
+ !celRegionScopes.some((scope) => pathMatchesScope(path, scope)) &&
1073
+ !pathCrossesNestedResource(m, path)) {
1074
+ diagnostics.push({
1075
+ severity: DiagnosticSeverity.Error,
1076
+ code: "CEL_IN_NON_EVAL_FIELD",
1077
+ source: SOURCE,
1078
+ message: `${m.kind}/${resource.name}: CEL at '${path}' is never evaluated — the field has no x-telo-eval / x-telo-context annotation, so its value is read as a literal. Annotate the field as a CEL slot or remove the !cel tag.`,
1079
+ data: { resource, filePath, path },
1080
+ });
1081
+ return;
1082
+ }
1012
1083
  let matchedContext = e.contextSchema ?? celInvocationContext;
1013
1084
  if (celStepContextSchema) {
1014
1085
  const base = matchedContext ?? { type: "object", properties: {}, additionalProperties: true };
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The single containment rule for `x-telo-eval` paths, shared by every matcher so
3
+ * the analyzer's coverage decision and the kernel's expansion/exclusion can't
4
+ * drift. True when `target` lies in the subtree rooted at `evalPath`: `"**"`
5
+ * covers everything; a dotted path covers itself and any descendant — `"handler"`
6
+ * covers `handler`, `handler.body`, `handler[0]`. Targets use `walkCelExpressions`
7
+ * form (`a.b[0].c`); eval paths are property-only (no array segments —
8
+ * `buildEvalPaths` does not descend into `items`), so `.`/`[` boundary prefixing
9
+ * is exact. Consumers: the analyzer's `evalPathsCover`, the kernel's `isExcluded`
10
+ * (applied in both directions), and — structurally — `expandPaths`' navigation.
11
+ */
12
+ export declare function evalPathCovers(evalPath: string, target: string): boolean;
13
+ /** True when any `x-telo-eval` path in the set covers `exprPath` (see
14
+ * {@link evalPathCovers}). */
15
+ export declare function evalPathsCover(evalPaths: readonly string[], exprPath: string): boolean;
16
+ /**
17
+ * Traverses a definition schema and collects all paths annotated with `x-telo-eval`.
18
+ * Root-level `x-telo-eval` produces the `"**"` wildcard (expand all fields).
19
+ * Property-level annotations produce the dot-notation path to that property.
20
+ */
21
+ export declare function buildEvalPaths(schema: Record<string, any>): {
22
+ compile: string[];
23
+ runtime: string[];
24
+ };
25
+ //# sourceMappingURL=eval-paths.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"eval-paths.d.ts","sourceRoot":"","sources":["../src/eval-paths.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,wBAAgB,cAAc,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAKxE;AAED;+BAC+B;AAC/B,wBAAgB,cAAc,CAAC,SAAS,EAAE,SAAS,MAAM,EAAE,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAEtF;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG;IAC3D,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,OAAO,EAAE,MAAM,EAAE,CAAC;CACnB,CAcA"}
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The single containment rule for `x-telo-eval` paths, shared by every matcher so
3
+ * the analyzer's coverage decision and the kernel's expansion/exclusion can't
4
+ * drift. True when `target` lies in the subtree rooted at `evalPath`: `"**"`
5
+ * covers everything; a dotted path covers itself and any descendant — `"handler"`
6
+ * covers `handler`, `handler.body`, `handler[0]`. Targets use `walkCelExpressions`
7
+ * form (`a.b[0].c`); eval paths are property-only (no array segments —
8
+ * `buildEvalPaths` does not descend into `items`), so `.`/`[` boundary prefixing
9
+ * is exact. Consumers: the analyzer's `evalPathsCover`, the kernel's `isExcluded`
10
+ * (applied in both directions), and — structurally — `expandPaths`' navigation.
11
+ */
12
+ export function evalPathCovers(evalPath, target) {
13
+ if (evalPath === "**")
14
+ return true;
15
+ return (target === evalPath || target.startsWith(`${evalPath}.`) || target.startsWith(`${evalPath}[`));
16
+ }
17
+ /** True when any `x-telo-eval` path in the set covers `exprPath` (see
18
+ * {@link evalPathCovers}). */
19
+ export function evalPathsCover(evalPaths, exprPath) {
20
+ return evalPaths.some((p) => evalPathCovers(p, exprPath));
21
+ }
22
+ /**
23
+ * Traverses a definition schema and collects all paths annotated with `x-telo-eval`.
24
+ * Root-level `x-telo-eval` produces the `"**"` wildcard (expand all fields).
25
+ * Property-level annotations produce the dot-notation path to that property.
26
+ */
27
+ export function buildEvalPaths(schema) {
28
+ const compile = [];
29
+ const runtime = [];
30
+ if (schema["x-telo-eval"] === "compile")
31
+ compile.push("**");
32
+ else if (schema["x-telo-eval"] === "runtime")
33
+ runtime.push("**");
34
+ if (schema.properties) {
35
+ for (const [key, propSchema] of Object.entries(schema.properties)) {
36
+ collectEvalPathsNode(propSchema, key, compile, runtime);
37
+ }
38
+ }
39
+ return { compile, runtime };
40
+ }
41
+ function collectEvalPathsNode(node, path, compile, runtime) {
42
+ if (node["x-telo-eval"] === "compile") {
43
+ compile.push(path);
44
+ return;
45
+ }
46
+ if (node["x-telo-eval"] === "runtime") {
47
+ runtime.push(path);
48
+ return;
49
+ }
50
+ if (node.properties) {
51
+ for (const [key, propSchema] of Object.entries(node.properties)) {
52
+ collectEvalPathsNode(propSchema, `${path}.${key}`, compile, runtime);
53
+ }
54
+ }
55
+ }
package/dist/index.d.ts CHANGED
@@ -3,6 +3,7 @@ export type { RefFieldInfo } from "./analysis-registry.js";
3
3
  export { StaticAnalyzer } from "./analyzer.js";
4
4
  export type { GraphLoadError, ImportEdge, LoadedFile, LoadedGraph, LoadedModule, ParseError, } from "./loaded-types.js";
5
5
  export { flattenForAnalyzer, flattenLoadedModule, forwardReExportManifests, parseExportEntry, reExportSpecsFromExports, resolveExportedKinds, selectModuleManifestsForAnalysis, stampReExportedKinds, type ParsedExportEntry, type ReExportSpec, } from "./flatten-for-analyzer.js";
6
+ export { buildEvalPaths, evalPathCovers } from "./eval-paths.js";
6
7
  export { visitManifest } from "./manifest-visitor.js";
7
8
  export type { CelSiteEvent, ManifestVisitor, RefSiteEvent, ResourceEnterEvent, ResourceExitEvent, ScopeBoundaryEvent, SchemaFromSiteEvent, VisitOptions, } from "./manifest-visitor.js";
8
9
  export { Loader } from "./manifest-loader.js";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAC1D,YAAY,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAC;AAC3D,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAC/C,YAAY,EACR,cAAc,EACd,UAAU,EACV,UAAU,EACV,WAAW,EACX,YAAY,EACZ,UAAU,GACb,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EACH,kBAAkB,EAClB,mBAAmB,EACnB,wBAAwB,EACxB,gBAAgB,EAChB,wBAAwB,EACxB,oBAAoB,EACpB,gCAAgC,EAChC,oBAAoB,EACpB,KAAK,iBAAiB,EACtB,KAAK,YAAY,GACpB,MAAM,2BAA2B,CAAC;AACnC,OAAO,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AACtD,YAAY,EACR,YAAY,EACZ,eAAe,EACf,YAAY,EACZ,kBAAkB,EAClB,iBAAiB,EACjB,kBAAkB,EAClB,mBAAmB,EACnB,YAAY,GACf,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EAAE,MAAM,EAAE,MAAM,sBAAsB,CAAC;AAC9C,OAAO,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAC/D,YAAY,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AACpD,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AACzD,YAAY,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAC;AAC3D,OAAO,EAAE,iBAAiB,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAC/E,YAAY,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AAC3D,OAAO,EAAE,uBAAuB,EAAE,MAAM,gCAAgC,CAAC;AACzE,YAAY,EAAE,qBAAqB,EAAE,MAAM,gCAAgC,CAAC;AAC5E,OAAO,EAAE,mBAAmB,EAAE,sBAAsB,EAAE,MAAM,sBAAsB,CAAC;AACnF,OAAO,EACH,sBAAsB,EACtB,gBAAgB,EAChB,kBAAkB,EAClB,mBAAmB,GACtB,MAAM,wBAAwB,CAAC;AAChC,YAAY,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAC/D,OAAO,EAAE,UAAU,EAAE,MAAM,0BAA0B,CAAC;AACtD,OAAO,EAAE,cAAc,EAAE,MAAM,8BAA8B,CAAC;AAC9D,OAAO,EAAE,cAAc,EAAE,MAAM,8BAA8B,CAAC;AAC9D,OAAO,EAAE,sBAAsB,EAAE,MAAM,+BAA+B,CAAC;AACvE,OAAO,EAAE,yBAAyB,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAC3E,YAAY,EACR,kBAAkB,EAClB,eAAe,EACf,iBAAiB,EACjB,WAAW,EACX,cAAc,EACd,QAAQ,EACR,aAAa,EACb,KAAK,EACR,MAAM,YAAY,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAC1D,YAAY,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAC;AAC3D,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAC/C,YAAY,EACR,cAAc,EACd,UAAU,EACV,UAAU,EACV,WAAW,EACX,YAAY,EACZ,UAAU,GACb,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EACH,kBAAkB,EAClB,mBAAmB,EACnB,wBAAwB,EACxB,gBAAgB,EAChB,wBAAwB,EACxB,oBAAoB,EACpB,gCAAgC,EAChC,oBAAoB,EACpB,KAAK,iBAAiB,EACtB,KAAK,YAAY,GACpB,MAAM,2BAA2B,CAAC;AACnC,OAAO,EAAE,cAAc,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AACjE,OAAO,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AACtD,YAAY,EACR,YAAY,EACZ,eAAe,EACf,YAAY,EACZ,kBAAkB,EAClB,iBAAiB,EACjB,kBAAkB,EAClB,mBAAmB,EACnB,YAAY,GACf,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EAAE,MAAM,EAAE,MAAM,sBAAsB,CAAC;AAC9C,OAAO,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAC/D,YAAY,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AACpD,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AACzD,YAAY,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAC;AAC3D,OAAO,EAAE,iBAAiB,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAC/E,YAAY,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AAC3D,OAAO,EAAE,uBAAuB,EAAE,MAAM,gCAAgC,CAAC;AACzE,YAAY,EAAE,qBAAqB,EAAE,MAAM,gCAAgC,CAAC;AAC5E,OAAO,EAAE,mBAAmB,EAAE,sBAAsB,EAAE,MAAM,sBAAsB,CAAC;AACnF,OAAO,EACH,sBAAsB,EACtB,gBAAgB,EAChB,kBAAkB,EAClB,mBAAmB,GACtB,MAAM,wBAAwB,CAAC;AAChC,YAAY,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAC/D,OAAO,EAAE,UAAU,EAAE,MAAM,0BAA0B,CAAC;AACtD,OAAO,EAAE,cAAc,EAAE,MAAM,8BAA8B,CAAC;AAC9D,OAAO,EAAE,cAAc,EAAE,MAAM,8BAA8B,CAAC;AAC9D,OAAO,EAAE,sBAAsB,EAAE,MAAM,+BAA+B,CAAC;AACvE,OAAO,EAAE,yBAAyB,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAC3E,YAAY,EACR,kBAAkB,EAClB,eAAe,EACf,iBAAiB,EACjB,WAAW,EACX,cAAc,EACd,QAAQ,EACR,aAAa,EACb,KAAK,EACR,MAAM,YAAY,CAAC"}
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  export { AnalysisRegistry } from "./analysis-registry.js";
2
2
  export { StaticAnalyzer } from "./analyzer.js";
3
3
  export { flattenForAnalyzer, flattenLoadedModule, forwardReExportManifests, parseExportEntry, reExportSpecsFromExports, resolveExportedKinds, selectModuleManifestsForAnalysis, stampReExportedKinds, } from "./flatten-for-analyzer.js";
4
+ export { buildEvalPaths, evalPathCovers } from "./eval-paths.js";
4
5
  export { visitManifest } from "./manifest-visitor.js";
5
6
  export { Loader } from "./manifest-loader.js";
6
7
  export { isModuleKind, MODULE_KINDS } from "./module-kinds.js";
@@ -20,7 +20,7 @@ export interface ContextResolveOpts {
20
20
  * - Object with `kind` + `schema`: inline type definition → return the `schema`
21
21
  * - Object with `type` or `properties`: raw JSON Schema, return as-is
22
22
  */
23
- export declare function resolveTypeFieldToSchema(value: unknown, allManifests: Record<string, any>[]): Record<string, any> | undefined;
23
+ export declare function resolveTypeFieldToSchema(value: unknown, allManifests: Record<string, any>[], ancestry?: ReadonlySet<string>): Record<string, any> | undefined;
24
24
  /**
25
25
  * Returns true when a CEL expression path (from walkCelExpressions, e.g. "routes[0].inputs.q")
26
26
  * falls within the scope of a context (e.g. "$.routes[*].inputs").
@@ -97,4 +97,13 @@ export declare function extractContextsFromSchema(schema: Record<string, any>, p
97
97
  scope: string;
98
98
  schema: Record<string, any>;
99
99
  }>;
100
+ /**
101
+ * Walk a JSON Schema tree and collect the JSONPath scopes of every field that
102
+ * declares a CEL-bearing region (`x-telo-context` / `x-telo-step-context` /
103
+ * `x-telo-error-context`). Used — alongside `x-telo-eval` paths — to decide
104
+ * whether a `!cel` expression sits in a slot the runtime actually evaluates.
105
+ * Scopes use the same `$.a.b[*]` form as `extractContextsFromSchema`, matched
106
+ * against expression paths with `pathMatchesScope`.
107
+ */
108
+ export declare function extractCelRegionScopes(schema: Record<string, any>, path?: string): string[];
100
109
  //# sourceMappingURL=validate-cel-context.d.ts.map
@@ -1 +1 @@
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,MAAM,WAAW,kBAAkB;IACjC;mEAC+D;IAC/D,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;IACnC;;kDAE8C;IAC9C,IAAI,CAAC,EAAE;QACL,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,SAAS,CAAC;KACxD,CAAC;IACF,OAAO,CAAC,EAAE;QACR,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAAC;KAC/C,CAAC;IACF,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,CAAC;CACtC;AAED;;;;;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,CAkCjC;AA6DD;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAoBzE;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+CG;AACH,wBAAgB,yBAAyB,CACvC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAC3B,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EACjC,IAAI,CAAC,EAAE,kBAAkB,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,GAChD,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAgHrB;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;AAWD;;;;;;;;;GASG;AACH,wBAAgB,yBAAyB,CACvC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAC3B,IAAI,SAAM,GACT,KAAK,CAAC;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;CAAE,CAAC,CAGvD"}
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;AAGtF,MAAM,WAAW,kBAAkB;IACjC;mEAC+D;IAC/D,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;IACnC;;kDAE8C;IAC9C,IAAI,CAAC,EAAE;QACL,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,SAAS,CAAC;KACxD,CAAC;IACF,OAAO,CAAC,EAAE;QACR,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAAC;KAC/C,CAAC;IACF,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,CAAC;CACtC;AAED;;;;;GAKG;AACH,wBAAgB,wBAAwB,CACtC,KAAK,EAAE,OAAO,EACd,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,EACnC,QAAQ,GAAE,WAAW,CAAC,MAAM,CAAa,GACxC,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,SAAS,CA0CjC;AAuFD;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAoBzE;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+CG;AACH,wBAAgB,yBAAyB,CACvC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAC3B,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EACjC,IAAI,CAAC,EAAE,kBAAkB,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,GAChD,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAgHrB;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;AAWD;;;;;;;;;GASG;AACH,wBAAgB,yBAAyB,CACvC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAC3B,IAAI,SAAM,GACT,KAAK,CAAC;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;CAAE,CAAC,CAGvD;AAUD;;;;;;;GAOG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,IAAI,SAAM,GAAG,MAAM,EAAE,CAqBxF"}
@@ -1,27 +1,33 @@
1
1
  export { extractAccessChains, validateChainAgainstSchema } from "@telorun/templating";
2
+ import { mergeTypeSchemas } from "@telorun/sdk";
2
3
  /**
3
4
  * Resolve a type field value (string name, inline type, or raw schema) to a JSON Schema.
4
5
  * - String: look up the named type in allManifests (Type.JsonSchema resources)
5
6
  * - Object with `kind` + `schema`: inline type definition → return the `schema`
6
7
  * - Object with `type` or `properties`: raw JSON Schema, return as-is
7
8
  */
8
- export function resolveTypeFieldToSchema(value, allManifests) {
9
+ export function resolveTypeFieldToSchema(value, allManifests, ancestry = new Set()) {
9
10
  if (!value)
10
11
  return undefined;
11
12
  if (typeof value === "string") {
13
+ // Cycle guard: a type already on the resolution path can't extend back into it.
14
+ if (ancestry.has(value))
15
+ return undefined;
12
16
  // Named type reference — find a Telo.Type resource by name
13
17
  const typeManifest = allManifests.find((m) => m.metadata?.name === value &&
14
18
  typeof m.kind === "string" &&
15
19
  /\bType\b/.test(m.kind) &&
16
20
  typeof m.schema === "object" &&
17
21
  m.schema !== null);
18
- return typeManifest?.schema;
22
+ if (!typeManifest)
23
+ return undefined;
24
+ return applyExtends(typeManifest.schema, typeManifest.extends, allManifests, new Set(ancestry).add(value));
19
25
  }
20
26
  if (typeof value === "object" && value !== null) {
21
27
  const obj = value;
22
28
  // Inline type resource: { kind: "Type.JsonSchema", schema: {...} }
23
29
  if (obj.schema && typeof obj.schema === "object") {
24
- return obj.schema;
30
+ return applyExtends(obj.schema, obj.extends, allManifests, ancestry);
25
31
  }
26
32
  // Raw JSON Schema (has type or properties)
27
33
  if (obj.type || obj.properties) {
@@ -30,11 +36,34 @@ export function resolveTypeFieldToSchema(value, allManifests) {
30
36
  // Named type reference resolved from a `!ref` → { kind, name } — resolve the
31
37
  // named Telo.Type the same way as the bare-string form.
32
38
  if (typeof obj.name === "string") {
33
- return resolveTypeFieldToSchema(obj.name, allManifests);
39
+ return resolveTypeFieldToSchema(obj.name, allManifests, ancestry);
34
40
  }
35
41
  }
36
42
  return undefined;
37
43
  }
44
+ /**
45
+ * Fold a `Type.JsonSchema`'s `extends` parents into its own schema, matching the
46
+ * runtime `type` controller exactly — both call the shared `mergeTypeSchemas`, so
47
+ * static analysis and runtime validation can never disagree on a type's effective
48
+ * shape. Without this the analyzer would see only a child type's own properties
49
+ * and reject valid access to an inherited field with a false `CEL_UNKNOWN_FIELD`.
50
+ * `ancestry` carries the resolution path for cycle detection (siblings share it
51
+ * unmutated, so diamond inheritance still re-includes a shared grandparent).
52
+ */
53
+ function applyExtends(ownSchema, extendsField, allManifests, ancestry) {
54
+ if (!extendsField)
55
+ return ownSchema;
56
+ const parents = Array.isArray(extendsField) ? extendsField : [extendsField];
57
+ const resolved = [];
58
+ for (const parent of parents) {
59
+ const parentSchema = resolveTypeFieldToSchema(parent, allManifests, ancestry);
60
+ if (parentSchema)
61
+ resolved.push(parentSchema);
62
+ }
63
+ if (resolved.length === 0)
64
+ return ownSchema;
65
+ return mergeTypeSchemas([...resolved, ownSchema]);
66
+ }
38
67
  /** Pull the raw expression source from a CEL field value — a compiled value
39
68
  * (`{ source }`), or a string (`!cel "x"` or `"${{ x }}"`). Strips a lone
40
69
  * `${{ }}` wrapper. Returns null when no source is recoverable. */
@@ -305,6 +334,43 @@ export function extractContextsFromSchema(schema, path = "$") {
305
334
  const all = collectContexts(schema, path);
306
335
  return all.sort((a, b) => b.scope.length - a.scope.length);
307
336
  }
337
+ /** Schema keys that declare a CEL-bearing region: a field carrying any of these
338
+ * is evaluated at runtime, so a `!cel` inside it (or a descendant) is live. */
339
+ const CEL_REGION_KEYS = [
340
+ "x-telo-context",
341
+ "x-telo-step-context",
342
+ "x-telo-error-context",
343
+ ];
344
+ /**
345
+ * Walk a JSON Schema tree and collect the JSONPath scopes of every field that
346
+ * declares a CEL-bearing region (`x-telo-context` / `x-telo-step-context` /
347
+ * `x-telo-error-context`). Used — alongside `x-telo-eval` paths — to decide
348
+ * whether a `!cel` expression sits in a slot the runtime actually evaluates.
349
+ * Scopes use the same `$.a.b[*]` form as `extractContextsFromSchema`, matched
350
+ * against expression paths with `pathMatchesScope`.
351
+ */
352
+ export function extractCelRegionScopes(schema, path = "$") {
353
+ if (!schema || typeof schema !== "object")
354
+ return [];
355
+ const out = [];
356
+ if (CEL_REGION_KEYS.some((k) => schema[k]))
357
+ out.push(path);
358
+ if (schema.properties) {
359
+ for (const [key, value] of Object.entries(schema.properties)) {
360
+ out.push(...extractCelRegionScopes(value, `${path}.${key}`));
361
+ }
362
+ }
363
+ if (schema.items && typeof schema.items === "object") {
364
+ out.push(...extractCelRegionScopes(schema.items, `${path}[*]`));
365
+ }
366
+ for (const key of ["oneOf", "anyOf", "allOf"]) {
367
+ if (Array.isArray(schema[key])) {
368
+ for (const subschema of schema[key])
369
+ out.push(...extractCelRegionScopes(subschema, path));
370
+ }
371
+ }
372
+ return out;
373
+ }
308
374
  function collectContexts(schema, path) {
309
375
  if (!schema || typeof schema !== "object")
310
376
  return [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telorun/analyzer",
3
- "version": "0.28.0",
3
+ "version": "0.29.0",
4
4
  "description": "Telo Analyzer - Static manifest validator for Telo manifests.",
5
5
  "keywords": [
6
6
  "telo",
@@ -48,7 +48,7 @@
48
48
  "@types/node": "^20.0.0",
49
49
  "typescript": "^5.0.0",
50
50
  "vitest": "^2.1.8",
51
- "@telorun/sdk": "0.36.0"
51
+ "@telorun/sdk": "0.38.0"
52
52
  },
53
53
  "peerDependencies": {
54
54
  "@telorun/sdk": "*"
package/src/analyzer.ts CHANGED
@@ -30,12 +30,14 @@ import {
30
30
  } from "./schema-compat.js";
31
31
  import { DiagnosticSeverity, type AnalysisDiagnostic, type AnalysisOptions } from "./types.js";
32
32
  import {
33
+ extractCelRegionScopes,
33
34
  extractContextsFromSchema,
34
35
  getManifestItem,
35
36
  pathMatchesScope,
36
37
  resolveContextAnnotations,
37
38
  resolveTypeFieldToSchema,
38
39
  } from "./validate-cel-context.js";
40
+ import { buildEvalPaths, evalPathsCover } from "./eval-paths.js";
39
41
  import { validateExtends } from "./validate-extends.js";
40
42
  import { validateNestedInlineResources } from "./validate-nested-inline.js";
41
43
  import { validateProviderCoherence } from "./validate-provider-coherence.js";
@@ -393,6 +395,32 @@ function buildStepContextSchema(
393
395
  * specific field name (or `Run.Sequence`) is hardcoded; any composer that tags
394
396
  * its error-bearing branch fields opts in the same way.
395
397
  */
398
+ /**
399
+ * True when a `walkCelExpressions` path (`with[0].handler.inputs.x`) crosses an
400
+ * inline nested resource — an `{ kind: … }` object below the host root — before
401
+ * reaching the leaf. Such CEL belongs to the nested resource's kind (validated
402
+ * when that resource is analyzed), not the host's schema, so the
403
+ * non-eval-field check must not attribute it to the host.
404
+ */
405
+ function pathCrossesNestedResource(root: unknown, path: string): boolean {
406
+ const segments = path.match(/[^.[\]]+/g) ?? [];
407
+ let node: unknown = root;
408
+ for (let i = 0; i < segments.length - 1; i++) {
409
+ node = Array.isArray(node)
410
+ ? node[Number(segments[i])]
411
+ : (node as Record<string, unknown> | undefined)?.[segments[i]!];
412
+ if (
413
+ node !== null &&
414
+ typeof node === "object" &&
415
+ !Array.isArray(node) &&
416
+ typeof (node as { kind?: unknown }).kind === "string"
417
+ ) {
418
+ return true;
419
+ }
420
+ }
421
+ return false;
422
+ }
423
+
396
424
  function collectErrorContextScopes(
397
425
  defSchema: Record<string, any> | undefined,
398
426
  ): Map<string, Record<string, any>> {
@@ -1189,6 +1217,13 @@ export class StaticAnalyzer {
1189
1217
  let celStepContextSchema: Record<string, any> | undefined;
1190
1218
  let celInvocationContext: Record<string, any> | undefined;
1191
1219
  let celErrorScopes: Map<string, Record<string, any>> = new Map();
1220
+ // Region coverage for the "CEL in a non-eval field" check: the union of
1221
+ // `x-telo-eval` paths (own + capability) and `x-telo-context` /
1222
+ // `x-telo-step-context` / `x-telo-error-context` scopes. A `!cel` outside
1223
+ // every region is read as a literal — the runtime never evaluates it.
1224
+ let celEvalPaths: string[] = [];
1225
+ let celRegionScopes: string[] = [];
1226
+ let celRuleApplies = false;
1192
1227
 
1193
1228
  visitManifest(
1194
1229
  allManifests,
@@ -1211,6 +1246,26 @@ export class StaticAnalyzer {
1211
1246
  celErrorScopes = collectErrorContextScopes(
1212
1247
  e.definition?.schema as Record<string, any> | undefined,
1213
1248
  );
1249
+
1250
+ // The non-eval-field check only applies to runtime resource instances:
1251
+ // structural / templating kinds (capability `Telo.Template`, or no
1252
+ // definition) carry CEL the kernel evaluates by other rules.
1253
+ const capability = e.definition?.capability;
1254
+ celRuleApplies =
1255
+ !!e.definition?.schema && capability !== undefined && capability !== "Telo.Template";
1256
+ if (celRuleApplies) {
1257
+ const ownSchema = e.definition!.schema as Record<string, any>;
1258
+ const own = buildEvalPaths(ownSchema);
1259
+ const capabilityDef = capability ? defs.resolve(capability) : undefined;
1260
+ const parent = capabilityDef?.schema
1261
+ ? buildEvalPaths(capabilityDef.schema as Record<string, any>)
1262
+ : { compile: [], runtime: [] };
1263
+ celEvalPaths = [...own.compile, ...own.runtime, ...parent.compile, ...parent.runtime];
1264
+ celRegionScopes = extractCelRegionScopes(ownSchema);
1265
+ } else {
1266
+ celEvalPaths = [];
1267
+ celRegionScopes = [];
1268
+ }
1214
1269
  },
1215
1270
  onCel: (e) => {
1216
1271
  const m = e.source;
@@ -1218,6 +1273,29 @@ export class StaticAnalyzer {
1218
1273
  const filePath = (m.metadata as { source?: string } | undefined)?.source;
1219
1274
  const { expr, path, engineName, matchedScope } = e;
1220
1275
 
1276
+ // A `!cel` (or `${{ }}`) in a field with no `x-telo-eval` / `x-telo-context`
1277
+ // is never evaluated — the runtime reads it as a literal (e.g. a
1278
+ // `concurrency` `!cel` that silently degraded to a sparse `[null, …]`).
1279
+ // Flag it rather than letting it pass as valid CEL. Inline resources
1280
+ // (resource-wide invocation context) carry CEL the kernel evaluates.
1281
+ if (
1282
+ celRuleApplies &&
1283
+ engineName === "cel" &&
1284
+ celInvocationContext === undefined &&
1285
+ !evalPathsCover(celEvalPaths, path) &&
1286
+ !celRegionScopes.some((scope) => pathMatchesScope(path, scope)) &&
1287
+ !pathCrossesNestedResource(m, path)
1288
+ ) {
1289
+ diagnostics.push({
1290
+ severity: DiagnosticSeverity.Error,
1291
+ code: "CEL_IN_NON_EVAL_FIELD",
1292
+ source: SOURCE,
1293
+ message: `${m.kind}/${resource.name}: CEL at '${path}' is never evaluated — the field has no x-telo-eval / x-telo-context annotation, so its value is read as a literal. Annotate the field as a CEL slot or remove the !cel tag.`,
1294
+ data: { resource, filePath, path },
1295
+ });
1296
+ return;
1297
+ }
1298
+
1221
1299
  let matchedContext: Record<string, any> | undefined =
1222
1300
  e.contextSchema ?? celInvocationContext;
1223
1301
 
@@ -0,0 +1,68 @@
1
+ /**
2
+ * The single containment rule for `x-telo-eval` paths, shared by every matcher so
3
+ * the analyzer's coverage decision and the kernel's expansion/exclusion can't
4
+ * drift. True when `target` lies in the subtree rooted at `evalPath`: `"**"`
5
+ * covers everything; a dotted path covers itself and any descendant — `"handler"`
6
+ * covers `handler`, `handler.body`, `handler[0]`. Targets use `walkCelExpressions`
7
+ * form (`a.b[0].c`); eval paths are property-only (no array segments —
8
+ * `buildEvalPaths` does not descend into `items`), so `.`/`[` boundary prefixing
9
+ * is exact. Consumers: the analyzer's `evalPathsCover`, the kernel's `isExcluded`
10
+ * (applied in both directions), and — structurally — `expandPaths`' navigation.
11
+ */
12
+ export function evalPathCovers(evalPath: string, target: string): boolean {
13
+ if (evalPath === "**") return true;
14
+ return (
15
+ target === evalPath || target.startsWith(`${evalPath}.`) || target.startsWith(`${evalPath}[`)
16
+ );
17
+ }
18
+
19
+ /** True when any `x-telo-eval` path in the set covers `exprPath` (see
20
+ * {@link evalPathCovers}). */
21
+ export function evalPathsCover(evalPaths: readonly string[], exprPath: string): boolean {
22
+ return evalPaths.some((p) => evalPathCovers(p, exprPath));
23
+ }
24
+
25
+ /**
26
+ * Traverses a definition schema and collects all paths annotated with `x-telo-eval`.
27
+ * Root-level `x-telo-eval` produces the `"**"` wildcard (expand all fields).
28
+ * Property-level annotations produce the dot-notation path to that property.
29
+ */
30
+ export function buildEvalPaths(schema: Record<string, any>): {
31
+ compile: string[];
32
+ runtime: string[];
33
+ } {
34
+ const compile: string[] = [];
35
+ const runtime: string[] = [];
36
+
37
+ if (schema["x-telo-eval"] === "compile") compile.push("**");
38
+ else if (schema["x-telo-eval"] === "runtime") runtime.push("**");
39
+
40
+ if (schema.properties) {
41
+ for (const [key, propSchema] of Object.entries(schema.properties as Record<string, any>)) {
42
+ collectEvalPathsNode(propSchema, key, compile, runtime);
43
+ }
44
+ }
45
+
46
+ return { compile, runtime };
47
+ }
48
+
49
+ function collectEvalPathsNode(
50
+ node: Record<string, any>,
51
+ path: string,
52
+ compile: string[],
53
+ runtime: string[],
54
+ ): void {
55
+ if (node["x-telo-eval"] === "compile") {
56
+ compile.push(path);
57
+ return;
58
+ }
59
+ if (node["x-telo-eval"] === "runtime") {
60
+ runtime.push(path);
61
+ return;
62
+ }
63
+ if (node.properties) {
64
+ for (const [key, propSchema] of Object.entries(node.properties as Record<string, any>)) {
65
+ collectEvalPathsNode(propSchema, `${path}.${key}`, compile, runtime);
66
+ }
67
+ }
68
+ }
package/src/index.ts CHANGED
@@ -21,6 +21,7 @@ export {
21
21
  type ParsedExportEntry,
22
22
  type ReExportSpec,
23
23
  } from "./flatten-for-analyzer.js";
24
+ export { buildEvalPaths, evalPathCovers } from "./eval-paths.js";
24
25
  export { visitManifest } from "./manifest-visitor.js";
25
26
  export type {
26
27
  CelSiteEvent,
@@ -1,4 +1,5 @@
1
1
  export { extractAccessChains, validateChainAgainstSchema } from "@telorun/templating";
2
+ import { mergeTypeSchemas } from "@telorun/sdk";
2
3
 
3
4
  export interface ContextResolveOpts {
4
5
  /** When provided, used to resolve `x-telo-context-from-root` annotations against the
@@ -25,10 +26,13 @@ export interface ContextResolveOpts {
25
26
  export function resolveTypeFieldToSchema(
26
27
  value: unknown,
27
28
  allManifests: Record<string, any>[],
29
+ ancestry: ReadonlySet<string> = new Set(),
28
30
  ): Record<string, any> | undefined {
29
31
  if (!value) return undefined;
30
32
 
31
33
  if (typeof value === "string") {
34
+ // Cycle guard: a type already on the resolution path can't extend back into it.
35
+ if (ancestry.has(value)) return undefined;
32
36
  // Named type reference — find a Telo.Type resource by name
33
37
  const typeManifest = allManifests.find(
34
38
  (m) =>
@@ -38,14 +42,20 @@ export function resolveTypeFieldToSchema(
38
42
  typeof m.schema === "object" &&
39
43
  m.schema !== null,
40
44
  );
41
- return typeManifest?.schema as Record<string, any> | undefined;
45
+ if (!typeManifest) return undefined;
46
+ return applyExtends(
47
+ typeManifest.schema as Record<string, any>,
48
+ typeManifest.extends,
49
+ allManifests,
50
+ new Set(ancestry).add(value),
51
+ );
42
52
  }
43
53
 
44
54
  if (typeof value === "object" && value !== null) {
45
55
  const obj = value as Record<string, any>;
46
56
  // Inline type resource: { kind: "Type.JsonSchema", schema: {...} }
47
57
  if (obj.schema && typeof obj.schema === "object") {
48
- return obj.schema as Record<string, any>;
58
+ return applyExtends(obj.schema as Record<string, any>, obj.extends, allManifests, ancestry);
49
59
  }
50
60
  // Raw JSON Schema (has type or properties)
51
61
  if (obj.type || obj.properties) {
@@ -54,13 +64,39 @@ export function resolveTypeFieldToSchema(
54
64
  // Named type reference resolved from a `!ref` → { kind, name } — resolve the
55
65
  // named Telo.Type the same way as the bare-string form.
56
66
  if (typeof obj.name === "string") {
57
- return resolveTypeFieldToSchema(obj.name, allManifests);
67
+ return resolveTypeFieldToSchema(obj.name, allManifests, ancestry);
58
68
  }
59
69
  }
60
70
 
61
71
  return undefined;
62
72
  }
63
73
 
74
+ /**
75
+ * Fold a `Type.JsonSchema`'s `extends` parents into its own schema, matching the
76
+ * runtime `type` controller exactly — both call the shared `mergeTypeSchemas`, so
77
+ * static analysis and runtime validation can never disagree on a type's effective
78
+ * shape. Without this the analyzer would see only a child type's own properties
79
+ * and reject valid access to an inherited field with a false `CEL_UNKNOWN_FIELD`.
80
+ * `ancestry` carries the resolution path for cycle detection (siblings share it
81
+ * unmutated, so diamond inheritance still re-includes a shared grandparent).
82
+ */
83
+ function applyExtends(
84
+ ownSchema: Record<string, any>,
85
+ extendsField: unknown,
86
+ allManifests: Record<string, any>[],
87
+ ancestry: ReadonlySet<string>,
88
+ ): Record<string, any> {
89
+ if (!extendsField) return ownSchema;
90
+ const parents = Array.isArray(extendsField) ? extendsField : [extendsField];
91
+ const resolved: Record<string, any>[] = [];
92
+ for (const parent of parents) {
93
+ const parentSchema = resolveTypeFieldToSchema(parent, allManifests, ancestry);
94
+ if (parentSchema) resolved.push(parentSchema);
95
+ }
96
+ if (resolved.length === 0) return ownSchema;
97
+ return mergeTypeSchemas([...resolved, ownSchema]) as Record<string, any>;
98
+ }
99
+
64
100
  /** Pull the raw expression source from a CEL field value — a compiled value
65
101
  * (`{ source }`), or a string (`!cel "x"` or `"${{ x }}"`). Strips a lone
66
102
  * `${{ }}` wrapper. Returns null when no source is recoverable. */
@@ -360,6 +396,45 @@ export function extractContextsFromSchema(
360
396
  return all.sort((a, b) => b.scope.length - a.scope.length);
361
397
  }
362
398
 
399
+ /** Schema keys that declare a CEL-bearing region: a field carrying any of these
400
+ * is evaluated at runtime, so a `!cel` inside it (or a descendant) is live. */
401
+ const CEL_REGION_KEYS = [
402
+ "x-telo-context",
403
+ "x-telo-step-context",
404
+ "x-telo-error-context",
405
+ ] as const;
406
+
407
+ /**
408
+ * Walk a JSON Schema tree and collect the JSONPath scopes of every field that
409
+ * declares a CEL-bearing region (`x-telo-context` / `x-telo-step-context` /
410
+ * `x-telo-error-context`). Used — alongside `x-telo-eval` paths — to decide
411
+ * whether a `!cel` expression sits in a slot the runtime actually evaluates.
412
+ * Scopes use the same `$.a.b[*]` form as `extractContextsFromSchema`, matched
413
+ * against expression paths with `pathMatchesScope`.
414
+ */
415
+ export function extractCelRegionScopes(schema: Record<string, any>, path = "$"): string[] {
416
+ if (!schema || typeof schema !== "object") return [];
417
+ const out: string[] = [];
418
+
419
+ if (CEL_REGION_KEYS.some((k) => schema[k])) out.push(path);
420
+
421
+ if (schema.properties) {
422
+ for (const [key, value] of Object.entries(schema.properties as Record<string, any>)) {
423
+ out.push(...extractCelRegionScopes(value, `${path}.${key}`));
424
+ }
425
+ }
426
+ if (schema.items && typeof schema.items === "object") {
427
+ out.push(...extractCelRegionScopes(schema.items, `${path}[*]`));
428
+ }
429
+ for (const key of ["oneOf", "anyOf", "allOf"] as const) {
430
+ if (Array.isArray(schema[key])) {
431
+ for (const subschema of schema[key]) out.push(...extractCelRegionScopes(subschema, path));
432
+ }
433
+ }
434
+
435
+ return out;
436
+ }
437
+
363
438
  function collectContexts(
364
439
  schema: Record<string, any>,
365
440
  path: string,