@telorun/analyzer 0.62.1 → 0.64.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 (171) 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 +41 -0
  4. package/dist/analyzer.d.ts +3 -37
  5. package/dist/analyzer.d.ts.map +1 -1
  6. package/dist/analyzer.js +396 -476
  7. package/dist/call-graph.d.ts.map +1 -1
  8. package/dist/call-graph.js +2 -7
  9. package/dist/cel-scope-query.d.ts +109 -0
  10. package/dist/cel-scope-query.d.ts.map +1 -0
  11. package/dist/cel-scope-query.js +270 -0
  12. package/dist/cel-scope.d.ts +166 -0
  13. package/dist/cel-scope.d.ts.map +1 -0
  14. package/dist/cel-scope.js +377 -0
  15. package/dist/definition-registry.d.ts +15 -0
  16. package/dist/definition-registry.d.ts.map +1 -1
  17. package/dist/definition-registry.js +25 -9
  18. package/dist/find-manifest.d.ts +10 -0
  19. package/dist/find-manifest.d.ts.map +1 -0
  20. package/dist/find-manifest.js +12 -0
  21. package/dist/flatten-for-analyzer.js +1 -1
  22. package/dist/import-resolution-diagnostics.d.ts +9 -2
  23. package/dist/import-resolution-diagnostics.d.ts.map +1 -1
  24. package/dist/import-resolution-diagnostics.js +26 -7
  25. package/dist/index.d.ts +22 -1
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +22 -1
  28. package/dist/invocation-contract.d.ts +17 -1
  29. package/dist/invocation-contract.d.ts.map +1 -1
  30. package/dist/invocation-contract.js +42 -4
  31. package/dist/loaded-types.d.ts +12 -3
  32. package/dist/loaded-types.d.ts.map +1 -1
  33. package/dist/manifest-analysis.d.ts +73 -0
  34. package/dist/manifest-analysis.d.ts.map +1 -0
  35. package/dist/manifest-analysis.js +78 -0
  36. package/dist/manifest-loader.d.ts +0 -1
  37. package/dist/manifest-loader.d.ts.map +1 -1
  38. package/dist/manifest-loader.js +62 -41
  39. package/dist/manifest-path.d.ts +18 -0
  40. package/dist/manifest-path.d.ts.map +1 -0
  41. package/dist/manifest-path.js +37 -0
  42. package/dist/manifest-schemas.d.ts +699 -0
  43. package/dist/manifest-schemas.d.ts.map +1 -1
  44. package/dist/manifest-schemas.js +306 -14
  45. package/dist/module-compatibility.d.ts +40 -0
  46. package/dist/module-compatibility.d.ts.map +1 -0
  47. package/dist/module-compatibility.js +52 -0
  48. package/dist/ref-slot-reading.d.ts +30 -0
  49. package/dist/ref-slot-reading.d.ts.map +1 -0
  50. package/dist/ref-slot-reading.js +139 -0
  51. package/dist/referrer-rule.d.ts +67 -0
  52. package/dist/referrer-rule.d.ts.map +1 -0
  53. package/dist/referrer-rule.js +96 -0
  54. package/dist/release/index.d.ts +1 -1
  55. package/dist/release/index.d.ts.map +1 -1
  56. package/dist/release/index.js +1 -1
  57. package/dist/release/version-stamp.d.ts +16 -0
  58. package/dist/release/version-stamp.d.ts.map +1 -1
  59. package/dist/release/version-stamp.js +52 -0
  60. package/dist/release/workspace-config.d.ts +10 -6
  61. package/dist/release/workspace-config.d.ts.map +1 -1
  62. package/dist/release/workspace-config.js +10 -6
  63. package/dist/resolve-schema-ref-kinds.d.ts +4 -3
  64. package/dist/resolve-schema-ref-kinds.d.ts.map +1 -1
  65. package/dist/resolve-schema-ref-kinds.js +16 -0
  66. package/dist/resolve-throws-union.d.ts.map +1 -1
  67. package/dist/resolve-throws-union.js +4 -3
  68. package/dist/resolve-zone-containment.d.ts +99 -0
  69. package/dist/resolve-zone-containment.d.ts.map +1 -0
  70. package/dist/resolve-zone-containment.js +212 -0
  71. package/dist/resolve-zone-requirements.d.ts +5 -0
  72. package/dist/resolve-zone-requirements.d.ts.map +1 -1
  73. package/dist/resolve-zone-requirements.js +48 -7
  74. package/dist/resource-rule.d.ts +106 -0
  75. package/dist/resource-rule.d.ts.map +1 -0
  76. package/dist/resource-rule.js +236 -0
  77. package/dist/rule-condition.d.ts +50 -0
  78. package/dist/rule-condition.d.ts.map +1 -0
  79. package/dist/rule-condition.js +108 -0
  80. package/dist/schema-keywords.d.ts.map +1 -1
  81. package/dist/schema-keywords.js +20 -0
  82. package/dist/schema-projection.d.ts +171 -0
  83. package/dist/schema-projection.d.ts.map +1 -0
  84. package/dist/schema-projection.js +317 -0
  85. package/dist/schema-walk.d.ts +25 -0
  86. package/dist/schema-walk.d.ts.map +1 -0
  87. package/dist/schema-walk.js +126 -0
  88. package/dist/step-slot.d.ts +39 -0
  89. package/dist/step-slot.d.ts.map +1 -0
  90. package/dist/step-slot.js +49 -0
  91. package/dist/telo-version.d.ts +1 -1
  92. package/dist/telo-version.js +1 -1
  93. package/dist/validate-cel-context.d.ts.map +1 -1
  94. package/dist/validate-cel-context.js +17 -3
  95. package/dist/validate-durable-regions.d.ts +15 -0
  96. package/dist/validate-durable-regions.d.ts.map +1 -0
  97. package/dist/validate-durable-regions.js +336 -0
  98. package/dist/validate-references.d.ts.map +1 -1
  99. package/dist/validate-references.js +44 -2
  100. package/dist/validate-referrer-rules.d.ts +120 -0
  101. package/dist/validate-referrer-rules.d.ts.map +1 -0
  102. package/dist/validate-referrer-rules.js +255 -0
  103. package/dist/validate-resource-rules.d.ts +114 -0
  104. package/dist/validate-resource-rules.d.ts.map +1 -0
  105. package/dist/validate-resource-rules.js +283 -0
  106. package/dist/validate-schema-projection.d.ts +32 -0
  107. package/dist/validate-schema-projection.d.ts.map +1 -0
  108. package/dist/validate-schema-projection.js +110 -0
  109. package/dist/validate-step-inputs.d.ts +19 -1
  110. package/dist/validate-step-inputs.d.ts.map +1 -1
  111. package/dist/validate-step-inputs.js +102 -9
  112. package/dist/validate-throws-coverage.d.ts.map +1 -1
  113. package/dist/validate-throws-coverage.js +11 -5
  114. package/dist/validate-zone-slots.d.ts +2 -2
  115. package/dist/validate-zone-slots.d.ts.map +1 -1
  116. package/dist/validate-zone-slots.js +202 -6
  117. package/dist/validate-zone-violations.d.ts +12 -0
  118. package/dist/validate-zone-violations.d.ts.map +1 -0
  119. package/dist/validate-zone-violations.js +169 -0
  120. package/dist/value-type-keyword.d.ts +1 -1
  121. package/dist/value-type-keyword.d.ts.map +1 -1
  122. package/dist/value-type-keyword.js +4 -0
  123. package/dist/zone-slot.d.ts +61 -0
  124. package/dist/zone-slot.d.ts.map +1 -1
  125. package/dist/zone-slot.js +86 -5
  126. package/package.json +2 -2
  127. package/src/analysis-registry.ts +43 -0
  128. package/src/analyzer.ts +452 -595
  129. package/src/call-graph.ts +2 -8
  130. package/src/cel-scope-query.ts +337 -0
  131. package/src/cel-scope.ts +570 -0
  132. package/src/definition-registry.ts +31 -13
  133. package/src/find-manifest.ts +19 -0
  134. package/src/flatten-for-analyzer.ts +1 -1
  135. package/src/import-resolution-diagnostics.ts +25 -7
  136. package/src/index.ts +86 -0
  137. package/src/invocation-contract.ts +67 -2
  138. package/src/loaded-types.ts +12 -3
  139. package/src/manifest-analysis.ts +132 -0
  140. package/src/manifest-loader.ts +87 -55
  141. package/src/manifest-path.ts +34 -0
  142. package/src/manifest-schemas.ts +328 -14
  143. package/src/module-compatibility.ts +72 -0
  144. package/src/ref-slot-reading.ts +156 -0
  145. package/src/referrer-rule.ts +116 -0
  146. package/src/release/index.ts +1 -0
  147. package/src/release/version-stamp.ts +58 -0
  148. package/src/release/workspace-config.ts +10 -6
  149. package/src/resolve-schema-ref-kinds.ts +28 -4
  150. package/src/resolve-throws-union.ts +4 -3
  151. package/src/resolve-zone-containment.ts +329 -0
  152. package/src/resolve-zone-requirements.ts +67 -7
  153. package/src/resource-rule.ts +252 -0
  154. package/src/rule-condition.ts +126 -0
  155. package/src/schema-keywords.ts +24 -0
  156. package/src/schema-projection.ts +403 -0
  157. package/src/schema-walk.ts +144 -0
  158. package/src/step-slot.ts +64 -0
  159. package/src/telo-version.ts +1 -1
  160. package/src/validate-cel-context.ts +17 -3
  161. package/src/validate-durable-regions.ts +401 -0
  162. package/src/validate-references.ts +57 -2
  163. package/src/validate-referrer-rules.ts +373 -0
  164. package/src/validate-resource-rules.ts +401 -0
  165. package/src/validate-schema-projection.ts +184 -0
  166. package/src/validate-step-inputs.ts +148 -10
  167. package/src/validate-throws-coverage.ts +12 -5
  168. package/src/validate-zone-slots.ts +192 -8
  169. package/src/validate-zone-violations.ts +190 -0
  170. package/src/value-type-keyword.ts +4 -0
  171. package/src/zone-slot.ts +114 -5
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,
@@ -51,8 +52,48 @@ import { REF_VALIDATION_SKIP_KINDS } from "./system-kinds.js";
51
52
  import { resolveRefSentinels } from "./resolve-ref-sentinels.js";
52
53
  import { resolveSchemaRefKinds, type RefConstraintIssue } from "./resolve-schema-ref-kinds.js";
53
54
  import { runZoneAnalysis, type ZoneExportCache } from "./resolve-zone-requirements.js";
54
- import { MANIFEST_SCHEMA_URI, ManifestRootSchema } from "./manifest-schemas.js";
55
+ import { validateDurableRegions } from "./validate-durable-regions.js";
56
+ import { validateZoneViolations } from "./validate-zone-violations.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";
55
67
  import { validateZoneSlotDeclarations, type ZoneSlotIssue } from "./validate-zone-slots.js";
68
+ import {
69
+ validateSchemaProjection,
70
+ type SchemaProjectionIssue,
71
+ } from "./validate-schema-projection.js";
72
+ import {
73
+ evaluateResourceRules,
74
+ reportResourceRules,
75
+ reportUnexercisedRule,
76
+ ruleExercised,
77
+ validateResourceRuleDeclarations,
78
+ type ResourceRuleDiagnostic,
79
+ type ResourceRuleIssue,
80
+ } from "./validate-resource-rules.js";
81
+ import { readResourceRules, type ResourceRule } from "./resource-rule.js";
82
+ import { readReferrerRules, type ReferrerRule } from "./referrer-rule.js";
83
+ import {
84
+ evaluateReferrerRules,
85
+ referrerRuleExercised,
86
+ reportReferrerRules,
87
+ reportUnexercisedReferrerRule,
88
+ validateReferrerRuleDeclarations,
89
+ type Referrer,
90
+ type ReferrerRuleDiagnostic,
91
+ type ReferrerRuleIssue,
92
+ } from "./validate-referrer-rules.js";
93
+ import {
94
+ describeProjectionFailure,
95
+ type ProjectionFailure,
96
+ } from "./schema-projection.js";
56
97
  import {
57
98
  validateDynamicSelectors,
58
99
  validateRefSlotDeclarations,
@@ -104,13 +145,14 @@ import { validateModuleMetadata } from "./validate-module-metadata.js";
104
145
  import { validateRequires } from "./validate-requires.js";
105
146
  import { validateBaseMapping } from "./validate-base-mapping.js";
106
147
  import { validateInvocationContract } from "./validate-invocation-contract.js";
107
- import { collectStepInputIssues } from "./validate-step-inputs.js";
148
+ import { collectRefInputIssues, collectStepInputIssues } from "./validate-step-inputs.js";
108
149
  import { validateNestedInlineResources } from "./validate-nested-inline.js";
109
150
  import { validateProviderCoherence } from "./validate-provider-coherence.js";
110
151
  import { validateReferences } from "./validate-references.js";
111
152
  import { validateReferenceForms } from "./validate-reference-forms.js";
112
153
  import { validateUnusedDeclarations } from "./validate-unused-declarations.js";
113
154
  import { validateThrowsCoverage } from "./validate-throws-coverage.js";
155
+ import { readStepSlot } from "./step-slot.js";
114
156
 
115
157
  const SELF_PREFIX = "Self.";
116
158
 
@@ -162,117 +204,8 @@ function resolveSelfOrAlias(
162
204
  return scopeResolver.resolveKind(value);
163
205
  }
164
206
 
165
- /** The {@link ContractScope} the analyzer resolves invocation contracts in: kinds
166
- * resolve in the module that declared the definition they were read off (so an
167
- * `extends` chain crossing module boundaries re-scopes at every hop), and named
168
- * `telo#Type` references resolve against the flattened manifest list. `resolveIn`
169
- * is the top-level entry point, where the kind was written by the READING
170
- * module and there is no declaring definition yet. */
171
- export function analyzerContractScope(
172
- defs: DefinitionRegistry,
173
- aliases: AliasResolver,
174
- scopes: ModuleScopes,
175
- allManifests: Record<string, any>[],
176
- ): ContractScope & { resolveIn(kind: string, module?: string): ResourceDefinition | undefined } {
177
- const resolve = moduleScopedDefResolver<ResourceDefinition>(defs, aliases, scopes);
178
- return {
179
- resolveDefinition: resolve,
180
- resolveIn: resolve.in,
181
- typeManifestsFor: () => allManifests,
182
- };
183
- }
184
-
185
207
  const SOURCE = "telo-analyzer";
186
208
 
187
- /** Build a closed JSON Schema for the `self` CEL variable available inside a
188
- * `Telo.Definition` template body. Mirrors the runtime template controller's
189
- * `const self = { ...resource, name: resource.metadata.name };` — every
190
- * property the user declared in `schema:` plus synthetic `name` / `kind` and
191
- * the metadata sub-object (kept open since metadata legitimately carries
192
- * arbitrary user-added fields). */
193
- function buildSelfSchema(
194
- definition: Record<string, any>,
195
- defs?: DefinitionRegistry,
196
- aliases?: AliasResolver,
197
- ): Record<string, any> {
198
- // The author-facing schema resolves inheritance: with `base:` the child's own
199
- // schema (the parent's config is internal); without it, `merge(parent, own)`.
200
- const userSchema = (
201
- defs
202
- ? effectiveAuthorSchema(definition as unknown as ResourceDefinition, (k) =>
203
- defs.resolve(aliases?.resolveKind(k) ?? k) ?? defs.resolve(k),
204
- )
205
- : (definition.schema ?? {})
206
- ) as Record<string, any>;
207
- const userProps = (userSchema.properties ?? {}) as Record<string, any>;
208
- const userRequired = Array.isArray(userSchema.required) ? userSchema.required : [];
209
- return {
210
- type: "object",
211
- additionalProperties: false,
212
- properties: {
213
- ...userProps,
214
- name: { type: "string" },
215
- kind: { type: "string" },
216
- metadata: {
217
- type: "object",
218
- additionalProperties: true,
219
- properties: { name: { type: "string" } },
220
- },
221
- },
222
- required: [...userRequired, "name", "kind"],
223
- };
224
- }
225
-
226
- /** Build the JSON Schema for the `inputs` CEL variable available inside an
227
- * invocable template body — the shared contract resolver applied to the
228
- * definition itself, so a body is typed against the exact signature callers are
229
- * checked against and dispatch enforces. Walks the whole `extends` chain rather
230
- * than one hop, so a definition two levels below the declaration still gets
231
- * typed inputs. Undefined when nothing in the chain declares a contract —
232
- * the caller signals opaque `map<string, dyn>` upstream. */
233
- function lookupTemplateInputsSchema(
234
- definition: Record<string, any>,
235
- defs: DefinitionRegistry,
236
- aliases: AliasResolver,
237
- allManifests: Record<string, any>[],
238
- scopes: ModuleScopes,
239
- ): Record<string, any> | undefined {
240
- return resolveContract(
241
- "inputType",
242
- undefined,
243
- definition as unknown as ResourceDefinition,
244
- analyzerContractScope(defs, aliases, scopes, allManifests),
245
- )?.schema;
246
- }
247
-
248
- /** Returns a "resolver-facing" view of the manifest where the fields used as
249
- * navigation roots by Telo.Definition's `x-telo-context-from-root` annotations
250
- * have been pre-augmented:
251
- * - `schema` → augmented `self` schema (synthetic `name`/`kind`/metadata).
252
- * - `inputType` → resolved through the shared contract resolver, so
253
- * `x-telo-context-from-root: inputType` substitutes the
254
- * real signature. Without it the annotation would replace
255
- * the node verbatim with the inline `{kind, schema}` wrapper
256
- * the standard library writes everywhere, typing `inputs` as
257
- * `{kind, schema}` instead of the declared properties.
258
- *
259
- * For non-definition manifests the original object is returned. */
260
- function manifestRootForResolver(
261
- m: Record<string, any>,
262
- defs: DefinitionRegistry,
263
- aliases: AliasResolver,
264
- allManifests: Record<string, any>[],
265
- scopes: ModuleScopes,
266
- ): Record<string, any> {
267
- if (m.kind !== "Telo.Definition") return m;
268
- const inputs = lookupTemplateInputsSchema(m, defs, aliases, allManifests, scopes);
269
- return {
270
- ...m,
271
- schema: buildSelfSchema(m, defs, aliases),
272
- ...(inputs ? { inputType: inputs } : {}),
273
- };
274
- }
275
-
276
209
  /** True when an issue reports a property that is absent — its path points at a
277
210
  * node the manifest does not contain. */
278
211
  export const missingRequired = (issue: { message: string }): boolean =>
@@ -306,273 +239,6 @@ function contractOwnerLabel(
306
239
  return canonical;
307
240
  }
308
241
 
309
- /** Resolve a local `$ref` (only `#/$defs/<name>` form) against the root schema.
310
- * Non-refs and unresolved refs pass through unchanged. */
311
- export function resolveLocalRef(
312
- schema: Record<string, any> | undefined,
313
- root: Record<string, any>,
314
- ): Record<string, any> | undefined {
315
- if (!schema) return undefined;
316
- const ref = schema.$ref;
317
- if (typeof ref === "string" && ref.startsWith("#/$defs/")) {
318
- const defName = ref.slice("#/$defs/".length);
319
- const resolved = root.$defs?.[defName];
320
- if (resolved && typeof resolved === "object") return resolved as Record<string, any>;
321
- }
322
- // A kernel-owned structural fragment (`telo://manifest#/$defs/InvokeStep`).
323
- // Resolved HERE rather than by each walker: this is the one chokepoint every
324
- // structural walk already goes through — the step-array walks, the call graph,
325
- // the zone projection, the eval-path collector — so a composer that points at a
326
- // shared shape stays legible to all of them at once. Nothing is inlined into
327
- // the stored schema, which keeps validator-cache identity stable and matches
328
- // what `resolveSchemaTypeRefs` does for a named user type.
329
- if (typeof ref === "string" && ref.startsWith(BUILTIN_FRAGMENT_PREFIX)) {
330
- const defName = ref.slice(BUILTIN_FRAGMENT_PREFIX.length);
331
- const resolved = (ManifestRootSchema.$defs as Record<string, unknown>)[defName];
332
- if (resolved && typeof resolved === "object") return resolved as Record<string, any>;
333
- }
334
- return schema;
335
- }
336
-
337
- const BUILTIN_FRAGMENT_PREFIX = `${MANIFEST_SCHEMA_URI}#/$defs/`;
338
-
339
- /** Gather property schemas from a (possibly variant-bearing) object schema:
340
- * top-level `properties` plus every `oneOf` / `anyOf` / `allOf` branch.
341
- *
342
- * Each branch is resolved through {@link resolveLocalRef} first, so a branch
343
- * that points at a shared shape — a `oneOf` arm that IS the kernel's dispatch
344
- * site — contributes its properties like an inline one. Without that, pointing a
345
- * composer at a shared shape would silently empty every role-driven lookup that
346
- * reads this (the inputs slot, the retry policy, the eval paths), which is a
347
- * failure with no diagnostic attached to it. */
348
- export function gatherPropertySchemas(
349
- schema: Record<string, any>,
350
- root?: Record<string, any>,
351
- ): Array<[string, Record<string, any>]> {
352
- const out: Array<[string, Record<string, any>]> = [];
353
- const base = resolveLocalRef(schema, root ?? schema) ?? schema;
354
- if (base.properties && typeof base.properties === "object") {
355
- for (const [k, v] of Object.entries(base.properties as Record<string, any>)) {
356
- out.push([k, v as Record<string, any>]);
357
- }
358
- }
359
- for (const variantKey of ["oneOf", "anyOf", "allOf"] as const) {
360
- const arr = base[variantKey];
361
- if (!Array.isArray(arr)) continue;
362
- for (const raw of arr) {
363
- if (!raw || typeof raw !== "object") continue;
364
- const variant = resolveLocalRef(raw as Record<string, any>, root ?? schema) ?? raw;
365
- if (variant.properties) {
366
- for (const [k, v] of Object.entries(variant.properties as Record<string, any>)) {
367
- out.push([k, v as Record<string, any>]);
368
- }
369
- }
370
- }
371
- }
372
- return out;
373
- }
374
-
375
- /**
376
- * Generic, role-driven walk over an `x-telo-step-context` step array. Calls
377
- * `visit(step, stepPath)` for every step — top-level and nested through the
378
- * `x-telo-topology-role` forms (`branch`, `branch-list`, `case-map`). This is
379
- * the single definition of how steps nest, shared by `buildStepContextSchema`
380
- * (which types `steps.<name>.result`) and `validateStepInvokeReferences` (which
381
- * checks invoke refs), so the topology contract lives in one place — adding a
382
- * role or nesting form updates both consumers at once. No resource kind is
383
- * hardcoded; recursion is driven entirely by the schema annotations.
384
- */
385
- export function walkStepArray(
386
- steps: unknown[],
387
- stepItemSchema: Record<string, any> | undefined,
388
- rootSchema: Record<string, any>,
389
- basePath: string,
390
- visit: (step: Record<string, any>, stepPath: string) => void,
391
- ): void {
392
- const dispatchRole = (
393
- data: unknown,
394
- role: string,
395
- itemsSchema: Record<string, any> | undefined,
396
- path: string,
397
- ): void => {
398
- if (role === "branch" && Array.isArray(data)) {
399
- walkStepArray(data, stepItemSchema, rootSchema, path, visit);
400
- } else if (role === "case-map" && data && typeof data === "object" && !Array.isArray(data)) {
401
- for (const [caseKey, arr] of Object.entries(data as Record<string, unknown>)) {
402
- if (Array.isArray(arr)) walkStepArray(arr, stepItemSchema, rootSchema, `${path}.${caseKey}`, visit);
403
- }
404
- } else if (role === "branch-list" && Array.isArray(data)) {
405
- const entrySchema = resolveLocalRef(itemsSchema, rootSchema);
406
- if (!entrySchema) return;
407
- data.forEach((entry, i) => {
408
- if (!entry || typeof entry !== "object") return;
409
- for (const [subKey, subSchema] of gatherPropertySchemas(entrySchema)) {
410
- const subRole = subSchema["x-telo-topology-role"];
411
- if (typeof subRole !== "string") continue;
412
- dispatchRole(
413
- (entry as Record<string, any>)[subKey],
414
- subRole,
415
- subSchema.items as Record<string, any> | undefined,
416
- `${path}[${i}].${subKey}`,
417
- );
418
- }
419
- });
420
- }
421
- };
422
-
423
- steps.forEach((step, i) => {
424
- if (!step || typeof step !== "object") return;
425
- const s = step as Record<string, any>;
426
- const stepPath = `${basePath}[${i}]`;
427
- visit(s, stepPath);
428
- if (!stepItemSchema) return;
429
- for (const [propKey, propSchema] of gatherPropertySchemas(stepItemSchema)) {
430
- const role = propSchema["x-telo-topology-role"];
431
- if (typeof role !== "string") continue;
432
- dispatchRole(
433
- s[propKey],
434
- role,
435
- propSchema.items as Record<string, any> | undefined,
436
- `${stepPath}.${propKey}`,
437
- );
438
- }
439
- });
440
- }
441
-
442
- /**
443
- * Build a `steps` context schema from `x-telo-step-context` annotation.
444
- * Walks each step in the manifest array, resolves the invoked resource's output
445
- * contract, and builds `steps.<name>.result` context entries.
446
- *
447
- * Resolution is the shared {@link resolveContract} — the invoked resource
448
- * manifest's own declaration, then the kind's, resolved to the nearest
449
- * declaration along `extends`, then permissive. Sharing it with the kernel is
450
- * what stops `telo check` from typing `steps.X.result` against one contract
451
- * while dispatch validates against another.
452
- *
453
- * The kind layer is what makes `x-telo-stream` properties on definitions
454
- * actually govern step-result chain validation — without it, the validator falls
455
- * back to permissive and the stream-opacity rule never fires.
456
- *
457
- * Recursion into nested step arrays is annotation-driven via
458
- * `x-telo-topology-role`. The analyzer recognises three role values:
459
- * - `branch` — value is an array of steps (e.g. then / else / do / catch).
460
- * - `branch-list`— value is an array of objects each carrying further roled
461
- * sub-properties (e.g. elseif: [{ if, then }]).
462
- * - `case-map` — value is an object whose values are step arrays (e.g. cases).
463
- * No specific Run.Sequence field name is hardcoded; any kind that uses
464
- * `x-telo-step-context` and tags its branch fields with these roles works.
465
- */
466
- function buildStepContextSchema(
467
- manifest: Record<string, any>,
468
- defSchema: Record<string, any>,
469
- allManifests: Record<string, any>[],
470
- defs: DefinitionRegistry,
471
- aliases: AliasResolver,
472
- scopes: ModuleScopes,
473
- ): Record<string, any> | undefined {
474
- const props = defSchema.properties as Record<string, any> | undefined;
475
- if (!props) return undefined;
476
-
477
- const contractScope = analyzerContractScope(defs, aliases, scopes, allManifests);
478
- const readingModule = (manifest.metadata as { module?: string } | undefined)?.module;
479
-
480
- for (const [fieldName, fieldSchema] of Object.entries(props)) {
481
- const stepCtx = fieldSchema["x-telo-step-context"] as Record<string, string> | undefined;
482
- if (!stepCtx) continue;
483
-
484
- const invokeField = stepCtx.invoke;
485
- const outputTypeField = stepCtx.outputType;
486
- // Optional: the field a step uses to produce a result without dispatching.
487
- // Only a kind that declares one has pure steps at all.
488
- const valueField = stepCtx.value;
489
- if (!invokeField || !outputTypeField) continue;
490
-
491
- const steps = manifest[fieldName];
492
- if (!Array.isArray(steps)) continue;
493
-
494
- const stepItemSchema = resolveLocalRef(
495
- fieldSchema.items as Record<string, any> | undefined,
496
- defSchema,
497
- );
498
-
499
- // The instance's own input contract, for typing a pure step that just
500
- // forwards one of its values.
501
- const ownInputs = resolveTypeFieldToSchema(
502
- (manifest as Record<string, any>).inputType,
503
- allManifests,
504
- );
505
-
506
- const stepProperties: Record<string, any> = {};
507
-
508
- walkStepArray(steps, stepItemSchema, defSchema, fieldName, (s) => {
509
- const name = s.name;
510
- const invoke = s[invokeField] as Record<string, any> | undefined;
511
- // Only invoke steps register a `steps.<name>.result` entry — control-flow
512
- // wrappers (try/if/while/switch/throw) don't produce a result and must
513
- // not shadow real entries with a permissive `additionalProperties: true`,
514
- // or unknown step references slip through chain validation.
515
- if (typeof name !== "string") return;
516
- if (!invoke || typeof invoke !== "object") {
517
- // A pure step dispatches nothing, so there is no contract to resolve.
518
- // Where its expression is a plain chain into something already typed —
519
- // an earlier step's result, or the kind's own inputs — that type carries
520
- // through; anything else (arithmetic, a call, a comprehension) stays
521
- // permissive rather than guessed. Same rule as a named binding's.
522
- if (valueField && valueField in s) {
523
- const scopeRoot = {
524
- properties: {
525
- steps: { type: "object", properties: { ...stepProperties } },
526
- ...(ownInputs ? { inputs: ownInputs } : {}),
527
- },
528
- };
529
- const chained = schemaAtChain(bindingPathChain(s[valueField]), scopeRoot);
530
- stepProperties[name] = {
531
- type: "object",
532
- properties: { result: chained ?? PERMISSIVE_CONTRACT },
533
- };
534
- }
535
- return;
536
- }
537
- const invokedKind = invoke.kind as string | undefined;
538
- const invokedName = invoke.name as string | undefined;
539
- // A named `!ref` carries the target's own manifest (which may narrow the
540
- // contract for this one instance); an inline `{ kind, ... }` step IS the
541
- // manifest. Either way the kind layer resolves through `extends`.
542
- const invokedManifest = invokedName
543
- ? (allManifests.find(
544
- (m) =>
545
- (m.metadata as any)?.name === invokedName && (!invokedKind || m.kind === invokedKind),
546
- ) as Record<string, any> | undefined)
547
- : (invoke as Record<string, any>);
548
- const invokedDef = invokedKind
549
- ? contractScope.resolveIn(invokedKind, readingModule)
550
- : undefined;
551
- const outputSchema = resolveContract(
552
- outputTypeField as ContractDirection,
553
- invokedManifest,
554
- invokedDef,
555
- contractScope,
556
- )?.schema;
557
- stepProperties[name] = {
558
- type: "object",
559
- properties: {
560
- result: outputSchema ?? PERMISSIVE_CONTRACT,
561
- },
562
- };
563
- });
564
-
565
- if (Object.keys(stepProperties).length > 0) {
566
- return {
567
- type: "object",
568
- properties: stepProperties,
569
- };
570
- }
571
- }
572
-
573
- return undefined;
574
- }
575
-
576
242
  /** The built-in namespace: globally resolvable, crossing no import boundary. */
577
243
  const TELO_BUILTIN_MODULE = "Telo";
578
244
 
@@ -647,7 +313,7 @@ const NO_ENTRY_POINT_CAPABILITIES = new Set([
647
313
  ]);
648
314
 
649
315
  /**
650
- * Validate `x-telo-step-context` step `invoke` references (e.g. `Run.Sequence`
316
+ * Validate step `invoke` references (e.g. `Run.Sequence`
651
317
  * steps).
652
318
  *
653
319
  * The reference field map deliberately does NOT descend into step `invoke`
@@ -666,7 +332,7 @@ const NO_ENTRY_POINT_CAPABILITIES = new Set([
666
332
  * contract has no entry point → `REFERENCE_KIND_MISMATCH` (runtime
667
333
  * `ERR_RESOURCE_NOT_INVOKABLE`).
668
334
  *
669
- * Generic and topology-driven — it walks steps via the same `x-telo-step-context`
335
+ * Generic and topology-driven — it walks steps via the same step-slot
670
336
  * / `x-telo-topology-role` annotations `buildStepContextSchema` uses (through the
671
337
  * shared `walkStepArray`), so nested branches (then/else/do/catch/cases) are
672
338
  * covered and no `Run.Sequence` field name is hardcoded. The cross-module
@@ -795,8 +461,7 @@ function validateStepInvokeReferences(
795
461
  for (const [fieldName, fieldSchema] of Object.entries(
796
462
  defSchema.properties as Record<string, any>,
797
463
  )) {
798
- const stepCtx = fieldSchema["x-telo-step-context"] as Record<string, string> | undefined;
799
- const invokeField = stepCtx?.invoke;
464
+ const invokeField = readStepSlot(fieldSchema)?.invoke;
800
465
  if (!invokeField) continue;
801
466
  const steps = m[fieldName];
802
467
  if (!Array.isArray(steps)) continue;
@@ -851,92 +516,6 @@ function pathCrossesNestedResource(root: unknown, path: string): boolean {
851
516
  return false;
852
517
  }
853
518
 
854
- function collectErrorContextScopes(
855
- defSchema: Record<string, any> | undefined,
856
- ): Map<string, Record<string, any>> {
857
- const out = new Map<string, Record<string, any>>();
858
- if (!defSchema || typeof defSchema !== "object") return out;
859
- const seen = new Set<Record<string, any>>();
860
-
861
- const walk = (schema: Record<string, any> | undefined): void => {
862
- if (!schema || typeof schema !== "object" || seen.has(schema)) return;
863
- seen.add(schema);
864
-
865
- const props = schema.properties as Record<string, any> | undefined;
866
- if (props) {
867
- for (const [fieldName, fieldSchema] of Object.entries(props)) {
868
- if (fieldSchema && typeof fieldSchema === "object") {
869
- const errCtx = (fieldSchema as Record<string, any>)["x-telo-error-context"];
870
- if (errCtx && typeof errCtx === "object" && !out.has(fieldName)) {
871
- out.set(fieldName, errCtx as Record<string, any>);
872
- }
873
- }
874
- walk(resolveLocalRef(fieldSchema as Record<string, any>, defSchema));
875
- }
876
- }
877
- if (schema.items) walk(resolveLocalRef(schema.items as Record<string, any>, defSchema));
878
- for (const key of ["oneOf", "anyOf", "allOf"] as const) {
879
- const arr = schema[key];
880
- if (Array.isArray(arr)) for (const sub of arr) walk(resolveLocalRef(sub, defSchema));
881
- }
882
- if (schema.$defs && typeof schema.$defs === "object") {
883
- for (const sub of Object.values(schema.$defs as Record<string, any>)) {
884
- walk(sub as Record<string, any>);
885
- }
886
- }
887
- };
888
-
889
- walk(defSchema);
890
- return out;
891
- }
892
-
893
- /**
894
- * Return the error-context schema for a CEL `path` when the path lies within
895
- * (any depth under) one of the error-bearing fields, else undefined. A path is
896
- * "within" field `f` when it contains a segment `f[<index>]`. When multiple
897
- * error-bearing fields match (e.g. a `finally` nested inside a `catch`), the
898
- * deepest — the one whose segment appears latest in the path — wins, so the
899
- * innermost branch's schema governs.
900
- */
901
- function errorContextForPath(
902
- path: string,
903
- scopes: Map<string, Record<string, any>>,
904
- ): Record<string, any> | undefined {
905
- let best: { index: number; schema: Record<string, any> } | undefined;
906
- for (const [fieldName, schema] of scopes) {
907
- const escaped = fieldName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
908
- for (const match of path.matchAll(new RegExp(`(^|\\.)${escaped}\\[\\d+\\]`, "g"))) {
909
- if (best === undefined || match.index > best.index) {
910
- best = { index: match.index, schema };
911
- }
912
- }
913
- }
914
- return best?.schema;
915
- }
916
-
917
- /** Add a kind's named bindings to a resolved context, when the context declares
918
- * a bindings region. They go UNDER the context's own properties: a scope
919
- * variable wins over a same-named binding at runtime, so static typing has to
920
- * agree (the collision itself is `BINDING_NAME_RESERVED`). */
921
- function withBindingNames(
922
- contextSchema: Record<string, any>,
923
- resource: Record<string, any>,
924
- ): Record<string, any> {
925
- const field = contextSchema[BINDINGS_ANNOTATION];
926
- if (typeof field !== "string") return contextSchema;
927
- const bindings = resource[field];
928
- if (bindings === null || typeof bindings !== "object" || Array.isArray(bindings)) {
929
- return contextSchema;
930
- }
931
- return {
932
- ...contextSchema,
933
- properties: {
934
- ...bindingContextProperties(bindings as Record<string, unknown>, contextSchema),
935
- ...(contextSchema.properties ?? {}),
936
- },
937
- };
938
- }
939
-
940
519
  /** Member-access chains in a CEL expression, or none when it doesn't parse.
941
520
  * Best-effort: a syntax error is reported by the engine pass, not here. */
942
521
  function celAccessChains(env: Environment, expr: string): string[][] {
@@ -1253,14 +832,18 @@ export class StaticAnalyzer {
1253
832
  }
1254
833
  seen.add(alias);
1255
834
  }
1256
- if (alias && source) {
1257
- const targetModule =
1258
- resolvedModuleName ?? source.split("/").filter(Boolean).pop() ?? source;
835
+ // An import whose target identity was never established registers NO
836
+ // alias. The name is never guessed from the source string: a guess that
837
+ // is usually right is what turned "this import did not resolve" into an
838
+ // assertion that a published dependency was malformed, naming a module
839
+ // no registry could ever hold. With no alias, every use degrades to
840
+ // "cannot resolve alias '<X>'" — which points at the import the author
841
+ // has to fix, and which the loader has already reported on its own line.
842
+ if (alias && source && resolvedModuleName) {
843
+ const targetModule = resolvedModuleName;
1259
844
  // Module identity is registered globally so x-telo-ref resolution sees
1260
845
  // transitively-imported modules regardless of which scope brought them in.
1261
- if (resolvedModuleName) {
1262
- defs.registerModuleIdentity(resolvedNamespace ?? null, resolvedModuleName);
1263
- }
846
+ defs.registerModuleIdentity(resolvedNamespace ?? null, resolvedModuleName);
1264
847
  // `metadata.reExportedKinds` (stamped by flattenForAnalyzer / the editor projection)
1265
848
  // maps an exported suffix to the true owning module's canonical kind for kinds this
1266
849
  // import transitively re-exports (`exports.kinds: [Alias.Kind]`).
@@ -1318,6 +901,61 @@ export class StaticAnalyzer {
1318
901
  const refConstraintIssues: RefConstraintIssue[] = [];
1319
902
  const refSlotIssues: RefSlotIssue[] = [];
1320
903
  const zoneSlotIssues: ZoneSlotIssue[] = [];
904
+ // One place a rule report becomes a diagnostic. The pass decided WHAT and
905
+ // WHERE; this only carries it across to the diagnostic shape.
906
+ const SEVERITY = {
907
+ error: DiagnosticSeverity.Error,
908
+ warning: DiagnosticSeverity.Warning,
909
+ information: DiagnosticSeverity.Information,
910
+ } as const;
911
+ const resourceRuleDiagnostic = (report: ResourceRuleDiagnostic): AnalysisDiagnostic => ({
912
+ severity: SEVERITY[report.severity],
913
+ code: report.code,
914
+ source: SOURCE,
915
+ message: report.message,
916
+ data: {
917
+ resource: {
918
+ kind: report.manifest.kind,
919
+ name: report.manifest.metadata?.name as string,
920
+ },
921
+ filePath: (report.manifest.metadata as { source?: string } | undefined)?.source,
922
+ path: report.path,
923
+ rule: report.rule,
924
+ },
925
+ });
926
+ const referrerRuleDiagnostic = (report: ReferrerRuleDiagnostic): AnalysisDiagnostic => ({
927
+ severity: SEVERITY[report.severity],
928
+ code: report.code,
929
+ source: SOURCE,
930
+ message: report.message,
931
+ data: {
932
+ resource: {
933
+ kind: report.manifest.kind,
934
+ name: report.manifest.metadata?.name as string,
935
+ },
936
+ filePath: (report.manifest.metadata as { source?: string } | undefined)?.source,
937
+ path: report.path,
938
+ rule: report.rule,
939
+ },
940
+ });
941
+ const projectionIssues: SchemaProjectionIssue[] = [];
942
+ const resourceRuleIssues: ResourceRuleIssue[] = [];
943
+ const referrerRuleIssues: ReferrerRuleIssue[] = [];
944
+ // A rule that never had anything to iterate is never proven — the second way
945
+ // coverage varies invisibly, beside the dynamic-leaf skip. Tracked across the
946
+ // whole run and reported once, since "empty on every resource" is not a fact
947
+ // any single resource can establish.
948
+ const ruleExercise = new Map<
949
+ string,
950
+ { manifest: ResourceManifest; rule: ResourceRule; exercised: boolean; seen: boolean }
951
+ >();
952
+ // Same for a referrer rule, where "never exercised" means nothing the
953
+ // `referrer:` filter matches ever referenced a resource of the kind — which
954
+ // is exactly what a typo in that filter looks like from the outside.
955
+ const referrerRuleExercise = new Map<
956
+ string,
957
+ { manifest: ResourceManifest; rule: ReferrerRule; exercised: boolean; seen: boolean }
958
+ >();
1321
959
  // `x-telo-type` is checked on EVERY manifest, not only on definition docs: a
1322
960
  // schema fragment is written wherever a kind declares a schema-valued field,
1323
961
  // so an inline `inputType:` on an ordinary resource carries one just as a
@@ -1350,6 +988,35 @@ export class StaticAnalyzer {
1350
988
  refConstraintIssues.push(...issues);
1351
989
  refSlotIssues.push(...validateRefSlotDeclarations(m as unknown as ResourceManifest));
1352
990
  zoneSlotIssues.push(...validateZoneSlotDeclarations(m as unknown as ResourceManifest));
991
+ projectionIssues.push(...validateSchemaProjection(m as unknown as ResourceManifest));
992
+ // Checked against the MERGED schema, so an `in:` pointer naming an
993
+ // inherited field resolves — which is what lets a rule shared by every
994
+ // backend be declared once on the abstract they extend.
995
+ resourceRuleIssues.push(
996
+ ...validateResourceRuleDeclarations(
997
+ m as unknown as ResourceManifest,
998
+ effectiveAuthorSchema(m as any, (k) => defs.resolve(aliases.resolveKind(k) ?? k) ?? defs.resolve(k)),
999
+ ),
1000
+ );
1001
+ referrerRuleIssues.push(
1002
+ ...validateReferrerRuleDeclarations(m as unknown as ResourceManifest),
1003
+ );
1004
+ for (const rule of readReferrerRules((m as Record<string, unknown>).schema)) {
1005
+ referrerRuleExercise.set(`${m.metadata?.module}.${m.metadata?.name}#${rule.code}`, {
1006
+ manifest: m as unknown as ResourceManifest,
1007
+ rule,
1008
+ exercised: false,
1009
+ seen: false,
1010
+ });
1011
+ }
1012
+ for (const rule of readResourceRules((m as Record<string, unknown>).schema)) {
1013
+ ruleExercise.set(`${m.metadata?.module}.${m.metadata?.name}#${rule.code}`, {
1014
+ manifest: m as unknown as ResourceManifest,
1015
+ rule,
1016
+ exercised: false,
1017
+ seen: false,
1018
+ });
1019
+ }
1353
1020
  }
1354
1021
  const resolvedCapability = def.capability
1355
1022
  ? (scopeResolver.resolveKind(def.capability) ?? def.capability)
@@ -1407,6 +1074,25 @@ export class StaticAnalyzer {
1407
1074
  };
1408
1075
  const filePath = (issue.manifest.metadata as { source?: string } | undefined)?.source;
1409
1076
  const data = { resource, filePath, path: issue.path };
1077
+ if (issue.annotation === "referrer") {
1078
+ // Mirrors ZONE_PROVIDER_UNRESOLVED: a filter naming no kind matches no
1079
+ // referrer, so the rule would pass on every manifest while checking
1080
+ // nothing — reported at the kind that wrote it, since the consumer
1081
+ // cannot see that the check is inert.
1082
+ diagnostics.push({
1083
+ severity: DiagnosticSeverity.Error,
1084
+ code: "REFERRER_RULE_INVALID",
1085
+ source: SOURCE,
1086
+ message:
1087
+ `x-telo-referrer-rules 'referrer: ${issue.ref}' at '${issue.path}' names no kind. ` +
1088
+ `The prefix must be an import alias declared in this file's 'imports:' map, ` +
1089
+ `'Self' for a kind in this library, or 'Telo' for a built-in. A filter that ` +
1090
+ `matches nothing leaves the rule inert. Known aliases: ` +
1091
+ `${issue.knownAliases?.join(", ") || "(none)"}.`,
1092
+ data,
1093
+ });
1094
+ continue;
1095
+ }
1410
1096
  if (issue.annotation === "zone") {
1411
1097
  // Mirrors X_TELO_REF_UNRESOLVED: an unresolvable provider kind would
1412
1098
  // leave the requirement silently unenforced — no provider ever
@@ -1493,6 +1179,57 @@ export class StaticAnalyzer {
1493
1179
  // OPPOSITE directions — a dropped requirement is silently unenforced, a
1494
1180
  // dropped provision invents failures — so neither can be left to
1495
1181
  // leniency.
1182
+ // A projection nothing can read does not fail — it stops typing the
1183
+ // consumers counting on it, which puts a misspelled field back where the
1184
+ // projection exists to catch it earlier.
1185
+ for (const issue of resourceRuleIssues) {
1186
+ diagnostics.push({
1187
+ severity: DiagnosticSeverity.Error,
1188
+ code: issue.code,
1189
+ source: SOURCE,
1190
+ message: issue.message,
1191
+ data: {
1192
+ resource: {
1193
+ kind: issue.manifest.kind,
1194
+ name: issue.manifest.metadata?.name as string,
1195
+ },
1196
+ filePath: (issue.manifest.metadata as { source?: string } | undefined)?.source,
1197
+ path: issue.path,
1198
+ },
1199
+ });
1200
+ }
1201
+ for (const issue of referrerRuleIssues) {
1202
+ diagnostics.push({
1203
+ severity: DiagnosticSeverity.Error,
1204
+ code: issue.code,
1205
+ source: SOURCE,
1206
+ message: issue.message,
1207
+ data: {
1208
+ resource: {
1209
+ kind: issue.manifest.kind,
1210
+ name: issue.manifest.metadata?.name as string,
1211
+ },
1212
+ filePath: (issue.manifest.metadata as { source?: string } | undefined)?.source,
1213
+ path: issue.path,
1214
+ },
1215
+ });
1216
+ }
1217
+ for (const issue of projectionIssues) {
1218
+ diagnostics.push({
1219
+ severity: DiagnosticSeverity.Error,
1220
+ code: issue.code,
1221
+ source: SOURCE,
1222
+ message: issue.message,
1223
+ data: {
1224
+ resource: {
1225
+ kind: issue.manifest.kind,
1226
+ name: issue.manifest.metadata?.name as string,
1227
+ },
1228
+ filePath: (issue.manifest.metadata as { source?: string } | undefined)?.source,
1229
+ path: issue.path,
1230
+ },
1231
+ });
1232
+ }
1496
1233
  for (const issue of zoneSlotIssues) {
1497
1234
  diagnostics.push({
1498
1235
  severity: DiagnosticSeverity.Error,
@@ -1532,6 +1269,57 @@ export class StaticAnalyzer {
1532
1269
  aliasesByModule,
1533
1270
  }));
1534
1271
 
1272
+ /**
1273
+ * Whether a referrer of `kind` satisfies a referrer rule's `referrer:`
1274
+ * filter, which is canonical by the time it gets here (`resolveSchemaRefKinds`
1275
+ * rewrote it in the DECLARING module's scope). The referring manifest's own
1276
+ * `kind:` is not — it is whatever alias its author imported the kind under —
1277
+ * so it is resolved the same way every other kind comparison in this pass
1278
+ * resolves one. Liskov-substitutable, matching `checkKind`: a child of the
1279
+ * named kind is one.
1280
+ */
1281
+ const kindMatches = (filter: string, kind: string): boolean => {
1282
+ const resolved = aliases.resolveKind(kind) ?? kind;
1283
+ if (resolved === filter) return true;
1284
+ return defs
1285
+ .getByExtends(filter)
1286
+ .some((d) => `${d.metadata.module}.${d.metadata.name}` === resolved);
1287
+ };
1288
+
1289
+ /**
1290
+ * The resources that reach `manifest`, with the slot each one reaches it
1291
+ * through. A step's edge is attributed to the resource whose body declares
1292
+ * it: a step is not a manifest, and the requirement is about the resource
1293
+ * that has to declare something.
1294
+ *
1295
+ * Deduplication is the evaluation's, not this function's — a referrer
1296
+ * reaching one resource through two slots is two sites, and which one anchors
1297
+ * the diagnostic is a reporting decision.
1298
+ */
1299
+ const referrersOf = (
1300
+ manifest: ResourceManifest,
1301
+ graph: ReturnType<typeof buildCallGraph>,
1302
+ ): Referrer[] => {
1303
+ const name = manifest.metadata?.name as string | undefined;
1304
+ if (!name) return [];
1305
+ const node = graph.resource(manifest.kind, name) ?? graph.resourceByName(name);
1306
+ if (!node) return [];
1307
+ const out: Referrer[] = [];
1308
+ for (const edge of graph.edgesTo(node.id)) {
1309
+ const from = graph.nodes.get(edge.from);
1310
+ if (!from) continue;
1311
+ const owner = from.type === "step" ? graph.nodes.get(from.owner) : from;
1312
+ if (!owner || owner.type !== "resource") continue;
1313
+ out.push({
1314
+ manifest: owner.manifest,
1315
+ kind: owner.kind,
1316
+ name: owner.name,
1317
+ path: edge.path,
1318
+ });
1319
+ }
1320
+ return out;
1321
+ };
1322
+
1535
1323
  // A `use` case map's selector written in CEL is a hard diagnostic — a call
1536
1324
  // graph known only at runtime is not statically analyzable, and no fallback
1537
1325
  // is conservative for every consumer. Scoped to the entry's own modules:
@@ -1567,6 +1355,32 @@ export class StaticAnalyzer {
1567
1355
  cache: zoneExportCache,
1568
1356
  }),
1569
1357
  );
1358
+
1359
+ // Durable regions — the SAME graph again, walked DOWNWARD this time.
1360
+ // Every rule here keys off a zone attribute rather than off any kind, so
1361
+ // a backend that ships its own workflow kind is covered without the
1362
+ // analyzer knowing it exists: going native costs a module, not a change
1363
+ // here.
1364
+ const resolveRegionDef = (kind: string, module?: string) => {
1365
+ const scope = (module ? aliasesByModule.get(module) : undefined) ?? aliases;
1366
+ const canonical = scope.resolveKind(kind);
1367
+ return defs.resolve(kind) ?? (canonical ? defs.resolve(canonical) : undefined);
1368
+ };
1369
+ diagnostics.push(
1370
+ ...validateDurableRegions({
1371
+ graph: getCallGraph(),
1372
+ resolveDef: resolveRegionDef,
1373
+ reportModules: rootModules,
1374
+ }),
1375
+ // The same walk once more, over EVERY attribute rather than the two
1376
+ // durability names — a region must not contain a resource that declares
1377
+ // it cannot honour what the region promises.
1378
+ ...validateZoneViolations({
1379
+ graph: getCallGraph(),
1380
+ resolveDef: resolveRegionDef,
1381
+ reportModules: rootModules,
1382
+ }),
1383
+ );
1570
1384
  }
1571
1385
 
1572
1386
  // Phase 2.6: register each named `Telo.Type` resource's schema under its
@@ -1811,9 +1625,20 @@ export class StaticAnalyzer {
1811
1625
  // fit the slot at all", the schema answers "do their type arguments agree",
1812
1626
  // which cel-js cannot express because it types by constructor identity.
1813
1627
  const celSourceSchemaByPath = new Map<ResourceManifest, Map<string, Record<string, any>>>();
1814
- // Context-free typed environments, one per manifest. Reused across every
1815
- // expression in it see the build site for why a matched context opts out.
1816
- const typedEnvByManifest = new Map<ResourceManifest, Environment>();
1628
+ // What every CEL expression in this set is typed against. The rule lives in
1629
+ // `cel-scope.ts` so the IDE asks the same question the pass does a
1630
+ // completion list is a claim that the name it offers will pass this check,
1631
+ // and two implementations of it could not be held in agreement.
1632
+ const celScope = new CelScopeResolver({
1633
+ celEnv: this.celEnv,
1634
+ defs,
1635
+ aliases,
1636
+ scopes: { aliasesByModule, rootModules },
1637
+ allManifests,
1638
+ kernelGlobals,
1639
+ moduleManifest,
1640
+ observedStateContext,
1641
+ });
1817
1642
 
1818
1643
  // Validate each non-definition, non-system resource
1819
1644
  for (const m of allManifests) {
@@ -1963,6 +1788,69 @@ export class StaticAnalyzer {
1963
1788
  }
1964
1789
  }
1965
1790
 
1791
+ // Resource rules — relationships between this resource's own fields that
1792
+ // JSON Schema cannot state, declared by the kind as CEL over `self` and
1793
+ // `this`. Read off the AUTHOR-FACING schema, so an `extends` child without
1794
+ // `base:` inherits its parent's rules and one that declares its own
1795
+ // replaces them, exactly as the rest of the config contract merges.
1796
+ // The finding→diagnostic mapping lives with the finding vocabulary in
1797
+ // `validate-resource-rules.ts`, the shape every neighbouring pass uses:
1798
+ // issues out, one emit here.
1799
+ const ruleDeclarer = (definition.metadata as { module?: string } | undefined)?.module;
1800
+ for (const report of reportResourceRules(
1801
+ m as unknown as ResourceManifest,
1802
+ definition as unknown as ResourceManifest,
1803
+ evaluateResourceRules(m as unknown as ResourceManifest, authorSchema),
1804
+ !ruleDeclarer || rootModules.has(ruleDeclarer),
1805
+ )) {
1806
+ diagnostics.push(resourceRuleDiagnostic(report));
1807
+ }
1808
+ for (const rule of readResourceRules(authorSchema)) {
1809
+ const key = `${definition.metadata?.module}.${definition.metadata?.name}#${rule.code}`;
1810
+ const tracked = ruleExercise.get(key);
1811
+ if (!tracked) continue;
1812
+ tracked.seen = true;
1813
+ if (ruleExercised(m as unknown as ResourceManifest, rule)) tracked.exercised = true;
1814
+ }
1815
+
1816
+ // Referrer rules — what must be true of whoever REFERENCES this resource,
1817
+ // declared by the kind that has the requirement rather than by the kind
1818
+ // that must satisfy it. The subject is chosen by the EDGE, so no kind
1819
+ // literal appears on the referring side, where the spelling would be the
1820
+ // consumer's import alias rather than anything the rule's author controls.
1821
+ // A consumer of the shared call graph, never a second traversal.
1822
+ const referrerRules = readReferrerRules(authorSchema);
1823
+ if (referrerRules.length > 0) {
1824
+ const referrers = referrersOf(m as unknown as ResourceManifest, getCallGraph());
1825
+ for (const report of reportReferrerRules(
1826
+ m as unknown as ResourceManifest,
1827
+ definition as unknown as ResourceManifest,
1828
+ evaluateReferrerRules(
1829
+ m as unknown as ResourceManifest,
1830
+ authorSchema,
1831
+ referrers,
1832
+ kindMatches,
1833
+ ),
1834
+ !ruleDeclarer || rootModules.has(ruleDeclarer),
1835
+ )) {
1836
+ // A VIOLATION is the referrer's data, so it is reported only when that
1837
+ // manifest is the entry's own — the same direction a resource-rule
1838
+ // violation takes, one hop further out.
1839
+ const owner = (report.manifest.metadata as { module?: string } | undefined)?.module;
1840
+ if (report.code === "REFERRER_RULE_VIOLATED" && owner && !rootModules.has(owner)) {
1841
+ continue;
1842
+ }
1843
+ diagnostics.push(referrerRuleDiagnostic(report));
1844
+ }
1845
+ for (const rule of referrerRules) {
1846
+ const key = `${definition.metadata?.module}.${definition.metadata?.name}#${rule.code}`;
1847
+ const tracked = referrerRuleExercise.get(key);
1848
+ if (!tracked) continue;
1849
+ tracked.seen = true;
1850
+ if (referrerRuleExercised(rule, referrers, kindMatches)) tracked.exercised = true;
1851
+ }
1852
+ }
1853
+
1966
1854
  // Validate inline resources nested inside this resource's body (e.g. a
1967
1855
  // Run.Sequence step's `invoke: { kind, ...config }`). These sit at
1968
1856
  // x-telo-ref slots reached only through local `$ref`s, which the
@@ -1998,6 +1886,24 @@ export class StaticAnalyzer {
1998
1886
  // (Invocation context compatibility check is handled via x-telo-context in the CEL pass below)
1999
1887
  }
2000
1888
 
1889
+ // A rule whose collection was empty on every resource of its kind is a rule
1890
+ // nothing proved. `check()` types `self` only shallowly (cel-js takes a flat
1891
+ // field map, so `columns` is `map`), which means a typo below the first level
1892
+ // survives declaration validation and is caught only by evaluation — so a
1893
+ // rule that never evaluated has been verified by nothing at all. Reported
1894
+ // only when the kind HAS resources here: a kind nobody instantiated in this
1895
+ // workspace says nothing about the rule.
1896
+ for (const tracked of ruleExercise.values()) {
1897
+ if (!tracked.seen || tracked.exercised) continue;
1898
+ diagnostics.push(resourceRuleDiagnostic(reportUnexercisedRule(tracked.manifest, tracked.rule)));
1899
+ }
1900
+ for (const tracked of referrerRuleExercise.values()) {
1901
+ if (!tracked.seen || tracked.exercised) continue;
1902
+ diagnostics.push(
1903
+ referrerRuleDiagnostic(reportUnexercisedReferrerRule(tracked.manifest, tracked.rule)),
1904
+ );
1905
+ }
1906
+
2001
1907
  // Template-body structural validations: check that template entry-points produce
2002
1908
  // values matching the contract of their dispatch target and (for `provide:`)
2003
1909
  // the abstract this definition `extends`. CEL fields inside the templated
@@ -2092,6 +1998,40 @@ export class StaticAnalyzer {
2092
1998
  }
2093
1999
  }
2094
2000
 
2001
+ // A consumer's slot typed from a referenced declaration
2002
+ // (`x-telo-schema-projection-from`) fails in the direction that is hardest
2003
+ // to notice: the contract silently reopens, so a misspelled field passes
2004
+ // `telo check` exactly as it did before the projection existed. That is the
2005
+ // failure the projection exists to move earlier, so it is reported here
2006
+ // rather than left to degrade. Entry-module-scoped, like
2007
+ // `X_TELO_REF_UNRESOLVED` — a published dependency's slot is not the
2008
+ // consumer's to fix.
2009
+ for (const m of allManifests) {
2010
+ const md = m as Record<string, any>;
2011
+ if (typeof md.kind !== "string" || md.kind.startsWith("Telo.")) continue;
2012
+ const ownModule = (md.metadata as { module?: string } | undefined)?.module;
2013
+ if (ownModule && !rootModules.has(ownModule)) continue;
2014
+ const definition = contractScope.resolveIn(md.kind, ownModule);
2015
+ if (!definition) continue;
2016
+ const failures: ProjectionFailure[] = [];
2017
+ for (const direction of ["inputType", "outputType"] as const) {
2018
+ resolveContract(direction, md, definition, contractScope, failures);
2019
+ }
2020
+ for (const failure of failures) {
2021
+ diagnostics.push({
2022
+ severity: DiagnosticSeverity.Error,
2023
+ code: "SCHEMA_PROJECTION_FROM_UNRESOLVED",
2024
+ source: SOURCE,
2025
+ message: `${md.kind}: ${describeProjectionFailure(failure)}`,
2026
+ data: {
2027
+ resource: { kind: md.kind, name: (md.metadata as any)?.name as string },
2028
+ filePath: (md.metadata as { source?: string } | undefined)?.source,
2029
+ path: failure.pointer.replace(/^\//, ""),
2030
+ },
2031
+ });
2032
+ }
2033
+ }
2034
+
2095
2035
  // Validate CEL syntax and context variable access in all manifests. The
2096
2036
  // walker discovers every compiled CEL node by scanning the value tree and
2097
2037
  // hands back the `x-telo-context` schema matched at the enclosing path; the
@@ -2100,11 +2040,11 @@ export class StaticAnalyzer {
2100
2040
  // state (definitions, aliases, the typed CEL env).
2101
2041
  // Per-resource state computed at enter and read by that resource's CEL
2102
2042
  // sites. The manifest / resource / filePath come straight off each CelSite's
2103
- // `source` (no need to capture them); only the derived step / invocation
2104
- // context which require analyzer state to build are stashed here.
2043
+ // `source` (no need to capture them); the derived step / invocation / error
2044
+ // context is the scope resolver's, read back from it where a CHECK needs the
2045
+ // same schema an expression is typed against.
2105
2046
  let celStepContextSchema: Record<string, any> | undefined;
2106
- let celInvocationContext: Record<string, any> | undefined;
2107
- let celErrorScopes: Map<string, Record<string, any>> = new Map();
2047
+ let celErrorScopes: ReadonlyMap<string, Record<string, any>> = new Map();
2108
2048
  // Region coverage for the "CEL in a non-eval field" check: the union of
2109
2049
  // `x-telo-eval` paths (own + capability) and `x-telo-context` /
2110
2050
  // `x-telo-step-context` / `x-telo-error-context` scopes. A `!cel` outside
@@ -2125,23 +2065,29 @@ export class StaticAnalyzer {
2125
2065
  {
2126
2066
  onResourceEnter: (e) => {
2127
2067
  const m = e.source;
2128
- celInvocationContext = (m.metadata as any)?.xTeloInvocationContext as
2129
- | Record<string, any>
2130
- | undefined;
2131
- celStepContextSchema = e.definition?.schema
2132
- ? buildStepContextSchema(
2068
+ celScope.enterResource(m, e.definition);
2069
+ // Read back rather than recomputed: the step-inputs check has to see
2070
+ // the same `steps` schema this resource's expressions are typed
2071
+ // against, and a second computation is how the two come to disagree.
2072
+ celStepContextSchema = celScope.stepContextSchema;
2073
+ celErrorScopes = celScope.errorContextScopes;
2074
+ if (e.definition?.schema) {
2075
+ const stepName = (m.metadata as any)?.name as string | undefined;
2076
+ const stepFile = (m.metadata as { source?: string } | undefined)?.source;
2077
+ // Both drivers of the SAME check: a step's `inputs:` found through
2078
+ // the step grammar, and a reference slot's found through the
2079
+ // `x-telo-ref` `inputs:` pointer. A call site the editor can
2080
+ // complete is a call site `telo check` validates.
2081
+ const inputIssues = [
2082
+ ...collectRefInputIssues(
2133
2083
  m as Record<string, any>,
2134
- e.definition.schema as Record<string, any>,
2084
+ defs.expandedFieldMapForResource(m, aliases, aliasesByModule),
2135
2085
  allManifests as Record<string, any>[],
2136
2086
  defs,
2137
2087
  aliases,
2138
2088
  { aliasesByModule, rootModules },
2139
- )
2140
- : undefined;
2141
- if (e.definition?.schema) {
2142
- const stepName = (m.metadata as any)?.name as string | undefined;
2143
- const stepFile = (m.metadata as { source?: string } | undefined)?.source;
2144
- for (const issue of collectStepInputIssues(
2089
+ ),
2090
+ ...collectStepInputIssues(
2145
2091
  m as Record<string, any>,
2146
2092
  e.definition.schema as Record<string, any>,
2147
2093
  allManifests as Record<string, any>[],
@@ -2149,7 +2095,9 @@ export class StaticAnalyzer {
2149
2095
  aliases,
2150
2096
  { aliasesByModule, rootModules },
2151
2097
  celStepContextSchema,
2152
- )) {
2098
+ ),
2099
+ ];
2100
+ for (const issue of inputIssues) {
2153
2101
  diagnostics.push({
2154
2102
  severity: DiagnosticSeverity.Error,
2155
2103
  code: issue.code ?? "CONTRACT_INPUTS_MISMATCH",
@@ -2168,10 +2116,6 @@ export class StaticAnalyzer {
2168
2116
  });
2169
2117
  }
2170
2118
  }
2171
- celErrorScopes = collectErrorContextScopes(
2172
- e.definition?.schema as Record<string, any> | undefined,
2173
- );
2174
-
2175
2119
  celBindingSites = findBindingSites(e.definition?.schema as Record<string, any>);
2176
2120
  if (celBindingSites) {
2177
2121
  const declared = (m as Record<string, any>)[celBindingSites.field];
@@ -2299,7 +2243,7 @@ export class StaticAnalyzer {
2299
2243
  if (
2300
2244
  celRuleApplies &&
2301
2245
  engineName === "cel" &&
2302
- celInvocationContext === undefined &&
2246
+ celScope.invocationContextSchema === undefined &&
2303
2247
  !evalPathsCover(celEvalPaths, path) &&
2304
2248
  !celRegionScopes.some((scope) => pathMatchesScope(path, scope)) &&
2305
2249
  !pathCrossesNestedResource(m, path)
@@ -2352,70 +2296,6 @@ export class StaticAnalyzer {
2352
2296
  }
2353
2297
  }
2354
2298
 
2355
- let matchedContext: Record<string, any> | undefined =
2356
- e.contextSchema ?? celInvocationContext;
2357
-
2358
- if (celStepContextSchema) {
2359
- const base =
2360
- matchedContext ?? { type: "object", properties: {}, additionalProperties: true };
2361
- matchedContext = {
2362
- ...base,
2363
- properties: {
2364
- ...(base.properties ?? {}),
2365
- steps: celStepContextSchema,
2366
- },
2367
- };
2368
- }
2369
-
2370
- // `error` is only in scope inside an error-bearing branch (e.g. a
2371
- // `catch:` / `finally:`), so it's merged per-path, not resource-wide.
2372
- const errorSchema =
2373
- celErrorScopes.size > 0 ? errorContextForPath(path, celErrorScopes) : undefined;
2374
- if (errorSchema) {
2375
- const base =
2376
- matchedContext ?? { type: "object", properties: {}, additionalProperties: true };
2377
- matchedContext = {
2378
- ...base,
2379
- properties: {
2380
- ...(base.properties ?? {}),
2381
- error: errorSchema,
2382
- },
2383
- };
2384
- }
2385
-
2386
- let effectiveContext: Record<string, any> | null = null;
2387
- if (matchedContext) {
2388
- const manifestItem = matchedScope
2389
- ? getManifestItem(path, matchedScope, m as Record<string, any>)
2390
- : (m as Record<string, any>);
2391
- const rootForResolver = manifestRootForResolver(
2392
- m as Record<string, any>,
2393
- defs,
2394
- aliases,
2395
- allManifests as Record<string, any>[],
2396
- { aliasesByModule, rootModules },
2397
- );
2398
- const resolvedContext = resolveContextAnnotations(matchedContext, manifestItem, {
2399
- manifestRoot: rootForResolver,
2400
- defs,
2401
- aliases,
2402
- allManifests: allManifests as Record<string, any>[],
2403
- });
2404
- effectiveContext = mergeKernelGlobalsIntoContext(
2405
- withBindingNames(resolvedContext, m as Record<string, any>),
2406
- // Typed in the module that DECLARED this resource — for a manifest
2407
- // forwarded from an imported library, that is its `moduleGlobals`
2408
- // stamp, not the consuming application's block.
2409
- kernelGlobals.forResource(m),
2410
- );
2411
- } else if (observedStateContext) {
2412
- // No `x-telo-context` matched, so nothing was chain-validated here
2413
- // before. Validate the observed-state segment alone rather than
2414
- // merging the kernel globals, whose closed `variables` / `ports`
2415
- // nodes would newly reject reads that pass today.
2416
- effectiveContext = observedStateContext;
2417
- }
2418
-
2419
2299
  const engine = defaultRegistry().get(engineName);
2420
2300
  if (!engine) {
2421
2301
  // No registered engine owns this tag — the expression would go
@@ -2430,38 +2310,15 @@ export class StaticAnalyzer {
2430
2310
  return;
2431
2311
  }
2432
2312
  // The engine type-checks, so it gets the environment typed for THIS
2433
- // path — not the bare base one. A `Telo.Import`'s variables/secrets
2434
- // are a config-only contract evaluated in the IMPORTING module's
2435
- // scope, so they type from the owning module doc and drop
2436
- // `resources`/`env`, making a reference to either an error.
2437
- //
2438
- // Cached per manifest when no `x-telo-context` applied, which is most
2439
- // expressions: the environment then depends only on the manifest, so
2440
- // rebuilding it per expression is pure waste — a clone plus a
2441
- // re-registration of every variable, on every keystroke in the IDE.
2442
- // A matched context makes the environment path-specific (its schema is
2443
- // resolved against the enclosing array item), so those still build
2444
- // fresh rather than risk one item's types leaking into another's.
2445
- const cached = effectiveContext === null ? typedEnvByManifest.get(m) : undefined;
2446
- const typedEnv =
2447
- cached ??
2448
- (m.kind === "Telo.Import"
2449
- ? buildImportInputCelEnvironment(
2450
- this.celEnv,
2451
- allManifests.find(
2452
- (mm) =>
2453
- (mm.kind === "Telo.Application" || mm.kind === "Telo.Library") &&
2454
- (mm.metadata as { name?: string } | undefined)?.name ===
2455
- (m.metadata as { module?: string } | undefined)?.module,
2456
- ),
2457
- )
2458
- : buildTypedCelEnvironment(
2459
- this.celEnv,
2460
- m,
2461
- effectiveContext ?? undefined,
2462
- moduleManifest,
2463
- ));
2464
- if (effectiveContext === null && !cached) typedEnvByManifest.set(m, typedEnv);
2313
+ // path — not the bare base one. Both halves come from the one scope
2314
+ // rule, which is what makes the IDE's answer and this check the same
2315
+ // answer rather than two that agree today.
2316
+ const { env: typedEnv, contextSchema: effectiveContext } = celScope.scopeFor({
2317
+ source: m,
2318
+ path,
2319
+ contextSchema: e.contextSchema,
2320
+ matchedScope,
2321
+ });
2465
2322
 
2466
2323
  const result = engine.analyze(expr, { celEnv: typedEnv, contextSchema: effectiveContext });
2467
2324