@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
package/src/analyzer.ts CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  import { DefinitionRegistry } from "./definition-registry.js";
26
26
  import { type ContractDirection, effectiveAuthorSchema } from "./extends-resolution.js";
27
27
  import {
28
+ analyzerContractScope,
28
29
  type ContractScope,
29
30
  PERMISSIVE_CONTRACT,
30
31
  resolveContract,
@@ -53,7 +54,16 @@ import { resolveSchemaRefKinds, type RefConstraintIssue } from "./resolve-schema
53
54
  import { runZoneAnalysis, type ZoneExportCache } from "./resolve-zone-requirements.js";
54
55
  import { validateDurableRegions } from "./validate-durable-regions.js";
55
56
  import { validateZoneViolations } from "./validate-zone-violations.js";
56
- import { MANIFEST_SCHEMA_URI, ManifestRootSchema } from "./manifest-schemas.js";
57
+ import { ManifestRootSchema } from "./manifest-schemas.js";
58
+ import { gatherPropertySchemas, resolveLocalRef, walkStepArray } from "./schema-walk.js";
59
+ import { buildStepContextSchema, CelScopeResolver, manifestRootForResolver } from "./cel-scope.js";
60
+
61
+ // The structural walks and the CEL scope rule moved out of this file — the
62
+ // first so both halves can reach them, the second so the IDE can ask what a
63
+ // cursor sees without pulling the analysis pass in behind it. Re-exported here
64
+ // because they were part of this module's surface before the split.
65
+ export { gatherPropertySchemas, resolveLocalRef, walkStepArray } from "./schema-walk.js";
66
+ export { analyzerContractScope } from "./invocation-contract.js";
57
67
  import { validateZoneSlotDeclarations, type ZoneSlotIssue } from "./validate-zone-slots.js";
58
68
  import {
59
69
  validateSchemaProjection,
@@ -135,7 +145,7 @@ import { validateModuleMetadata } from "./validate-module-metadata.js";
135
145
  import { validateRequires } from "./validate-requires.js";
136
146
  import { validateBaseMapping } from "./validate-base-mapping.js";
137
147
  import { validateInvocationContract } from "./validate-invocation-contract.js";
138
- import { collectStepInputIssues } from "./validate-step-inputs.js";
148
+ import { collectRefInputIssues, collectStepInputIssues } from "./validate-step-inputs.js";
139
149
  import { validateNestedInlineResources } from "./validate-nested-inline.js";
140
150
  import { validateProviderCoherence } from "./validate-provider-coherence.js";
141
151
  import { validateReferences } from "./validate-references.js";
@@ -194,117 +204,8 @@ function resolveSelfOrAlias(
194
204
  return scopeResolver.resolveKind(value);
195
205
  }
196
206
 
197
- /** The {@link ContractScope} the analyzer resolves invocation contracts in: kinds
198
- * resolve in the module that declared the definition they were read off (so an
199
- * `extends` chain crossing module boundaries re-scopes at every hop), and named
200
- * `telo#Type` references resolve against the flattened manifest list. `resolveIn`
201
- * is the top-level entry point, where the kind was written by the READING
202
- * module and there is no declaring definition yet. */
203
- export function analyzerContractScope(
204
- defs: DefinitionRegistry,
205
- aliases: AliasResolver,
206
- scopes: ModuleScopes,
207
- allManifests: Record<string, any>[],
208
- ): ContractScope & { resolveIn(kind: string, module?: string): ResourceDefinition | undefined } {
209
- const resolve = moduleScopedDefResolver<ResourceDefinition>(defs, aliases, scopes);
210
- return {
211
- resolveDefinition: resolve,
212
- resolveIn: resolve.in,
213
- typeManifestsFor: () => allManifests,
214
- };
215
- }
216
-
217
207
  const SOURCE = "telo-analyzer";
218
208
 
219
- /** Build a closed JSON Schema for the `self` CEL variable available inside a
220
- * `Telo.Definition` template body. Mirrors the runtime template controller's
221
- * `const self = { ...resource, name: resource.metadata.name };` — every
222
- * property the user declared in `schema:` plus synthetic `name` / `kind` and
223
- * the metadata sub-object (kept open since metadata legitimately carries
224
- * arbitrary user-added fields). */
225
- function buildSelfSchema(
226
- definition: Record<string, any>,
227
- defs?: DefinitionRegistry,
228
- aliases?: AliasResolver,
229
- ): Record<string, any> {
230
- // The author-facing schema resolves inheritance: with `base:` the child's own
231
- // schema (the parent's config is internal); without it, `merge(parent, own)`.
232
- const userSchema = (
233
- defs
234
- ? effectiveAuthorSchema(definition as unknown as ResourceDefinition, (k) =>
235
- defs.resolve(aliases?.resolveKind(k) ?? k) ?? defs.resolve(k),
236
- )
237
- : (definition.schema ?? {})
238
- ) as Record<string, any>;
239
- const userProps = (userSchema.properties ?? {}) as Record<string, any>;
240
- const userRequired = Array.isArray(userSchema.required) ? userSchema.required : [];
241
- return {
242
- type: "object",
243
- additionalProperties: false,
244
- properties: {
245
- ...userProps,
246
- name: { type: "string" },
247
- kind: { type: "string" },
248
- metadata: {
249
- type: "object",
250
- additionalProperties: true,
251
- properties: { name: { type: "string" } },
252
- },
253
- },
254
- required: [...userRequired, "name", "kind"],
255
- };
256
- }
257
-
258
- /** Build the JSON Schema for the `inputs` CEL variable available inside an
259
- * invocable template body — the shared contract resolver applied to the
260
- * definition itself, so a body is typed against the exact signature callers are
261
- * checked against and dispatch enforces. Walks the whole `extends` chain rather
262
- * than one hop, so a definition two levels below the declaration still gets
263
- * typed inputs. Undefined when nothing in the chain declares a contract —
264
- * the caller signals opaque `map<string, dyn>` upstream. */
265
- function lookupTemplateInputsSchema(
266
- definition: Record<string, any>,
267
- defs: DefinitionRegistry,
268
- aliases: AliasResolver,
269
- allManifests: Record<string, any>[],
270
- scopes: ModuleScopes,
271
- ): Record<string, any> | undefined {
272
- return resolveContract(
273
- "inputType",
274
- undefined,
275
- definition as unknown as ResourceDefinition,
276
- analyzerContractScope(defs, aliases, scopes, allManifests),
277
- )?.schema;
278
- }
279
-
280
- /** Returns a "resolver-facing" view of the manifest where the fields used as
281
- * navigation roots by Telo.Definition's `x-telo-context-from-root` annotations
282
- * have been pre-augmented:
283
- * - `schema` → augmented `self` schema (synthetic `name`/`kind`/metadata).
284
- * - `inputType` → resolved through the shared contract resolver, so
285
- * `x-telo-context-from-root: inputType` substitutes the
286
- * real signature. Without it the annotation would replace
287
- * the node verbatim with the inline `{kind, schema}` wrapper
288
- * the standard library writes everywhere, typing `inputs` as
289
- * `{kind, schema}` instead of the declared properties.
290
- *
291
- * For non-definition manifests the original object is returned. */
292
- function manifestRootForResolver(
293
- m: Record<string, any>,
294
- defs: DefinitionRegistry,
295
- aliases: AliasResolver,
296
- allManifests: Record<string, any>[],
297
- scopes: ModuleScopes,
298
- ): Record<string, any> {
299
- if (m.kind !== "Telo.Definition") return m;
300
- const inputs = lookupTemplateInputsSchema(m, defs, aliases, allManifests, scopes);
301
- return {
302
- ...m,
303
- schema: buildSelfSchema(m, defs, aliases),
304
- ...(inputs ? { inputType: inputs } : {}),
305
- };
306
- }
307
-
308
209
  /** True when an issue reports a property that is absent — its path points at a
309
210
  * node the manifest does not contain. */
310
211
  export const missingRequired = (issue: { message: string }): boolean =>
@@ -338,273 +239,6 @@ function contractOwnerLabel(
338
239
  return canonical;
339
240
  }
340
241
 
341
- /** Resolve a local `$ref` (only `#/$defs/<name>` form) against the root schema.
342
- * Non-refs and unresolved refs pass through unchanged. */
343
- export function resolveLocalRef(
344
- schema: Record<string, any> | undefined,
345
- root: Record<string, any>,
346
- ): Record<string, any> | undefined {
347
- if (!schema) return undefined;
348
- const ref = schema.$ref;
349
- if (typeof ref === "string" && ref.startsWith("#/$defs/")) {
350
- const defName = ref.slice("#/$defs/".length);
351
- const resolved = root.$defs?.[defName];
352
- if (resolved && typeof resolved === "object") return resolved as Record<string, any>;
353
- }
354
- // A kernel-owned structural fragment (`telo://manifest#/$defs/InvokeStep`).
355
- // Resolved HERE rather than by each walker: this is the one chokepoint every
356
- // structural walk already goes through — the step-array walks, the call graph,
357
- // the zone projection, the eval-path collector — so a composer that points at a
358
- // shared shape stays legible to all of them at once. Nothing is inlined into
359
- // the stored schema, which keeps validator-cache identity stable and matches
360
- // what `resolveSchemaTypeRefs` does for a named user type.
361
- if (typeof ref === "string" && ref.startsWith(BUILTIN_FRAGMENT_PREFIX)) {
362
- const defName = ref.slice(BUILTIN_FRAGMENT_PREFIX.length);
363
- const resolved = (ManifestRootSchema.$defs as Record<string, unknown>)[defName];
364
- if (resolved && typeof resolved === "object") return resolved as Record<string, any>;
365
- }
366
- return schema;
367
- }
368
-
369
- const BUILTIN_FRAGMENT_PREFIX = `${MANIFEST_SCHEMA_URI}#/$defs/`;
370
-
371
- /** Gather property schemas from a (possibly variant-bearing) object schema:
372
- * top-level `properties` plus every `oneOf` / `anyOf` / `allOf` branch.
373
- *
374
- * Each branch is resolved through {@link resolveLocalRef} first, so a branch
375
- * that points at a shared shape — a `oneOf` arm that IS the kernel's dispatch
376
- * site — contributes its properties like an inline one. Without that, pointing a
377
- * composer at a shared shape would silently empty every role-driven lookup that
378
- * reads this (the inputs slot, the retry policy, the eval paths), which is a
379
- * failure with no diagnostic attached to it. */
380
- export function gatherPropertySchemas(
381
- schema: Record<string, any>,
382
- root?: Record<string, any>,
383
- ): Array<[string, Record<string, any>]> {
384
- const out: Array<[string, Record<string, any>]> = [];
385
- const base = resolveLocalRef(schema, root ?? schema) ?? schema;
386
- if (base.properties && typeof base.properties === "object") {
387
- for (const [k, v] of Object.entries(base.properties as Record<string, any>)) {
388
- out.push([k, v as Record<string, any>]);
389
- }
390
- }
391
- for (const variantKey of ["oneOf", "anyOf", "allOf"] as const) {
392
- const arr = base[variantKey];
393
- if (!Array.isArray(arr)) continue;
394
- for (const raw of arr) {
395
- if (!raw || typeof raw !== "object") continue;
396
- const variant = resolveLocalRef(raw as Record<string, any>, root ?? schema) ?? raw;
397
- if (variant.properties) {
398
- for (const [k, v] of Object.entries(variant.properties as Record<string, any>)) {
399
- out.push([k, v as Record<string, any>]);
400
- }
401
- }
402
- }
403
- }
404
- return out;
405
- }
406
-
407
- /**
408
- * Generic, role-driven walk over a step array. Calls
409
- * `visit(step, stepPath)` for every step — top-level and nested through the
410
- * `x-telo-topology-role` forms (`branch`, `branch-list`, `case-map`). This is
411
- * the single definition of how steps nest, shared by `buildStepContextSchema`
412
- * (which types `steps.<name>.result`) and `validateStepInvokeReferences` (which
413
- * checks invoke refs), so the topology contract lives in one place — adding a
414
- * role or nesting form updates both consumers at once. No resource kind is
415
- * hardcoded; recursion is driven entirely by the schema annotations.
416
- */
417
- export function walkStepArray(
418
- steps: unknown[],
419
- stepItemSchema: Record<string, any> | undefined,
420
- rootSchema: Record<string, any>,
421
- basePath: string,
422
- visit: (step: Record<string, any>, stepPath: string) => void,
423
- ): void {
424
- const dispatchRole = (
425
- data: unknown,
426
- role: string,
427
- itemsSchema: Record<string, any> | undefined,
428
- path: string,
429
- ): void => {
430
- if (role === "branch" && Array.isArray(data)) {
431
- walkStepArray(data, stepItemSchema, rootSchema, path, visit);
432
- } else if (role === "case-map" && data && typeof data === "object" && !Array.isArray(data)) {
433
- for (const [caseKey, arr] of Object.entries(data as Record<string, unknown>)) {
434
- if (Array.isArray(arr)) walkStepArray(arr, stepItemSchema, rootSchema, `${path}.${caseKey}`, visit);
435
- }
436
- } else if (role === "branch-list" && Array.isArray(data)) {
437
- const entrySchema = resolveLocalRef(itemsSchema, rootSchema);
438
- if (!entrySchema) return;
439
- data.forEach((entry, i) => {
440
- if (!entry || typeof entry !== "object") return;
441
- for (const [subKey, subSchema] of gatherPropertySchemas(entrySchema)) {
442
- const subRole = subSchema["x-telo-topology-role"];
443
- if (typeof subRole !== "string") continue;
444
- dispatchRole(
445
- (entry as Record<string, any>)[subKey],
446
- subRole,
447
- subSchema.items as Record<string, any> | undefined,
448
- `${path}[${i}].${subKey}`,
449
- );
450
- }
451
- });
452
- }
453
- };
454
-
455
- steps.forEach((step, i) => {
456
- if (!step || typeof step !== "object") return;
457
- const s = step as Record<string, any>;
458
- const stepPath = `${basePath}[${i}]`;
459
- visit(s, stepPath);
460
- if (!stepItemSchema) return;
461
- for (const [propKey, propSchema] of gatherPropertySchemas(stepItemSchema)) {
462
- const role = propSchema["x-telo-topology-role"];
463
- if (typeof role !== "string") continue;
464
- dispatchRole(
465
- s[propKey],
466
- role,
467
- propSchema.items as Record<string, any> | undefined,
468
- `${stepPath}.${propKey}`,
469
- );
470
- }
471
- });
472
- }
473
-
474
- /**
475
- * Build a `steps` context schema for a kind's step body.
476
- * Walks each step in the manifest array, resolves the invoked resource's output
477
- * contract, and builds `steps.<name>.result` context entries.
478
- *
479
- * Resolution is the shared {@link resolveContract} — the invoked resource
480
- * manifest's own declaration, then the kind's, resolved to the nearest
481
- * declaration along `extends`, then permissive. Sharing it with the kernel is
482
- * what stops `telo check` from typing `steps.X.result` against one contract
483
- * while dispatch validates against another.
484
- *
485
- * The kind layer is what makes `x-telo-stream` properties on definitions
486
- * actually govern step-result chain validation — without it, the validator falls
487
- * back to permissive and the stream-opacity rule never fires.
488
- *
489
- * Recursion into nested step arrays is annotation-driven via
490
- * `x-telo-topology-role`. The analyzer recognises three role values:
491
- * - `branch` — value is an array of steps (e.g. then / else / do / catch).
492
- * - `branch-list`— value is an array of objects each carrying further roled
493
- * sub-properties (e.g. elseif: [{ if, then }]).
494
- * - `case-map` — value is an object whose values are step arrays (e.g. cases).
495
- * No specific Run.Sequence field name is hardcoded; any kind that uses
496
- * a step body and tags its branch fields with these roles works.
497
- */
498
- function buildStepContextSchema(
499
- manifest: Record<string, any>,
500
- defSchema: Record<string, any>,
501
- allManifests: Record<string, any>[],
502
- defs: DefinitionRegistry,
503
- aliases: AliasResolver,
504
- scopes: ModuleScopes,
505
- ): Record<string, any> | undefined {
506
- const props = defSchema.properties as Record<string, any> | undefined;
507
- if (!props) return undefined;
508
-
509
- const contractScope = analyzerContractScope(defs, aliases, scopes, allManifests);
510
- const readingModule = (manifest.metadata as { module?: string } | undefined)?.module;
511
-
512
- for (const [fieldName, fieldSchema] of Object.entries(props)) {
513
- const stepCtx = readStepSlot(fieldSchema);
514
- if (!stepCtx) continue;
515
-
516
- const invokeField = stepCtx.invoke;
517
- const outputTypeField = stepCtx.outputType;
518
- // Optional: the field a step uses to produce a result without dispatching.
519
- // Only a kind that declares one has pure steps at all.
520
- const valueField = stepCtx.value;
521
- if (!invokeField || !outputTypeField) continue;
522
-
523
- const steps = manifest[fieldName];
524
- if (!Array.isArray(steps)) continue;
525
-
526
- const stepItemSchema = resolveLocalRef(
527
- fieldSchema.items as Record<string, any> | undefined,
528
- defSchema,
529
- );
530
-
531
- // The instance's own input contract, for typing a pure step that just
532
- // forwards one of its values.
533
- const ownInputs = resolveTypeFieldToSchema(
534
- (manifest as Record<string, any>).inputType,
535
- allManifests,
536
- );
537
-
538
- const stepProperties: Record<string, any> = {};
539
-
540
- walkStepArray(steps, stepItemSchema, defSchema, fieldName, (s) => {
541
- const name = s.name;
542
- const invoke = s[invokeField] as Record<string, any> | undefined;
543
- // Only invoke steps register a `steps.<name>.result` entry — control-flow
544
- // wrappers (try/if/while/switch/throw) don't produce a result and must
545
- // not shadow real entries with a permissive `additionalProperties: true`,
546
- // or unknown step references slip through chain validation.
547
- if (typeof name !== "string") return;
548
- if (!invoke || typeof invoke !== "object") {
549
- // A pure step dispatches nothing, so there is no contract to resolve.
550
- // Where its expression is a plain chain into something already typed —
551
- // an earlier step's result, or the kind's own inputs — that type carries
552
- // through; anything else (arithmetic, a call, a comprehension) stays
553
- // permissive rather than guessed. Same rule as a named binding's.
554
- if (valueField && valueField in s) {
555
- const scopeRoot = {
556
- properties: {
557
- steps: { type: "object", properties: { ...stepProperties } },
558
- ...(ownInputs ? { inputs: ownInputs } : {}),
559
- },
560
- };
561
- const chained = schemaAtChain(bindingPathChain(s[valueField]), scopeRoot);
562
- stepProperties[name] = {
563
- type: "object",
564
- properties: { result: chained ?? PERMISSIVE_CONTRACT },
565
- };
566
- }
567
- return;
568
- }
569
- const invokedKind = invoke.kind as string | undefined;
570
- const invokedName = invoke.name as string | undefined;
571
- // A named `!ref` carries the target's own manifest (which may narrow the
572
- // contract for this one instance); an inline `{ kind, ... }` step IS the
573
- // manifest. Either way the kind layer resolves through `extends`.
574
- const invokedManifest = invokedName
575
- ? (allManifests.find(
576
- (m) =>
577
- (m.metadata as any)?.name === invokedName && (!invokedKind || m.kind === invokedKind),
578
- ) as Record<string, any> | undefined)
579
- : (invoke as Record<string, any>);
580
- const invokedDef = invokedKind
581
- ? contractScope.resolveIn(invokedKind, readingModule)
582
- : undefined;
583
- const outputSchema = resolveContract(
584
- outputTypeField as ContractDirection,
585
- invokedManifest,
586
- invokedDef,
587
- contractScope,
588
- )?.schema;
589
- stepProperties[name] = {
590
- type: "object",
591
- properties: {
592
- result: outputSchema ?? PERMISSIVE_CONTRACT,
593
- },
594
- };
595
- });
596
-
597
- if (Object.keys(stepProperties).length > 0) {
598
- return {
599
- type: "object",
600
- properties: stepProperties,
601
- };
602
- }
603
- }
604
-
605
- return undefined;
606
- }
607
-
608
242
  /** The built-in namespace: globally resolvable, crossing no import boundary. */
609
243
  const TELO_BUILTIN_MODULE = "Telo";
610
244
 
@@ -882,92 +516,6 @@ function pathCrossesNestedResource(root: unknown, path: string): boolean {
882
516
  return false;
883
517
  }
884
518
 
885
- function collectErrorContextScopes(
886
- defSchema: Record<string, any> | undefined,
887
- ): Map<string, Record<string, any>> {
888
- const out = new Map<string, Record<string, any>>();
889
- if (!defSchema || typeof defSchema !== "object") return out;
890
- const seen = new Set<Record<string, any>>();
891
-
892
- const walk = (schema: Record<string, any> | undefined): void => {
893
- if (!schema || typeof schema !== "object" || seen.has(schema)) return;
894
- seen.add(schema);
895
-
896
- const props = schema.properties as Record<string, any> | undefined;
897
- if (props) {
898
- for (const [fieldName, fieldSchema] of Object.entries(props)) {
899
- if (fieldSchema && typeof fieldSchema === "object") {
900
- const errCtx = (fieldSchema as Record<string, any>)["x-telo-error-context"];
901
- if (errCtx && typeof errCtx === "object" && !out.has(fieldName)) {
902
- out.set(fieldName, errCtx as Record<string, any>);
903
- }
904
- }
905
- walk(resolveLocalRef(fieldSchema as Record<string, any>, defSchema));
906
- }
907
- }
908
- if (schema.items) walk(resolveLocalRef(schema.items as Record<string, any>, defSchema));
909
- for (const key of ["oneOf", "anyOf", "allOf"] as const) {
910
- const arr = schema[key];
911
- if (Array.isArray(arr)) for (const sub of arr) walk(resolveLocalRef(sub, defSchema));
912
- }
913
- if (schema.$defs && typeof schema.$defs === "object") {
914
- for (const sub of Object.values(schema.$defs as Record<string, any>)) {
915
- walk(sub as Record<string, any>);
916
- }
917
- }
918
- };
919
-
920
- walk(defSchema);
921
- return out;
922
- }
923
-
924
- /**
925
- * Return the error-context schema for a CEL `path` when the path lies within
926
- * (any depth under) one of the error-bearing fields, else undefined. A path is
927
- * "within" field `f` when it contains a segment `f[<index>]`. When multiple
928
- * error-bearing fields match (e.g. a `finally` nested inside a `catch`), the
929
- * deepest — the one whose segment appears latest in the path — wins, so the
930
- * innermost branch's schema governs.
931
- */
932
- function errorContextForPath(
933
- path: string,
934
- scopes: Map<string, Record<string, any>>,
935
- ): Record<string, any> | undefined {
936
- let best: { index: number; schema: Record<string, any> } | undefined;
937
- for (const [fieldName, schema] of scopes) {
938
- const escaped = fieldName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
939
- for (const match of path.matchAll(new RegExp(`(^|\\.)${escaped}\\[\\d+\\]`, "g"))) {
940
- if (best === undefined || match.index > best.index) {
941
- best = { index: match.index, schema };
942
- }
943
- }
944
- }
945
- return best?.schema;
946
- }
947
-
948
- /** Add a kind's named bindings to a resolved context, when the context declares
949
- * a bindings region. They go UNDER the context's own properties: a scope
950
- * variable wins over a same-named binding at runtime, so static typing has to
951
- * agree (the collision itself is `BINDING_NAME_RESERVED`). */
952
- function withBindingNames(
953
- contextSchema: Record<string, any>,
954
- resource: Record<string, any>,
955
- ): Record<string, any> {
956
- const field = contextSchema[BINDINGS_ANNOTATION];
957
- if (typeof field !== "string") return contextSchema;
958
- const bindings = resource[field];
959
- if (bindings === null || typeof bindings !== "object" || Array.isArray(bindings)) {
960
- return contextSchema;
961
- }
962
- return {
963
- ...contextSchema,
964
- properties: {
965
- ...bindingContextProperties(bindings as Record<string, unknown>, contextSchema),
966
- ...(contextSchema.properties ?? {}),
967
- },
968
- };
969
- }
970
-
971
519
  /** Member-access chains in a CEL expression, or none when it doesn't parse.
972
520
  * Best-effort: a syntax error is reported by the engine pass, not here. */
973
521
  function celAccessChains(env: Environment, expr: string): string[][] {
@@ -1928,6 +1476,17 @@ export class StaticAnalyzer {
1928
1476
  diagnostics.push(...validateIncludePlacement(allManifests));
1929
1477
  }
1930
1478
  resolveSchemaTypeRefs(allManifests, aliases, aliasesByModule);
1479
+ // ...and over the manifests the DEFINITION REGISTRY holds, which are not
1480
+ // these. `normalizeInlineResources` deep-clones — that clone is the
1481
+ // analyzer's immutability boundary — while `defs.register` ran before it, on
1482
+ // the originals. So a kind whose `schema:` references a named shape kept the
1483
+ // authored `telo://Self/<Type>` spelling everywhere the registry is read,
1484
+ // which is where a resource's configuration is validated: the schema failed
1485
+ // to compile, the failure was swallowed, and `telo check` reported nothing
1486
+ // about a resource the kernel then rejected at boot. The pass is idempotent
1487
+ // — a canonical id parses as no authority and is left alone — so running it
1488
+ // over both is the whole repair.
1489
+ resolveSchemaTypeRefs(manifests, aliases, aliasesByModule);
1931
1490
 
1932
1491
  // Trusted-input fast path: when the caller has already attested that
1933
1492
  // this exact manifest set passes analysis (e.g. via the kernel's
@@ -2077,9 +1636,20 @@ export class StaticAnalyzer {
2077
1636
  // fit the slot at all", the schema answers "do their type arguments agree",
2078
1637
  // which cel-js cannot express because it types by constructor identity.
2079
1638
  const celSourceSchemaByPath = new Map<ResourceManifest, Map<string, Record<string, any>>>();
2080
- // Context-free typed environments, one per manifest. Reused across every
2081
- // expression in it see the build site for why a matched context opts out.
2082
- const typedEnvByManifest = new Map<ResourceManifest, Environment>();
1639
+ // What every CEL expression in this set is typed against. The rule lives in
1640
+ // `cel-scope.ts` so the IDE asks the same question the pass does a
1641
+ // completion list is a claim that the name it offers will pass this check,
1642
+ // and two implementations of it could not be held in agreement.
1643
+ const celScope = new CelScopeResolver({
1644
+ celEnv: this.celEnv,
1645
+ defs,
1646
+ aliases,
1647
+ scopes: { aliasesByModule, rootModules },
1648
+ allManifests,
1649
+ kernelGlobals,
1650
+ moduleManifest,
1651
+ observedStateContext,
1652
+ });
2083
1653
 
2084
1654
  // Validate each non-definition, non-system resource
2085
1655
  for (const m of allManifests) {
@@ -2206,8 +1776,13 @@ export class StaticAnalyzer {
2206
1776
  for (const slot of collectCelValueSlots(m, schema, "")) {
2207
1777
  celReturnSlots.push({ manifest: m, resource, filePath, ...slot });
2208
1778
  }
2209
- // Phase 2+3: AJV on substituted data — CEL fields replaced with typed placeholders
2210
- const ajvIssues = validateAgainstSchema(substituteCelFields(m, schema), schema);
1779
+ // Phase 2+3: AJV on substituted data — CEL fields replaced with typed
1780
+ // placeholders. Through the REGISTRY, so a kind whose schema references
1781
+ // a shape declared elsewhere is checked on the instance that holds it.
1782
+ const ajvIssues = defs.validateResourceConfig(
1783
+ substituteCelFields(m, schema, undefined, { external: (ref) => defs.schemaForId(ref) }),
1784
+ schema,
1785
+ );
2211
1786
  // Phase 4: value slots that must satisfy a type declared elsewhere on
2212
1787
  // the resource (`x-telo-value-schema-from`) — e.g. every row of a
2213
1788
  // decision table against its declared `outputType`, so a mistyped branch
@@ -2320,6 +1895,10 @@ export class StaticAnalyzer {
2320
1895
  return viaRoot ? defs.resolve(viaRoot) : undefined;
2321
1896
  },
2322
1897
  allManifests as Record<string, any>[],
1898
+ {
1899
+ validate: (data, target) => defs.validateResourceConfig(data, target),
1900
+ external: (ref) => defs.schemaForId(ref),
1901
+ },
2323
1902
  ),
2324
1903
  );
2325
1904
  }
@@ -2481,11 +2060,11 @@ export class StaticAnalyzer {
2481
2060
  // state (definitions, aliases, the typed CEL env).
2482
2061
  // Per-resource state computed at enter and read by that resource's CEL
2483
2062
  // sites. The manifest / resource / filePath come straight off each CelSite's
2484
- // `source` (no need to capture them); only the derived step / invocation
2485
- // context which require analyzer state to build are stashed here.
2063
+ // `source` (no need to capture them); the derived step / invocation / error
2064
+ // context is the scope resolver's, read back from it where a CHECK needs the
2065
+ // same schema an expression is typed against.
2486
2066
  let celStepContextSchema: Record<string, any> | undefined;
2487
- let celInvocationContext: Record<string, any> | undefined;
2488
- let celErrorScopes: Map<string, Record<string, any>> = new Map();
2067
+ let celErrorScopes: ReadonlyMap<string, Record<string, any>> = new Map();
2489
2068
  // Region coverage for the "CEL in a non-eval field" check: the union of
2490
2069
  // `x-telo-eval` paths (own + capability) and `x-telo-context` /
2491
2070
  // `x-telo-step-context` / `x-telo-error-context` scopes. A `!cel` outside
@@ -2506,23 +2085,29 @@ export class StaticAnalyzer {
2506
2085
  {
2507
2086
  onResourceEnter: (e) => {
2508
2087
  const m = e.source;
2509
- celInvocationContext = (m.metadata as any)?.xTeloInvocationContext as
2510
- | Record<string, any>
2511
- | undefined;
2512
- celStepContextSchema = e.definition?.schema
2513
- ? buildStepContextSchema(
2088
+ celScope.enterResource(m, e.definition);
2089
+ // Read back rather than recomputed: the step-inputs check has to see
2090
+ // the same `steps` schema this resource's expressions are typed
2091
+ // against, and a second computation is how the two come to disagree.
2092
+ celStepContextSchema = celScope.stepContextSchema;
2093
+ celErrorScopes = celScope.errorContextScopes;
2094
+ if (e.definition?.schema) {
2095
+ const stepName = (m.metadata as any)?.name as string | undefined;
2096
+ const stepFile = (m.metadata as { source?: string } | undefined)?.source;
2097
+ // Both drivers of the SAME check: a step's `inputs:` found through
2098
+ // the step grammar, and a reference slot's found through the
2099
+ // `x-telo-ref` `inputs:` pointer. A call site the editor can
2100
+ // complete is a call site `telo check` validates.
2101
+ const inputIssues = [
2102
+ ...collectRefInputIssues(
2514
2103
  m as Record<string, any>,
2515
- e.definition.schema as Record<string, any>,
2104
+ defs.expandedFieldMapForResource(m, aliases, aliasesByModule),
2516
2105
  allManifests as Record<string, any>[],
2517
2106
  defs,
2518
2107
  aliases,
2519
2108
  { aliasesByModule, rootModules },
2520
- )
2521
- : undefined;
2522
- if (e.definition?.schema) {
2523
- const stepName = (m.metadata as any)?.name as string | undefined;
2524
- const stepFile = (m.metadata as { source?: string } | undefined)?.source;
2525
- for (const issue of collectStepInputIssues(
2109
+ ),
2110
+ ...collectStepInputIssues(
2526
2111
  m as Record<string, any>,
2527
2112
  e.definition.schema as Record<string, any>,
2528
2113
  allManifests as Record<string, any>[],
@@ -2530,7 +2115,9 @@ export class StaticAnalyzer {
2530
2115
  aliases,
2531
2116
  { aliasesByModule, rootModules },
2532
2117
  celStepContextSchema,
2533
- )) {
2118
+ ),
2119
+ ];
2120
+ for (const issue of inputIssues) {
2534
2121
  diagnostics.push({
2535
2122
  severity: DiagnosticSeverity.Error,
2536
2123
  code: issue.code ?? "CONTRACT_INPUTS_MISMATCH",
@@ -2549,10 +2136,6 @@ export class StaticAnalyzer {
2549
2136
  });
2550
2137
  }
2551
2138
  }
2552
- celErrorScopes = collectErrorContextScopes(
2553
- e.definition?.schema as Record<string, any> | undefined,
2554
- );
2555
-
2556
2139
  celBindingSites = findBindingSites(e.definition?.schema as Record<string, any>);
2557
2140
  if (celBindingSites) {
2558
2141
  const declared = (m as Record<string, any>)[celBindingSites.field];
@@ -2680,7 +2263,7 @@ export class StaticAnalyzer {
2680
2263
  if (
2681
2264
  celRuleApplies &&
2682
2265
  engineName === "cel" &&
2683
- celInvocationContext === undefined &&
2266
+ celScope.invocationContextSchema === undefined &&
2684
2267
  !evalPathsCover(celEvalPaths, path) &&
2685
2268
  !celRegionScopes.some((scope) => pathMatchesScope(path, scope)) &&
2686
2269
  !pathCrossesNestedResource(m, path)
@@ -2733,70 +2316,6 @@ export class StaticAnalyzer {
2733
2316
  }
2734
2317
  }
2735
2318
 
2736
- let matchedContext: Record<string, any> | undefined =
2737
- e.contextSchema ?? celInvocationContext;
2738
-
2739
- if (celStepContextSchema) {
2740
- const base =
2741
- matchedContext ?? { type: "object", properties: {}, additionalProperties: true };
2742
- matchedContext = {
2743
- ...base,
2744
- properties: {
2745
- ...(base.properties ?? {}),
2746
- steps: celStepContextSchema,
2747
- },
2748
- };
2749
- }
2750
-
2751
- // `error` is only in scope inside an error-bearing branch (e.g. a
2752
- // `catch:` / `finally:`), so it's merged per-path, not resource-wide.
2753
- const errorSchema =
2754
- celErrorScopes.size > 0 ? errorContextForPath(path, celErrorScopes) : undefined;
2755
- if (errorSchema) {
2756
- const base =
2757
- matchedContext ?? { type: "object", properties: {}, additionalProperties: true };
2758
- matchedContext = {
2759
- ...base,
2760
- properties: {
2761
- ...(base.properties ?? {}),
2762
- error: errorSchema,
2763
- },
2764
- };
2765
- }
2766
-
2767
- let effectiveContext: Record<string, any> | null = null;
2768
- if (matchedContext) {
2769
- const manifestItem = matchedScope
2770
- ? getManifestItem(path, matchedScope, m as Record<string, any>)
2771
- : (m as Record<string, any>);
2772
- const rootForResolver = manifestRootForResolver(
2773
- m as Record<string, any>,
2774
- defs,
2775
- aliases,
2776
- allManifests as Record<string, any>[],
2777
- { aliasesByModule, rootModules },
2778
- );
2779
- const resolvedContext = resolveContextAnnotations(matchedContext, manifestItem, {
2780
- manifestRoot: rootForResolver,
2781
- defs,
2782
- aliases,
2783
- allManifests: allManifests as Record<string, any>[],
2784
- });
2785
- effectiveContext = mergeKernelGlobalsIntoContext(
2786
- withBindingNames(resolvedContext, m as Record<string, any>),
2787
- // Typed in the module that DECLARED this resource — for a manifest
2788
- // forwarded from an imported library, that is its `moduleGlobals`
2789
- // stamp, not the consuming application's block.
2790
- kernelGlobals.forResource(m),
2791
- );
2792
- } else if (observedStateContext) {
2793
- // No `x-telo-context` matched, so nothing was chain-validated here
2794
- // before. Validate the observed-state segment alone rather than
2795
- // merging the kernel globals, whose closed `variables` / `ports`
2796
- // nodes would newly reject reads that pass today.
2797
- effectiveContext = observedStateContext;
2798
- }
2799
-
2800
2319
  const engine = defaultRegistry().get(engineName);
2801
2320
  if (!engine) {
2802
2321
  // No registered engine owns this tag — the expression would go
@@ -2811,38 +2330,15 @@ export class StaticAnalyzer {
2811
2330
  return;
2812
2331
  }
2813
2332
  // The engine type-checks, so it gets the environment typed for THIS
2814
- // path — not the bare base one. A `Telo.Import`'s variables/secrets
2815
- // are a config-only contract evaluated in the IMPORTING module's
2816
- // scope, so they type from the owning module doc and drop
2817
- // `resources`/`env`, making a reference to either an error.
2818
- //
2819
- // Cached per manifest when no `x-telo-context` applied, which is most
2820
- // expressions: the environment then depends only on the manifest, so
2821
- // rebuilding it per expression is pure waste — a clone plus a
2822
- // re-registration of every variable, on every keystroke in the IDE.
2823
- // A matched context makes the environment path-specific (its schema is
2824
- // resolved against the enclosing array item), so those still build
2825
- // fresh rather than risk one item's types leaking into another's.
2826
- const cached = effectiveContext === null ? typedEnvByManifest.get(m) : undefined;
2827
- const typedEnv =
2828
- cached ??
2829
- (m.kind === "Telo.Import"
2830
- ? buildImportInputCelEnvironment(
2831
- this.celEnv,
2832
- allManifests.find(
2833
- (mm) =>
2834
- (mm.kind === "Telo.Application" || mm.kind === "Telo.Library") &&
2835
- (mm.metadata as { name?: string } | undefined)?.name ===
2836
- (m.metadata as { module?: string } | undefined)?.module,
2837
- ),
2838
- )
2839
- : buildTypedCelEnvironment(
2840
- this.celEnv,
2841
- m,
2842
- effectiveContext ?? undefined,
2843
- moduleManifest,
2844
- ));
2845
- if (effectiveContext === null && !cached) typedEnvByManifest.set(m, typedEnv);
2333
+ // path — not the bare base one. Both halves come from the one scope
2334
+ // rule, which is what makes the IDE's answer and this check the same
2335
+ // answer rather than two that agree today.
2336
+ const { env: typedEnv, contextSchema: effectiveContext } = celScope.scopeFor({
2337
+ source: m,
2338
+ path,
2339
+ contextSchema: e.contextSchema,
2340
+ matchedScope,
2341
+ });
2846
2342
 
2847
2343
  const result = engine.analyze(expr, { celEnv: typedEnv, contextSchema: effectiveContext });
2848
2344