@telorun/analyzer 0.50.0 → 0.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -3,6 +3,7 @@ export { StaticAnalyzer } from "./analyzer.js";
3
3
  export { importResolutionDiagnostics } from "./import-resolution-diagnostics.js";
4
4
  export { flattenForAnalyzer, flattenLoadedModule, forwardReExportManifests, parseExportEntry, reExportSpecsFromExports, resolveExportedKinds, selectModuleManifestsForAnalysis, stampExportedKinds, stampReExportedKinds, } from "./flatten-for-analyzer.js";
5
5
  export { buildEvalPaths, evalPathCovers } from "./eval-paths.js";
6
+ export { BINDINGS_ANNOTATION, bindingContextProperties, bindingDependencies, findBindingSites, resolveBindingOrder, } from "./cel-bindings.js";
6
7
  export { applyObservedStateNode, buildObservedStateIndex, buildObservedStateResourcesSchema, collectRunReachableNames, observedStateRead, validateObservedStateDeclarations, OBSERVED_STATE_SCHEMA, } from "./validate-observed-state.js";
7
8
  export { moduleScopedDefResolver, scopeResolverForModule } from "./alias-resolver.js";
8
9
  export { ancestorChain, contractDeclarer, controllerBearingAncestor, effectiveAuthorSchema, effectiveContractField, effectiveStatusSchema, hasOwnControllerOrTemplate, inheritedCapability, isInheritedDelegation, mappingFieldFor, needsContractMapping, resolveParent, } from "./extends-resolution.js";
@@ -21,7 +22,7 @@ export { buildDocumentPositions, buildLineOffsets, buildPositionIndex, documentL
21
22
  export { HttpSource } from "./sources/http-source.js";
22
23
  export { RegistrySource } from "./sources/registry-source.js";
23
24
  export { defaultSources } from "./sources/default-sources.js";
24
- export { splitIntegrity, foldIntegrity, verifyIntegrity, verifiedFetch, sha256Base64Url, IntegrityError, } from "./sources/integrity.js";
25
+ export { splitIntegrity, foldIntegrity, isCanonicalIntegrity, verifyIntegrity, verifiedFetch, sha256Base64Url, IntegrityError, } from "./sources/integrity.js";
25
26
  export { parseModuleRef, isRegistryRef } from "./sources/module-ref.js";
26
27
  export { OCI_SCHEME, isOciRef, parseOciRef } from "./sources/oci-ref.js";
27
28
  export { parseVersionedRef, withRefVersion } from "./sources/versioned-ref.js";
@@ -32,5 +33,5 @@ export { LayerIndexError, matchControllerLayers, parseLayerIndex, singletonLayer
32
33
  export { validateModuleArtifact } from "./validate-module-artifact.js";
33
34
  export { withSyntheticPositions } from "./with-synthetic-positions.js";
34
35
  export { documentToAst, parseToAst } from "./yaml-ast.js";
35
- export { buildCelSegments, wrapCelAst } from "./cel-ast.js";
36
+ export { CelParseError, buildCelSegments, wrapCelAst } from "./cel-ast.js";
36
37
  export { DEFAULT_MANIFEST_FILENAME, DiagnosticSeverity } from "./types.js";
@@ -24,6 +24,16 @@ export declare function splitIntegrity(ref: string): {
24
24
  * author-authored pin is never overwritten). Inverse of `splitIntegrity`;
25
25
  * used to fold the object form's `integrity:` sibling into the source string. */
26
26
  export declare function foldIntegrity(source: string, integrity: unknown): string;
27
+ /** True for a value safe to WRITE into a manifest as an integrity pin.
28
+ *
29
+ * A pin arriving from outside the file — the hub's version index, a registry
30
+ * response — is untrusted text before it is untrusted *content*: a value
31
+ * carrying a quote, a `#`, or a newline corrupts the YAML it is spliced into,
32
+ * which no later install-time verification can catch because the manifest no
33
+ * longer parses. A wrong-but-well-formed hash is the case install *does*
34
+ * catch; this is the case it cannot. Callers writing a pin they did not
35
+ * compute check here first and fall back to writing none. */
36
+ export declare function isCanonicalIntegrity(value: unknown): value is string;
27
37
  /** SHA-256 of `bytes` as unpadded base64url — the canonical inline-hash form. */
28
38
  export declare function sha256Base64Url(bytes: Uint8Array): Promise<string>;
29
39
  /** Hash `bytes` and compare against `integrity` (`<alg>-<digest>`). Throws a
@@ -1 +1 @@
1
- {"version":3,"file":"integrity.d.ts","sourceRoot":"","sources":["../../src/sources/integrity.ts"],"names":[],"mappings":"AAAA;;;;;;6EAM6E;AAO7E;;;qDAGqD;AACrD,qBAAa,cAAe,SAAQ,KAAK;gBAC3B,OAAO,EAAE,MAAM;CAI5B;AAED;;2EAE2E;AAC3E,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,CAIhF;AAED;;;kFAGkF;AAClF,wBAAgB,aAAa,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,OAAO,GAAG,MAAM,CAIxE;AAcD,iFAAiF;AACjF,wBAAsB,eAAe,CAAC,KAAK,EAAE,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC,CAOxE;AAED;;gFAEgF;AAChF,wBAAsB,eAAe,CACnC,KAAK,EAAE,UAAU,EACjB,SAAS,EAAE,MAAM,EACjB,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,IAAI,CAAC,CAkBf;AAED;;;;sCAIsC;AACtC,wBAAsB,aAAa,CACjC,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,MAAM,GAAG,SAAS,EAC7B,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC;IAAE,KAAK,EAAE,UAAU,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,CAU9C"}
1
+ {"version":3,"file":"integrity.d.ts","sourceRoot":"","sources":["../../src/sources/integrity.ts"],"names":[],"mappings":"AAAA;;;;;;6EAM6E;AAc7E;;;qDAGqD;AACrD,qBAAa,cAAe,SAAQ,KAAK;gBAC3B,OAAO,EAAE,MAAM;CAI5B;AAED;;2EAE2E;AAC3E,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,CAIhF;AAED;;;kFAGkF;AAClF,wBAAgB,aAAa,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,OAAO,GAAG,MAAM,CAIxE;AAED;;;;;;;;8DAQ8D;AAC9D,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,MAAM,CAEpE;AAcD,iFAAiF;AACjF,wBAAsB,eAAe,CAAC,KAAK,EAAE,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC,CAOxE;AAED;;gFAEgF;AAChF,wBAAsB,eAAe,CACnC,KAAK,EAAE,UAAU,EACjB,SAAS,EAAE,MAAM,EACjB,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,IAAI,CAAC,CAkBf;AAED;;;;sCAIsC;AACtC,wBAAsB,aAAa,CACjC,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,MAAM,GAAG,SAAS,EAC7B,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC;IAAE,KAAK,EAAE,UAAU,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,CAU9C"}
@@ -9,6 +9,12 @@
9
9
  * fragments (rare in module refs) pass through untouched. `sha256` is the
10
10
  * only algorithm accepted today; the prefix leaves room to migrate. */
11
11
  const INTEGRITY_FRAGMENT = /#(sha256-[A-Za-z0-9_+/=-]+)$/;
12
+ /** The canonical written form: SHA-256 as unpadded base64url, exactly what
13
+ * {@link sha256Base64Url} emits and what the `layers[].integrity` schema
14
+ * accepts. Stricter than {@link INTEGRITY_FRAGMENT}, which also tolerates the
15
+ * padded / standard-base64 spellings {@link verifyIntegrity} normalizes on the
16
+ * way in — reading is forgiving, writing is not. */
17
+ const CANONICAL_INTEGRITY = /^sha256-[A-Za-z0-9_-]{43}$/;
12
18
  /** A failed integrity/tamper check — always terminal, never best-effort. A
13
19
  * distinct type so a caller doing best-effort network handling (e.g. the
14
20
  * bundle extractor warning-and-skipping on a fetch blip) can still let a
@@ -37,6 +43,18 @@ export function foldIntegrity(source, integrity) {
37
43
  ? `${source}#${integrity}`
38
44
  : source;
39
45
  }
46
+ /** True for a value safe to WRITE into a manifest as an integrity pin.
47
+ *
48
+ * A pin arriving from outside the file — the hub's version index, a registry
49
+ * response — is untrusted text before it is untrusted *content*: a value
50
+ * carrying a quote, a `#`, or a newline corrupts the YAML it is spliced into,
51
+ * which no later install-time verification can catch because the manifest no
52
+ * longer parses. A wrong-but-well-formed hash is the case install *does*
53
+ * catch; this is the case it cannot. Callers writing a pin they did not
54
+ * compute check here first and fall back to writing none. */
55
+ export function isCanonicalIntegrity(value) {
56
+ return typeof value === "string" && CANONICAL_INTEGRITY.test(value);
57
+ }
40
58
  function toBase64Url(bytes) {
41
59
  let binary = "";
42
60
  for (let i = 0; i < bytes.length; i++)
@@ -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;AAItF,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;AA4CD,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,CAyCjC;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,CA4IrB;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
+ {"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;AAItF,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;AA4CD,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,CAyCjC;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,CAiJrB;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"}
@@ -283,9 +283,14 @@ export function resolveContextAnnotations(schema, manifestItem, opts) {
283
283
  : [fromRefKindRaw];
284
284
  if (fromRoot || fromRefKinds.length > 0) {
285
285
  if (fromRoot) {
286
- const resolved = navigatePath(manifestRoot, fromRoot.split("/"));
287
- if (resolved && typeof resolved === "object" && !Array.isArray(resolved)) {
288
- return resolved;
286
+ const navigated = navigatePath(manifestRoot, fromRoot.split("/"));
287
+ if (navigated && typeof navigated === "object" && !Array.isArray(navigated)) {
288
+ // A `telo#Type` slot resolves to the schema it names — the inline
289
+ // `{ kind, schema }` wrapper, a `!ref` to a named type, or a bare name —
290
+ // so the variable is typed by the CONTRACT rather than by the wrapper
291
+ // around it. A raw JSON Schema resolves to itself, and a plain property
292
+ // map (a transport scope) resolves to nothing and is used verbatim.
293
+ return resolveTypeFieldToSchema(navigated, allManifests ?? []) ?? navigated;
289
294
  }
290
295
  }
291
296
  if (defs) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telorun/analyzer",
3
- "version": "0.50.0",
3
+ "version": "0.52.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.63.0"
51
+ "@telorun/sdk": "0.65.0"
52
52
  },
53
53
  "peerDependencies": {
54
54
  "@telorun/sdk": "*"
package/src/analyzer.ts CHANGED
@@ -23,7 +23,11 @@ import {
23
23
  resolveContract,
24
24
  } from "./invocation-contract.js";
25
25
  import { buildDependencyGraph, formatCycle } from "./dependency-graph.js";
26
- import { buildKernelGlobalsSchema, mergeKernelGlobalsIntoContext } from "./kernel-globals.js";
26
+ import {
27
+ buildKernelGlobalsSchema,
28
+ KERNEL_GLOBAL_NAMES,
29
+ mergeKernelGlobalsIntoContext,
30
+ } from "./kernel-globals.js";
27
31
  import {
28
32
  buildObservedStateIndex,
29
33
  buildObservedStateResourcesSchema,
@@ -59,6 +63,16 @@ import {
59
63
  resolveTypeFieldToSchema,
60
64
  } from "./validate-cel-context.js";
61
65
  import { buildEvalPaths, evalPathsCover } from "./eval-paths.js";
66
+ import {
67
+ BINDINGS_ANNOTATION,
68
+ bindingContextProperties,
69
+ bindingPathChain,
70
+ CEL_RESERVED_WORDS,
71
+ findBindingSites,
72
+ resolveBindingOrder,
73
+ schemaAtChain,
74
+ type BindingSites,
75
+ } from "./cel-bindings.js";
62
76
  import { validateExtends } from "./validate-extends.js";
63
77
  import { validateLogging } from "./validate-logging.js";
64
78
  import { validateModuleArtifact } from "./validate-module-artifact.js";
@@ -416,6 +430,9 @@ function buildStepContextSchema(
416
430
 
417
431
  const invokeField = stepCtx.invoke;
418
432
  const outputTypeField = stepCtx.outputType;
433
+ // Optional: the field a step uses to produce a result without dispatching.
434
+ // Only a kind that declares one has pure steps at all.
435
+ const valueField = stepCtx.value;
419
436
  if (!invokeField || !outputTypeField) continue;
420
437
 
421
438
  const steps = manifest[fieldName];
@@ -426,6 +443,13 @@ function buildStepContextSchema(
426
443
  defSchema,
427
444
  );
428
445
 
446
+ // The instance's own input contract, for typing a pure step that just
447
+ // forwards one of its values.
448
+ const ownInputs = resolveTypeFieldToSchema(
449
+ (manifest as Record<string, any>).inputType,
450
+ allManifests,
451
+ );
452
+
429
453
  const stepProperties: Record<string, any> = {};
430
454
 
431
455
  walkStepArray(steps, stepItemSchema, defSchema, fieldName, (s) => {
@@ -435,7 +459,28 @@ function buildStepContextSchema(
435
459
  // wrappers (try/if/while/switch/throw) don't produce a result and must
436
460
  // not shadow real entries with a permissive `additionalProperties: true`,
437
461
  // or unknown step references slip through chain validation.
438
- if (typeof name !== "string" || !invoke || typeof invoke !== "object") return;
462
+ if (typeof name !== "string") return;
463
+ if (!invoke || typeof invoke !== "object") {
464
+ // A pure step dispatches nothing, so there is no contract to resolve.
465
+ // Where its expression is a plain chain into something already typed —
466
+ // an earlier step's result, or the kind's own inputs — that type carries
467
+ // through; anything else (arithmetic, a call, a comprehension) stays
468
+ // permissive rather than guessed. Same rule as a named binding's.
469
+ if (valueField && valueField in s) {
470
+ const scopeRoot = {
471
+ properties: {
472
+ steps: { type: "object", properties: { ...stepProperties } },
473
+ ...(ownInputs ? { inputs: ownInputs } : {}),
474
+ },
475
+ };
476
+ const chained = schemaAtChain(bindingPathChain(s[valueField]), scopeRoot);
477
+ stepProperties[name] = {
478
+ type: "object",
479
+ properties: { result: chained ?? PERMISSIVE_CONTRACT },
480
+ };
481
+ }
482
+ return;
483
+ }
439
484
  const invokedKind = invoke.kind as string | undefined;
440
485
  const invokedName = invoke.name as string | undefined;
441
486
  // A named `!ref` carries the target's own manifest (which may narrow the
@@ -765,6 +810,29 @@ function errorContextForPath(
765
810
  return best?.schema;
766
811
  }
767
812
 
813
+ /** Add a kind's named bindings to a resolved context, when the context declares
814
+ * a bindings region. They go UNDER the context's own properties: a scope
815
+ * variable wins over a same-named binding at runtime, so static typing has to
816
+ * agree (the collision itself is `BINDING_NAME_RESERVED`). */
817
+ function withBindingNames(
818
+ contextSchema: Record<string, any>,
819
+ resource: Record<string, any>,
820
+ ): Record<string, any> {
821
+ const field = contextSchema[BINDINGS_ANNOTATION];
822
+ if (typeof field !== "string") return contextSchema;
823
+ const bindings = resource[field];
824
+ if (bindings === null || typeof bindings !== "object" || Array.isArray(bindings)) {
825
+ return contextSchema;
826
+ }
827
+ return {
828
+ ...contextSchema,
829
+ properties: {
830
+ ...bindingContextProperties(bindings as Record<string, unknown>, contextSchema),
831
+ ...(contextSchema.properties ?? {}),
832
+ },
833
+ };
834
+ }
835
+
768
836
  /** Member-access chains in a CEL expression, or none when it doesn't parse.
769
837
  * Best-effort: a syntax error is reported by the engine pass, not here. */
770
838
  function celAccessChains(env: Environment, expr: string): string[][] {
@@ -1695,6 +1763,9 @@ export class StaticAnalyzer {
1695
1763
  // `x-telo-step-context` / `x-telo-error-context` scopes. A `!cel` outside
1696
1764
  // every region is read as a literal — the runtime never evaluates it.
1697
1765
  let celEvalPaths: string[] = [];
1766
+ // The bindings field this kind declares (if any), read by the CEL sites that
1767
+ // see the names it introduces.
1768
+ let celBindingSites: BindingSites | undefined;
1698
1769
  // The compile half alone: a field that resolves at startup, where observed
1699
1770
  // state cannot exist yet.
1700
1771
  let celCompilePaths: string[] = [];
@@ -1748,6 +1819,75 @@ export class StaticAnalyzer {
1748
1819
  e.definition?.schema as Record<string, any> | undefined,
1749
1820
  );
1750
1821
 
1822
+ celBindingSites = findBindingSites(e.definition?.schema as Record<string, any>);
1823
+ if (celBindingSites) {
1824
+ const declared = (m as Record<string, any>)[celBindingSites.field];
1825
+ const bindingsName = (m.metadata as any)?.name as string | undefined;
1826
+ const bindingsFile = (m.metadata as { source?: string } | undefined)?.source;
1827
+ const resourceRef = { kind: m.kind, name: bindingsName ?? "" };
1828
+
1829
+ // Which field holds the bindings would otherwise be decided by
1830
+ // schema walk order, silently.
1831
+ if (celBindingSites.fields.length > 1) {
1832
+ diagnostics.push({
1833
+ severity: DiagnosticSeverity.Error,
1834
+ code: "BINDING_FIELD_AMBIGUOUS",
1835
+ source: SOURCE,
1836
+ message: `${m.kind}/${bindingsName}: the kind's schema points '${BINDINGS_ANNOTATION}' at more than one field (${celBindingSites.fields.join(", ")}). Every annotated context must name the same bindings field.`,
1837
+ data: { resource: resourceRef, filePath: bindingsFile, path: celBindingSites.field },
1838
+ });
1839
+ }
1840
+
1841
+ if (declared !== null && typeof declared === "object" && !Array.isArray(declared)) {
1842
+
1843
+ for (const cycle of resolveBindingOrder(declared).cycles) {
1844
+ diagnostics.push({
1845
+ severity: DiagnosticSeverity.Error,
1846
+ code: "BINDING_CYCLE",
1847
+ source: SOURCE,
1848
+ message: `${m.kind}/${bindingsName}: '${celBindingSites.field}' has a cycle — ${cycle.join(" → ")}. A binding is resolved from the ones it references, so it cannot reference itself, directly or through others.`,
1849
+ data: {
1850
+ resource: resourceRef,
1851
+ filePath: bindingsFile,
1852
+ path: `${celBindingSites.field}.${cycle[0]}`,
1853
+ },
1854
+ });
1855
+ }
1856
+
1857
+ // Every name the CEL environment already binds at this site: the
1858
+ // kernel globals (registered straight onto the environment, not
1859
+ // contributed by any annotation), the scope the annotated contexts
1860
+ // declare, and the two the analyzer merges per site. Shadowing one
1861
+ // would leave the binding silently unreachable there — as would
1862
+ // naming it after a CEL keyword, which never lexes as a reference.
1863
+ const inScope = new Set<string>([
1864
+ ...KERNEL_GLOBAL_NAMES,
1865
+ ...celBindingSites.scopeNames,
1866
+ ]);
1867
+ if (celStepContextSchema) inScope.add("steps");
1868
+ if (celErrorScopes.size > 0) inScope.add("error");
1869
+ const keywords = new Set<string>(CEL_RESERVED_WORDS);
1870
+
1871
+ for (const name of Object.keys(declared)) {
1872
+ const shadows = inScope.has(name);
1873
+ if (!shadows && !keywords.has(name)) continue;
1874
+ diagnostics.push({
1875
+ severity: DiagnosticSeverity.Error,
1876
+ code: "BINDING_NAME_RESERVED",
1877
+ source: SOURCE,
1878
+ message: shadows
1879
+ ? `${m.kind}/${bindingsName}: binding '${name}' shadows a variable already in scope here (${[...inScope].sort().join(", ")}). Rename the binding — a scope variable always wins, so this one would never be read.`
1880
+ : `${m.kind}/${bindingsName}: binding '${name}' is a CEL keyword, so no expression can read it as a reference. Rename the binding.`,
1881
+ data: {
1882
+ resource: resourceRef,
1883
+ filePath: bindingsFile,
1884
+ path: `${celBindingSites.field}.${name}`,
1885
+ },
1886
+ });
1887
+ }
1888
+ }
1889
+ }
1890
+
1751
1891
  // The non-eval-field check only applies to runtime resource instances:
1752
1892
  // structural / templating kinds (capability `Telo.Template`, or no
1753
1893
  // definition) carry CEL the kernel evaluates by other rules.
@@ -1889,7 +2029,10 @@ export class StaticAnalyzer {
1889
2029
  aliases,
1890
2030
  allManifests: allManifests as Record<string, any>[],
1891
2031
  });
1892
- effectiveContext = mergeKernelGlobalsIntoContext(resolvedContext, kernelGlobals);
2032
+ effectiveContext = mergeKernelGlobalsIntoContext(
2033
+ withBindingNames(resolvedContext, m as Record<string, any>),
2034
+ kernelGlobals,
2035
+ );
1893
2036
  } else if (observedStateContext) {
1894
2037
  // No `x-telo-context` matched, so nothing was chain-validated here
1895
2038
  // before. Validate the observed-state segment alone rather than
package/src/cel-ast.ts CHANGED
@@ -1,4 +1,30 @@
1
- import { parse, type ASTNode as CelJsNode } from "@marcbachmann/cel-js";
1
+ import { ParseError, parse, type ASTNode as CelJsNode } from "@marcbachmann/cel-js";
2
+
3
+ /** A CEL body that does not parse — an author's expression, mid-typing or
4
+ * malformed. Owned here so a consumer can be lenient about author syntax
5
+ * (navigation, completion) without also swallowing a defect in the wrapper
6
+ * below, and so the third-party parser's error type stays internal, exactly as
7
+ * its AST type does. */
8
+ export class CelParseError extends Error {
9
+ constructor(
10
+ readonly source: string,
11
+ override readonly cause: unknown,
12
+ ) {
13
+ super(cause instanceof Error ? cause.message : String(cause));
14
+ this.name = "CelParseError";
15
+ }
16
+ }
17
+
18
+ /** Parse a CEL body, translating the parser's own failure into `CelParseError`.
19
+ * Anything else (a bug in `wrapCelAst`) propagates untouched. */
20
+ function parseCel(source: string): CelJsNode {
21
+ try {
22
+ return parse(source).ast;
23
+ } catch (error) {
24
+ if (error instanceof ParseError) throw new CelParseError(source, error);
25
+ throw error;
26
+ }
27
+ }
2
28
 
3
29
  /** Read-only CEL expression tree owned by the analyzer. The third-party
4
30
  * `@marcbachmann/cel-js` `ASTNode` stays an internal detail — `wrapCelAst`
@@ -57,7 +83,9 @@ export interface CelSegment {
57
83
  source: string;
58
84
  /** True when a `${{` has no matching `}}` yet (the user is mid-typing). */
59
85
  open: boolean;
60
- /** Lazily parse + wrap; ranges are already absolute. */
86
+ /** Lazily parse + wrap; ranges are already absolute. Throws `CelParseError`
87
+ * when the body doesn't parse — an `open` segment recovers to its longest
88
+ * parseable prefix instead, so only a closed one can throw. */
61
89
  ast(): CelNode;
62
90
  }
63
91
 
@@ -178,9 +206,11 @@ function parseLenient(source: string, segmentStart: number, range: [number, numb
178
206
  const trimmed = candidate.trim();
179
207
  if (!trimmed) break;
180
208
  try {
181
- return wrapCelAst(parse(trimmed).ast, segmentStart);
182
- } catch {
183
- // try the next-shorter prefix
209
+ return wrapCelAst(parseCel(trimmed), segmentStart);
210
+ } catch (error) {
211
+ // Only a body that doesn't parse warrants the next-shorter prefix; a
212
+ // wrapper defect is not something a shorter prefix fixes.
213
+ if (!(error instanceof CelParseError)) throw error;
184
214
  }
185
215
  }
186
216
  return { kind: "ident", range, name: source.trim() };
@@ -211,7 +241,7 @@ export function buildCelSegments(
211
241
  range,
212
242
  source: taggedSource,
213
243
  open: false,
214
- ast: () => wrapCelAst(parse(taggedSource).ast, bodyStart),
244
+ ast: () => wrapCelAst(parseCel(taggedSource), bodyStart),
215
245
  },
216
246
  ];
217
247
  }
@@ -230,7 +260,7 @@ export function buildCelSegments(
230
260
  range: [scalarStart + match.index, scalarStart + match.index + whole.length],
231
261
  source,
232
262
  open: false,
233
- ast: () => wrapCelAst(parse(source).ast, bodyStart),
263
+ ast: () => wrapCelAst(parseCel(source), bodyStart),
234
264
  });
235
265
  lastClosedEnd = match.index + whole.length;
236
266
  }
@@ -0,0 +1,222 @@
1
+ import { isCompiledValue } from "@telorun/sdk";
2
+ import { buildCelEnvironment, extractAccessChains } from "@telorun/templating";
3
+ import { extractContextsFromSchema } from "./validate-cel-context.js";
4
+
5
+ /** Annotation on an `x-telo-context` node naming the resource field that holds
6
+ * the kind's named CEL bindings. The field is read from the RESOURCE ROOT, not
7
+ * the per-scope manifest item: a bindings map belongs to the resource, while the
8
+ * contexts that see it may be anchored anywhere (a decision table annotates both
9
+ * `choices` and `default`). */
10
+ export const BINDINGS_ANNOTATION = "x-telo-bindings-from";
11
+
12
+ export interface BindingSites {
13
+ /** Resource field holding the bindings map. */
14
+ field: string;
15
+ /** Every field named by an annotation on this kind. More than one is a
16
+ * kind-authoring mistake: which of them holds the bindings would be decided
17
+ * by schema walk order. */
18
+ fields: string[];
19
+ /** Variable names the annotated contexts declare — a binding may not shadow one. */
20
+ scopeNames: Set<string>;
21
+ }
22
+
23
+ /**
24
+ * CEL keywords. A binding named after one is unreachable — `true` lexes as a
25
+ * literal, `in` as an operator — so it is reserved alongside the scope names,
26
+ * which turns silence into a diagnostic.
27
+ */
28
+ export const CEL_RESERVED_WORDS: readonly string[] = [
29
+ "as",
30
+ "break",
31
+ "const",
32
+ "continue",
33
+ "else",
34
+ "false",
35
+ "for",
36
+ "function",
37
+ "if",
38
+ "import",
39
+ "in",
40
+ "let",
41
+ "loop",
42
+ "namespace",
43
+ "null",
44
+ "package",
45
+ "return",
46
+ "true",
47
+ "var",
48
+ "void",
49
+ "while",
50
+ ];
51
+
52
+ /** Locate a kind's bindings field and the scope names its annotated contexts
53
+ * declare. Returns undefined for a kind that declares no bindings region. */
54
+ export function findBindingSites(
55
+ definitionSchema: Record<string, any> | undefined,
56
+ ): BindingSites | undefined {
57
+ if (!definitionSchema) return undefined;
58
+ const fields: string[] = [];
59
+ const scopeNames = new Set<string>();
60
+ for (const { schema } of extractContextsFromSchema(definitionSchema)) {
61
+ const declared = schema?.[BINDINGS_ANNOTATION];
62
+ if (typeof declared !== "string" || declared.length === 0) continue;
63
+ if (!fields.includes(declared)) fields.push(declared);
64
+ for (const name of Object.keys(schema.properties ?? {})) scopeNames.add(name);
65
+ }
66
+ return fields.length > 0 ? { field: fields[0]!, fields, scopeNames } : undefined;
67
+ }
68
+
69
+ const TEMPLATE_RE = /\$\{\{\s*([^}]+?)\s*\}\}/g;
70
+ const EXACT_TEMPLATE_RE = /^\s*\$\{\{\s*([^}]+?)\s*\}\}\s*$/;
71
+
72
+ /** Parser for expressions that reach here uncompiled. Built once; the base
73
+ * environment is stateless and shared with the runtime's own. */
74
+ let parseEnv: ReturnType<typeof buildCelEnvironment> | undefined;
75
+
76
+ /**
77
+ * Root identifiers an expression source reads — the first element of every
78
+ * member-access chain, which is what a dependency edge is made of.
79
+ *
80
+ * Parsed, never lexed: `inputs.total` reads `inputs`, not `total`, and a name
81
+ * inside a string literal reads nothing. A token scan would make two bindings
82
+ * named after each other's *fields* look mutually recursive and reject a correct
83
+ * manifest — the worst outcome a static check has. An expression that does not
84
+ * parse contributes no edges; its syntax error is the engine pass's to report.
85
+ */
86
+ function addRootIdentifiers(source: string, out: Set<string>): void {
87
+ try {
88
+ parseEnv ??= buildCelEnvironment();
89
+ for (const chain of extractAccessChains(parseEnv.parse(source).ast)) {
90
+ if (chain.length > 0) out.add(chain[0]!);
91
+ }
92
+ } catch {
93
+ // Unparseable — see above.
94
+ }
95
+ }
96
+
97
+ /** Root identifiers a binding's value reads. Walks the whole value so a
98
+ * structured binding (a map with `!cel` leaves) is covered, and reads both a
99
+ * compiled expression and a still-raw `${{ }}` string — the editor's
100
+ * round-trip view never compiles. An untagged plain string is a literal, not
101
+ * an expression, and contributes nothing. */
102
+ function collectRefs(value: unknown, out: Set<string>): void {
103
+ if (isCompiledValue(value)) {
104
+ const refs = (value as { refs?: readonly string[] }).refs;
105
+ if (refs) for (const ref of refs) out.add(ref);
106
+ else addRootIdentifiers((value as { source?: string }).source ?? "", out);
107
+ return;
108
+ }
109
+ if (typeof value === "string") {
110
+ for (const match of value.matchAll(TEMPLATE_RE)) addRootIdentifiers(match[1]!, out);
111
+ return;
112
+ }
113
+ if (Array.isArray(value)) {
114
+ for (const entry of value) collectRefs(entry, out);
115
+ return;
116
+ }
117
+ if (value !== null && typeof value === "object") {
118
+ for (const entry of Object.values(value)) collectRefs(entry, out);
119
+ }
120
+ }
121
+
122
+ /** Each binding's dependencies, restricted to its siblings — an identifier that
123
+ * names something else in scope (`inputs`, `item`) is not an edge. */
124
+ export function bindingDependencies(
125
+ bindings: Record<string, unknown>,
126
+ ): Map<string, Set<string>> {
127
+ const names = new Set(Object.keys(bindings));
128
+ const deps = new Map<string, Set<string>>();
129
+ for (const [name, value] of Object.entries(bindings)) {
130
+ const refs = new Set<string>();
131
+ collectRefs(value, refs);
132
+ const own = new Set<string>();
133
+ for (const ref of refs) if (names.has(ref)) own.add(ref);
134
+ deps.set(name, own);
135
+ }
136
+ return deps;
137
+ }
138
+
139
+ /** Member-access chain for a binding whose value is one bare dotted identifier
140
+ * expression (`inputs.user.name`). Null for anything else — a literal, a call,
141
+ * a comprehension, a structured value — none of which reduces to a typed path. */
142
+ export function bindingPathChain(value: unknown): string[] | null {
143
+ let source: string | undefined;
144
+ if (isCompiledValue(value)) source = (value as { source?: string }).source;
145
+ else if (typeof value === "string") source = value.match(EXACT_TEMPLATE_RE)?.[1];
146
+ if (source === undefined) return null;
147
+ const expr = source.trim();
148
+ if (!/^[A-Za-z_]\w*(\.[A-Za-z_]\w*)*$/.test(expr)) return null;
149
+ return expr.split(".");
150
+ }
151
+
152
+ /**
153
+ * The context properties a bindings map contributes: each name typed from its
154
+ * expression where that expression is a chain into an already-typed scope
155
+ * variable, and left open otherwise.
156
+ *
157
+ * Gradual by design — the same stance `x-telo-context-element-from` takes. An
158
+ * open schema costs a missed `CEL_UNKNOWN_FIELD` under that name; inventing a
159
+ * type would cost a false one.
160
+ */
161
+ export function bindingContextProperties(
162
+ bindings: Record<string, unknown>,
163
+ contextSchema: Record<string, any>,
164
+ ): Record<string, any> {
165
+ const props: Record<string, any> = {};
166
+ for (const [name, value] of Object.entries(bindings)) {
167
+ props[name] = schemaAtChain(bindingPathChain(value), contextSchema) ?? {};
168
+ }
169
+ return props;
170
+ }
171
+
172
+ /** Walk a member-access chain through a schema's `properties`, returning the
173
+ * terminal node or undefined once the path leaves typed schema. */
174
+ export function schemaAtChain(
175
+ chain: string[] | null,
176
+ root: Record<string, any>,
177
+ ): Record<string, any> | undefined {
178
+ if (!chain) return undefined;
179
+ let current: Record<string, any> | undefined = root;
180
+ for (const key of chain) {
181
+ const props = current?.properties as Record<string, any> | undefined;
182
+ if (!props || !(key in props)) return undefined;
183
+ current = props[key] as Record<string, any>;
184
+ }
185
+ return current && typeof current === "object" ? current : undefined;
186
+ }
187
+
188
+ /**
189
+ * Evaluation order derived from the reference graph, plus any cycles found.
190
+ *
191
+ * Order is what the editor and diagnostics show; the runtime does not need it,
192
+ * since lazy evaluation reaches a binding's dependencies by construction. A
193
+ * cycle is reported as the path that closes it (`a → b → a`).
194
+ */
195
+ export function resolveBindingOrder(bindings: Record<string, unknown>): {
196
+ order: string[];
197
+ cycles: string[][];
198
+ } {
199
+ const deps = bindingDependencies(bindings);
200
+ const order: string[] = [];
201
+ const cycles: string[][] = [];
202
+ const state = new Map<string, "visiting" | "done">();
203
+ const path: string[] = [];
204
+
205
+ const visit = (name: string): void => {
206
+ const seen = state.get(name);
207
+ if (seen === "done") return;
208
+ if (seen === "visiting") {
209
+ cycles.push([...path.slice(path.indexOf(name)), name]);
210
+ return;
211
+ }
212
+ state.set(name, "visiting");
213
+ path.push(name);
214
+ for (const dep of deps.get(name) ?? []) visit(dep);
215
+ path.pop();
216
+ state.set(name, "done");
217
+ order.push(name);
218
+ };
219
+
220
+ for (const name of Object.keys(bindings)) visit(name);
221
+ return { order, cycles };
222
+ }
package/src/index.ts CHANGED
@@ -24,6 +24,14 @@ export {
24
24
  type ReExportSpec,
25
25
  } from "./flatten-for-analyzer.js";
26
26
  export { buildEvalPaths, evalPathCovers } from "./eval-paths.js";
27
+ export {
28
+ BINDINGS_ANNOTATION,
29
+ bindingContextProperties,
30
+ bindingDependencies,
31
+ findBindingSites,
32
+ resolveBindingOrder,
33
+ } from "./cel-bindings.js";
34
+ export type { BindingSites } from "./cel-bindings.js";
27
35
  export {
28
36
  applyObservedStateNode,
29
37
  buildObservedStateIndex,
@@ -111,6 +119,7 @@ export { defaultSources } from "./sources/default-sources.js";
111
119
  export {
112
120
  splitIntegrity,
113
121
  foldIntegrity,
122
+ isCanonicalIntegrity,
114
123
  verifyIntegrity,
115
124
  verifiedFetch,
116
125
  sha256Base64Url,
@@ -161,7 +170,7 @@ export { validateModuleArtifact } from "./validate-module-artifact.js";
161
170
  export { withSyntheticPositions } from "./with-synthetic-positions.js";
162
171
  export { documentToAst, parseToAst } from "./yaml-ast.js";
163
172
  export type { AstDocument, AstMap, AstNode, AstPair, AstScalar, AstSeq } from "./yaml-ast.js";
164
- export { buildCelSegments, wrapCelAst } from "./cel-ast.js";
173
+ export { CelParseError, buildCelSegments, wrapCelAst } from "./cel-ast.js";
165
174
  export type { CelNode, CelSegment } from "./cel-ast.js";
166
175
  export { DEFAULT_MANIFEST_FILENAME, DiagnosticSeverity } from "./types.js";
167
176
  export type {