@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
package/src/kernel-globals.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ResourceManifest } from "@telorun/sdk";
|
|
2
2
|
import { residualEntrySchemaMap } from "./residual-schema.js";
|
|
3
|
+
import { applyObservedStateNode } from "./validate-observed-state.js";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Kernel global names available in every CEL evaluation context at runtime.
|
|
@@ -36,6 +37,11 @@ const SYSTEM_KINDS = new Set([
|
|
|
36
37
|
*/
|
|
37
38
|
export function buildKernelGlobalsSchema(
|
|
38
39
|
manifests: ResourceManifest[],
|
|
40
|
+
/** Every resource a CEL read can name, including scope-declared ones (see
|
|
41
|
+
* `buildObservedStateIndex`). Kinds that declare a `status:` get a typed,
|
|
42
|
+
* closed `status` node; every other resource node stays open, so no flat read
|
|
43
|
+
* that passes today can start failing. */
|
|
44
|
+
resources?: ReadonlyMap<string, { kind: string; status?: Record<string, any> }>,
|
|
39
45
|
): Record<string, any> {
|
|
40
46
|
const moduleManifest =
|
|
41
47
|
(manifests.find((m) => m.kind === "Telo.Application") as
|
|
@@ -55,6 +61,20 @@ export function buildKernelGlobalsSchema(
|
|
|
55
61
|
resourceProps[name] = { type: "object", additionalProperties: true };
|
|
56
62
|
}
|
|
57
63
|
}
|
|
64
|
+
// Scope-declared resources (a `Run.Sequence`'s `with:`) publish like any other
|
|
65
|
+
// now, so their names resolve too — inside the scope's regions, which is where
|
|
66
|
+
// the only expressions that can name them live.
|
|
67
|
+
for (const [key, entry] of resources ?? []) {
|
|
68
|
+
if (key.includes(".")) continue;
|
|
69
|
+
resourceProps[key] ??= { type: "object", additionalProperties: true };
|
|
70
|
+
if (entry.status) applyObservedStateNode(resourceProps, key, entry.status);
|
|
71
|
+
}
|
|
72
|
+
// Imports' exported instances publish two levels deep (`resources.<Alias>.<name>`);
|
|
73
|
+
// the alias node stays open so its other keys keep resolving.
|
|
74
|
+
for (const [key, entry] of resources ?? []) {
|
|
75
|
+
if (!key.includes(".") || !entry.status) continue;
|
|
76
|
+
applyObservedStateNode(resourceProps, key, entry.status);
|
|
77
|
+
}
|
|
58
78
|
|
|
59
79
|
return {
|
|
60
80
|
type: "object",
|
|
@@ -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
|
+
}
|