@telorun/analyzer 0.63.0 → 0.65.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.
Files changed (64) hide show
  1. package/dist/analysis-registry.d.ts +24 -0
  2. package/dist/analysis-registry.d.ts.map +1 -1
  3. package/dist/analysis-registry.js +35 -0
  4. package/dist/analyzer.d.ts +3 -37
  5. package/dist/analyzer.d.ts.map +1 -1
  6. package/dist/analyzer.js +76 -470
  7. package/dist/cel-scope-query.d.ts +109 -0
  8. package/dist/cel-scope-query.d.ts.map +1 -0
  9. package/dist/cel-scope-query.js +270 -0
  10. package/dist/cel-scope.d.ts +166 -0
  11. package/dist/cel-scope.d.ts.map +1 -0
  12. package/dist/cel-scope.js +377 -0
  13. package/dist/definition-registry.d.ts +38 -6
  14. package/dist/definition-registry.d.ts.map +1 -1
  15. package/dist/definition-registry.js +66 -22
  16. package/dist/find-manifest.d.ts +10 -0
  17. package/dist/find-manifest.d.ts.map +1 -0
  18. package/dist/find-manifest.js +12 -0
  19. package/dist/index.d.ts +11 -2
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +11 -1
  22. package/dist/invocation-contract.d.ts +11 -0
  23. package/dist/invocation-contract.d.ts.map +1 -1
  24. package/dist/invocation-contract.js +15 -0
  25. package/dist/manifest-analysis.d.ts +73 -0
  26. package/dist/manifest-analysis.d.ts.map +1 -0
  27. package/dist/manifest-analysis.js +78 -0
  28. package/dist/manifest-path.d.ts +18 -0
  29. package/dist/manifest-path.d.ts.map +1 -0
  30. package/dist/manifest-path.js +37 -0
  31. package/dist/schema-compat.d.ts +59 -22
  32. package/dist/schema-compat.d.ts.map +1 -1
  33. package/dist/schema-compat.js +60 -75
  34. package/dist/schema-error-report.d.ts +68 -0
  35. package/dist/schema-error-report.d.ts.map +1 -0
  36. package/dist/schema-error-report.js +356 -0
  37. package/dist/schema-walk.d.ts +25 -0
  38. package/dist/schema-walk.d.ts.map +1 -0
  39. package/dist/schema-walk.js +126 -0
  40. package/dist/telo-version.d.ts +1 -1
  41. package/dist/telo-version.js +1 -1
  42. package/dist/validate-nested-inline.d.ts +22 -1
  43. package/dist/validate-nested-inline.d.ts.map +1 -1
  44. package/dist/validate-nested-inline.js +17 -9
  45. package/dist/validate-step-inputs.d.ts +17 -0
  46. package/dist/validate-step-inputs.d.ts.map +1 -1
  47. package/dist/validate-step-inputs.js +108 -9
  48. package/package.json +2 -2
  49. package/src/analysis-registry.ts +37 -0
  50. package/src/analyzer.ts +83 -587
  51. package/src/cel-scope-query.ts +337 -0
  52. package/src/cel-scope.ts +570 -0
  53. package/src/definition-registry.ts +79 -24
  54. package/src/find-manifest.ts +19 -0
  55. package/src/index.ts +23 -2
  56. package/src/invocation-contract.ts +22 -0
  57. package/src/manifest-analysis.ts +132 -0
  58. package/src/manifest-path.ts +34 -0
  59. package/src/schema-compat.ts +92 -79
  60. package/src/schema-error-report.ts +417 -0
  61. package/src/schema-walk.ts +144 -0
  62. package/src/telo-version.ts +1 -1
  63. package/src/validate-nested-inline.ts +35 -14
  64. package/src/validate-step-inputs.ts +153 -11
@@ -0,0 +1,570 @@
1
+ /**
2
+ * **What is in scope for CEL at one expression site.**
3
+ *
4
+ * A CEL expression is typed against an environment and a resolved context
5
+ * schema that depend on WHERE it sits: the kind's step body contributes
6
+ * `steps.<name>.result`, an error-bearing branch contributes `error`, a
7
+ * `x-telo-bindings-from` field contributes its named bindings, an
8
+ * `x-telo-context` region contributes its own scope resolved against the
9
+ * enclosing array item, and the kernel globals are merged in per declaring
10
+ * module. That assembly used to live inline in the analysis pass, built for one
11
+ * `engine.analyze` call and discarded — so nothing outside the pass could ask
12
+ * what a cursor sees.
13
+ *
14
+ * It is a QUERY here, and the pass is one of its callers. The other is the IDE:
15
+ * completion, hover and signature help must offer exactly the names
16
+ * `telo check` accepts, and two implementations of an open, growing scope rule
17
+ * cannot be held in agreement by tests.
18
+ *
19
+ * Nothing here reports a diagnostic. The pass keeps every check it ever had;
20
+ * what moved is the answer both halves need first.
21
+ */
22
+ import type { ResourceDefinition, ResourceManifest } from "@telorun/sdk";
23
+ import type { Environment } from "@marcbachmann/cel-js";
24
+ import { AliasResolver, type ModuleScopes } from "./alias-resolver.js";
25
+ import {
26
+ bindingContextProperties,
27
+ bindingPathChain,
28
+ BINDINGS_ANNOTATION,
29
+ schemaAtChain,
30
+ } from "./cel-bindings.js";
31
+ import { buildImportInputCelEnvironment, buildTypedCelEnvironment } from "./cel-environment.js";
32
+ import { DefinitionRegistry } from "./definition-registry.js";
33
+ import { type ContractDirection, effectiveAuthorSchema } from "./extends-resolution.js";
34
+ import {
35
+ analyzerContractScope,
36
+ PERMISSIVE_CONTRACT,
37
+ resolveContract,
38
+ } from "./invocation-contract.js";
39
+ import {
40
+ mergeKernelGlobalsIntoContext,
41
+ type KernelGlobalsIndex,
42
+ } from "./kernel-globals.js";
43
+ import { gatherPropertySchemas, resolveLocalRef, walkStepArray } from "./schema-walk.js";
44
+ import { readStepSlot } from "./step-slot.js";
45
+ import {
46
+ getManifestItem,
47
+ resolveContextAnnotations,
48
+ resolveTypeFieldToSchema,
49
+ } from "./validate-cel-context.js";
50
+
51
+ /** Build a closed JSON Schema for the `self` CEL variable available inside a
52
+ * `Telo.Definition` template body. Mirrors the runtime template controller's
53
+ * `const self = { ...resource, name: resource.metadata.name };` — every
54
+ * property the user declared in `schema:` plus synthetic `name` / `kind` and
55
+ * the metadata sub-object (kept open since metadata legitimately carries
56
+ * arbitrary user-added fields). */
57
+ function buildSelfSchema(
58
+ definition: Record<string, any>,
59
+ defs?: DefinitionRegistry,
60
+ aliases?: AliasResolver,
61
+ ): Record<string, any> {
62
+ // The author-facing schema resolves inheritance: with `base:` the child's own
63
+ // schema (the parent's config is internal); without it, `merge(parent, own)`.
64
+ const userSchema = (
65
+ defs
66
+ ? effectiveAuthorSchema(definition as unknown as ResourceDefinition, (k) =>
67
+ defs.resolve(aliases?.resolveKind(k) ?? k) ?? defs.resolve(k),
68
+ )
69
+ : (definition.schema ?? {})
70
+ ) as Record<string, any>;
71
+ const userProps = (userSchema.properties ?? {}) as Record<string, any>;
72
+ const userRequired = Array.isArray(userSchema.required) ? userSchema.required : [];
73
+ return {
74
+ type: "object",
75
+ additionalProperties: false,
76
+ properties: {
77
+ ...userProps,
78
+ name: { type: "string" },
79
+ kind: { type: "string" },
80
+ metadata: {
81
+ type: "object",
82
+ additionalProperties: true,
83
+ properties: { name: { type: "string" } },
84
+ },
85
+ },
86
+ required: [...userRequired, "name", "kind"],
87
+ };
88
+ }
89
+
90
+ /** Build the JSON Schema for the `inputs` CEL variable available inside an
91
+ * invocable template body — the shared contract resolver applied to the
92
+ * definition itself, so a body is typed against the exact signature callers are
93
+ * checked against and dispatch enforces. Walks the whole `extends` chain rather
94
+ * than one hop, so a definition two levels below the declaration still gets
95
+ * typed inputs. Undefined when nothing in the chain declares a contract —
96
+ * the caller signals opaque `map<string, dyn>` upstream. */
97
+ function lookupTemplateInputsSchema(
98
+ definition: Record<string, any>,
99
+ defs: DefinitionRegistry,
100
+ aliases: AliasResolver,
101
+ allManifests: Record<string, any>[],
102
+ scopes: ModuleScopes,
103
+ ): Record<string, any> | undefined {
104
+ return resolveContract(
105
+ "inputType",
106
+ undefined,
107
+ definition as unknown as ResourceDefinition,
108
+ analyzerContractScope(defs, aliases, scopes, allManifests),
109
+ )?.schema;
110
+ }
111
+
112
+ /** Returns a "resolver-facing" view of the manifest where the fields used as
113
+ * navigation roots by Telo.Definition's `x-telo-context-from-root` annotations
114
+ * have been pre-augmented:
115
+ * - `schema` → augmented `self` schema (synthetic `name`/`kind`/metadata).
116
+ * - `inputType` → resolved through the shared contract resolver, so
117
+ * `x-telo-context-from-root: inputType` substitutes the
118
+ * real signature. Without it the annotation would replace
119
+ * the node verbatim with the inline `{kind, schema}` wrapper
120
+ * the standard library writes everywhere, typing `inputs` as
121
+ * `{kind, schema}` instead of the declared properties.
122
+ *
123
+ * For non-definition manifests the original object is returned. */
124
+ export function manifestRootForResolver(
125
+ m: Record<string, any>,
126
+ defs: DefinitionRegistry,
127
+ aliases: AliasResolver,
128
+ allManifests: Record<string, any>[],
129
+ scopes: ModuleScopes,
130
+ ): Record<string, any> {
131
+ if (m.kind !== "Telo.Definition") return m;
132
+ const inputs = lookupTemplateInputsSchema(m, defs, aliases, allManifests, scopes);
133
+ return {
134
+ ...m,
135
+ schema: buildSelfSchema(m, defs, aliases),
136
+ ...(inputs ? { inputType: inputs } : {}),
137
+ };
138
+ }
139
+
140
+ /**
141
+ * Build a `steps` context schema for a kind's step body.
142
+ * Walks each step in the manifest array, resolves the invoked resource's output
143
+ * contract, and builds `steps.<name>.result` context entries.
144
+ *
145
+ * Resolution is the shared {@link resolveContract} — the invoked resource
146
+ * manifest's own declaration, then the kind's, resolved to the nearest
147
+ * declaration along `extends`, then permissive. Sharing it with the kernel is
148
+ * what stops `telo check` from typing `steps.X.result` against one contract
149
+ * while dispatch validates against another.
150
+ *
151
+ * The kind layer is what makes `x-telo-stream` properties on definitions
152
+ * actually govern step-result chain validation — without it, the validator falls
153
+ * back to permissive and the stream-opacity rule never fires.
154
+ *
155
+ * Recursion into nested step arrays is annotation-driven via
156
+ * `x-telo-topology-role`. The analyzer recognises three role values:
157
+ * - `branch` — value is an array of steps (e.g. then / else / do / catch).
158
+ * - `branch-list`— value is an array of objects each carrying further roled
159
+ * sub-properties (e.g. elseif: [{ if, then }]).
160
+ * - `case-map` — value is an object whose values are step arrays (e.g. cases).
161
+ * No specific Run.Sequence field name is hardcoded; any kind that uses
162
+ * a step body and tags its branch fields with these roles works.
163
+ */
164
+ export function buildStepContextSchema(
165
+ manifest: Record<string, any>,
166
+ defSchema: Record<string, any>,
167
+ allManifests: Record<string, any>[],
168
+ defs: DefinitionRegistry,
169
+ aliases: AliasResolver,
170
+ scopes: ModuleScopes,
171
+ ): Record<string, any> | undefined {
172
+ const props = defSchema.properties as Record<string, any> | undefined;
173
+ if (!props) return undefined;
174
+
175
+ const contractScope = analyzerContractScope(defs, aliases, scopes, allManifests);
176
+ const readingModule = (manifest.metadata as { module?: string } | undefined)?.module;
177
+
178
+ for (const [fieldName, fieldSchema] of Object.entries(props)) {
179
+ const stepCtx = readStepSlot(fieldSchema);
180
+ if (!stepCtx) continue;
181
+
182
+ const invokeField = stepCtx.invoke;
183
+ const outputTypeField = stepCtx.outputType;
184
+ // Optional: the field a step uses to produce a result without dispatching.
185
+ // Only a kind that declares one has pure steps at all.
186
+ const valueField = stepCtx.value;
187
+ if (!invokeField || !outputTypeField) continue;
188
+
189
+ const steps = manifest[fieldName];
190
+ if (!Array.isArray(steps)) continue;
191
+
192
+ const stepItemSchema = resolveLocalRef(
193
+ fieldSchema.items as Record<string, any> | undefined,
194
+ defSchema,
195
+ );
196
+
197
+ // The instance's own input contract, for typing a pure step that just
198
+ // forwards one of its values.
199
+ const ownInputs = resolveTypeFieldToSchema(
200
+ (manifest as Record<string, any>).inputType,
201
+ allManifests,
202
+ );
203
+
204
+ const stepProperties: Record<string, any> = {};
205
+
206
+ walkStepArray(steps, stepItemSchema, defSchema, fieldName, (s) => {
207
+ const name = s.name;
208
+ const invoke = s[invokeField] as Record<string, any> | undefined;
209
+ // Only invoke steps register a `steps.<name>.result` entry — control-flow
210
+ // wrappers (try/if/while/switch/throw) don't produce a result and must
211
+ // not shadow real entries with a permissive `additionalProperties: true`,
212
+ // or unknown step references slip through chain validation.
213
+ if (typeof name !== "string") return;
214
+ if (!invoke || typeof invoke !== "object") {
215
+ // A pure step dispatches nothing, so there is no contract to resolve.
216
+ // Where its expression is a plain chain into something already typed —
217
+ // an earlier step's result, or the kind's own inputs — that type carries
218
+ // through; anything else (arithmetic, a call, a comprehension) stays
219
+ // permissive rather than guessed. Same rule as a named binding's.
220
+ if (valueField && valueField in s) {
221
+ const scopeRoot = {
222
+ properties: {
223
+ steps: { type: "object", properties: { ...stepProperties } },
224
+ ...(ownInputs ? { inputs: ownInputs } : {}),
225
+ },
226
+ };
227
+ const chained = schemaAtChain(bindingPathChain(s[valueField]), scopeRoot);
228
+ stepProperties[name] = {
229
+ type: "object",
230
+ properties: { result: chained ?? PERMISSIVE_CONTRACT },
231
+ };
232
+ }
233
+ return;
234
+ }
235
+ const invokedKind = invoke.kind as string | undefined;
236
+ const invokedName = invoke.name as string | undefined;
237
+ // A named `!ref` carries the target's own manifest (which may narrow the
238
+ // contract for this one instance); an inline `{ kind, ... }` step IS the
239
+ // manifest. Either way the kind layer resolves through `extends`.
240
+ const invokedManifest = invokedName
241
+ ? (allManifests.find(
242
+ (m) =>
243
+ (m.metadata as any)?.name === invokedName && (!invokedKind || m.kind === invokedKind),
244
+ ) as Record<string, any> | undefined)
245
+ : (invoke as Record<string, any>);
246
+ const invokedDef = invokedKind
247
+ ? contractScope.resolveIn(invokedKind, readingModule)
248
+ : undefined;
249
+ const outputSchema = resolveContract(
250
+ outputTypeField as ContractDirection,
251
+ invokedManifest,
252
+ invokedDef,
253
+ contractScope,
254
+ )?.schema;
255
+ stepProperties[name] = {
256
+ type: "object",
257
+ properties: {
258
+ result: outputSchema ?? PERMISSIVE_CONTRACT,
259
+ },
260
+ };
261
+ });
262
+
263
+ if (Object.keys(stepProperties).length > 0) {
264
+ return {
265
+ type: "object",
266
+ properties: stepProperties,
267
+ };
268
+ }
269
+ }
270
+
271
+ return undefined;
272
+ }
273
+
274
+ export function collectErrorContextScopes(
275
+ defSchema: Record<string, any> | undefined,
276
+ ): Map<string, Record<string, any>> {
277
+ const out = new Map<string, Record<string, any>>();
278
+ if (!defSchema || typeof defSchema !== "object") return out;
279
+ const seen = new Set<Record<string, any>>();
280
+
281
+ const walk = (schema: Record<string, any> | undefined): void => {
282
+ if (!schema || typeof schema !== "object" || seen.has(schema)) return;
283
+ seen.add(schema);
284
+
285
+ const props = schema.properties as Record<string, any> | undefined;
286
+ if (props) {
287
+ for (const [fieldName, fieldSchema] of Object.entries(props)) {
288
+ if (fieldSchema && typeof fieldSchema === "object") {
289
+ const errCtx = (fieldSchema as Record<string, any>)["x-telo-error-context"];
290
+ if (errCtx && typeof errCtx === "object" && !out.has(fieldName)) {
291
+ out.set(fieldName, errCtx as Record<string, any>);
292
+ }
293
+ }
294
+ walk(resolveLocalRef(fieldSchema as Record<string, any>, defSchema));
295
+ }
296
+ }
297
+ if (schema.items) walk(resolveLocalRef(schema.items as Record<string, any>, defSchema));
298
+ for (const key of ["oneOf", "anyOf", "allOf"] as const) {
299
+ const arr = schema[key];
300
+ if (Array.isArray(arr)) for (const sub of arr) walk(resolveLocalRef(sub, defSchema));
301
+ }
302
+ if (schema.$defs && typeof schema.$defs === "object") {
303
+ for (const sub of Object.values(schema.$defs as Record<string, any>)) {
304
+ walk(sub as Record<string, any>);
305
+ }
306
+ }
307
+ };
308
+
309
+ walk(defSchema);
310
+ return out;
311
+ }
312
+
313
+ /**
314
+ * Return the error-context schema for a CEL `path` when the path lies within
315
+ * (any depth under) one of the error-bearing fields, else undefined. A path is
316
+ * "within" field `f` when it contains a segment `f[<index>]`. When multiple
317
+ * error-bearing fields match (e.g. a `finally` nested inside a `catch`), the
318
+ * deepest — the one whose segment appears latest in the path — wins, so the
319
+ * innermost branch's schema governs.
320
+ */
321
+ export function errorContextForPath(
322
+ path: string,
323
+ scopes: Map<string, Record<string, any>>,
324
+ ): Record<string, any> | undefined {
325
+ let best: { index: number; schema: Record<string, any> } | undefined;
326
+ for (const [fieldName, schema] of scopes) {
327
+ const escaped = fieldName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
328
+ for (const match of path.matchAll(new RegExp(`(^|\\.)${escaped}\\[\\d+\\]`, "g"))) {
329
+ if (best === undefined || match.index > best.index) {
330
+ best = { index: match.index, schema };
331
+ }
332
+ }
333
+ }
334
+ return best?.schema;
335
+ }
336
+
337
+ /** Add a kind's named bindings to a resolved context, when the context declares
338
+ * a bindings region. They go UNDER the context's own properties: a scope
339
+ * variable wins over a same-named binding at runtime, so static typing has to
340
+ * agree (the collision itself is `BINDING_NAME_RESERVED`). */
341
+ export function withBindingNames(
342
+ contextSchema: Record<string, any>,
343
+ resource: Record<string, any>,
344
+ ): Record<string, any> {
345
+ const field = contextSchema[BINDINGS_ANNOTATION];
346
+ if (typeof field !== "string") return contextSchema;
347
+ const bindings = resource[field];
348
+ if (bindings === null || typeof bindings !== "object" || Array.isArray(bindings)) {
349
+ return contextSchema;
350
+ }
351
+ return {
352
+ ...contextSchema,
353
+ properties: {
354
+ ...bindingContextProperties(bindings as Record<string, unknown>, contextSchema),
355
+ ...(contextSchema.properties ?? {}),
356
+ },
357
+ };
358
+ }
359
+
360
+ /**
361
+ * What one CEL expression is typed against.
362
+ *
363
+ * Both halves, never a flattened name list: the environment answers "what
364
+ * names exist and what type does this expression have", the context schema
365
+ * answers "what shape does that name carry" — which is what a hover tooltip and
366
+ * a member completion are made of, and what a name list throws away.
367
+ */
368
+ export interface CelScope {
369
+ /** The environment typed for this expression's path. */
370
+ env: Environment;
371
+ /** The resolved `x-telo-context` schema merged with the kernel globals, or
372
+ * null when no context applied (the environment alone types the site). */
373
+ contextSchema: Record<string, any> | null;
374
+ }
375
+
376
+ /** The analyzer state a scope is resolved against — everything a manifest set
377
+ * contributes, gathered once per analysis. */
378
+ export interface CelScopeInputs {
379
+ /** The base (untyped) CEL environment. */
380
+ celEnv: Environment;
381
+ defs: DefinitionRegistry;
382
+ aliases: AliasResolver;
383
+ scopes: ModuleScopes;
384
+ allManifests: ResourceManifest[];
385
+ kernelGlobals: KernelGlobalsIndex;
386
+ /** The module doc carrying the Application-only `ports` namespace. */
387
+ moduleManifest?: ResourceManifest;
388
+ /** The observed-state-only context, used where no `x-telo-context` matched. */
389
+ observedStateContext: Record<string, any> | null;
390
+ }
391
+
392
+ /** One CEL site, as the manifest visitor reports it. Structural on purpose —
393
+ * the resolver takes the fields it reads, not the visitor's event type, so a
394
+ * caller that located a site some other way (a cursor in an editor buffer) can
395
+ * ask the same question. */
396
+ export interface CelSiteRef {
397
+ source: ResourceManifest;
398
+ path: string;
399
+ contextSchema?: Record<string, any>;
400
+ matchedScope?: string;
401
+ }
402
+
403
+ /**
404
+ * The CEL scope rule, applied per resource and then per expression.
405
+ *
406
+ * Stateful across a resource because the per-resource half — the step context,
407
+ * the error-bearing regions, the invocation context — is derived from the kind's
408
+ * schema once and read by every expression in that resource. `enterResource`
409
+ * establishes it; `scopeFor` answers for one path.
410
+ */
411
+ export class CelScopeResolver {
412
+ private readonly typedEnvByManifest = new Map<ResourceManifest, Environment>();
413
+
414
+ /** Per-resource state, replaced at each `enterResource`. */
415
+ private stepContext: Record<string, any> | undefined;
416
+ private invocationContext: Record<string, any> | undefined;
417
+ private errorScopes: Map<string, Record<string, any>> = new Map();
418
+
419
+ constructor(private readonly inputs: CelScopeInputs) {}
420
+
421
+ /** The `steps` context schema for the current resource, or undefined when its
422
+ * kind declares no step body. Exposed because the step-inputs check needs the
423
+ * same schema this resource's expressions are typed against — recomputing it
424
+ * there is how the two would come to disagree. */
425
+ get stepContextSchema(): Record<string, any> | undefined {
426
+ return this.stepContext;
427
+ }
428
+
429
+ /** The error-bearing regions the current resource's kind declares. */
430
+ get errorContextScopes(): ReadonlyMap<string, Record<string, any>> {
431
+ return this.errorScopes;
432
+ }
433
+
434
+ /** The resource-wide invocation context, when this resource is an inline
435
+ * declaration that carries one. Read by the "CEL in a non-eval field" check:
436
+ * such a resource's CEL is evaluated by the enclosing kind, not by an
437
+ * `x-telo-eval` annotation of its own. */
438
+ get invocationContextSchema(): Record<string, any> | undefined {
439
+ return this.invocationContext;
440
+ }
441
+
442
+ /** Establish the per-resource half of the scope. */
443
+ enterResource(m: ResourceManifest, definition: ResourceDefinition | undefined): void {
444
+ const { allManifests, defs, aliases, scopes } = this.inputs;
445
+ this.invocationContext = (m.metadata as any)?.xTeloInvocationContext as
446
+ | Record<string, any>
447
+ | undefined;
448
+ this.stepContext = definition?.schema
449
+ ? buildStepContextSchema(
450
+ m as Record<string, any>,
451
+ definition.schema as Record<string, any>,
452
+ allManifests as Record<string, any>[],
453
+ defs,
454
+ aliases,
455
+ scopes,
456
+ )
457
+ : undefined;
458
+ this.errorScopes = collectErrorContextScopes(
459
+ definition?.schema as Record<string, any> | undefined,
460
+ );
461
+ }
462
+
463
+ /**
464
+ * What the expression at `site` is typed against.
465
+ *
466
+ * The environment is cached per manifest when no context applied, which is
467
+ * most expressions: it then depends only on the manifest, so rebuilding it per
468
+ * expression is pure waste — a clone plus a re-registration of every variable,
469
+ * on every keystroke in the IDE. A matched context makes it path-specific (its
470
+ * schema is resolved against the enclosing array item), so those build fresh
471
+ * rather than risk one item's types leaking into another's.
472
+ */
473
+ scopeFor(site: CelSiteRef): CelScope {
474
+ const m = site.source;
475
+ const contextSchema = this.resolveContextFor(site);
476
+ const {
477
+ celEnv,
478
+ allManifests,
479
+ moduleManifest,
480
+ } = this.inputs;
481
+
482
+ const cached = contextSchema === null ? this.typedEnvByManifest.get(m) : undefined;
483
+ const env =
484
+ cached ??
485
+ // A `Telo.Import`'s variables/secrets are a config-only contract evaluated
486
+ // in the IMPORTING module's scope, so they type from the owning module doc
487
+ // and drop `resources`/`env`, making a reference to either an error.
488
+ (m.kind === "Telo.Import"
489
+ ? buildImportInputCelEnvironment(
490
+ celEnv,
491
+ allManifests.find(
492
+ (mm) =>
493
+ (mm.kind === "Telo.Application" || mm.kind === "Telo.Library") &&
494
+ (mm.metadata as { name?: string } | undefined)?.name ===
495
+ (m.metadata as { module?: string } | undefined)?.module,
496
+ ),
497
+ )
498
+ : buildTypedCelEnvironment(
499
+ celEnv,
500
+ m,
501
+ contextSchema ?? undefined,
502
+ moduleManifest,
503
+ ));
504
+ if (contextSchema === null && !cached) this.typedEnvByManifest.set(m, env);
505
+
506
+ return { env, contextSchema };
507
+ }
508
+
509
+ /** The context schema in force at `site`: the matched `x-telo-context` (or the
510
+ * resource-wide invocation context), plus the step and error regions this
511
+ * path falls in, resolved and merged with the kernel globals. */
512
+ private resolveContextFor(site: CelSiteRef): Record<string, any> | null {
513
+ const { path } = site;
514
+ const m = site.source;
515
+ let matched: Record<string, any> | undefined = site.contextSchema ?? this.invocationContext;
516
+
517
+ if (this.stepContext) {
518
+ const base = matched ?? { type: "object", properties: {}, additionalProperties: true };
519
+ matched = {
520
+ ...base,
521
+ properties: { ...(base.properties ?? {}), steps: this.stepContext },
522
+ };
523
+ }
524
+
525
+ // `error` is only in scope inside an error-bearing branch (e.g. a
526
+ // `catch:` / `finally:`), so it's merged per-path, not resource-wide.
527
+ const errorSchema =
528
+ this.errorScopes.size > 0 ? errorContextForPath(path, this.errorScopes) : undefined;
529
+ if (errorSchema) {
530
+ const base = matched ?? { type: "object", properties: {}, additionalProperties: true };
531
+ matched = {
532
+ ...base,
533
+ properties: { ...(base.properties ?? {}), error: errorSchema },
534
+ };
535
+ }
536
+
537
+ if (!matched) {
538
+ // No `x-telo-context` matched, so nothing was chain-validated here before.
539
+ // Validate the observed-state segment alone rather than merging the kernel
540
+ // globals, whose closed `variables` / `ports` nodes would newly reject
541
+ // reads that pass today.
542
+ return this.inputs.observedStateContext;
543
+ }
544
+
545
+ const { defs, aliases, scopes, allManifests, kernelGlobals } = this.inputs;
546
+ const manifestItem = site.matchedScope
547
+ ? getManifestItem(path, site.matchedScope, m as Record<string, any>)
548
+ : (m as Record<string, any>);
549
+ const rootForResolver = manifestRootForResolver(
550
+ m as Record<string, any>,
551
+ defs,
552
+ aliases,
553
+ allManifests as Record<string, any>[],
554
+ scopes,
555
+ );
556
+ const resolved = resolveContextAnnotations(matched, manifestItem, {
557
+ manifestRoot: rootForResolver,
558
+ defs,
559
+ aliases,
560
+ allManifests: allManifests as Record<string, any>[],
561
+ });
562
+ return mergeKernelGlobalsIntoContext(
563
+ withBindingNames(resolved, m as Record<string, any>),
564
+ // Typed in the module that DECLARED this resource — for a manifest
565
+ // forwarded from an imported library, that is its `moduleGlobals` stamp,
566
+ // not the consuming application's block.
567
+ kernelGlobals.forResource(m),
568
+ );
569
+ }
570
+ }