@telorun/analyzer 0.45.0 → 0.47.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 (37) hide show
  1. package/dist/alias-resolver.d.ts +45 -0
  2. package/dist/alias-resolver.d.ts.map +1 -1
  3. package/dist/alias-resolver.js +33 -0
  4. package/dist/analyzer.d.ts.map +1 -1
  5. package/dist/analyzer.js +106 -5
  6. package/dist/builtins.d.ts.map +1 -1
  7. package/dist/builtins.js +3 -0
  8. package/dist/extends-resolution.d.ts +19 -2
  9. package/dist/extends-resolution.d.ts.map +1 -1
  10. package/dist/extends-resolution.js +25 -1
  11. package/dist/index.d.ts +5 -1
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +3 -1
  14. package/dist/kernel-globals.d.ts +9 -1
  15. package/dist/kernel-globals.d.ts.map +1 -1
  16. package/dist/kernel-globals.js +24 -1
  17. package/dist/resolve-ref-sentinels.d.ts +13 -1
  18. package/dist/resolve-ref-sentinels.d.ts.map +1 -1
  19. package/dist/resolve-ref-sentinels.js +56 -5
  20. package/dist/validate-cel-context.d.ts.map +1 -1
  21. package/dist/validate-cel-context.js +17 -1
  22. package/dist/validate-observed-state.d.ts +98 -0
  23. package/dist/validate-observed-state.d.ts.map +1 -0
  24. package/dist/validate-observed-state.js +304 -0
  25. package/dist/validate-references.d.ts.map +1 -1
  26. package/dist/validate-references.js +8 -2
  27. package/package.json +2 -2
  28. package/src/alias-resolver.ts +58 -0
  29. package/src/analyzer.ts +126 -4
  30. package/src/builtins.ts +3 -0
  31. package/src/extends-resolution.ts +37 -3
  32. package/src/index.ts +13 -0
  33. package/src/kernel-globals.ts +20 -0
  34. package/src/resolve-ref-sentinels.ts +68 -4
  35. package/src/validate-cel-context.ts +20 -1
  36. package/src/validate-observed-state.ts +354 -0
  37. package/src/validate-references.ts +8 -2
@@ -0,0 +1,354 @@
1
+ import type { ResourceDefinition, ResourceManifest } from "@telorun/sdk";
2
+ import { OBSERVED_STATE_KEY } from "@telorun/sdk";
3
+ import { type DefResolver, effectiveStatusSchema } from "./extends-resolution.js";
4
+ import { parseExportEntry } from "./flatten-for-analyzer.js";
5
+ import { moduleScopedDefResolver, type ModuleScopes } from "./alias-resolver.js";
6
+ import {
7
+ buildReferenceFieldMap,
8
+ isRefEntry,
9
+ isScopeEntry,
10
+ resolveFieldValues,
11
+ } from "./reference-field-map.js";
12
+
13
+ /** The kernel capabilities whose `run()` the kernel dispatches. A ref slot that
14
+ * accepts one of them is a slot that can start a resource — `targets:` on an
15
+ * Application or a `Run.Sequence`, and a step's `invoke:` (whose schema accepts
16
+ * `Telo.Runnable` alongside `Telo.Invocable`, and which the kernel dispatches
17
+ * through `run()` when the target has no `invoke()`). Keyed on the declared
18
+ * capability, never on a field name or a kind, so any composer that accepts a
19
+ * runnable participates without the analyzer knowing about it. */
20
+ const RUN_DISPATCH_CONTRACTS = new Set(["Telo.Runnable", "Telo.Service"]);
21
+
22
+ const SYSTEM_KINDS = new Set([
23
+ "Telo.Definition",
24
+ "Telo.Abstract",
25
+ "Telo.Import",
26
+ "Telo.Application",
27
+ "Telo.Library",
28
+ ]);
29
+
30
+ /**
31
+ * The `status:` block's own schema — a plain JSON Schema, structurally. The one
32
+ * normative restriction (`required:` is rejected) is enforced by
33
+ * {@link validateObservedStateDeclarations} rather than here, so the author gets
34
+ * a message naming the rule and the fix instead of AJV's "must NOT be valid".
35
+ *
36
+ * Exported from the analyzer and re-used by the kernel's manifest schemas, so
37
+ * the rule has one definition rather than two kept in sync by hand.
38
+ */
39
+ export const OBSERVED_STATE_SCHEMA = {
40
+ type: "object",
41
+ additionalProperties: true,
42
+ };
43
+
44
+ /**
45
+ * `required:` inside a `status:` block. Every declared field is mandatory once
46
+ * the resource has run, so the list would be either redundant or a lie; a
47
+ * genuinely sometimes-absent value is declared with a nullable type, which
48
+ * `CEL_NULLABLE_ACCESS` already guards.
49
+ */
50
+ export function validateObservedStateDeclarations(
51
+ manifests: readonly ResourceManifest[],
52
+ ): Array<{ kind: string; name: string; filePath?: string; message: string }> {
53
+ const out: Array<{ kind: string; name: string; filePath?: string; message: string }> = [];
54
+ for (const m of manifests) {
55
+ if (m.kind !== "Telo.Definition" && m.kind !== "Telo.Abstract") continue;
56
+ const status = (m as { status?: Record<string, any> }).status;
57
+ if (!status || typeof status !== "object" || !Array.isArray(status.required)) continue;
58
+ const name = (m.metadata?.name as string | undefined) ?? "<unnamed>";
59
+ out.push({
60
+ kind: m.kind as string,
61
+ name,
62
+ filePath: (m.metadata as { source?: string } | undefined)?.source,
63
+ message:
64
+ `${m.kind}/${name}: 'status:' must not declare 'required:' — every field a kind declares ` +
65
+ `it reports is mandatory once the resource has run, so the list is either redundant or a ` +
66
+ `lie. Declare a sometimes-absent field with a nullable type instead ` +
67
+ `(e.g. type: [string, "null"]); CEL_NULLABLE_ACCESS then forces the reader to guard it.`,
68
+ });
69
+ }
70
+ return out;
71
+ }
72
+
73
+ /** A CEL access into a resource's observed-state segment. */
74
+ export interface ObservedStateRead {
75
+ /** Import alias, when the read crosses a module boundary
76
+ * (`resources.<Alias>.<name>.status`). */
77
+ alias?: string;
78
+ /** Resource name. */
79
+ name: string;
80
+ /** The field read under `.status`, when the chain names one. */
81
+ field?: string;
82
+ }
83
+
84
+ /**
85
+ * Recognise an observed-state read in a member-access chain. Purely syntactic —
86
+ * it inspects the chain, not the topology — so the availability rule it feeds
87
+ * applies to every kind, declared or not.
88
+ *
89
+ * `resources.<name>.status.<field>` and the two-level cross-module form
90
+ * `resources.<Alias>.<name>.status.<field>` are both observed-state reads.
91
+ */
92
+ export function observedStateRead(chain: readonly string[]): ObservedStateRead | undefined {
93
+ if (chain[0] !== "resources") return undefined;
94
+ if (chain[2] === OBSERVED_STATE_KEY) return { name: chain[1]!, field: chain[3] };
95
+ if (chain[3] === OBSERVED_STATE_KEY) {
96
+ return { alias: chain[1], name: chain[2]!, field: chain[4] };
97
+ }
98
+ return undefined;
99
+ }
100
+
101
+ /**
102
+ * The names of every resource some slot can start: referenced from a ref slot
103
+ * that accepts a `Telo.Runnable` / `Telo.Service`, or named as a step's
104
+ * `invoke:` target. A resource in none of them can never `run()`, so it can
105
+ * never report observed state.
106
+ *
107
+ * Deliberately an over-approximation — a name reachable through any of these
108
+ * routes counts as runnable — because the cost of a false "can never run" is a
109
+ * valid manifest rejected, while the cost of a miss is only that the reader
110
+ * finds out at runtime instead, with a message that names the same fix.
111
+ */
112
+ export function collectRunReachableNames(
113
+ manifests: readonly ResourceManifest[],
114
+ defs: { resolve(kind: string): ResourceDefinition | undefined },
115
+ aliases?: { resolveKind(kind: string): string | undefined },
116
+ ): Set<string> {
117
+ const names = new Set<string>();
118
+ const resolve: DefResolver = (kind) =>
119
+ defs.resolve(aliases?.resolveKind(kind) ?? kind) ?? defs.resolve(kind);
120
+
121
+ for (const manifest of manifests) {
122
+ const def = resolve(manifest.kind as string);
123
+ const schema = def?.schema as Record<string, any> | undefined;
124
+ if (!schema) continue;
125
+
126
+ for (const [path, entry] of buildReferenceFieldMap(schema)) {
127
+ if (!isRefEntry(entry)) continue;
128
+ if (!entry.refs.some((ref) => RUN_DISPATCH_CONTRACTS.has(ref))) continue;
129
+ for (const value of resolveFieldValues(manifest, path)) collectRefName(value, names);
130
+ }
131
+
132
+ // Step arrays nest through `if` / `while` / `switch` / `try`, and the step
133
+ // `invoke:` slot sits behind a local `$ref` the field map does not follow.
134
+ // Match the declared invoke key at any depth instead of re-deriving the
135
+ // nesting rules — over-approximating in the safe direction.
136
+ const invokeKey = stepInvokeKey(schema);
137
+ if (invokeKey) collectKeyedRefs(manifest, invokeKey, names);
138
+ }
139
+
140
+ return names;
141
+ }
142
+
143
+ /** The property name a kind's `x-telo-step-context` declares as its dispatch
144
+ * slot (`invoke`), or undefined when the kind has no step array. */
145
+ function stepInvokeKey(schema: Record<string, any>): string | undefined {
146
+ for (const fieldSchema of Object.values(
147
+ (schema.properties ?? {}) as Record<string, any>,
148
+ )) {
149
+ const stepCtx = fieldSchema?.["x-telo-step-context"] as
150
+ | Record<string, string>
151
+ | undefined;
152
+ if (stepCtx?.invoke) return stepCtx.invoke;
153
+ }
154
+ return undefined;
155
+ }
156
+
157
+ /** Collect ref names at every `key` property anywhere in `node`. */
158
+ function collectKeyedRefs(node: unknown, key: string, out: Set<string>): void {
159
+ if (Array.isArray(node)) {
160
+ for (const item of node) collectKeyedRefs(item, key, out);
161
+ return;
162
+ }
163
+ if (node === null || typeof node !== "object") return;
164
+ for (const [k, value] of Object.entries(node as Record<string, unknown>)) {
165
+ if (k === key) collectRefName(value, out);
166
+ collectKeyedRefs(value, key, out);
167
+ }
168
+ }
169
+
170
+ /** Record the resource name a slot value points at — a resolved `{kind, name}`
171
+ * ref, an unresolved `!ref` sentinel, or a `{ ref }` / `{ invoke }` wrapper. */
172
+ function collectRefName(value: unknown, out: Set<string>): void {
173
+ if (value === null || typeof value !== "object") return;
174
+ if (Array.isArray(value)) {
175
+ for (const item of value) collectRefName(item, out);
176
+ return;
177
+ }
178
+ const v = value as Record<string, unknown>;
179
+ if (typeof v.name === "string") out.add(v.name);
180
+ if (typeof v.source === "string") {
181
+ const dot = v.source.lastIndexOf(".");
182
+ out.add(dot >= 0 ? v.source.slice(dot + 1) : v.source);
183
+ }
184
+ for (const wrapper of ["ref", "invoke"]) {
185
+ if (v[wrapper] !== undefined) collectRefName(v[wrapper], out);
186
+ }
187
+ }
188
+
189
+ /** What a resource name resolves to for CEL purposes. `status` is present only
190
+ * when the kind declares one; `scoped` marks a resource declared inside an
191
+ * `x-telo-scope` slot, which resolves only within that scope's regions. */
192
+ export interface AnalyzedResource {
193
+ kind: string;
194
+ status?: Record<string, any>;
195
+ scoped?: boolean;
196
+ }
197
+
198
+ /**
199
+ * Index every resource a CEL `resources.…` read can name: the module's own
200
+ * top-level resources, the ones declared inside `x-telo-scope` slots (a
201
+ * `Run.Sequence`'s `with:`), and each import's exported instances — keyed
202
+ * `<Alias>.<name>`, the two-level shape those publish under.
203
+ *
204
+ * Scope slots are found through the declaring kind's schema annotation, not by
205
+ * field name, so any composer with a scope participates.
206
+ */
207
+ export function buildObservedStateIndex(
208
+ manifests: readonly ResourceManifest[],
209
+ defs: { resolve(kind: string): ResourceDefinition | undefined },
210
+ aliases?: { resolveKind(kind: string): string | undefined; moduleForAlias?(alias: string): string | undefined },
211
+ scopes?: ModuleScopes,
212
+ ): Map<string, AnalyzedResource> {
213
+ const out = new Map<string, AnalyzedResource>();
214
+ const resolve = moduleScopedDefResolver(defs, aliases, scopes);
215
+
216
+ /** `module` is the resource's DECLARING module: an exported instance is
217
+ * written with that library's aliases (`kind: Self.Listener`), which the
218
+ * consumer's table cannot resolve. */
219
+ const record = (kind: string, key: string, scoped: boolean, module?: string): void => {
220
+ const status = effectiveStatusSchema(resolve.in(kind, module), resolve);
221
+ out.set(key, { kind, ...(status ? { status } : {}), ...(scoped ? { scoped } : {}) });
222
+ };
223
+
224
+ for (const manifest of manifests) {
225
+ const kind = manifest.kind as string | undefined;
226
+ const name = manifest.metadata?.name as string | undefined;
227
+ if (!kind || SYSTEM_KINDS.has(kind)) continue;
228
+ if (name) record(kind, name, false, manifest.metadata?.module as string | undefined);
229
+
230
+ const schema = resolve(kind)?.schema as Record<string, any> | undefined;
231
+ if (!schema) continue;
232
+ for (const [path, entry] of buildReferenceFieldMap(schema)) {
233
+ if (!isScopeEntry(entry)) continue;
234
+ for (const value of resolveFieldValues(manifest, path)) {
235
+ for (const scopedEntry of Array.isArray(value) ? value : [value]) {
236
+ const scopedKind = (scopedEntry as ResourceManifest)?.kind;
237
+ const scopedName = (scopedEntry as ResourceManifest)?.metadata?.name;
238
+ if (typeof scopedKind === "string" && typeof scopedName === "string") {
239
+ record(scopedKind, scopedName, true);
240
+ }
241
+ }
242
+ }
243
+ }
244
+ }
245
+
246
+ for (const [alias, name, kind, module] of importedExports(manifests, aliases)) {
247
+ record(kind, `${alias}.${name}`, false, module);
248
+ }
249
+ return out;
250
+ }
251
+
252
+ /**
253
+ * Every `<alias, exported name, kind>` an import makes readable as
254
+ * `resources.<Alias>.<name>`. The importer's `Telo.Import` docs give the
255
+ * aliases; the exported instances are the ones already stamped
256
+ * `metadata.forwardedExport` by `selectModuleManifestsForAnalysis` — the module
257
+ * doc that declared `exports.resources` is dropped for non-root modules, so the
258
+ * stamp, not the declaration, is what survives into the consumer's manifest
259
+ * list. A module doc is still consulted when one IS present (a single-library
260
+ * analysis, the editor's projection).
261
+ */
262
+ function* importedExports(
263
+ manifests: readonly ResourceManifest[],
264
+ aliases?: { moduleForAlias?(alias: string): string | undefined },
265
+ ): Generator<[alias: string, name: string, kind: string, module: string]> {
266
+ if (!aliases?.moduleForAlias) return;
267
+
268
+ const declaredByModule = new Map<string, Set<string>>();
269
+ for (const m of manifests) {
270
+ if (m.kind !== "Telo.Library") continue;
271
+ const libName = (m.metadata?.name ?? m.metadata?.module) as string | undefined;
272
+ const declared = (m as { exports?: { resources?: unknown[] } }).exports?.resources;
273
+ if (!libName || !Array.isArray(declared)) continue;
274
+ declaredByModule.set(
275
+ libName,
276
+ new Set(
277
+ declared
278
+ .filter((e): e is string => typeof e === "string")
279
+ .map((e) => parseExportEntry(e).name),
280
+ ),
281
+ );
282
+ }
283
+
284
+ const exportsByModule = new Map<string, Map<string, string>>();
285
+ for (const m of manifests) {
286
+ const module = m.metadata?.module as string | undefined;
287
+ const name = m.metadata?.name as string | undefined;
288
+ if (!module || !name || SYSTEM_KINDS.has(m.kind as string)) continue;
289
+ const forwarded = (m.metadata as { forwardedExport?: boolean } | undefined)?.forwardedExport;
290
+ if (!forwarded && !declaredByModule.get(module)?.has(name)) continue;
291
+ let byName = exportsByModule.get(module);
292
+ if (!byName) exportsByModule.set(module, (byName = new Map()));
293
+ byName.set(name, m.kind as string);
294
+ }
295
+
296
+ for (const m of manifests) {
297
+ if (m.kind !== "Telo.Import") continue;
298
+ const alias = m.metadata?.name as string | undefined;
299
+ if (!alias) continue;
300
+ const targetModule = aliases.moduleForAlias(alias);
301
+ const exported = targetModule && exportsByModule.get(targetModule);
302
+ if (!exported) continue;
303
+ for (const [name, kind] of exported) yield [alias, name, kind, targetModule];
304
+ }
305
+ }
306
+
307
+ /** The `resources` node of a CEL context schema: one entry per resource, each
308
+ * open except for a typed, closed `status` node on kinds that declare one.
309
+ * `open` keeps the map itself permissive, so unknown resource names and every
310
+ * flat field pass exactly as they do today. */
311
+ export function buildObservedStateResourcesSchema(
312
+ index: ReadonlyMap<string, AnalyzedResource>,
313
+ open: boolean,
314
+ ): Record<string, any> {
315
+ const properties: Record<string, any> = {};
316
+ for (const [key, { status }] of index) {
317
+ if (!status) continue;
318
+ applyObservedStateNode(properties, key, status);
319
+ }
320
+ return open
321
+ ? { type: "object", additionalProperties: true, properties }
322
+ : { type: "object", properties };
323
+ }
324
+
325
+ /**
326
+ * Write the typed `status` node for one index key into a `resources` property
327
+ * map. A dotted key (`Alias.name`) is an import's exported instance, which
328
+ * publishes two levels deep — the alias node stays open so every other name
329
+ * under it keeps resolving as it does today.
330
+ */
331
+ export function applyObservedStateNode(
332
+ properties: Record<string, any>,
333
+ key: string,
334
+ status: Record<string, any>,
335
+ ): void {
336
+ const dot = key.indexOf(".");
337
+ const leaf = {
338
+ type: "object",
339
+ additionalProperties: true,
340
+ properties: { [OBSERVED_STATE_KEY]: { ...status, additionalProperties: false } },
341
+ };
342
+ if (dot < 0) {
343
+ properties[key] = leaf;
344
+ return;
345
+ }
346
+ const alias = key.slice(0, dot);
347
+ const name = key.slice(dot + 1);
348
+ const aliasNode = (properties[alias] ??= {
349
+ type: "object",
350
+ additionalProperties: true,
351
+ properties: {},
352
+ });
353
+ (aliasNode.properties ??= {})[name] = leaf;
354
+ }
@@ -267,9 +267,15 @@ export function validateReferences(
267
267
 
268
268
  // Local reference (bare name or explicit `Self.`-qualified).
269
269
  const localName = aliasPrefix === "Self" ? refName.slice(dot + 1) : refName;
270
+ // Scope-local FIRST, enclosing module as the fallback — the order the
271
+ // runtime uses at every name-resolution site (`ScopeContext.getInstance`,
272
+ // `ResourceContext.resolveRef`, and the CEL `resources` layering). Module-first
273
+ // here would validate a shadowed name against the resource the kernel will
274
+ // never bind: a false pass when the outer kind fits and the scoped one does
275
+ // not, a false REFERENCE_KIND_MISMATCH when it is the other way round.
270
276
  const target =
271
- byName.get(localName) ??
272
- visibleScopeManifests.find((m) => m.metadata?.name === localName);
277
+ visibleScopeManifests.find((m) => m.metadata?.name === localName) ??
278
+ byName.get(localName);
273
279
  if (!target) {
274
280
  diagnostics.push({
275
281
  severity: DiagnosticSeverity.Error,