@telorun/analyzer 0.65.0 → 0.67.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 (142) hide show
  1. package/dist/analyzer.d.ts +1 -1
  2. package/dist/analyzer.d.ts.map +1 -1
  3. package/dist/analyzer.js +194 -65
  4. package/dist/builtins.d.ts.map +1 -1
  5. package/dist/builtins.js +67 -16
  6. package/dist/call-graph.d.ts.map +1 -1
  7. package/dist/call-graph.js +7 -1
  8. package/dist/cel-environment.d.ts +12 -0
  9. package/dist/cel-environment.d.ts.map +1 -1
  10. package/dist/cel-environment.js +35 -19
  11. package/dist/cel-scope.d.ts +8 -0
  12. package/dist/cel-scope.d.ts.map +1 -1
  13. package/dist/cel-scope.js +89 -11
  14. package/dist/definition-registry.d.ts +41 -0
  15. package/dist/definition-registry.d.ts.map +1 -1
  16. package/dist/definition-registry.js +76 -0
  17. package/dist/dependency-graph.d.ts.map +1 -1
  18. package/dist/dependency-graph.js +65 -0
  19. package/dist/eval-paths.d.ts +68 -0
  20. package/dist/eval-paths.d.ts.map +1 -1
  21. package/dist/eval-paths.js +138 -0
  22. package/dist/flatten-for-analyzer.d.ts +37 -1
  23. package/dist/flatten-for-analyzer.d.ts.map +1 -1
  24. package/dist/flatten-for-analyzer.js +104 -5
  25. package/dist/import-resolution-diagnostics.d.ts +1 -1
  26. package/dist/import-resolution-diagnostics.js +1 -1
  27. package/dist/index.d.ts +12 -8
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +8 -6
  30. package/dist/inline-imports.d.ts.map +1 -1
  31. package/dist/inline-imports.js +1 -0
  32. package/dist/manifest-visitor.d.ts +4 -0
  33. package/dist/manifest-visitor.d.ts.map +1 -1
  34. package/dist/manifest-visitor.js +36 -1
  35. package/dist/migrations/entries/index.d.ts.map +1 -1
  36. package/dist/migrations/entries/index.js +2 -0
  37. package/dist/migrations/entries/schema-prepare-bucket.json +27 -0
  38. package/dist/peer-binding.d.ts +232 -0
  39. package/dist/peer-binding.d.ts.map +1 -0
  40. package/dist/peer-binding.js +418 -0
  41. package/dist/precompile.d.ts.map +1 -1
  42. package/dist/precompile.js +8 -0
  43. package/dist/ref-slot.d.ts +13 -0
  44. package/dist/ref-slot.d.ts.map +1 -1
  45. package/dist/ref-slot.js +26 -0
  46. package/dist/reference-field-map.d.ts +28 -0
  47. package/dist/reference-field-map.d.ts.map +1 -1
  48. package/dist/reference-field-map.js +25 -0
  49. package/dist/referrer-rule.d.ts +17 -0
  50. package/dist/referrer-rule.d.ts.map +1 -1
  51. package/dist/referrer-rule.js +4 -1
  52. package/dist/release/index.d.ts +1 -1
  53. package/dist/release/index.js +1 -1
  54. package/dist/resolve-ref-sentinels.d.ts.map +1 -1
  55. package/dist/resolve-ref-sentinels.js +14 -1
  56. package/dist/resource-input.d.ts +75 -0
  57. package/dist/resource-input.d.ts.map +1 -0
  58. package/dist/resource-input.js +90 -0
  59. package/dist/resource-rule.d.ts +40 -27
  60. package/dist/resource-rule.d.ts.map +1 -1
  61. package/dist/resource-rule.js +61 -7
  62. package/dist/rule-condition.d.ts +11 -0
  63. package/dist/rule-condition.d.ts.map +1 -1
  64. package/dist/rule-condition.js +13 -0
  65. package/dist/schema-projection.d.ts +119 -4
  66. package/dist/schema-projection.d.ts.map +1 -1
  67. package/dist/schema-projection.js +213 -18
  68. package/dist/system-kinds.d.ts +7 -2
  69. package/dist/system-kinds.d.ts.map +1 -1
  70. package/dist/system-kinds.js +7 -2
  71. package/dist/telo-version.d.ts +1 -1
  72. package/dist/telo-version.js +1 -1
  73. package/dist/template-body.d.ts +50 -0
  74. package/dist/template-body.d.ts.map +1 -0
  75. package/dist/template-body.js +58 -0
  76. package/dist/validate-cel-context.d.ts +1 -17
  77. package/dist/validate-cel-context.d.ts.map +1 -1
  78. package/dist/validate-cel-context.js +73 -78
  79. package/dist/validate-identifier-names.d.ts.map +1 -1
  80. package/dist/validate-identifier-names.js +17 -2
  81. package/dist/validate-reference-forms.d.ts +10 -0
  82. package/dist/validate-reference-forms.d.ts.map +1 -1
  83. package/dist/validate-reference-forms.js +23 -0
  84. package/dist/validate-references.d.ts +17 -0
  85. package/dist/validate-references.d.ts.map +1 -1
  86. package/dist/validate-references.js +77 -18
  87. package/dist/validate-referrer-rules.d.ts +38 -4
  88. package/dist/validate-referrer-rules.d.ts.map +1 -1
  89. package/dist/validate-referrer-rules.js +139 -31
  90. package/dist/validate-resource-inputs.d.ts +35 -0
  91. package/dist/validate-resource-inputs.d.ts.map +1 -0
  92. package/dist/validate-resource-inputs.js +319 -0
  93. package/dist/validate-resource-rules.d.ts +11 -2
  94. package/dist/validate-resource-rules.d.ts.map +1 -1
  95. package/dist/validate-resource-rules.js +18 -12
  96. package/dist/validate-schema-projection.d.ts.map +1 -1
  97. package/dist/validate-schema-projection.js +33 -2
  98. package/dist/validate-template-dispatch.d.ts +27 -0
  99. package/dist/validate-template-dispatch.d.ts.map +1 -0
  100. package/dist/validate-template-dispatch.js +95 -0
  101. package/dist/with-synthetic-positions.d.ts +1 -1
  102. package/dist/with-synthetic-positions.js +1 -1
  103. package/package.json +3 -3
  104. package/src/analyzer.ts +236 -70
  105. package/src/builtins.ts +69 -16
  106. package/src/call-graph.ts +7 -1
  107. package/src/cel-environment.ts +48 -19
  108. package/src/cel-scope.ts +113 -19
  109. package/src/definition-registry.ts +78 -0
  110. package/src/dependency-graph.ts +66 -0
  111. package/src/eval-paths.ts +152 -0
  112. package/src/flatten-for-analyzer.ts +117 -4
  113. package/src/import-resolution-diagnostics.ts +1 -1
  114. package/src/index.ts +57 -4
  115. package/src/inline-imports.ts +1 -0
  116. package/src/manifest-visitor.ts +41 -1
  117. package/src/migrations/entries/index.ts +2 -0
  118. package/src/migrations/entries/schema-prepare-bucket.json +27 -0
  119. package/src/peer-binding.ts +641 -0
  120. package/src/precompile.ts +8 -0
  121. package/src/ref-slot.ts +36 -0
  122. package/src/reference-field-map.ts +42 -0
  123. package/src/referrer-rule.ts +20 -1
  124. package/src/release/index.ts +1 -1
  125. package/src/resolve-ref-sentinels.ts +12 -1
  126. package/src/resource-input.ts +132 -0
  127. package/src/resource-rule.ts +69 -7
  128. package/src/rule-condition.ts +15 -0
  129. package/src/schema-projection.ts +302 -20
  130. package/src/system-kinds.ts +7 -2
  131. package/src/telo-version.ts +1 -1
  132. package/src/template-body.ts +104 -0
  133. package/src/validate-cel-context.ts +73 -78
  134. package/src/validate-identifier-names.ts +18 -3
  135. package/src/validate-reference-forms.ts +22 -0
  136. package/src/validate-references.ts +85 -16
  137. package/src/validate-referrer-rules.ts +215 -28
  138. package/src/validate-resource-inputs.ts +367 -0
  139. package/src/validate-resource-rules.ts +26 -12
  140. package/src/validate-schema-projection.ts +50 -1
  141. package/src/validate-template-dispatch.ts +99 -0
  142. package/src/with-synthetic-positions.ts +1 -1
package/src/call-graph.ts CHANGED
@@ -284,7 +284,13 @@ function resolveUseAtSite(
284
284
  const resolved = entry.useCases.cases[String(selector)];
285
285
  if (resolved) return { use: resolved };
286
286
  }
287
- const slot = { kinds: entry.refs, uses: entry.uses, useCases: entry.useCases, inline: false };
287
+ const slot = {
288
+ kinds: entry.refs,
289
+ uses: entry.uses,
290
+ useCases: entry.useCases,
291
+ inline: false,
292
+ valueBranches: [],
293
+ };
288
294
  const unresolvedReason = isTaggedSentinel(selector)
289
295
  ? "dynamic"
290
296
  : selector === undefined
@@ -47,24 +47,34 @@ export function buildTypedCelEnvironment(
47
47
  (env as any).registerType(brand, { fields: {} });
48
48
  }
49
49
 
50
- // Build typed ObjectSchema from manifest.variables if it looks like a schema map
51
- const vars = (manifest as Record<string, unknown>).variables;
52
- if (vars !== null && typeof vars === "object" && !Array.isArray(vars)) {
53
- const entries = Object.entries(vars as Record<string, unknown>).filter(
54
- ([, v]) => v !== null && typeof v === "object" && !Array.isArray(v),
55
- );
56
- if (entries.length > 0) {
57
- const schema: Record<string, string> = {};
58
- for (const [k, v] of entries) {
59
- schema[k] = jsonSchemaToCelType(v as Record<string, any>);
60
- }
61
- (env as any).registerVariable({ name: "variables", schema });
62
- } else {
63
- env.registerVariable("variables", "map");
64
- }
65
- } else {
66
- env.registerVariable("variables", "map");
67
- }
50
+ // `variables` / `secrets`: the DECLARING module's blocks, which is the
51
+ // contract the resource's CEL is evaluated against at runtime. Read the
52
+ // same three ways `ports` and `module` are, and for the same reason — a
53
+ // resource doc does not carry them, so typing from `manifest` alone left
54
+ // every ordinary resource with an open `variables` and no check at all,
55
+ // while `ports.<typo>` one line away was an error.
56
+ //
57
+ // Order matters: a module-identity doc analyzing itself carries its own
58
+ // block; a resource forwarded from an imported library carries its
59
+ // library's as `metadata.moduleGlobals`, which must win over the consuming
60
+ // application's; everything else is the entry module's own doc.
61
+ const moduleGlobals = (manifest.metadata as Record<string, any> | undefined)?.moduleGlobals as
62
+ | Record<string, unknown>
63
+ | undefined;
64
+ // A KIND document is the exception, and it is not a detail: the CEL inside
65
+ // a `Telo.Definition`'s `schema:` — an `examples:` entry, a `description`
66
+ // showing `${{ secrets.API_KEY }}` — illustrates what a CONSUMER writes, in
67
+ // the consumer's scope. Closing those over the declaring module's blocks
68
+ // reported an error against a name the module never meant to declare, and
69
+ // one nobody could fix without deleting the example.
70
+ const root = (
71
+ isKindDocument(manifest) ? undefined : (rootModuleManifest as Record<string, unknown>)
72
+ ) as Record<string, unknown> | undefined;
73
+ registerConfigNamespace(
74
+ env,
75
+ (manifest as Record<string, unknown>).variables ?? moduleGlobals?.variables ?? root?.variables,
76
+ "variables",
77
+ );
68
78
 
69
79
  // `ports` namespace: each entry types as the brand its `protocol` selects
70
80
  // (tcp → TcpPort, udp → UdpPort), so `${{ ports.http }}` carries a nominal
@@ -87,7 +97,11 @@ export function buildTypedCelEnvironment(
87
97
  env.registerVariable("ports", "map");
88
98
  }
89
99
 
90
- env.registerVariable("secrets", "map");
100
+ registerConfigNamespace(
101
+ env,
102
+ (manifest as Record<string, unknown>).secrets ?? moduleGlobals?.secrets ?? root?.secrets,
103
+ "secrets",
104
+ );
91
105
  env.registerVariable("resources", "map");
92
106
 
93
107
  // `module` — the declaring module's own `metadata`, so a manifest reads its
@@ -132,6 +146,21 @@ export function buildTypedCelEnvironment(
132
146
  }
133
147
  }
134
148
 
149
+ /**
150
+ * A kind document — whose CEL is written for whoever instantiates the kind, not
151
+ * evaluated in the declaring module's own scope.
152
+ *
153
+ * Its `examples:` show a consumer's route reading `request` and `result`, its
154
+ * `description`s show `${{ secrets.API_KEY }}`, and a rule condition reads the
155
+ * `self` / `referrer` its own evaluator binds. None of those names are in scope
156
+ * where they are WRITTEN, and all of them are correct where they are READ — so
157
+ * every check that asks "is this name in scope here" has to stand down on these
158
+ * documents, or it reports errors nobody can fix without deleting the example.
159
+ */
160
+ export function isKindDocument(manifest: ResourceManifest): boolean {
161
+ return manifest.kind === "Telo.Definition" || manifest.kind === "Telo.Abstract";
162
+ }
163
+
135
164
  /** Register a `variables`/`secrets` namespace typed from a module doc's schema map
136
165
  * (`{ name: <schema>, … }`), falling back to dyn `map` when absent or untyped. */
137
166
  function registerConfigNamespace(
package/src/cel-scope.ts CHANGED
@@ -42,6 +42,7 @@ import {
42
42
  } from "./kernel-globals.js";
43
43
  import { gatherPropertySchemas, resolveLocalRef, walkStepArray } from "./schema-walk.js";
44
44
  import { readStepSlot } from "./step-slot.js";
45
+ import { bodyForPath, templateBodies } from "./template-body.js";
45
46
  import {
46
47
  getManifestItem,
47
48
  resolveContextAnnotations,
@@ -415,6 +416,20 @@ export class CelScopeResolver {
415
416
  private stepContext: Record<string, any> | undefined;
416
417
  private invocationContext: Record<string, any> | undefined;
417
418
  private errorScopes: Map<string, Record<string, any>> = new Map();
419
+ /** The same two facts per `resources:` entry of a `Telo.Definition` — a
420
+ * template body is a declaration of ANOTHER kind, so its step body and its
421
+ * error branches are that kind's, not the enclosing definition's. */
422
+ private bodyScopes: Array<{
423
+ prefix: string;
424
+ scopePrefix: string;
425
+ manifest: Record<string, any>;
426
+ stepContext: Record<string, any> | undefined;
427
+ errorScopes: Map<string, Record<string, any>>;
428
+ }> = [];
429
+ /** The enclosing definition's `self` schema, resolved once per resource. A
430
+ * template body's contexts resolve against the BODY, so `self` — the one
431
+ * binding anchored on the definition — is substituted before they do. */
432
+ private selfSchema: Record<string, any> | undefined;
418
433
 
419
434
  constructor(private readonly inputs: CelScopeInputs) {}
420
435
 
@@ -445,19 +460,44 @@ export class CelScopeResolver {
445
460
  this.invocationContext = (m.metadata as any)?.xTeloInvocationContext as
446
461
  | Record<string, any>
447
462
  | undefined;
448
- this.stepContext = definition?.schema
463
+ // The INHERITANCE-RESOLVED schema at both sites, for the reason the CEL
464
+ // context regions use it: an `extends` child is authored against
465
+ // merge(parent, own), so a step body or a `catch:` region the parent
466
+ // declares is in force on the child. All three consumers share one memo so
467
+ // they cannot come to disagree about which schema a kind means.
468
+ const authorSchema = defs.effectiveSchemaOf(definition) as Record<string, any> | undefined;
469
+ this.stepContext = authorSchema
449
470
  ? buildStepContextSchema(
450
471
  m as Record<string, any>,
451
- definition.schema as Record<string, any>,
472
+ authorSchema,
452
473
  allManifests as Record<string, any>[],
453
474
  defs,
454
475
  aliases,
455
476
  scopes,
456
477
  )
457
478
  : undefined;
458
- this.errorScopes = collectErrorContextScopes(
459
- definition?.schema as Record<string, any> | undefined,
460
- );
479
+ this.errorScopes = collectErrorContextScopes(authorSchema);
480
+ this.selfSchema =
481
+ m.kind === "Telo.Definition" ? buildSelfSchema(m as Record<string, any>, defs, aliases) : undefined;
482
+ this.bodyScopes = templateBodies(m, defs, aliases, scopes).map((body) => {
483
+ const bodySchema = defs.effectiveSchemaOf(body.definition) as Record<string, any> | undefined;
484
+ return {
485
+ prefix: body.prefix,
486
+ scopePrefix: body.scopePrefix,
487
+ manifest: body.manifest as Record<string, any>,
488
+ stepContext: bodySchema
489
+ ? buildStepContextSchema(
490
+ body.manifest as Record<string, any>,
491
+ bodySchema,
492
+ allManifests as Record<string, any>[],
493
+ defs,
494
+ aliases,
495
+ scopes,
496
+ )
497
+ : undefined,
498
+ errorScopes: collectErrorContextScopes(bodySchema),
499
+ };
500
+ });
461
501
  }
462
502
 
463
503
  /**
@@ -514,18 +554,39 @@ export class CelScopeResolver {
514
554
  const m = site.source;
515
555
  let matched: Record<string, any> | undefined = site.contextSchema ?? this.invocationContext;
516
556
 
517
- if (this.stepContext) {
557
+ // Inside a template body the step and error regions are the NESTED kind's.
558
+ // Its `steps` accumulator and its `catch:` branches are declared there, and
559
+ // the enclosing definition's (there are none) would say nothing about them.
560
+ const inBody = this.bodyScopes.length > 0 ? bodyForPath(this.bodyScopes, path) : undefined;
561
+ const stepContext = inBody ? inBody.stepContext : this.stepContext;
562
+ const errorScopes = inBody ? inBody.errorScopes : this.errorScopes;
563
+
564
+ if (stepContext) {
518
565
  const base = matched ?? { type: "object", properties: {}, additionalProperties: true };
519
566
  matched = {
520
567
  ...base,
521
- properties: { ...(base.properties ?? {}), steps: this.stepContext },
568
+ properties: {
569
+ // `inputs` is in scope beside `steps` wherever a step body runs — the
570
+ // step engine evaluates a step against the enclosing kind's own
571
+ // arguments, which is what `steps[0].inputs.x` reads. Modelled here
572
+ // because it was modelled NOWHERE: the step context is open, so a
573
+ // chain through it was never checked and the name's absence went
574
+ // unnoticed until something asked whether the root existed.
575
+ //
576
+ // OPEN, not typed from the kind's `inputType`: closing it would newly
577
+ // reject every read of an argument the contract does not spell out,
578
+ // which is a separate decision from knowing the name is legal.
579
+ inputs: { type: "object", additionalProperties: true },
580
+ ...(base.properties ?? {}),
581
+ steps: stepContext,
582
+ },
522
583
  };
523
584
  }
524
585
 
525
586
  // `error` is only in scope inside an error-bearing branch (e.g. a
526
587
  // `catch:` / `finally:`), so it's merged per-path, not resource-wide.
527
588
  const errorSchema =
528
- this.errorScopes.size > 0 ? errorContextForPath(path, this.errorScopes) : undefined;
589
+ errorScopes.size > 0 ? errorContextForPath(path, errorScopes) : undefined;
529
590
  if (errorSchema) {
530
591
  const base = matched ?? { type: "object", properties: {}, additionalProperties: true };
531
592
  matched = {
@@ -543,16 +604,49 @@ export class CelScopeResolver {
543
604
  }
544
605
 
545
606
  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
- );
607
+
608
+ // A template body's context annotations are the NESTED kind's, and every one
609
+ // that anchors at a root — `x-telo-context-element-from`,
610
+ // `-collection-from`, `-from-root`, `x-telo-bindings-from` — means the root
611
+ // of the DECLARATION they were written for. Resolving them against the
612
+ // enclosing `Telo.Definition` looked `collection:` up on a document that has
613
+ // no such field, so `item` typed open and a typo below it went unreported —
614
+ // the one place a nested declaration did not answer as the same declaration
615
+ // written at the top level.
616
+ //
617
+ // So the path and the scope are rebased into the body and the body becomes
618
+ // the resolution root. `self` is the single binding that genuinely belongs to
619
+ // the enclosing definition, and it is substituted below rather than left as
620
+ // an annotation the rebased root would misread.
621
+ const body = inBody;
622
+ const localPath = body ? path.slice(body.prefix.length + 1) : path;
623
+ const localScope =
624
+ body && site.matchedScope?.startsWith(`${body.scopePrefix}.`)
625
+ ? `$.${site.matchedScope.slice(body.scopePrefix.length + 1)}`
626
+ : body
627
+ ? undefined
628
+ : site.matchedScope;
629
+ const rootManifest = body ? body.manifest : (m as Record<string, any>);
630
+
631
+ if (body && this.selfSchema && matched.properties?.self) {
632
+ matched = {
633
+ ...matched,
634
+ properties: { ...matched.properties, self: this.selfSchema },
635
+ };
636
+ }
637
+
638
+ const manifestItem = localScope
639
+ ? getManifestItem(localPath, localScope, rootManifest)
640
+ : rootManifest;
641
+ const rootForResolver = body
642
+ ? rootManifest
643
+ : manifestRootForResolver(
644
+ m as Record<string, any>,
645
+ defs,
646
+ aliases,
647
+ allManifests as Record<string, any>[],
648
+ scopes,
649
+ );
556
650
  const resolved = resolveContextAnnotations(matched, manifestItem, {
557
651
  manifestRoot: rootForResolver,
558
652
  defs,
@@ -560,7 +654,7 @@ export class CelScopeResolver {
560
654
  allManifests: allManifests as Record<string, any>[],
561
655
  });
562
656
  return mergeKernelGlobalsIntoContext(
563
- withBindingNames(resolved, m as Record<string, any>),
657
+ withBindingNames(resolved, rootManifest),
564
658
  // Typed in the module that DECLARED this resource — for a manifest
565
659
  // forwarded from an imported library, that is its `moduleGlobals` stamp,
566
660
  // not the consuming application's block.
@@ -41,6 +41,9 @@ export class DefinitionRegistry {
41
41
  private readonly fieldMaps = new Map<string, ReferenceFieldMap>();
42
42
  /** Reverse inheritance index: parent kind → direct child kinds. */
43
43
  private readonly extendedBy = new Map<string, string[]>();
44
+ /** Memoized inheritance-resolved schemas. `null` records "resolved to
45
+ * nothing", so a kind with no schema is not re-walked on every resource. */
46
+ private readonly effectiveSchemas = new Map<string, Record<string, unknown> | null>();
44
47
  /** DEPRECATED module identity table: identity string → canonical module name
45
48
  * ("std/pipeline" → "pipeline"). Serves only the legacy
46
49
  * `<namespace>/<module>#<Kind>` form of `x-telo-ref`, kept resolvable for
@@ -57,6 +60,7 @@ export class DefinitionRegistry {
57
60
  // so any already-computed map recomputes against the now-larger def set; the
58
61
  // maps rebuild lazily on first `getFieldMap` (after all defs are registered).
59
62
  this.fieldMaps.clear();
63
+ this.effectiveSchemas.clear();
60
64
  // `capability` populates extendedBy for backward-compat with the legacy pattern where
61
65
  // a concrete definition overloaded `capability: <AbstractKind>` to mean "implements
62
66
  // this abstract." The canonical pattern is `extends` (below). Both populate the index,
@@ -285,6 +289,44 @@ export class DefinitionRegistry {
285
289
  return this.defs.get(kind);
286
290
  }
287
291
 
292
+ /**
293
+ * The kind's AUTHOR-FACING schema — its own merged with everything it
294
+ * inherits along `extends` — memoized per kind.
295
+ *
296
+ * This is what every consumer reading a kind's schema to interpret an
297
+ * INSTANCE must use, because that is the schema the instance was authored
298
+ * against and the one the kernel stamps at definition registration. Reading
299
+ * `resolve(kind).schema` instead sees only what the leaf declared, so an
300
+ * annotation on a parent — a CEL context region, a step body, an error
301
+ * context — is invisible on every child, silently and per consumer. Sharing
302
+ * one memo is what keeps those consumers from drifting apart.
303
+ *
304
+ * Lazy for the same reason `getFieldMap` is: a child registered before its
305
+ * parent still sees the inherited half once both are present.
306
+ */
307
+ effectiveSchema(kind: string): Record<string, unknown> | undefined {
308
+ const cached = this.effectiveSchemas.get(kind);
309
+ if (cached !== undefined) return cached ?? undefined;
310
+ const def = this.defs.get(kind);
311
+ if (!def) return undefined;
312
+ const schema = effectiveAuthorSchema(def, (k) => this.resolve(k)) as
313
+ | Record<string, unknown>
314
+ | undefined;
315
+ this.effectiveSchemas.set(kind, schema ?? null);
316
+ return schema;
317
+ }
318
+
319
+ /** The effective schema for a definition, by the kind it is registered under.
320
+ * Falls back to the definition's own schema when it is not in this registry
321
+ * (a synthesized or not-yet-registered kind), which is the pre-inheritance
322
+ * behaviour and the safe direction: it types less, never wrongly. */
323
+ effectiveSchemaOf(definition: ResourceDefinition | undefined): Record<string, unknown> | undefined {
324
+ if (!definition) return undefined;
325
+ const { name, module: mod } = definition.metadata;
326
+ const resolved = this.effectiveSchema(mod ? `${mod}.${name}` : name);
327
+ return resolved ?? (definition.schema as Record<string, unknown> | undefined);
328
+ }
329
+
288
330
  /** Returns the reference field map for the given kind, computed lazily from the
289
331
  * kind's AUTHOR-FACING (inheritance-resolved) schema and memoized. Lazy so a
290
332
  * child registered before its parent still sees the parent's inherited ref
@@ -407,6 +449,42 @@ export class DefinitionRegistry {
407
449
  return buildFieldMapAtPath(subSchema, fieldPath);
408
450
  }
409
451
 
452
+ /** The kinds a definition descends from DIRECTLY — the same two edges
453
+ * `register` feeds into `extendedBy`, read the other way.
454
+ *
455
+ * Both spellings, because either can carry the edge that satisfies a slot:
456
+ * `capability:` is the legacy implements-this form, `extends:` the canonical
457
+ * one. A consumer walking these by hand would be a second reading of what an
458
+ * inheritance edge IS, and the downward index and the upward walk have to
459
+ * agree about that forever. */
460
+ parentsOf(kind: string): string[] {
461
+ const def = this.defs.get(kind);
462
+ if (!def) return [];
463
+ return [def.capability, def.extends].filter(
464
+ (parent): parent is string => typeof parent === "string" && parent.length > 0,
465
+ );
466
+ }
467
+
468
+ /** Whether every kind this one descends from is registered.
469
+ *
470
+ * When it is, what the kind implements is FULLY KNOWN: a target it does not
471
+ * reach is one it genuinely does not implement, so a mismatch is a verdict.
472
+ * When a hop is missing — an unimported abstract, an alias the declaring file
473
+ * could not resolve — a mismatch cannot be told from a missing dependency,
474
+ * which is the partial context every reference check stays lenient in. */
475
+ ancestryResolved(kind: string): boolean {
476
+ const queue = [kind];
477
+ const seen = new Set<string>();
478
+ while (queue.length > 0) {
479
+ const current = queue.shift()!;
480
+ if (seen.has(current)) continue;
481
+ seen.add(current);
482
+ if (!this.defs.has(current)) return false;
483
+ queue.push(...this.parentsOf(current));
484
+ }
485
+ return true;
486
+ }
487
+
410
488
  /** Returns all definitions that transitively extend the given abstract kind.
411
489
  * Follows the capability chain to any depth (equivalent to instanceof in OOP).
412
490
  * Definitions are included regardless of registration order. */
@@ -2,6 +2,7 @@ import type { ResourceManifest } from "@telorun/sdk";
2
2
  import type { AliasResolver } from "./alias-resolver.js";
3
3
  import { buildCallGraph, projectToPairs, type ResourceGraphNode } from "./call-graph.js";
4
4
  import type { DefinitionRegistry } from "./definition-registry.js";
5
+ import { readSuppliedResources } from "./resource-input.js";
5
6
 
6
7
  export interface ResourceNode {
7
8
  kind: string;
@@ -67,6 +68,8 @@ export function buildDependencyGraph(
67
68
  }
68
69
  for (const key of nodes.keys()) if (!deps.has(key)) deps.set(key, new Set());
69
70
 
71
+ addResourceInputEdges(resources, nodes, deps);
72
+
70
73
  // --- Kahn's topological sort ---
71
74
  // in-degree[X] = number of X's dependencies (size of deps[X])
72
75
  // reverse[dep] = set of nodes that depend on dep (for degree decrement)
@@ -164,3 +167,66 @@ function findCycle(
164
167
 
165
168
  return [];
166
169
  }
170
+
171
+ /**
172
+ * Boot-order edges for the one thing a `Telo.Import` does hold: the instances it
173
+ * hands DOWN to its target library's declared `resources:` inputs.
174
+ *
175
+ * An import is otherwise module wiring rather than a runtime node — it is in
176
+ * `DEPENDENCY_GRAPH_SKIP_KINDS` and the call graph gives it no node at all — but
177
+ * a borrowed instance must exist before the import initializes, and a cycle
178
+ * through one is a cycle like any other. The edges are added here rather than
179
+ * read off the reference field map because the accepted KIND at this slot is
180
+ * declared by the TARGET library, not by the `Telo.Import` schema, so there is
181
+ * no `x-telo-ref` for the map to read; the constraint itself is checked by
182
+ * `validate-resource-inputs`.
183
+ */
184
+ function addResourceInputEdges(
185
+ resources: ResourceManifest[],
186
+ nodes: Map<string, ResourceNode>,
187
+ deps: Map<string, Set<string>>,
188
+ ): void {
189
+ // Every import that supplies inputs becomes a node FIRST, so a cross-module
190
+ // reference below can resolve to the import that exports its target.
191
+ const imports: Array<{ key: string; supplied: Record<string, unknown> }> = [];
192
+ for (const m of resources) {
193
+ if (m.kind !== "Telo.Import") continue;
194
+ const alias = m.metadata?.name as string | undefined;
195
+ const supplied = readSuppliedResources(m);
196
+ if (!alias || Object.keys(supplied).length === 0) continue;
197
+ const key = nodeKey(m.kind, alias);
198
+ nodes.set(key, { kind: m.kind, name: alias });
199
+ if (!deps.has(key)) deps.set(key, new Set<string>());
200
+ imports.push({ key, supplied });
201
+ }
202
+ if (imports.length === 0) return;
203
+
204
+ const byName = new Map<string, string>();
205
+ for (const [key, node] of nodes) byName.set(node.name, key);
206
+ // An import is keyed by its alias, and that is also how a cross-module
207
+ // reference names it — index those so one resolves.
208
+ for (const m of resources) {
209
+ if (m.kind !== "Telo.Import") continue;
210
+ const alias = m.metadata?.name as string | undefined;
211
+ const key = alias ? nodeKey(m.kind, alias) : undefined;
212
+ if (alias && key && nodes.has(key)) byName.set(alias, key);
213
+ }
214
+
215
+ for (const { key, supplied } of imports) {
216
+ const set = deps.get(key)!;
217
+ for (const value of Object.values(supplied)) {
218
+ const ref = value as { name?: unknown; alias?: unknown } | undefined;
219
+ // A CROSS-MODULE reference (`!ref Other.db`) names an instance exported by
220
+ // another import, never a local resource. Looking it up in the local name
221
+ // map would find an unrelated resource of the same name — a wrong edge,
222
+ // and possibly a phantom cycle — or nothing at all. What it depends on is
223
+ // the IMPORT that exports it, which is the projection
224
+ // `localDependencyNames` already makes at runtime.
225
+ const alias = typeof ref?.alias === "string" ? ref.alias : undefined;
226
+ const targetName =
227
+ alias && alias !== "Self" ? alias : typeof ref?.name === "string" ? ref.name : undefined;
228
+ const target = targetName ? byName.get(targetName) : undefined;
229
+ if (target && target !== key) set.add(target);
230
+ }
231
+ }
232
+ }
package/src/eval-paths.ts CHANGED
@@ -1,3 +1,27 @@
1
+ /**
2
+ * WHERE IS CEL EVALUATED — the one reader, and the one decision built on it.
3
+ *
4
+ * Two independent ways a value is evaluated, and every consumer needs both:
5
+ * `x-telo-eval` names a field directly, while a REGION annotation
6
+ * (`x-telo-context` / `x-telo-step-context` / `x-telo-error-context`, or a step
7
+ * body) covers everything beneath it — which is how `Run.Choice`'s rows and an
8
+ * `Http.Api` route's `returns:` entries hold expressions while declaring no
9
+ * annotation of their own.
10
+ *
11
+ * The consumers are on both sides of the runtime: `telo check` decides whether a
12
+ * `!cel` here is live (`CEL_IN_NON_EVAL_FIELD`) or resolved at startup
13
+ * (`OBSERVED_STATE_IN_STARTUP_FIELD`), the kernel decides what to expand, and
14
+ * the editor decides whether to offer an expression at all. The editor's answer
15
+ * is a CLAIM that `telo check` will accept what it writes, so a second
16
+ * implementation there is a promise nothing keeps — it read `x-telo-eval` alone
17
+ * and left every predicate inside a region as a bare checkbox, with no way to
18
+ * write the expression the field exists to hold.
19
+ *
20
+ * Browser-safe: no Node built-ins.
21
+ */
22
+
23
+ import { isStepSlot } from "./step-slot.js";
24
+
1
25
  /**
2
26
  * The single containment rule for `x-telo-eval` paths, shared by every matcher so
3
27
  * the analyzer's coverage decision and the kernel's expansion/exclusion can't
@@ -66,3 +90,131 @@ function collectEvalPathsNode(
66
90
  }
67
91
  }
68
92
  }
93
+
94
+ /** Schema keys that declare a CEL-bearing region: a field carrying any of these
95
+ * is evaluated at runtime, so a `!cel` inside it (or a descendant) is live. A
96
+ * STEP BODY is one too, and says so through the grammar its items point at
97
+ * rather than through a key — {@link isStepSlot} reads either spelling. */
98
+ const CEL_REGION_KEYS = [
99
+ "x-telo-context",
100
+ "x-telo-step-context",
101
+ "x-telo-error-context",
102
+ ] as const;
103
+
104
+ /** True when this schema node declares a CEL-bearing region. Node-level, so a
105
+ * consumer holding one schema (an editor rendering one field) asks it directly
106
+ * rather than deriving scopes it would then have to match against. */
107
+ export function declaresCelRegion(schema: unknown): boolean {
108
+ if (!schema || typeof schema !== "object") return false;
109
+ const node = schema as Record<string, unknown>;
110
+ return CEL_REGION_KEYS.some((key) => node[key] !== undefined) || isStepSlot(node);
111
+ }
112
+
113
+ /**
114
+ * Walk a JSON Schema tree and collect the JSONPath scopes of every field that
115
+ * declares a CEL-bearing region (`x-telo-context` / `x-telo-step-context` /
116
+ * `x-telo-error-context`). Used — alongside `x-telo-eval` paths — to decide
117
+ * whether a `!cel` expression sits in a slot the runtime actually evaluates.
118
+ * Scopes use the same `$.a.b[*]` form as `extractContextsFromSchema`, matched
119
+ * against expression paths with `pathMatchesScope`.
120
+ */
121
+ export function extractCelRegionScopes(schema: Record<string, any>, path = "$"): string[] {
122
+ if (!schema || typeof schema !== "object") return [];
123
+ const out: string[] = [];
124
+
125
+ if (declaresCelRegion(schema)) out.push(path);
126
+
127
+ if (schema.properties) {
128
+ for (const [key, value] of Object.entries(schema.properties as Record<string, any>)) {
129
+ out.push(...extractCelRegionScopes(value, `${path}.${key}`));
130
+ }
131
+ }
132
+ if (schema.items && typeof schema.items === "object") {
133
+ out.push(...extractCelRegionScopes(schema.items, `${path}[*]`));
134
+ }
135
+ for (const key of ["oneOf", "anyOf", "allOf"] as const) {
136
+ if (Array.isArray(schema[key])) {
137
+ for (const subschema of schema[key]) out.push(...extractCelRegionScopes(subschema, path));
138
+ }
139
+ }
140
+
141
+ return out;
142
+ }
143
+
144
+ /**
145
+ * Returns true when a CEL expression path (from walkCelExpressions, e.g. "routes[0].inputs.q")
146
+ * falls within the scope of a context (e.g. "$.routes[*].inputs").
147
+ *
148
+ * The scope is matched directly (no sibling sharing): a context at "$.routes[*].inputs" only
149
+ * applies to expressions whose path starts with "routes[N].inputs", not to other sibling fields.
150
+ */
151
+ export function pathMatchesScope(exprPath: string, scope: string): boolean {
152
+ const stripped = scope.startsWith("$.") ? scope.slice(2) : scope;
153
+ if (!stripped) return false;
154
+
155
+ // Split on wildcard array segments; each [*] must match a concrete [N] in exprPath
156
+ const parts = stripped.split("[*]");
157
+ let remaining = exprPath;
158
+ for (let i = 0; i < parts.length; i++) {
159
+ const part = parts[i]!;
160
+ if (!remaining.startsWith(part)) return false;
161
+ remaining = remaining.slice(part.length);
162
+ if (i < parts.length - 1) {
163
+ // Expect a concrete array index like [0], [12], ...
164
+ const m = remaining.match(/^\[\d+\]/);
165
+ if (!m) return false;
166
+ remaining = remaining.slice(m[0].length);
167
+ }
168
+ }
169
+ // Expression must end here or continue into a child path
170
+ return remaining === "" || remaining[0] === "." || remaining[0] === "[";
171
+ }
172
+
173
+ /** Every place a schema says its values are evaluated: the `x-telo-eval` paths,
174
+ * by mode, plus the scopes of the regions that cover their contents. */
175
+ export interface CelEvalSites {
176
+ compile: readonly string[];
177
+ runtime: readonly string[];
178
+ regions: readonly string[];
179
+ }
180
+
181
+ export const NO_CEL_EVAL_SITES: CelEvalSites = { compile: [], runtime: [], regions: [] };
182
+
183
+ export function celEvalSites(schema: Record<string, any> | undefined): CelEvalSites {
184
+ if (!schema) return NO_CEL_EVAL_SITES;
185
+ const { compile, runtime } = buildEvalPaths(schema);
186
+ return { compile, runtime, regions: extractCelRegionScopes(schema) };
187
+ }
188
+
189
+ /** The union of several schemas' sites — a kind's own and its capability
190
+ * abstract's, which is how a `Telo.Provider`'s implicit compile-eval reaches
191
+ * fields the provider never annotated. */
192
+ export function mergeCelEvalSites(...sites: CelEvalSites[]): CelEvalSites {
193
+ return {
194
+ compile: sites.flatMap((s) => s.compile),
195
+ runtime: sites.flatMap((s) => s.runtime),
196
+ regions: sites.flatMap((s) => s.regions),
197
+ };
198
+ }
199
+
200
+ /**
201
+ * Whether the value at `path` is evaluated, and when — null for a field whose
202
+ * value is read as a literal.
203
+ *
204
+ * `compile` wins over `runtime`, and both win over a region: a field's own
205
+ * annotation is more specific than the region it sits in, which is the same
206
+ * precedence a nested annotation has over an enclosing one. A region resolves to
207
+ * `runtime` because that is what a region IS — a per-invocation scope naming
208
+ * what its expressions can read.
209
+ *
210
+ * `path` is the `walkCelExpressions` spelling (`routes[0].returns[1].when`).
211
+ */
212
+ export function celEvalModeAt(
213
+ sites: CelEvalSites,
214
+ path: string,
215
+ ): "compile" | "runtime" | null {
216
+ if (evalPathsCover(sites.compile, path)) return "compile";
217
+ if (evalPathsCover(sites.runtime, path)) return "runtime";
218
+ if (sites.regions.some((scope) => pathMatchesScope(path, scope))) return "runtime";
219
+ return null;
220
+ }