@telorun/analyzer 0.45.0 → 0.46.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/alias-resolver.d.ts +45 -0
- package/dist/alias-resolver.d.ts.map +1 -1
- package/dist/alias-resolver.js +33 -0
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/analyzer.js +104 -3
- package/dist/builtins.d.ts.map +1 -1
- package/dist/builtins.js +3 -0
- package/dist/extends-resolution.d.ts +19 -2
- package/dist/extends-resolution.d.ts.map +1 -1
- package/dist/extends-resolution.js +25 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/kernel-globals.d.ts +9 -1
- package/dist/kernel-globals.d.ts.map +1 -1
- package/dist/kernel-globals.js +24 -1
- package/dist/validate-observed-state.d.ts +98 -0
- package/dist/validate-observed-state.d.ts.map +1 -0
- package/dist/validate-observed-state.js +304 -0
- package/package.json +2 -2
- package/src/alias-resolver.ts +58 -0
- package/src/analyzer.ts +118 -2
- package/src/builtins.ts +3 -0
- package/src/extends-resolution.ts +37 -3
- package/src/index.ts +13 -0
- package/src/kernel-globals.ts +20 -0
- package/src/validate-observed-state.ts +354 -0
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { OBSERVED_STATE_KEY } from "@telorun/sdk";
|
|
2
|
+
import { effectiveStatusSchema } from "./extends-resolution.js";
|
|
3
|
+
import { parseExportEntry } from "./flatten-for-analyzer.js";
|
|
4
|
+
import { moduleScopedDefResolver } from "./alias-resolver.js";
|
|
5
|
+
import { buildReferenceFieldMap, isRefEntry, isScopeEntry, resolveFieldValues, } from "./reference-field-map.js";
|
|
6
|
+
/** The kernel capabilities whose `run()` the kernel dispatches. A ref slot that
|
|
7
|
+
* accepts one of them is a slot that can start a resource — `targets:` on an
|
|
8
|
+
* Application or a `Run.Sequence`, and a step's `invoke:` (whose schema accepts
|
|
9
|
+
* `Telo.Runnable` alongside `Telo.Invocable`, and which the kernel dispatches
|
|
10
|
+
* through `run()` when the target has no `invoke()`). Keyed on the declared
|
|
11
|
+
* capability, never on a field name or a kind, so any composer that accepts a
|
|
12
|
+
* runnable participates without the analyzer knowing about it. */
|
|
13
|
+
const RUN_DISPATCH_CONTRACTS = new Set(["Telo.Runnable", "Telo.Service"]);
|
|
14
|
+
const SYSTEM_KINDS = new Set([
|
|
15
|
+
"Telo.Definition",
|
|
16
|
+
"Telo.Abstract",
|
|
17
|
+
"Telo.Import",
|
|
18
|
+
"Telo.Application",
|
|
19
|
+
"Telo.Library",
|
|
20
|
+
]);
|
|
21
|
+
/**
|
|
22
|
+
* The `status:` block's own schema — a plain JSON Schema, structurally. The one
|
|
23
|
+
* normative restriction (`required:` is rejected) is enforced by
|
|
24
|
+
* {@link validateObservedStateDeclarations} rather than here, so the author gets
|
|
25
|
+
* a message naming the rule and the fix instead of AJV's "must NOT be valid".
|
|
26
|
+
*
|
|
27
|
+
* Exported from the analyzer and re-used by the kernel's manifest schemas, so
|
|
28
|
+
* the rule has one definition rather than two kept in sync by hand.
|
|
29
|
+
*/
|
|
30
|
+
export const OBSERVED_STATE_SCHEMA = {
|
|
31
|
+
type: "object",
|
|
32
|
+
additionalProperties: true,
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* `required:` inside a `status:` block. Every declared field is mandatory once
|
|
36
|
+
* the resource has run, so the list would be either redundant or a lie; a
|
|
37
|
+
* genuinely sometimes-absent value is declared with a nullable type, which
|
|
38
|
+
* `CEL_NULLABLE_ACCESS` already guards.
|
|
39
|
+
*/
|
|
40
|
+
export function validateObservedStateDeclarations(manifests) {
|
|
41
|
+
const out = [];
|
|
42
|
+
for (const m of manifests) {
|
|
43
|
+
if (m.kind !== "Telo.Definition" && m.kind !== "Telo.Abstract")
|
|
44
|
+
continue;
|
|
45
|
+
const status = m.status;
|
|
46
|
+
if (!status || typeof status !== "object" || !Array.isArray(status.required))
|
|
47
|
+
continue;
|
|
48
|
+
const name = m.metadata?.name ?? "<unnamed>";
|
|
49
|
+
out.push({
|
|
50
|
+
kind: m.kind,
|
|
51
|
+
name,
|
|
52
|
+
filePath: m.metadata?.source,
|
|
53
|
+
message: `${m.kind}/${name}: 'status:' must not declare 'required:' — every field a kind declares ` +
|
|
54
|
+
`it reports is mandatory once the resource has run, so the list is either redundant or a ` +
|
|
55
|
+
`lie. Declare a sometimes-absent field with a nullable type instead ` +
|
|
56
|
+
`(e.g. type: [string, "null"]); CEL_NULLABLE_ACCESS then forces the reader to guard it.`,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Recognise an observed-state read in a member-access chain. Purely syntactic —
|
|
63
|
+
* it inspects the chain, not the topology — so the availability rule it feeds
|
|
64
|
+
* applies to every kind, declared or not.
|
|
65
|
+
*
|
|
66
|
+
* `resources.<name>.status.<field>` and the two-level cross-module form
|
|
67
|
+
* `resources.<Alias>.<name>.status.<field>` are both observed-state reads.
|
|
68
|
+
*/
|
|
69
|
+
export function observedStateRead(chain) {
|
|
70
|
+
if (chain[0] !== "resources")
|
|
71
|
+
return undefined;
|
|
72
|
+
if (chain[2] === OBSERVED_STATE_KEY)
|
|
73
|
+
return { name: chain[1], field: chain[3] };
|
|
74
|
+
if (chain[3] === OBSERVED_STATE_KEY) {
|
|
75
|
+
return { alias: chain[1], name: chain[2], field: chain[4] };
|
|
76
|
+
}
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* The names of every resource some slot can start: referenced from a ref slot
|
|
81
|
+
* that accepts a `Telo.Runnable` / `Telo.Service`, or named as a step's
|
|
82
|
+
* `invoke:` target. A resource in none of them can never `run()`, so it can
|
|
83
|
+
* never report observed state.
|
|
84
|
+
*
|
|
85
|
+
* Deliberately an over-approximation — a name reachable through any of these
|
|
86
|
+
* routes counts as runnable — because the cost of a false "can never run" is a
|
|
87
|
+
* valid manifest rejected, while the cost of a miss is only that the reader
|
|
88
|
+
* finds out at runtime instead, with a message that names the same fix.
|
|
89
|
+
*/
|
|
90
|
+
export function collectRunReachableNames(manifests, defs, aliases) {
|
|
91
|
+
const names = new Set();
|
|
92
|
+
const resolve = (kind) => defs.resolve(aliases?.resolveKind(kind) ?? kind) ?? defs.resolve(kind);
|
|
93
|
+
for (const manifest of manifests) {
|
|
94
|
+
const def = resolve(manifest.kind);
|
|
95
|
+
const schema = def?.schema;
|
|
96
|
+
if (!schema)
|
|
97
|
+
continue;
|
|
98
|
+
for (const [path, entry] of buildReferenceFieldMap(schema)) {
|
|
99
|
+
if (!isRefEntry(entry))
|
|
100
|
+
continue;
|
|
101
|
+
if (!entry.refs.some((ref) => RUN_DISPATCH_CONTRACTS.has(ref)))
|
|
102
|
+
continue;
|
|
103
|
+
for (const value of resolveFieldValues(manifest, path))
|
|
104
|
+
collectRefName(value, names);
|
|
105
|
+
}
|
|
106
|
+
// Step arrays nest through `if` / `while` / `switch` / `try`, and the step
|
|
107
|
+
// `invoke:` slot sits behind a local `$ref` the field map does not follow.
|
|
108
|
+
// Match the declared invoke key at any depth instead of re-deriving the
|
|
109
|
+
// nesting rules — over-approximating in the safe direction.
|
|
110
|
+
const invokeKey = stepInvokeKey(schema);
|
|
111
|
+
if (invokeKey)
|
|
112
|
+
collectKeyedRefs(manifest, invokeKey, names);
|
|
113
|
+
}
|
|
114
|
+
return names;
|
|
115
|
+
}
|
|
116
|
+
/** The property name a kind's `x-telo-step-context` declares as its dispatch
|
|
117
|
+
* slot (`invoke`), or undefined when the kind has no step array. */
|
|
118
|
+
function stepInvokeKey(schema) {
|
|
119
|
+
for (const fieldSchema of Object.values((schema.properties ?? {}))) {
|
|
120
|
+
const stepCtx = fieldSchema?.["x-telo-step-context"];
|
|
121
|
+
if (stepCtx?.invoke)
|
|
122
|
+
return stepCtx.invoke;
|
|
123
|
+
}
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
/** Collect ref names at every `key` property anywhere in `node`. */
|
|
127
|
+
function collectKeyedRefs(node, key, out) {
|
|
128
|
+
if (Array.isArray(node)) {
|
|
129
|
+
for (const item of node)
|
|
130
|
+
collectKeyedRefs(item, key, out);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (node === null || typeof node !== "object")
|
|
134
|
+
return;
|
|
135
|
+
for (const [k, value] of Object.entries(node)) {
|
|
136
|
+
if (k === key)
|
|
137
|
+
collectRefName(value, out);
|
|
138
|
+
collectKeyedRefs(value, key, out);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/** Record the resource name a slot value points at — a resolved `{kind, name}`
|
|
142
|
+
* ref, an unresolved `!ref` sentinel, or a `{ ref }` / `{ invoke }` wrapper. */
|
|
143
|
+
function collectRefName(value, out) {
|
|
144
|
+
if (value === null || typeof value !== "object")
|
|
145
|
+
return;
|
|
146
|
+
if (Array.isArray(value)) {
|
|
147
|
+
for (const item of value)
|
|
148
|
+
collectRefName(item, out);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const v = value;
|
|
152
|
+
if (typeof v.name === "string")
|
|
153
|
+
out.add(v.name);
|
|
154
|
+
if (typeof v.source === "string") {
|
|
155
|
+
const dot = v.source.lastIndexOf(".");
|
|
156
|
+
out.add(dot >= 0 ? v.source.slice(dot + 1) : v.source);
|
|
157
|
+
}
|
|
158
|
+
for (const wrapper of ["ref", "invoke"]) {
|
|
159
|
+
if (v[wrapper] !== undefined)
|
|
160
|
+
collectRefName(v[wrapper], out);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Index every resource a CEL `resources.…` read can name: the module's own
|
|
165
|
+
* top-level resources, the ones declared inside `x-telo-scope` slots (a
|
|
166
|
+
* `Run.Sequence`'s `with:`), and each import's exported instances — keyed
|
|
167
|
+
* `<Alias>.<name>`, the two-level shape those publish under.
|
|
168
|
+
*
|
|
169
|
+
* Scope slots are found through the declaring kind's schema annotation, not by
|
|
170
|
+
* field name, so any composer with a scope participates.
|
|
171
|
+
*/
|
|
172
|
+
export function buildObservedStateIndex(manifests, defs, aliases, scopes) {
|
|
173
|
+
const out = new Map();
|
|
174
|
+
const resolve = moduleScopedDefResolver(defs, aliases, scopes);
|
|
175
|
+
/** `module` is the resource's DECLARING module: an exported instance is
|
|
176
|
+
* written with that library's aliases (`kind: Self.Listener`), which the
|
|
177
|
+
* consumer's table cannot resolve. */
|
|
178
|
+
const record = (kind, key, scoped, module) => {
|
|
179
|
+
const status = effectiveStatusSchema(resolve.in(kind, module), resolve);
|
|
180
|
+
out.set(key, { kind, ...(status ? { status } : {}), ...(scoped ? { scoped } : {}) });
|
|
181
|
+
};
|
|
182
|
+
for (const manifest of manifests) {
|
|
183
|
+
const kind = manifest.kind;
|
|
184
|
+
const name = manifest.metadata?.name;
|
|
185
|
+
if (!kind || SYSTEM_KINDS.has(kind))
|
|
186
|
+
continue;
|
|
187
|
+
if (name)
|
|
188
|
+
record(kind, name, false, manifest.metadata?.module);
|
|
189
|
+
const schema = resolve(kind)?.schema;
|
|
190
|
+
if (!schema)
|
|
191
|
+
continue;
|
|
192
|
+
for (const [path, entry] of buildReferenceFieldMap(schema)) {
|
|
193
|
+
if (!isScopeEntry(entry))
|
|
194
|
+
continue;
|
|
195
|
+
for (const value of resolveFieldValues(manifest, path)) {
|
|
196
|
+
for (const scopedEntry of Array.isArray(value) ? value : [value]) {
|
|
197
|
+
const scopedKind = scopedEntry?.kind;
|
|
198
|
+
const scopedName = scopedEntry?.metadata?.name;
|
|
199
|
+
if (typeof scopedKind === "string" && typeof scopedName === "string") {
|
|
200
|
+
record(scopedKind, scopedName, true);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
for (const [alias, name, kind, module] of importedExports(manifests, aliases)) {
|
|
207
|
+
record(kind, `${alias}.${name}`, false, module);
|
|
208
|
+
}
|
|
209
|
+
return out;
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Every `<alias, exported name, kind>` an import makes readable as
|
|
213
|
+
* `resources.<Alias>.<name>`. The importer's `Telo.Import` docs give the
|
|
214
|
+
* aliases; the exported instances are the ones already stamped
|
|
215
|
+
* `metadata.forwardedExport` by `selectModuleManifestsForAnalysis` — the module
|
|
216
|
+
* doc that declared `exports.resources` is dropped for non-root modules, so the
|
|
217
|
+
* stamp, not the declaration, is what survives into the consumer's manifest
|
|
218
|
+
* list. A module doc is still consulted when one IS present (a single-library
|
|
219
|
+
* analysis, the editor's projection).
|
|
220
|
+
*/
|
|
221
|
+
function* importedExports(manifests, aliases) {
|
|
222
|
+
if (!aliases?.moduleForAlias)
|
|
223
|
+
return;
|
|
224
|
+
const declaredByModule = new Map();
|
|
225
|
+
for (const m of manifests) {
|
|
226
|
+
if (m.kind !== "Telo.Library")
|
|
227
|
+
continue;
|
|
228
|
+
const libName = (m.metadata?.name ?? m.metadata?.module);
|
|
229
|
+
const declared = m.exports?.resources;
|
|
230
|
+
if (!libName || !Array.isArray(declared))
|
|
231
|
+
continue;
|
|
232
|
+
declaredByModule.set(libName, new Set(declared
|
|
233
|
+
.filter((e) => typeof e === "string")
|
|
234
|
+
.map((e) => parseExportEntry(e).name)));
|
|
235
|
+
}
|
|
236
|
+
const exportsByModule = new Map();
|
|
237
|
+
for (const m of manifests) {
|
|
238
|
+
const module = m.metadata?.module;
|
|
239
|
+
const name = m.metadata?.name;
|
|
240
|
+
if (!module || !name || SYSTEM_KINDS.has(m.kind))
|
|
241
|
+
continue;
|
|
242
|
+
const forwarded = m.metadata?.forwardedExport;
|
|
243
|
+
if (!forwarded && !declaredByModule.get(module)?.has(name))
|
|
244
|
+
continue;
|
|
245
|
+
let byName = exportsByModule.get(module);
|
|
246
|
+
if (!byName)
|
|
247
|
+
exportsByModule.set(module, (byName = new Map()));
|
|
248
|
+
byName.set(name, m.kind);
|
|
249
|
+
}
|
|
250
|
+
for (const m of manifests) {
|
|
251
|
+
if (m.kind !== "Telo.Import")
|
|
252
|
+
continue;
|
|
253
|
+
const alias = m.metadata?.name;
|
|
254
|
+
if (!alias)
|
|
255
|
+
continue;
|
|
256
|
+
const targetModule = aliases.moduleForAlias(alias);
|
|
257
|
+
const exported = targetModule && exportsByModule.get(targetModule);
|
|
258
|
+
if (!exported)
|
|
259
|
+
continue;
|
|
260
|
+
for (const [name, kind] of exported)
|
|
261
|
+
yield [alias, name, kind, targetModule];
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
/** The `resources` node of a CEL context schema: one entry per resource, each
|
|
265
|
+
* open except for a typed, closed `status` node on kinds that declare one.
|
|
266
|
+
* `open` keeps the map itself permissive, so unknown resource names and every
|
|
267
|
+
* flat field pass exactly as they do today. */
|
|
268
|
+
export function buildObservedStateResourcesSchema(index, open) {
|
|
269
|
+
const properties = {};
|
|
270
|
+
for (const [key, { status }] of index) {
|
|
271
|
+
if (!status)
|
|
272
|
+
continue;
|
|
273
|
+
applyObservedStateNode(properties, key, status);
|
|
274
|
+
}
|
|
275
|
+
return open
|
|
276
|
+
? { type: "object", additionalProperties: true, properties }
|
|
277
|
+
: { type: "object", properties };
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Write the typed `status` node for one index key into a `resources` property
|
|
281
|
+
* map. A dotted key (`Alias.name`) is an import's exported instance, which
|
|
282
|
+
* publishes two levels deep — the alias node stays open so every other name
|
|
283
|
+
* under it keeps resolving as it does today.
|
|
284
|
+
*/
|
|
285
|
+
export function applyObservedStateNode(properties, key, status) {
|
|
286
|
+
const dot = key.indexOf(".");
|
|
287
|
+
const leaf = {
|
|
288
|
+
type: "object",
|
|
289
|
+
additionalProperties: true,
|
|
290
|
+
properties: { [OBSERVED_STATE_KEY]: { ...status, additionalProperties: false } },
|
|
291
|
+
};
|
|
292
|
+
if (dot < 0) {
|
|
293
|
+
properties[key] = leaf;
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
const alias = key.slice(0, dot);
|
|
297
|
+
const name = key.slice(dot + 1);
|
|
298
|
+
const aliasNode = (properties[alias] ??= {
|
|
299
|
+
type: "object",
|
|
300
|
+
additionalProperties: true,
|
|
301
|
+
properties: {},
|
|
302
|
+
});
|
|
303
|
+
(aliasNode.properties ??= {})[name] = leaf;
|
|
304
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@telorun/analyzer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.46.0",
|
|
4
4
|
"description": "Telo Analyzer - Static manifest validator for Telo manifests.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"telo",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"@types/node": "^20.0.0",
|
|
49
49
|
"typescript": "^5.0.0",
|
|
50
50
|
"vitest": "^2.1.8",
|
|
51
|
-
"@telorun/sdk": "0.
|
|
51
|
+
"@telorun/sdk": "0.58.0"
|
|
52
52
|
},
|
|
53
53
|
"peerDependencies": {
|
|
54
54
|
"@telorun/sdk": "*"
|
package/src/alias-resolver.ts
CHANGED
|
@@ -121,3 +121,61 @@ export function scopeResolverForModule(
|
|
|
121
121
|
? aliasesByModule.get(ownModule)
|
|
122
122
|
: undefined;
|
|
123
123
|
}
|
|
124
|
+
|
|
125
|
+
/** Per-declaring-module alias tables plus the set of root (consumer-owned)
|
|
126
|
+
* modules. The shape `StaticAnalyzer.analyze` already threads through its
|
|
127
|
+
* passes, and what {@link moduleScopedDefResolver} needs to re-scope. */
|
|
128
|
+
export interface ModuleScopes {
|
|
129
|
+
aliasesByModule: ReadonlyMap<string, { resolveKind(kind: string): string | undefined }>;
|
|
130
|
+
rootModules: ReadonlySet<string>;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Minimal view of the definition registry a kind lookup needs. */
|
|
134
|
+
export interface DefinitionLookup {
|
|
135
|
+
resolve(kind: string): unknown;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Resolve a kind to its definition **in the scope that declared it**.
|
|
140
|
+
*
|
|
141
|
+
* An `extends` alias belongs to the file it was written in, not to whoever is
|
|
142
|
+
* reading it: a consumer that imports only a backend (the sanctioned "one import
|
|
143
|
+
* instead of two") has no alias for the abstract's library, so folding an
|
|
144
|
+
* inheritance chain with the consumer's table silently stops at the first hop
|
|
145
|
+
* and yields an un-merged schema. `from` — the definition the kind was read off —
|
|
146
|
+
* carries `metadata.module`, which is the scope to resolve in; a chain crossing
|
|
147
|
+
* several modules re-scopes at every hop.
|
|
148
|
+
*
|
|
149
|
+
* `resolveIn` takes the module explicitly, for the top-level lookup where there
|
|
150
|
+
* is no `from` yet (an exported instance's `kind: Self.X` is written in the
|
|
151
|
+
* exporting library's scope, which the consumer's table cannot resolve either).
|
|
152
|
+
*
|
|
153
|
+
* The runtime counterpart is `resource-definition-controller`, which resolves
|
|
154
|
+
* against the defining module context and stamps the result; keeping both on the
|
|
155
|
+
* same rule is what stops `telo check` and the kernel from disagreeing about
|
|
156
|
+
* which inherited fields a child kind may set.
|
|
157
|
+
*/
|
|
158
|
+
export function moduleScopedDefResolver<T>(
|
|
159
|
+
defs: { resolve(kind: string): T | undefined },
|
|
160
|
+
aliases?: { resolveKind(kind: string): string | undefined },
|
|
161
|
+
scopes?: ModuleScopes,
|
|
162
|
+
): {
|
|
163
|
+
(kind: string, from?: { metadata?: { module?: string } }): T | undefined;
|
|
164
|
+
in(kind: string, module?: string): T | undefined;
|
|
165
|
+
} {
|
|
166
|
+
const resolveIn = (kind: string, module?: string): T | undefined => {
|
|
167
|
+
const scoped =
|
|
168
|
+
module && scopes && !scopes.rootModules.has(module)
|
|
169
|
+
? scopes.aliasesByModule.get(module)
|
|
170
|
+
: undefined;
|
|
171
|
+
return (
|
|
172
|
+
(scoped ? defs.resolve(scoped.resolveKind(kind) ?? kind) : undefined) ??
|
|
173
|
+
defs.resolve(aliases?.resolveKind(kind) ?? kind) ??
|
|
174
|
+
defs.resolve(kind)
|
|
175
|
+
);
|
|
176
|
+
};
|
|
177
|
+
const resolver = ((kind: string, from?: { metadata?: { module?: string } }) =>
|
|
178
|
+
resolveIn(kind, from?.metadata?.module)) as ReturnType<typeof moduleScopedDefResolver<T>>;
|
|
179
|
+
resolver.in = resolveIn;
|
|
180
|
+
return resolver;
|
|
181
|
+
}
|
package/src/analyzer.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ResourceDefinition, ResourceManifest } from "@telorun/sdk";
|
|
2
|
-
import { canonicalTypeSchemaId } from "@telorun/sdk";
|
|
2
|
+
import { canonicalTypeSchemaId, OBSERVED_STATE_KEY } from "@telorun/sdk";
|
|
3
3
|
import type { Environment } from "@marcbachmann/cel-js";
|
|
4
4
|
import { defaultRegistry, isRefSentinel, isTaggedSentinel } from "@telorun/templating";
|
|
5
5
|
import { AliasResolver, scopeResolverForModule } from "./alias-resolver.js";
|
|
@@ -14,6 +14,13 @@ import { DefinitionRegistry } from "./definition-registry.js";
|
|
|
14
14
|
import { effectiveAuthorSchema } from "./extends-resolution.js";
|
|
15
15
|
import { buildDependencyGraph, formatCycle } from "./dependency-graph.js";
|
|
16
16
|
import { buildKernelGlobalsSchema, mergeKernelGlobalsIntoContext } from "./kernel-globals.js";
|
|
17
|
+
import {
|
|
18
|
+
buildObservedStateIndex,
|
|
19
|
+
buildObservedStateResourcesSchema,
|
|
20
|
+
collectRunReachableNames,
|
|
21
|
+
observedStateRead,
|
|
22
|
+
validateObservedStateDeclarations,
|
|
23
|
+
} from "./validate-observed-state.js";
|
|
17
24
|
import { computeSuggestKind } from "./kind-suggest.js";
|
|
18
25
|
import { visitManifest } from "./manifest-visitor.js";
|
|
19
26
|
import { isModuleKind } from "./module-kinds.js";
|
|
@@ -33,6 +40,7 @@ import {
|
|
|
33
40
|
import { collectValueSchemaIssues } from "./validate-value-schema.js";
|
|
34
41
|
import { DiagnosticSeverity, type AnalysisDiagnostic, type AnalysisOptions } from "./types.js";
|
|
35
42
|
import {
|
|
43
|
+
extractAccessChains,
|
|
36
44
|
extractCelRegionScopes,
|
|
37
45
|
extractContextsFromSchema,
|
|
38
46
|
getManifestItem,
|
|
@@ -717,6 +725,16 @@ function errorContextForPath(
|
|
|
717
725
|
return best?.schema;
|
|
718
726
|
}
|
|
719
727
|
|
|
728
|
+
/** Member-access chains in a CEL expression, or none when it doesn't parse.
|
|
729
|
+
* Best-effort: a syntax error is reported by the engine pass, not here. */
|
|
730
|
+
function celAccessChains(env: Environment, expr: string): string[][] {
|
|
731
|
+
try {
|
|
732
|
+
return extractAccessChains(env.parse(expr).ast);
|
|
733
|
+
} catch {
|
|
734
|
+
return [];
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
720
738
|
const CEL_PURE_RE = /^\s*\$\{\{[^}]*\}\}\s*$/;
|
|
721
739
|
const CEL_EXPR_RE = /\$\{\{\s*([^}]+?)\s*\}\}/;
|
|
722
740
|
|
|
@@ -1280,9 +1298,38 @@ export class StaticAnalyzer {
|
|
|
1280
1298
|
}
|
|
1281
1299
|
}
|
|
1282
1300
|
|
|
1301
|
+
// What each resource reports while running (`status:`), and which resources
|
|
1302
|
+
// some slot can actually start. Both feed the observed-state checks below.
|
|
1303
|
+
// A RESOURCE's kind is written in the module that declares it and is never
|
|
1304
|
+
// canonicalized (unlike a definition's `extends`, normalized at registration
|
|
1305
|
+
// above), so an exported instance's `kind: Self.X` only resolves in its own
|
|
1306
|
+
// library's scope.
|
|
1307
|
+
const moduleScopes = { aliasesByModule, rootModules };
|
|
1308
|
+
|
|
1309
|
+
const observedState = buildObservedStateIndex(allManifests, defs, aliases, moduleScopes);
|
|
1310
|
+
const reportsObservedState = [...observedState.values()].some((r) => r.status);
|
|
1311
|
+
const runReachable = reportsObservedState
|
|
1312
|
+
? collectRunReachableNames(allManifests, defs, aliases)
|
|
1313
|
+
: new Set<string>();
|
|
1314
|
+
|
|
1283
1315
|
// Build typed kernel globals schema so x-telo-context chain validation
|
|
1284
1316
|
// recognises variables, secrets, resources, env automatically
|
|
1285
|
-
const kernelGlobals = buildKernelGlobalsSchema(allManifests);
|
|
1317
|
+
const kernelGlobals = buildKernelGlobalsSchema(allManifests, observedState);
|
|
1318
|
+
|
|
1319
|
+
// Fallback context for CEL in a slot with no `x-telo-context` annotation:
|
|
1320
|
+
// everything stays open except the typed `.status` nodes, so unknown-field
|
|
1321
|
+
// checking reaches observed state everywhere without newly rejecting any
|
|
1322
|
+
// read that passes today.
|
|
1323
|
+
const observedStateContext: Record<string, any> | null =
|
|
1324
|
+
reportsObservedState
|
|
1325
|
+
? {
|
|
1326
|
+
type: "object",
|
|
1327
|
+
additionalProperties: true,
|
|
1328
|
+
properties: {
|
|
1329
|
+
resources: buildObservedStateResourcesSchema(observedState, true),
|
|
1330
|
+
},
|
|
1331
|
+
}
|
|
1332
|
+
: null;
|
|
1286
1333
|
|
|
1287
1334
|
// The module doc (Application/Library) carries the Application-only `ports`
|
|
1288
1335
|
// namespace; threaded into per-resource CEL typing so `${{ ports.X }}`
|
|
@@ -1600,6 +1647,9 @@ export class StaticAnalyzer {
|
|
|
1600
1647
|
// `x-telo-step-context` / `x-telo-error-context` scopes. A `!cel` outside
|
|
1601
1648
|
// every region is read as a literal — the runtime never evaluates it.
|
|
1602
1649
|
let celEvalPaths: string[] = [];
|
|
1650
|
+
// The compile half alone: a field that resolves at startup, where observed
|
|
1651
|
+
// state cannot exist yet.
|
|
1652
|
+
let celCompilePaths: string[] = [];
|
|
1603
1653
|
let celRegionScopes: string[] = [];
|
|
1604
1654
|
let celRuleApplies = false;
|
|
1605
1655
|
|
|
@@ -1639,9 +1689,14 @@ export class StaticAnalyzer {
|
|
|
1639
1689
|
? buildEvalPaths(capabilityDef.schema as Record<string, any>)
|
|
1640
1690
|
: { compile: [], runtime: [] };
|
|
1641
1691
|
celEvalPaths = [...own.compile, ...own.runtime, ...parent.compile, ...parent.runtime];
|
|
1692
|
+
// A `Telo.Provider`'s fields are implicitly compile-eval — the
|
|
1693
|
+
// capability abstract carries the root annotation — so its reads are
|
|
1694
|
+
// covered here without the provider restating anything.
|
|
1695
|
+
celCompilePaths = [...own.compile, ...parent.compile];
|
|
1642
1696
|
celRegionScopes = extractCelRegionScopes(ownSchema);
|
|
1643
1697
|
} else {
|
|
1644
1698
|
celEvalPaths = [];
|
|
1699
|
+
celCompilePaths = [];
|
|
1645
1700
|
celRegionScopes = [];
|
|
1646
1701
|
}
|
|
1647
1702
|
},
|
|
@@ -1674,6 +1729,44 @@ export class StaticAnalyzer {
|
|
|
1674
1729
|
return;
|
|
1675
1730
|
}
|
|
1676
1731
|
|
|
1732
|
+
// Observed state exists only while the application runs, so a path
|
|
1733
|
+
// through `.status` is illegal in a field that resolves at startup —
|
|
1734
|
+
// and a resource nothing can start reports nothing, ever. Both are
|
|
1735
|
+
// decided from the expression and the manifest alone.
|
|
1736
|
+
if (reportsObservedState && engineName === "cel" && expr.includes(OBSERVED_STATE_KEY)) {
|
|
1737
|
+
for (const chain of celAccessChains(this.celEnv, expr)) {
|
|
1738
|
+
const read = observedStateRead(chain);
|
|
1739
|
+
if (!read) continue;
|
|
1740
|
+
// An import's exported instance is indexed under `<Alias>.<name>`,
|
|
1741
|
+
// the two-level shape it publishes under, so a cross-module read
|
|
1742
|
+
// is checked exactly like a local one.
|
|
1743
|
+
const reported = observedState.get(
|
|
1744
|
+
read.alias ? `${read.alias}.${read.name}` : read.name,
|
|
1745
|
+
);
|
|
1746
|
+
|
|
1747
|
+
if (celRuleApplies && evalPathsCover(celCompilePaths, path)) {
|
|
1748
|
+
diagnostics.push({
|
|
1749
|
+
severity: DiagnosticSeverity.Error,
|
|
1750
|
+
code: "OBSERVED_STATE_IN_STARTUP_FIELD",
|
|
1751
|
+
source: SOURCE,
|
|
1752
|
+
message: `${m.kind}/${resource.name}: '${path}' is resolved once at startup, so '${chain.join(".")}' does not exist yet — '${read.name}' reports it only while the application is running. Read reported values where the call happens: a step's inputs:, a request's url, a route handler, or a returns: expression.`,
|
|
1753
|
+
data: { resource, filePath, path },
|
|
1754
|
+
});
|
|
1755
|
+
continue;
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
if (reported && !runReachable.has(read.name)) {
|
|
1759
|
+
diagnostics.push({
|
|
1760
|
+
severity: DiagnosticSeverity.Error,
|
|
1761
|
+
code: "OBSERVED_STATE_NEVER_RUN",
|
|
1762
|
+
source: SOURCE,
|
|
1763
|
+
message: `${m.kind}/${resource.name}: '${read.name}' reports '${read.field ?? OBSERVED_STATE_KEY}' only while it is running, and nothing starts it. Add '!ref ${read.name}' to a targets: list, or invoke it from a step.`,
|
|
1764
|
+
data: { resource, filePath, path },
|
|
1765
|
+
});
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1677
1770
|
let matchedContext: Record<string, any> | undefined =
|
|
1678
1771
|
e.contextSchema ?? celInvocationContext;
|
|
1679
1772
|
|
|
@@ -1723,6 +1816,12 @@ export class StaticAnalyzer {
|
|
|
1723
1816
|
allManifests: allManifests as Record<string, any>[],
|
|
1724
1817
|
});
|
|
1725
1818
|
effectiveContext = mergeKernelGlobalsIntoContext(resolvedContext, kernelGlobals);
|
|
1819
|
+
} else if (observedStateContext) {
|
|
1820
|
+
// No `x-telo-context` matched, so nothing was chain-validated here
|
|
1821
|
+
// before. Validate the observed-state segment alone rather than
|
|
1822
|
+
// merging the kernel globals, whose closed `variables` / `ports`
|
|
1823
|
+
// nodes would newly reject reads that pass today.
|
|
1824
|
+
effectiveContext = observedStateContext;
|
|
1726
1825
|
}
|
|
1727
1826
|
|
|
1728
1827
|
const engine = defaultRegistry().get(engineName);
|
|
@@ -1792,6 +1891,23 @@ export class StaticAnalyzer {
|
|
|
1792
1891
|
// kind-instead-of-instance ref there is caught statically, not at runtime.
|
|
1793
1892
|
diagnostics.push(...validateStepInvokeReferences(allManifests, defs, aliases));
|
|
1794
1893
|
|
|
1894
|
+
// `required:` inside a `status:` block — reported here rather than by the
|
|
1895
|
+
// AJV shape, which could only say "must NOT be valid" without naming the
|
|
1896
|
+
// rule or the fix.
|
|
1897
|
+
for (const issue of validateObservedStateDeclarations(allManifests)) {
|
|
1898
|
+
diagnostics.push({
|
|
1899
|
+
severity: DiagnosticSeverity.Error,
|
|
1900
|
+
code: "OBSERVED_STATE_REQUIRED_FORBIDDEN",
|
|
1901
|
+
source: SOURCE,
|
|
1902
|
+
message: issue.message,
|
|
1903
|
+
data: {
|
|
1904
|
+
resource: { kind: issue.kind, name: issue.name },
|
|
1905
|
+
filePath: issue.filePath,
|
|
1906
|
+
path: "status.required",
|
|
1907
|
+
},
|
|
1908
|
+
});
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1795
1911
|
// Validate `extends` fields and flag legacy `capability: <UserAbstract>` overload.
|
|
1796
1912
|
diagnostics.push(...validateExtends(allManifests, defs, aliases));
|
|
1797
1913
|
|
package/src/builtins.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ResourceDefinition } from "@telorun/sdk";
|
|
2
|
+
import { OBSERVED_STATE_SCHEMA } from "./validate-observed-state.js";
|
|
2
3
|
|
|
3
4
|
/** Descriptive provenance a module declares about itself, shared by
|
|
4
5
|
* `Telo.Application` and `Telo.Library`.
|
|
@@ -186,6 +187,7 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
|
|
|
186
187
|
},
|
|
187
188
|
capability: { type: "string" },
|
|
188
189
|
schema: { type: "object", additionalProperties: true },
|
|
190
|
+
status: OBSERVED_STATE_SCHEMA,
|
|
189
191
|
},
|
|
190
192
|
required: ["metadata"],
|
|
191
193
|
// Telo.Abstract is an extension point by design — it must accept forward-compatible
|
|
@@ -214,6 +216,7 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
|
|
|
214
216
|
type: "object",
|
|
215
217
|
additionalProperties: true,
|
|
216
218
|
properties: {
|
|
219
|
+
status: OBSERVED_STATE_SCHEMA,
|
|
217
220
|
resources: {
|
|
218
221
|
type: "array",
|
|
219
222
|
items: {
|
|
@@ -2,8 +2,17 @@ import type { ResourceDefinition } from "@telorun/sdk";
|
|
|
2
2
|
import { mergeTypeSchemas } from "@telorun/sdk";
|
|
3
3
|
|
|
4
4
|
/** Resolves a kind string (canonical or alias form, depending on the caller's
|
|
5
|
-
* registry) to its `Telo.Definition` / `Telo.Abstract`, or undefined.
|
|
6
|
-
|
|
5
|
+
* registry) to its `Telo.Definition` / `Telo.Abstract`, or undefined.
|
|
6
|
+
*
|
|
7
|
+
* `from` is the definition the kind was read off. An `extends` alias belongs to
|
|
8
|
+
* the file that DECLARES the definition, not to whoever is reading it, so a
|
|
9
|
+
* resolver that walks an inheritance chain across module boundaries must
|
|
10
|
+
* re-scope at each hop — `from.metadata.module` is what it scopes to. Resolvers
|
|
11
|
+
* that operate in a single scope ignore the parameter. */
|
|
12
|
+
export type DefResolver = (
|
|
13
|
+
kind: string,
|
|
14
|
+
from?: ResourceDefinition,
|
|
15
|
+
) => ResourceDefinition | undefined;
|
|
7
16
|
|
|
8
17
|
/** The template-body / controller fields a definition may carry. Kept local
|
|
9
18
|
* because `ResourceDefinition` intentionally types only the stable surface;
|
|
@@ -19,6 +28,7 @@ interface DefinitionBody {
|
|
|
19
28
|
resources?: unknown[];
|
|
20
29
|
base?: Record<string, unknown>;
|
|
21
30
|
schema?: Record<string, any>;
|
|
31
|
+
status?: Record<string, any>;
|
|
22
32
|
}
|
|
23
33
|
|
|
24
34
|
const body = (def: ResourceDefinition | undefined): DefinitionBody =>
|
|
@@ -32,7 +42,7 @@ export function resolveParent(
|
|
|
32
42
|
): ResourceDefinition | undefined {
|
|
33
43
|
const ext = body(def).extends;
|
|
34
44
|
if (typeof ext !== "string" || ext.length === 0) return undefined;
|
|
35
|
-
return resolve(ext);
|
|
45
|
+
return resolve(ext, def);
|
|
36
46
|
}
|
|
37
47
|
|
|
38
48
|
/** The `extends` ancestor chain, nearest-first, excluding `def` itself.
|
|
@@ -122,3 +132,27 @@ export function effectiveAuthorSchema(
|
|
|
122
132
|
const parentSchema = effectiveAuthorSchema(parent, resolve);
|
|
123
133
|
return mergeTypeSchemas([parentSchema, own]) as Record<string, any>;
|
|
124
134
|
}
|
|
135
|
+
|
|
136
|
+
/** The observed state a kind reports (`status:`), folded through `extends`:
|
|
137
|
+
* - with `base:` present → the **parent's** effective status unchanged; the
|
|
138
|
+
* child delegates to the parent's controller and *is* a parent instance, so
|
|
139
|
+
* it publishes exactly what the parent publishes.
|
|
140
|
+
* - without `base:` but with `extends` → `merge(parent-effective, own)`, so a
|
|
141
|
+
* contract can mandate what its implementations report and an implementation
|
|
142
|
+
* can add to it.
|
|
143
|
+
* - no `extends` → the own block unchanged.
|
|
144
|
+
* Undefined when nothing in the chain declares one — the signal that the kind
|
|
145
|
+
* has not opted into typed `.status` reads. */
|
|
146
|
+
export function effectiveStatusSchema(
|
|
147
|
+
def: ResourceDefinition | undefined,
|
|
148
|
+
resolve: DefResolver,
|
|
149
|
+
): Record<string, any> | undefined {
|
|
150
|
+
const own = body(def).status;
|
|
151
|
+
const parent = resolveParent(def, resolve);
|
|
152
|
+
if (!parent) return own;
|
|
153
|
+
const parentStatus = effectiveStatusSchema(parent, resolve);
|
|
154
|
+
if (body(def).base) return parentStatus;
|
|
155
|
+
if (!parentStatus) return own;
|
|
156
|
+
if (!own) return parentStatus;
|
|
157
|
+
return mergeTypeSchemas([parentStatus, own]) as Record<string, any>;
|
|
158
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -24,10 +24,23 @@ export {
|
|
|
24
24
|
type ReExportSpec,
|
|
25
25
|
} from "./flatten-for-analyzer.js";
|
|
26
26
|
export { buildEvalPaths, evalPathCovers } from "./eval-paths.js";
|
|
27
|
+
export {
|
|
28
|
+
applyObservedStateNode,
|
|
29
|
+
buildObservedStateIndex,
|
|
30
|
+
buildObservedStateResourcesSchema,
|
|
31
|
+
collectRunReachableNames,
|
|
32
|
+
observedStateRead,
|
|
33
|
+
validateObservedStateDeclarations,
|
|
34
|
+
OBSERVED_STATE_SCHEMA,
|
|
35
|
+
} from "./validate-observed-state.js";
|
|
36
|
+
export type { AnalyzedResource, ObservedStateRead } from "./validate-observed-state.js";
|
|
37
|
+
export { moduleScopedDefResolver, scopeResolverForModule } from "./alias-resolver.js";
|
|
38
|
+
export type { ModuleScopes } from "./alias-resolver.js";
|
|
27
39
|
export {
|
|
28
40
|
ancestorChain,
|
|
29
41
|
controllerBearingAncestor,
|
|
30
42
|
effectiveAuthorSchema,
|
|
43
|
+
effectiveStatusSchema,
|
|
31
44
|
hasOwnControllerOrTemplate,
|
|
32
45
|
inheritedCapability,
|
|
33
46
|
isInheritedDelegation,
|