@telorun/analyzer 0.62.0 → 0.63.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 (146) hide show
  1. package/dist/analysis-registry.d.ts.map +1 -1
  2. package/dist/analysis-registry.js +6 -0
  3. package/dist/analyzer.d.ts +1 -1
  4. package/dist/analyzer.d.ts.map +1 -1
  5. package/dist/analyzer.js +343 -13
  6. package/dist/call-graph.d.ts.map +1 -1
  7. package/dist/call-graph.js +2 -7
  8. package/dist/flatten-for-analyzer.js +1 -1
  9. package/dist/import-resolution-diagnostics.d.ts +9 -2
  10. package/dist/import-resolution-diagnostics.d.ts.map +1 -1
  11. package/dist/import-resolution-diagnostics.js +26 -7
  12. package/dist/index.d.ts +15 -1
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +13 -1
  15. package/dist/invocation-contract.d.ts +6 -1
  16. package/dist/invocation-contract.d.ts.map +1 -1
  17. package/dist/invocation-contract.js +27 -4
  18. package/dist/loaded-types.d.ts +12 -3
  19. package/dist/loaded-types.d.ts.map +1 -1
  20. package/dist/manifest-loader.d.ts +0 -1
  21. package/dist/manifest-loader.d.ts.map +1 -1
  22. package/dist/manifest-loader.js +62 -41
  23. package/dist/manifest-schemas.d.ts +699 -0
  24. package/dist/manifest-schemas.d.ts.map +1 -1
  25. package/dist/manifest-schemas.js +306 -14
  26. package/dist/module-compatibility.d.ts +40 -0
  27. package/dist/module-compatibility.d.ts.map +1 -0
  28. package/dist/module-compatibility.js +52 -0
  29. package/dist/ref-slot-reading.d.ts +30 -0
  30. package/dist/ref-slot-reading.d.ts.map +1 -0
  31. package/dist/ref-slot-reading.js +139 -0
  32. package/dist/referrer-rule.d.ts +67 -0
  33. package/dist/referrer-rule.d.ts.map +1 -0
  34. package/dist/referrer-rule.js +96 -0
  35. package/dist/release/index.d.ts +1 -1
  36. package/dist/release/index.d.ts.map +1 -1
  37. package/dist/release/index.js +1 -1
  38. package/dist/release/payload-digest.d.ts +7 -3
  39. package/dist/release/payload-digest.d.ts.map +1 -1
  40. package/dist/release/payload-digest.js +7 -3
  41. package/dist/release/version-stamp.d.ts +16 -0
  42. package/dist/release/version-stamp.d.ts.map +1 -1
  43. package/dist/release/version-stamp.js +52 -0
  44. package/dist/release/workspace-config.d.ts +10 -6
  45. package/dist/release/workspace-config.d.ts.map +1 -1
  46. package/dist/release/workspace-config.js +10 -6
  47. package/dist/resolve-schema-ref-kinds.d.ts +4 -3
  48. package/dist/resolve-schema-ref-kinds.d.ts.map +1 -1
  49. package/dist/resolve-schema-ref-kinds.js +16 -0
  50. package/dist/resolve-throws-union.d.ts.map +1 -1
  51. package/dist/resolve-throws-union.js +4 -3
  52. package/dist/resolve-zone-containment.d.ts +99 -0
  53. package/dist/resolve-zone-containment.d.ts.map +1 -0
  54. package/dist/resolve-zone-containment.js +212 -0
  55. package/dist/resolve-zone-requirements.d.ts +5 -0
  56. package/dist/resolve-zone-requirements.d.ts.map +1 -1
  57. package/dist/resolve-zone-requirements.js +48 -7
  58. package/dist/resource-rule.d.ts +106 -0
  59. package/dist/resource-rule.d.ts.map +1 -0
  60. package/dist/resource-rule.js +236 -0
  61. package/dist/rule-condition.d.ts +50 -0
  62. package/dist/rule-condition.d.ts.map +1 -0
  63. package/dist/rule-condition.js +108 -0
  64. package/dist/schema-keywords.d.ts.map +1 -1
  65. package/dist/schema-keywords.js +20 -0
  66. package/dist/schema-projection.d.ts +171 -0
  67. package/dist/schema-projection.d.ts.map +1 -0
  68. package/dist/schema-projection.js +317 -0
  69. package/dist/step-slot.d.ts +39 -0
  70. package/dist/step-slot.d.ts.map +1 -0
  71. package/dist/step-slot.js +49 -0
  72. package/dist/telo-version.d.ts +1 -1
  73. package/dist/telo-version.js +1 -1
  74. package/dist/validate-cel-context.d.ts.map +1 -1
  75. package/dist/validate-cel-context.js +17 -3
  76. package/dist/validate-durable-regions.d.ts +15 -0
  77. package/dist/validate-durable-regions.d.ts.map +1 -0
  78. package/dist/validate-durable-regions.js +336 -0
  79. package/dist/validate-references.d.ts.map +1 -1
  80. package/dist/validate-references.js +44 -2
  81. package/dist/validate-referrer-rules.d.ts +120 -0
  82. package/dist/validate-referrer-rules.d.ts.map +1 -0
  83. package/dist/validate-referrer-rules.js +255 -0
  84. package/dist/validate-resource-rules.d.ts +114 -0
  85. package/dist/validate-resource-rules.d.ts.map +1 -0
  86. package/dist/validate-resource-rules.js +283 -0
  87. package/dist/validate-schema-projection.d.ts +32 -0
  88. package/dist/validate-schema-projection.d.ts.map +1 -0
  89. package/dist/validate-schema-projection.js +110 -0
  90. package/dist/validate-step-inputs.d.ts +2 -1
  91. package/dist/validate-step-inputs.d.ts.map +1 -1
  92. package/dist/validate-step-inputs.js +5 -3
  93. package/dist/validate-throws-coverage.d.ts.map +1 -1
  94. package/dist/validate-throws-coverage.js +11 -5
  95. package/dist/validate-zone-slots.d.ts +2 -2
  96. package/dist/validate-zone-slots.d.ts.map +1 -1
  97. package/dist/validate-zone-slots.js +202 -6
  98. package/dist/validate-zone-violations.d.ts +12 -0
  99. package/dist/validate-zone-violations.d.ts.map +1 -0
  100. package/dist/validate-zone-violations.js +169 -0
  101. package/dist/value-type-keyword.d.ts +1 -1
  102. package/dist/value-type-keyword.d.ts.map +1 -1
  103. package/dist/value-type-keyword.js +4 -0
  104. package/dist/zone-slot.d.ts +61 -0
  105. package/dist/zone-slot.d.ts.map +1 -1
  106. package/dist/zone-slot.js +86 -5
  107. package/package.json +2 -2
  108. package/src/analysis-registry.ts +6 -0
  109. package/src/analyzer.ts +395 -14
  110. package/src/call-graph.ts +2 -8
  111. package/src/flatten-for-analyzer.ts +1 -1
  112. package/src/import-resolution-diagnostics.ts +25 -7
  113. package/src/index.ts +73 -0
  114. package/src/invocation-contract.ts +45 -2
  115. package/src/loaded-types.ts +12 -3
  116. package/src/manifest-loader.ts +87 -55
  117. package/src/manifest-schemas.ts +328 -14
  118. package/src/module-compatibility.ts +72 -0
  119. package/src/ref-slot-reading.ts +156 -0
  120. package/src/referrer-rule.ts +116 -0
  121. package/src/release/index.ts +1 -0
  122. package/src/release/payload-digest.ts +7 -3
  123. package/src/release/version-stamp.ts +58 -0
  124. package/src/release/workspace-config.ts +10 -6
  125. package/src/resolve-schema-ref-kinds.ts +28 -4
  126. package/src/resolve-throws-union.ts +4 -3
  127. package/src/resolve-zone-containment.ts +329 -0
  128. package/src/resolve-zone-requirements.ts +67 -7
  129. package/src/resource-rule.ts +252 -0
  130. package/src/rule-condition.ts +126 -0
  131. package/src/schema-keywords.ts +24 -0
  132. package/src/schema-projection.ts +403 -0
  133. package/src/step-slot.ts +64 -0
  134. package/src/telo-version.ts +1 -1
  135. package/src/validate-cel-context.ts +17 -3
  136. package/src/validate-durable-regions.ts +401 -0
  137. package/src/validate-references.ts +57 -2
  138. package/src/validate-referrer-rules.ts +373 -0
  139. package/src/validate-resource-rules.ts +401 -0
  140. package/src/validate-schema-projection.ts +184 -0
  141. package/src/validate-step-inputs.ts +5 -3
  142. package/src/validate-throws-coverage.ts +12 -5
  143. package/src/validate-zone-slots.ts +192 -8
  144. package/src/validate-zone-violations.ts +190 -0
  145. package/src/value-type-keyword.ts +4 -0
  146. package/src/zone-slot.ts +114 -5
@@ -0,0 +1,401 @@
1
+ /**
2
+ * Static checks over durable regions — everything that keys off the `replayed`
3
+ * and `idempotent` zone attributes.
4
+ *
5
+ * All of them are consumers of the ONE containment walk
6
+ * (`resolve-zone-containment.ts`), parameterized over the attribute that opens
7
+ * the region. The zones differ in what they forbid, not in how their contents
8
+ * are found, and no check here names a kind: `modules/durable` is not part of
9
+ * the analyzer's surface, which is what the topology-driven constraint requires
10
+ * and what makes going native cost a module rather than an analyzer change.
11
+ *
12
+ * **Enforced at runtime, warned early.** Every rule below may under-approximate
13
+ * — an edge the call graph cannot see is invisible here — and the runtime is the
14
+ * real enforcement. The static half moves a failure to `telo check` for the
15
+ * paths it can see, which is worth having and is not a guarantee. Where a rule
16
+ * cannot be sound it is a warning rather than an error.
17
+ *
18
+ * Browser-safe: no Node built-ins.
19
+ */
20
+ import type { ResourceDefinition, ResourceManifest } from "@telorun/sdk";
21
+ import { isCompiledValue, VALUE_TYPES } from "@telorun/sdk";
22
+ import { auditCalls, buildCelEnvironment, isTaggedSentinel, CEL_ENGINE } from "@telorun/templating";
23
+ import type { CallGraph, CallGraphNode, StepGraphNode } from "./call-graph.js";
24
+ import { DiagnosticSeverity, type AnalysisDiagnostic } from "./types.js";
25
+ import {
26
+ findZoneRegions,
27
+ type DefinitionLookup,
28
+ type ZoneRegion,
29
+ } from "./resolve-zone-containment.js";
30
+
31
+ const SOURCE = "telo";
32
+
33
+ /**
34
+ * The impure functions one CEL source actually CALLS, in source order.
35
+ *
36
+ * Parsed, never text-matched. A regex over the source is wrong in both
37
+ * directions and in exactly the ways this repo has already retired elsewhere:
38
+ * it fires on a function name inside a string literal, on an unrelated receiver
39
+ * method that happens to share a name, and on text inside an interpolation it
40
+ * has no business reading — while `analyze` already returns every call site the
41
+ * parser found, each carrying the `deterministic` flag resolved from the
42
+ * registry. One expression, one verdict, and it is the engine's.
43
+ *
44
+ * `deterministic === false` specifically: `undefined` means the name resolved to
45
+ * nothing, or to a function carrying no determinism metadata, and absent is not
46
+ * "impure" any more than it is "pure" — a name the registry cannot account for
47
+ * is `CEL_UNKNOWN_FUNCTION`'s to report, not this rule's to guess at.
48
+ */
49
+ function impureCalls(source: string): string[] {
50
+ let ast;
51
+ try {
52
+ ast = CEL_ENV.parse(source).ast;
53
+ } catch {
54
+ // Unparseable CEL is `CEL_SYNTAX_ERROR`'s to report, and it will be, from
55
+ // the pass that owns the expression. Reporting nothing here is right:
56
+ // guessing at the calls in text that does not parse is exactly the
57
+ // text-matching this replaced.
58
+ return [];
59
+ }
60
+ const names: string[] = [];
61
+ for (const call of auditCalls(source, ast, CEL_ENV).calls) {
62
+ if (call.deterministic === false && !names.includes(call.name)) names.push(call.name);
63
+ }
64
+ return names;
65
+ }
66
+
67
+ /** One environment for the whole pass: it carries the function registry, which
68
+ * is what resolves a call's determinism, and nothing manifest-specific — the
69
+ * typed scope belongs to the CEL validation pass, which asks a different
70
+ * question of the same expressions. */
71
+ const CEL_ENV = buildCelEnvironment();
72
+
73
+ /** Every CEL source string reachable in a value tree, with the path that holds
74
+ * it. Stops at nested `{ kind }` declarations, which belong to another
75
+ * resource. */
76
+ function celSources(value: unknown, path: string, out: Array<[string, string]>): void {
77
+ // A raw `${{ }}` string, which is what a consumer holding an unprecompiled
78
+ // manifest sees. Each interpolation is pushed SEPARATELY: the surrounding
79
+ // literal text is not CEL, and handing the whole string to a CEL parser
80
+ // fails — which would silently report no calls at all for every expression
81
+ // written this way.
82
+ if (typeof value === "string") {
83
+ for (const expression of interpolatedExpressions(value)) out.push([path, expression]);
84
+ return;
85
+ }
86
+ if (!value || typeof value !== "object") return;
87
+ // By the time this pass runs the loader has PRECOMPILED every CEL slot, so the
88
+ // common shape is a CompiledValue rather than a string or a tag sentinel.
89
+ // Reading only the authored spellings is why this check was silent on every
90
+ // manifest that reached it — the one shape it never met was the one it always
91
+ // gets. An interpolated string keeps its expressions in `parts`, so those are
92
+ // descended into rather than read off the joined source.
93
+ if (isCompiledValue(value)) {
94
+ if (typeof value.source === "string") out.push([path, value.source]);
95
+ for (const [i, part] of (value.parts ?? []).entries()) {
96
+ if (typeof part !== "string") celSources(part, `${path}[${i}]`, out);
97
+ }
98
+ return;
99
+ }
100
+ // An unprecompiled `!cel` tag — what a round-trip consumer (the editor) holds,
101
+ // and what reaches this pass whenever precompilation was not requested. Read
102
+ // through the templating predicates rather than by testing a marker key: the
103
+ // sentinel's shape is that package's, and spelling it here is a second place
104
+ // it would have to be kept right.
105
+ if (isTaggedSentinel(value) && value.engine === CEL_ENGINE) {
106
+ out.push([path, value.source]);
107
+ return;
108
+ }
109
+ if (Array.isArray(value)) {
110
+ value.forEach((item, i) => celSources(item, `${path}[${i}]`, out));
111
+ return;
112
+ }
113
+ // STOP at a nested resource declaration. Skipping the `kind` key alone was not
114
+ // that: it walked straight into the declaration's own configuration and
115
+ // reported its CEL against the enclosing resource, which is both the wrong
116
+ // anchor and a claim about a region that expression is not in. The boundary is
117
+ // the presence of `kind`, the same line Phase-5 injection and the include walk
118
+ // already draw.
119
+ const record = value as Record<string, unknown>;
120
+ if (path && typeof record.kind === "string") return;
121
+ for (const [key, child] of Object.entries(record)) {
122
+ if (key === "kind" || key === "metadata") continue;
123
+ celSources(child, path ? `${path}.${key}` : key, out);
124
+ }
125
+ }
126
+
127
+ /** The expressions inside a `${{ … }}` interpolated string, unwrapped. Balanced
128
+ * on `}}` rather than on the first `}`, so an expression containing a map
129
+ * literal is not cut in half. */
130
+ function interpolatedExpressions(text: string): string[] {
131
+ const out: string[] = [];
132
+ let at = 0;
133
+ for (;;) {
134
+ const open = text.indexOf("${{", at);
135
+ if (open < 0) return out;
136
+ const close = text.indexOf("}}", open + 3);
137
+ if (close < 0) return out;
138
+ const expression = text.slice(open + 3, close).trim();
139
+ if (expression) out.push(expression);
140
+ at = close + 2;
141
+ }
142
+ }
143
+
144
+ /**
145
+ * `DURABLE_NONDETERMINISM` — impure CEL inside an `idempotent` region.
146
+ *
147
+ * **It keys on `idempotent`, and only there does it say something true.** Impure
148
+ * CEL in a *journaled* position is not a defect at all — it is the correct
149
+ * semantic: `now()` in a step's inputs is recorded on first execution and
150
+ * replayed identically, which is exactly what a durable timestamp should do.
151
+ *
152
+ * What makes the idempotent case different is that such a region re-runs on
153
+ * resume with its prior effects INTACT, because nothing discarded them — the
154
+ * region's whole claim is that re-running is a no-op. Impure CEL falsifies
155
+ * exactly that claim: `uuid()` as a key writes record A on the first pass and
156
+ * record B on the second, so the re-run is not a no-op and the assertion the
157
+ * author signed is false. The diagnostic can therefore say precisely that.
158
+ *
159
+ * **`atomic` is the wrong trigger, in both directions.** Too wide: collapse
160
+ * there is conditional on a runtime attestation, so a transaction sharing the
161
+ * journal's own transaction is NOT collapsed and its decisions genuinely are
162
+ * journaled — a static trigger would fire on the configuration this design
163
+ * recommends. Too narrow: an atomic zone that does re-run is a RETRY, not a
164
+ * replay — its effects were discarded, so a fresh timestamp is simply a fresh
165
+ * attempt.
166
+ */
167
+ function checkNondeterminism(
168
+ region: ZoneRegion,
169
+ graph: CallGraph,
170
+ reportModules: ReadonlySet<string>,
171
+ diagnostics: AnalysisDiagnostic[],
172
+ ): void {
173
+ for (const { node } of region.contents.values()) {
174
+ const [manifest, base, prefix] = celScopeOf(node, graph);
175
+ if (!manifest || !owned(manifest, reportModules)) continue;
176
+ const found: Array<[string, string]> = [];
177
+ celSources(base, prefix, found);
178
+ for (const [path, source] of found) {
179
+ for (const fn of impureCalls(source)) {
180
+ diagnostics.push({
181
+ severity: DiagnosticSeverity.Error,
182
+ code: "DURABLE_NONDETERMINISM",
183
+ source: SOURCE,
184
+ message:
185
+ `'${fn}()' is evaluated inside a region declared idempotent by ` +
186
+ `${region.provider.kind} '${region.provider.name}' — "${region.reason}". A ` +
187
+ `region with that claim re-runs on a resume with its earlier effects intact, ` +
188
+ `so an expression that produces a different value each time makes the ` +
189
+ `re-run something other than a no-op and the claim false. Pin the value ` +
190
+ `once for the region instead of computing it per pass.`,
191
+ data: {
192
+ resource: { kind: manifest.kind, name: manifest.metadata?.name as string },
193
+ filePath: (manifest.metadata as { source?: string } | undefined)?.source,
194
+ path,
195
+ },
196
+ });
197
+ // One diagnostic per expression: naming every impure call in a single
198
+ // expression would repeat one fix several times.
199
+ break;
200
+ }
201
+ }
202
+ }
203
+ }
204
+
205
+ /**
206
+ * `DURABLE_DETACH_FORBIDDEN` — a detached dispatch inside a `replayed` region.
207
+ *
208
+ * Journal-on-completion would record the DISPATCH as done while the work runs
209
+ * on, so a resume skips it and a crash loses it — durability's exact inverse.
210
+ *
211
+ * The replacement is better than what it forbids, which is why this is an error
212
+ * rather than a warning: what an author wants there is a nested durable run
213
+ * started without awaiting, so the step's outcome is a journalable run id, the
214
+ * child gets its own identity and its own durability, and nothing is lost on
215
+ * either side. That is what Temporal's child workflows and Restate's one-way
216
+ * send already are.
217
+ *
218
+ * It keys off the dispatch BEING detached rather than off any kind, so it covers
219
+ * a detaching decorator's configured mode as readily as an explicit detach.
220
+ */
221
+ function checkDetach(
222
+ region: ZoneRegion,
223
+ reportModules: ReadonlySet<string>,
224
+ diagnostics: AnalysisDiagnostic[],
225
+ ): void {
226
+ for (const boundary of region.boundaries) {
227
+ if (!boundary.escaping.includes("detached")) continue;
228
+ if (!owned(boundary.from.manifest, reportModules)) continue;
229
+ diagnostics.push({
230
+ severity: DiagnosticSeverity.Error,
231
+ code: "DURABLE_DETACH_FORBIDDEN",
232
+ source: SOURCE,
233
+ message:
234
+ `${boundary.from.kind} '${boundary.from.name}' dispatches '${boundary.edge.slot}' ` +
235
+ `detached inside a durable region (${region.provider.kind} ` +
236
+ `'${region.provider.name}'). Progress is recorded when a step COMPLETES, so a ` +
237
+ `detached dispatch would be recorded as done while its work was still running — ` +
238
+ `a resume would skip it and a crash would lose it, which is the opposite of what ` +
239
+ `durability provides. Start a nested durable run instead and let the step record ` +
240
+ `its run id: the work keeps its own identity and its own recovery, and nothing is ` +
241
+ `awaited.`,
242
+ data: {
243
+ resource: { kind: boundary.from.kind, name: boundary.from.name },
244
+ filePath: (boundary.from.manifest.metadata as { source?: string } | undefined)?.source,
245
+ path: boundary.edge.path,
246
+ },
247
+ });
248
+ }
249
+ }
250
+
251
+ /**
252
+ * `DURABLE_UNJOURNALABLE_RESULT` — a live value in a journaled position.
253
+ *
254
+ * A live handle is consumed by reading, so it exists exactly once and a
255
+ * recording of it is a recording of nothing. The rule keys off the `live` field
256
+ * of the value-type vocabulary rather than off a type NAME, for the reason that
257
+ * vocabulary is data: a live type added later is covered by its entry alone, and
258
+ * the analyzer names a representation rather than a type.
259
+ *
260
+ * A WARNING rather than an error, and the runtime is the gate: what a step
261
+ * actually produces is only as knowable as its declared contract, and a kind
262
+ * that declares none falls back to a permissive shape that proves nothing. The
263
+ * runtime raises `ERR_DURABLE_UNJOURNALABLE_VALUE` at the step path that
264
+ * produced the value, which is as actionable and is true regardless of what was
265
+ * declared.
266
+ */
267
+ function checkUnjournalableResults(
268
+ region: ZoneRegion,
269
+ resolveDef: DefinitionLookup,
270
+ graph: CallGraph,
271
+ reportModules: ReadonlySet<string>,
272
+ diagnostics: AnalysisDiagnostic[],
273
+ ): void {
274
+ for (const { node } of region.contents.values()) {
275
+ if (node.type !== "step") continue;
276
+ for (const edge of graph.edgesFrom(node.id)) {
277
+ const target = edge.to ? graph.nodes.get(edge.to) : undefined;
278
+ if (!target || target.type !== "resource") continue;
279
+ const def = resolveDef(
280
+ target.kind,
281
+ (target.manifest.metadata as { module?: string } | undefined)?.module,
282
+ );
283
+ const declared =
284
+ (target.manifest as Record<string, unknown>).outputType ??
285
+ (def as Record<string, unknown> | undefined)?.outputType;
286
+ if (!isLiveSchema(declared)) continue;
287
+ const owner = graph.nodes.get((node as StepGraphNode).owner);
288
+ const manifest = owner?.type === "resource" ? owner.manifest : target.manifest;
289
+ if (!owned(manifest, reportModules)) continue;
290
+ diagnostics.push({
291
+ severity: DiagnosticSeverity.Warning,
292
+ code: "DURABLE_UNJOURNALABLE_RESULT",
293
+ source: SOURCE,
294
+ message:
295
+ `Step '${(node as StepGraphNode).name ?? (node as StepGraphNode).path}' invokes ` +
296
+ `${target.kind} '${target.name}', whose declared output is a live value, inside a ` +
297
+ `durable region (${region.provider.kind} '${region.provider.name}'). A live handle ` +
298
+ `is produced by consuming it, so it cannot be recorded and replayed — the run will ` +
299
+ `fail at this step with ERR_DURABLE_UNJOURNALABLE_VALUE. Collect what you need ` +
300
+ `from it into a plain value inside the step, or move the streaming work outside ` +
301
+ `the durable body.`,
302
+ data: {
303
+ resource: { kind: manifest.kind, name: manifest.metadata?.name as string },
304
+ filePath: (manifest.metadata as { source?: string } | undefined)?.source,
305
+ path: (node as StepGraphNode).path,
306
+ },
307
+ });
308
+ }
309
+ }
310
+ }
311
+
312
+ /** Is this declared type a live-representation value? Reads the annotation the
313
+ * vocabulary defines rather than testing against a type name. */
314
+ function isLiveSchema(schema: unknown): boolean {
315
+ if (!schema || typeof schema !== "object") return false;
316
+ const annotation = (schema as Record<string, unknown>)["x-telo-type"];
317
+ const name =
318
+ typeof annotation === "string"
319
+ ? annotation
320
+ : ((annotation as Record<string, unknown> | undefined)?.name as string | undefined);
321
+ if (!name) {
322
+ const inner = (schema as Record<string, unknown>).schema;
323
+ return inner !== undefined && inner !== schema ? isLiveSchema(inner) : false;
324
+ }
325
+ // The one place a name is compared, and it is compared against the LIVE flag
326
+ // of the vocabulary entry rather than against a literal — so this stays true
327
+ // when a second live type is declared.
328
+ return LIVE_TYPE_NAMES.has(name);
329
+ }
330
+
331
+ /** Names of the value types whose representation is live, derived from the
332
+ * vocabulary itself. A constant rather than one-shot mutable state: the
333
+ * vocabulary is fixed at module load, so there is nothing to initialize and
334
+ * nothing an initialization order could get wrong. */
335
+ const LIVE_TYPE_NAMES: ReadonlySet<string> = new Set(
336
+ [...VALUE_TYPES.values()].filter((e) => e.live).map((e) => e.name),
337
+ );
338
+
339
+ /**
340
+ * Where a node's CEL lives, and which manifest a diagnostic about it anchors on.
341
+ *
342
+ * A STEP is where most of a body's CEL actually is — a step's `inputs`, its
343
+ * `when`, a branch predicate — and a step node carries no manifest of its own,
344
+ * so it anchors on the resource whose body declares it. Reading only resource
345
+ * nodes would have made this check silent for exactly the position it exists to
346
+ * cover.
347
+ */
348
+ function celScopeOf(
349
+ node: CallGraphNode,
350
+ graph: CallGraph,
351
+ ): [ResourceManifest | undefined, unknown, string] {
352
+ if (node.type === "resource") return [node.manifest, node.manifest, ""];
353
+ const owner = graph.nodes.get(node.owner);
354
+ const manifest = owner?.type === "resource" ? owner.manifest : undefined;
355
+ return [manifest, node.step, node.path];
356
+ }
357
+
358
+ /**
359
+ * Is this manifest the entry's to fix?
360
+ *
361
+ * Scoped by the manifest's own DECLARING MODULE, not by its file path: a
362
+ * published dependency's body is not the consumer's to fix (the
363
+ * `X_TELO_REF_UNRESOLVED` precedent), and a path is not the module — a fixture
364
+ * nested under one module's directory belongs to its own.
365
+ */
366
+ function owned(manifest: ResourceManifest, reportModules: ReadonlySet<string>): boolean {
367
+ const module = (manifest.metadata as { module?: string } | undefined)?.module;
368
+ return !module || reportModules.size === 0 || reportModules.has(module);
369
+ }
370
+
371
+ export interface DurableRegionArgs {
372
+ graph: CallGraph;
373
+ resolveDef: DefinitionLookup;
374
+ /** Only report against modules the entry owns — a published dependency's body
375
+ * is not the consumer's to fix, the `X_TELO_REF_UNRESOLVED` precedent. */
376
+ reportModules: ReadonlySet<string>;
377
+ }
378
+
379
+ /** Every durable-region diagnostic, over one graph. */
380
+ export function validateDurableRegions(args: DurableRegionArgs): AnalysisDiagnostic[] {
381
+ const diagnostics: AnalysisDiagnostic[] = [];
382
+
383
+ for (const region of findZoneRegions(args.graph, args.resolveDef, "replayed")) {
384
+ checkDetach(region, args.reportModules, diagnostics);
385
+ checkUnjournalableResults(
386
+ region,
387
+ args.resolveDef,
388
+ args.graph,
389
+ args.reportModules,
390
+ diagnostics,
391
+ );
392
+ }
393
+ for (const region of findZoneRegions(args.graph, args.resolveDef, "idempotent")) {
394
+ checkNondeterminism(region, args.graph, args.reportModules, diagnostics);
395
+ }
396
+
397
+ return diagnostics;
398
+ }
399
+
400
+ /** Re-exported so a consumer can name a region without importing two files. */
401
+ export type { ZoneRegion };
@@ -2,8 +2,9 @@ import type { ResourceManifest } from "@telorun/sdk";
2
2
  import { isRefSentinel } from "@telorun/templating";
3
3
  import { visitManifest } from "./manifest-visitor.js";
4
4
  import { isInlineResource, resolveFieldEntries, resolveFieldValues, type RefFieldEntry } from "./reference-field-map.js";
5
- import { navigateJsonPointer } from "./schema-compat.js";
5
+ import { navigateJsonPointer, substituteCelFields } from "./schema-compat.js";
6
6
  import { REF_VALIDATION_SKIP_KINDS as SYSTEM_KINDS } from "./system-kinds.js";
7
+ import { resolveTypeFieldToSchema } from "./validate-cel-context.js";
7
8
  import { DiagnosticSeverity, type AnalysisDiagnostic, type AnalysisContext } from "./types.js";
8
9
  import type { AliasResolver } from "./alias-resolver.js";
9
10
  import type { DefinitionRegistry } from "./definition-registry.js";
@@ -420,11 +421,17 @@ export function validateReferences(
420
421
 
421
422
  const targetKind = ownerScope.resolveKind(anchorName);
422
423
  if (!targetKind) {
424
+ // Names the ALIAS, not the whole kind path: the alias is what an
425
+ // author declares, and the usual cause is the import that binds it
426
+ // having failed — which is reported on its own line.
427
+ const aliasName = anchorName.slice(0, anchorName.indexOf("."));
423
428
  diagnostics.push({
424
429
  severity: DiagnosticSeverity.Error,
425
430
  code: "SCHEMA_FROM_MISSING_PATH",
426
431
  source: SOURCE,
427
- message: `${resourceLabel}: x-telo-schema-from at '${fieldPath}' → cannot resolve alias '${anchorName}'`,
432
+ message:
433
+ `${resourceLabel}: x-telo-schema-from at '${fieldPath}' → cannot resolve alias ` +
434
+ `'${aliasName}' (in '${anchorName}'). Check the import that declares it.`,
428
435
  data: { resource: resourceData, filePath, path: fieldPath },
429
436
  });
430
437
  return;
@@ -497,6 +504,54 @@ export function validateReferences(
497
504
  const refVal = anchorVal as Record<string, unknown>;
498
505
  if (typeof refVal.kind !== "string") continue;
499
506
 
507
+ // THE INSTANCE FIRST, then the kind — the layering
508
+ // `x-telo-context-ref-from` already uses, and for the same reason. A
509
+ // kind that declares one fixed shape declares it on its definition; a
510
+ // kind whose shape is per instance declares it as a FIELD, and reading
511
+ // only the definition would type every instance against nothing. A
512
+ // `Durable.Await`'s `outputType:` is exactly the second: what a
513
+ // delivery carries is a property of that await and of no other, so a
514
+ // delivery naming it must be checked against the instance's own.
515
+ const target =
516
+ typeof refVal.name === "string" ? byName.get(refVal.name) : undefined;
517
+ const perInstance =
518
+ target === undefined
519
+ ? undefined
520
+ : navigateJsonPointer(target as Record<string, unknown>, jsonPointer);
521
+ if (perInstance !== undefined) {
522
+ const instanceSchema = resolveTypeFieldToSchema(
523
+ perInstance,
524
+ resources as Record<string, any>[],
525
+ );
526
+ if (instanceSchema) {
527
+ // CEL leaves become schema-shaped placeholders first. A payload is
528
+ // overwhelmingly written as expressions over the call's inputs, so
529
+ // validating it raw would report every one of them as a type
530
+ // error — the check would fire only on the literal case, which is
531
+ // the case nobody writes. What survives substitution is exactly
532
+ // what is worth reporting: a missing required field, an unknown
533
+ // property, a literal of the wrong type. The same treatment
534
+ // `x-telo-value-schema-from` documents.
535
+ const substituted = substituteCelFields(
536
+ fieldValue,
537
+ instanceSchema as Record<string, any>,
538
+ );
539
+ for (const issue of registry.validateWithRefs(
540
+ substituted,
541
+ instanceSchema as Record<string, any>,
542
+ )) {
543
+ diagnostics.push({
544
+ severity: DiagnosticSeverity.Error,
545
+ code: "DEPENDENT_SCHEMA_MISMATCH",
546
+ source: SOURCE,
547
+ message: `${resourceLabel}: '${concretePath}' does not match the schema '${refVal.name}' declares at '${jsonPointer}': ${issue}`,
548
+ data: { resource: resourceData, filePath, path: concretePath },
549
+ });
550
+ }
551
+ continue;
552
+ }
553
+ }
554
+
500
555
  const refResolvedKind = aliases.resolveKind(refVal.kind) ?? refVal.kind;
501
556
  const refDef = registry.resolve(refVal.kind) ?? registry.resolve(refResolvedKind);
502
557
  if (!refDef?.schema) {