@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.
- package/dist/analysis-registry.d.ts +24 -0
- package/dist/analysis-registry.d.ts.map +1 -1
- package/dist/analysis-registry.js +41 -0
- package/dist/analyzer.d.ts +3 -37
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/analyzer.js +396 -476
- package/dist/call-graph.d.ts.map +1 -1
- package/dist/call-graph.js +2 -7
- package/dist/cel-scope-query.d.ts +109 -0
- package/dist/cel-scope-query.d.ts.map +1 -0
- package/dist/cel-scope-query.js +270 -0
- package/dist/cel-scope.d.ts +166 -0
- package/dist/cel-scope.d.ts.map +1 -0
- package/dist/cel-scope.js +377 -0
- package/dist/definition-registry.d.ts +15 -0
- package/dist/definition-registry.d.ts.map +1 -1
- package/dist/definition-registry.js +25 -9
- package/dist/find-manifest.d.ts +10 -0
- package/dist/find-manifest.d.ts.map +1 -0
- package/dist/find-manifest.js +12 -0
- package/dist/flatten-for-analyzer.js +1 -1
- package/dist/import-resolution-diagnostics.d.ts +9 -2
- package/dist/import-resolution-diagnostics.d.ts.map +1 -1
- package/dist/import-resolution-diagnostics.js +26 -7
- package/dist/index.d.ts +22 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +22 -1
- package/dist/invocation-contract.d.ts +17 -1
- package/dist/invocation-contract.d.ts.map +1 -1
- package/dist/invocation-contract.js +42 -4
- package/dist/loaded-types.d.ts +12 -3
- package/dist/loaded-types.d.ts.map +1 -1
- package/dist/manifest-analysis.d.ts +73 -0
- package/dist/manifest-analysis.d.ts.map +1 -0
- package/dist/manifest-analysis.js +78 -0
- package/dist/manifest-loader.d.ts +0 -1
- package/dist/manifest-loader.d.ts.map +1 -1
- package/dist/manifest-loader.js +62 -41
- package/dist/manifest-path.d.ts +18 -0
- package/dist/manifest-path.d.ts.map +1 -0
- package/dist/manifest-path.js +37 -0
- package/dist/manifest-schemas.d.ts +699 -0
- package/dist/manifest-schemas.d.ts.map +1 -1
- package/dist/manifest-schemas.js +306 -14
- package/dist/module-compatibility.d.ts +40 -0
- package/dist/module-compatibility.d.ts.map +1 -0
- package/dist/module-compatibility.js +52 -0
- package/dist/ref-slot-reading.d.ts +30 -0
- package/dist/ref-slot-reading.d.ts.map +1 -0
- package/dist/ref-slot-reading.js +139 -0
- package/dist/referrer-rule.d.ts +67 -0
- package/dist/referrer-rule.d.ts.map +1 -0
- package/dist/referrer-rule.js +96 -0
- package/dist/release/index.d.ts +1 -1
- package/dist/release/index.d.ts.map +1 -1
- package/dist/release/index.js +1 -1
- package/dist/release/version-stamp.d.ts +16 -0
- package/dist/release/version-stamp.d.ts.map +1 -1
- package/dist/release/version-stamp.js +52 -0
- package/dist/release/workspace-config.d.ts +10 -6
- package/dist/release/workspace-config.d.ts.map +1 -1
- package/dist/release/workspace-config.js +10 -6
- package/dist/resolve-schema-ref-kinds.d.ts +4 -3
- package/dist/resolve-schema-ref-kinds.d.ts.map +1 -1
- package/dist/resolve-schema-ref-kinds.js +16 -0
- package/dist/resolve-throws-union.d.ts.map +1 -1
- package/dist/resolve-throws-union.js +4 -3
- package/dist/resolve-zone-containment.d.ts +99 -0
- package/dist/resolve-zone-containment.d.ts.map +1 -0
- package/dist/resolve-zone-containment.js +212 -0
- package/dist/resolve-zone-requirements.d.ts +5 -0
- package/dist/resolve-zone-requirements.d.ts.map +1 -1
- package/dist/resolve-zone-requirements.js +48 -7
- package/dist/resource-rule.d.ts +106 -0
- package/dist/resource-rule.d.ts.map +1 -0
- package/dist/resource-rule.js +236 -0
- package/dist/rule-condition.d.ts +50 -0
- package/dist/rule-condition.d.ts.map +1 -0
- package/dist/rule-condition.js +108 -0
- package/dist/schema-keywords.d.ts.map +1 -1
- package/dist/schema-keywords.js +20 -0
- package/dist/schema-projection.d.ts +171 -0
- package/dist/schema-projection.d.ts.map +1 -0
- package/dist/schema-projection.js +317 -0
- package/dist/schema-walk.d.ts +25 -0
- package/dist/schema-walk.d.ts.map +1 -0
- package/dist/schema-walk.js +126 -0
- package/dist/step-slot.d.ts +39 -0
- package/dist/step-slot.d.ts.map +1 -0
- package/dist/step-slot.js +49 -0
- package/dist/telo-version.d.ts +1 -1
- package/dist/telo-version.js +1 -1
- package/dist/validate-cel-context.d.ts.map +1 -1
- package/dist/validate-cel-context.js +17 -3
- package/dist/validate-durable-regions.d.ts +15 -0
- package/dist/validate-durable-regions.d.ts.map +1 -0
- package/dist/validate-durable-regions.js +336 -0
- package/dist/validate-references.d.ts.map +1 -1
- package/dist/validate-references.js +44 -2
- package/dist/validate-referrer-rules.d.ts +120 -0
- package/dist/validate-referrer-rules.d.ts.map +1 -0
- package/dist/validate-referrer-rules.js +255 -0
- package/dist/validate-resource-rules.d.ts +114 -0
- package/dist/validate-resource-rules.d.ts.map +1 -0
- package/dist/validate-resource-rules.js +283 -0
- package/dist/validate-schema-projection.d.ts +32 -0
- package/dist/validate-schema-projection.d.ts.map +1 -0
- package/dist/validate-schema-projection.js +110 -0
- package/dist/validate-step-inputs.d.ts +19 -1
- package/dist/validate-step-inputs.d.ts.map +1 -1
- package/dist/validate-step-inputs.js +102 -9
- package/dist/validate-throws-coverage.d.ts.map +1 -1
- package/dist/validate-throws-coverage.js +11 -5
- package/dist/validate-zone-slots.d.ts +2 -2
- package/dist/validate-zone-slots.d.ts.map +1 -1
- package/dist/validate-zone-slots.js +202 -6
- package/dist/validate-zone-violations.d.ts +12 -0
- package/dist/validate-zone-violations.d.ts.map +1 -0
- package/dist/validate-zone-violations.js +169 -0
- package/dist/value-type-keyword.d.ts +1 -1
- package/dist/value-type-keyword.d.ts.map +1 -1
- package/dist/value-type-keyword.js +4 -0
- package/dist/zone-slot.d.ts +61 -0
- package/dist/zone-slot.d.ts.map +1 -1
- package/dist/zone-slot.js +86 -5
- package/package.json +2 -2
- package/src/analysis-registry.ts +43 -0
- package/src/analyzer.ts +452 -595
- package/src/call-graph.ts +2 -8
- package/src/cel-scope-query.ts +337 -0
- package/src/cel-scope.ts +570 -0
- package/src/definition-registry.ts +31 -13
- package/src/find-manifest.ts +19 -0
- package/src/flatten-for-analyzer.ts +1 -1
- package/src/import-resolution-diagnostics.ts +25 -7
- package/src/index.ts +86 -0
- package/src/invocation-contract.ts +67 -2
- package/src/loaded-types.ts +12 -3
- package/src/manifest-analysis.ts +132 -0
- package/src/manifest-loader.ts +87 -55
- package/src/manifest-path.ts +34 -0
- package/src/manifest-schemas.ts +328 -14
- package/src/module-compatibility.ts +72 -0
- package/src/ref-slot-reading.ts +156 -0
- package/src/referrer-rule.ts +116 -0
- package/src/release/index.ts +1 -0
- package/src/release/version-stamp.ts +58 -0
- package/src/release/workspace-config.ts +10 -6
- package/src/resolve-schema-ref-kinds.ts +28 -4
- package/src/resolve-throws-union.ts +4 -3
- package/src/resolve-zone-containment.ts +329 -0
- package/src/resolve-zone-requirements.ts +67 -7
- package/src/resource-rule.ts +252 -0
- package/src/rule-condition.ts +126 -0
- package/src/schema-keywords.ts +24 -0
- package/src/schema-projection.ts +403 -0
- package/src/schema-walk.ts +144 -0
- package/src/step-slot.ts +64 -0
- package/src/telo-version.ts +1 -1
- package/src/validate-cel-context.ts +17 -3
- package/src/validate-durable-regions.ts +401 -0
- package/src/validate-references.ts +57 -2
- package/src/validate-referrer-rules.ts +373 -0
- package/src/validate-resource-rules.ts +401 -0
- package/src/validate-schema-projection.ts +184 -0
- package/src/validate-step-inputs.ts +148 -10
- package/src/validate-throws-coverage.ts +12 -5
- package/src/validate-zone-slots.ts +192 -8
- package/src/validate-zone-violations.ts +190 -0
- package/src/value-type-keyword.ts +4 -0
- 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:
|
|
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) {
|