@telorun/analyzer 0.53.0 → 0.55.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 +20 -0
- package/dist/analysis-registry.d.ts.map +1 -1
- package/dist/analysis-registry.js +36 -3
- package/dist/analyzer.d.ts +3 -2
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/analyzer.js +188 -26
- package/dist/builtins.d.ts.map +1 -1
- package/dist/builtins.js +32 -12
- package/dist/call-graph.d.ts +189 -0
- package/dist/call-graph.d.ts.map +1 -0
- package/dist/call-graph.js +617 -0
- package/dist/dependency-graph.d.ts +17 -7
- package/dist/dependency-graph.d.ts.map +1 -1
- package/dist/dependency-graph.js +36 -65
- package/dist/flatten-for-analyzer.d.ts +8 -0
- package/dist/flatten-for-analyzer.d.ts.map +1 -1
- package/dist/flatten-for-analyzer.js +32 -0
- package/dist/index.d.ts +14 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -1
- package/dist/manifest-navigation.d.ts +32 -0
- package/dist/manifest-navigation.d.ts.map +1 -0
- package/dist/manifest-navigation.js +91 -0
- package/dist/manifest-visitor.js +1 -1
- package/dist/ref-slot.d.ts +125 -0
- package/dist/ref-slot.d.ts.map +1 -0
- package/dist/ref-slot.js +226 -0
- package/dist/reference-field-map.d.ts +15 -1
- package/dist/reference-field-map.d.ts.map +1 -1
- package/dist/reference-field-map.js +29 -35
- package/dist/resolve-schema-ref-kinds.d.ts +4 -0
- package/dist/resolve-schema-ref-kinds.d.ts.map +1 -1
- package/dist/resolve-schema-ref-kinds.js +31 -8
- package/dist/resolve-zone-requirements.d.ts +110 -0
- package/dist/resolve-zone-requirements.d.ts.map +1 -0
- package/dist/resolve-zone-requirements.js +541 -0
- package/dist/types.d.ts +8 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/validate-observed-state.d.ts +14 -13
- package/dist/validate-observed-state.d.ts.map +1 -1
- package/dist/validate-observed-state.js +21 -88
- package/dist/validate-ref-slots.d.ts +48 -0
- package/dist/validate-ref-slots.d.ts.map +1 -0
- package/dist/validate-ref-slots.js +219 -0
- package/dist/validate-references.d.ts.map +1 -1
- package/dist/validate-references.js +8 -1
- package/dist/validate-zone-slots.d.ts +39 -0
- package/dist/validate-zone-slots.d.ts.map +1 -0
- package/dist/validate-zone-slots.js +114 -0
- package/dist/zone-module-documents.d.ts +27 -0
- package/dist/zone-module-documents.d.ts.map +1 -0
- package/dist/zone-module-documents.js +1 -0
- package/dist/zone-slot.d.ts +61 -0
- package/dist/zone-slot.d.ts.map +1 -0
- package/dist/zone-slot.js +91 -0
- package/package.json +3 -3
- package/src/analysis-registry.ts +36 -2
- package/src/analyzer.ts +206 -24
- package/src/builtins.ts +32 -12
- package/src/call-graph.ts +827 -0
- package/src/dependency-graph.ts +34 -68
- package/src/flatten-for-analyzer.ts +32 -0
- package/src/index.ts +47 -0
- package/src/manifest-navigation.ts +91 -0
- package/src/manifest-visitor.ts +1 -1
- package/src/ref-slot.ts +273 -0
- package/src/reference-field-map.ts +39 -36
- package/src/resolve-schema-ref-kinds.ts +34 -7
- package/src/resolve-zone-requirements.ts +781 -0
- package/src/types.ts +8 -0
- package/src/validate-observed-state.ts +26 -92
- package/src/validate-ref-slots.ts +293 -0
- package/src/validate-references.ts +8 -1
- package/src/validate-zone-slots.ts +175 -0
- package/src/zone-module-documents.ts +27 -0
- package/src/zone-slot.ts +116 -0
|
@@ -0,0 +1,617 @@
|
|
|
1
|
+
import { isRefSentinel, isTaggedSentinel } from "@telorun/templating";
|
|
2
|
+
import { enclosingOf, propertySchemas, resolveLocalRef, } from "./manifest-navigation.js";
|
|
3
|
+
import { visitManifest } from "./manifest-visitor.js";
|
|
4
|
+
import { possibleUses, readRefSlot, transfersControl, } from "./ref-slot.js";
|
|
5
|
+
import { isRefEntry, resolveFieldEntries } from "./reference-field-map.js";
|
|
6
|
+
import { DEPENDENCY_GRAPH_SKIP_KINDS as SYSTEM_KINDS } from "./system-kinds.js";
|
|
7
|
+
export const resourceId = (kind, name) => `${kind}\0${name}`;
|
|
8
|
+
/**
|
|
9
|
+
* Does control reach this edge's target?
|
|
10
|
+
*
|
|
11
|
+
* An edge whose slot declares NO use — the bare-string form, still accepted
|
|
12
|
+
* while the ecosystem migrates, and every value-tree-discovered ref — counts as
|
|
13
|
+
* control-transferring. That is the conservative direction for every consumer
|
|
14
|
+
* of this predicate: the cost of a false "control reaches here" is a check that
|
|
15
|
+
* stays silent, while the cost of a false "it never does" is a valid manifest
|
|
16
|
+
* rejected. It is also exactly what the walkers this replaced did, so an
|
|
17
|
+
* unannotated third-party kind behaves as it did before. The branch disappears
|
|
18
|
+
* when `use` becomes mandatory.
|
|
19
|
+
*/
|
|
20
|
+
function reachesTarget(edge) {
|
|
21
|
+
return edge.use.length === 0 || edge.use.some(transfersControl);
|
|
22
|
+
}
|
|
23
|
+
/** Navigate a JSON Pointer relative to the object enclosing the annotated slot.
|
|
24
|
+
*
|
|
25
|
+
* One rule serves a resource-level sibling and an array item's sibling, and
|
|
26
|
+
* nothing can address across an array boundary — if a case for root anchoring
|
|
27
|
+
* ever appears it gets its own spelling, the split `x-telo-context-from` /
|
|
28
|
+
* `x-telo-context-from-root` already make. */
|
|
29
|
+
function navigatePointer(enclosing, pointer) {
|
|
30
|
+
if (!pointer.startsWith("/"))
|
|
31
|
+
return undefined;
|
|
32
|
+
let current = enclosing;
|
|
33
|
+
for (const rawSegment of pointer.slice(1).split("/")) {
|
|
34
|
+
if (current == null || typeof current !== "object")
|
|
35
|
+
return undefined;
|
|
36
|
+
const segment = rawSegment.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
37
|
+
current = Array.isArray(current)
|
|
38
|
+
? current[Number(segment)]
|
|
39
|
+
: current[segment];
|
|
40
|
+
}
|
|
41
|
+
return current;
|
|
42
|
+
}
|
|
43
|
+
const NO_DEFAULT = () => undefined;
|
|
44
|
+
/** Schema-declared `default:` for a selector pointer, resolved against the
|
|
45
|
+
* schema of the object ENCLOSING the annotated slot — the same anchoring the
|
|
46
|
+
* runtime value walk uses. This is what classifies the common spelling: a
|
|
47
|
+
* `Lease.Critical` that omits `detach:` takes the schema's `default: false`
|
|
48
|
+
* and is a `call` edge, not an unresolved one. */
|
|
49
|
+
function schemaDefaultOf(enclosingSchema) {
|
|
50
|
+
if (!enclosingSchema)
|
|
51
|
+
return NO_DEFAULT;
|
|
52
|
+
return (pointer) => {
|
|
53
|
+
if (!pointer.startsWith("/"))
|
|
54
|
+
return undefined;
|
|
55
|
+
let current = enclosingSchema;
|
|
56
|
+
let value;
|
|
57
|
+
for (const rawSegment of pointer.slice(1).split("/")) {
|
|
58
|
+
if (!current)
|
|
59
|
+
return undefined;
|
|
60
|
+
const segment = rawSegment.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
61
|
+
const next = propertySchemas(current).find(([k]) => k === segment)?.[1];
|
|
62
|
+
if (!next)
|
|
63
|
+
return undefined;
|
|
64
|
+
value = next.default;
|
|
65
|
+
current = next;
|
|
66
|
+
}
|
|
67
|
+
return value;
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/** The schema node describing the object that ENCLOSES a slot, from the slot's
|
|
71
|
+
* field-map path (`routes[].handler` → `routes`' item schema). Follows `[]`
|
|
72
|
+
* into `items` and `{}` into `additionalProperties`, resolving local `$ref`s. */
|
|
73
|
+
function enclosingSchemaOf(rootSchema, slotFieldPath) {
|
|
74
|
+
const segments = slotFieldPath.split(".");
|
|
75
|
+
segments.pop(); // the slot itself — we want its parent object
|
|
76
|
+
let current = rootSchema;
|
|
77
|
+
for (const segment of segments) {
|
|
78
|
+
if (!current)
|
|
79
|
+
return undefined;
|
|
80
|
+
const bare = segment.replace(/(\[\]|\{\})+$/g, "");
|
|
81
|
+
let next = propertySchemas(current).find(([k]) => k === bare)?.[1];
|
|
82
|
+
if (!next)
|
|
83
|
+
return undefined;
|
|
84
|
+
for (const marker of segment.slice(bare.length).match(/\[\]|\{\}/g) ?? []) {
|
|
85
|
+
next =
|
|
86
|
+
marker === "[]"
|
|
87
|
+
? next?.items
|
|
88
|
+
: next?.additionalProperties;
|
|
89
|
+
next = resolveLocalRef(next, rootSchema);
|
|
90
|
+
if (!next || typeof next !== "object")
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
current = resolveLocalRef(next, rootSchema);
|
|
94
|
+
}
|
|
95
|
+
return current;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Resolve a slot's declared use at one concrete site.
|
|
99
|
+
*
|
|
100
|
+
* A case map's selector must be statically resolvable — a literal or a schema
|
|
101
|
+
* default. There is deliberately no fallback: no single value is conservative
|
|
102
|
+
* for every consumer, since the throws union must assume `call` to keep an error
|
|
103
|
+
* path and a zone requirement must assume the opposite to avoid inventing one.
|
|
104
|
+
* When the selector cannot be read the edge reports every case's use AND says
|
|
105
|
+
* why (`unresolvedReason`), so a consumer chooses its own reading — and
|
|
106
|
+
* `validate-ref-slots.ts` turns the `dynamic` reason into a diagnostic, because
|
|
107
|
+
* a call graph known only at runtime is not statically analyzable.
|
|
108
|
+
*/
|
|
109
|
+
function resolveUseAtSite(entry, root, concretePath, schemaDefault) {
|
|
110
|
+
if (!entry.useCases)
|
|
111
|
+
return { use: entry.uses };
|
|
112
|
+
const enclosing = enclosingOf(root, concretePath);
|
|
113
|
+
let selector = navigatePointer(enclosing, entry.useCases.by);
|
|
114
|
+
if (selector === undefined)
|
|
115
|
+
selector = schemaDefault(entry.useCases.by);
|
|
116
|
+
if (selector !== undefined && typeof selector !== "object") {
|
|
117
|
+
const resolved = entry.useCases.cases[String(selector)];
|
|
118
|
+
if (resolved)
|
|
119
|
+
return { use: resolved };
|
|
120
|
+
}
|
|
121
|
+
const slot = { kinds: entry.refs, uses: entry.uses, useCases: entry.useCases, inline: false };
|
|
122
|
+
const unresolvedReason = isTaggedSentinel(selector)
|
|
123
|
+
? "dynamic"
|
|
124
|
+
: selector === undefined
|
|
125
|
+
? "absent"
|
|
126
|
+
: "unmatched";
|
|
127
|
+
return { use: possibleUses(slot), unresolved: entry.useCases, unresolvedReason };
|
|
128
|
+
}
|
|
129
|
+
/** Names the step-list annotation on an array property, if any. */
|
|
130
|
+
function stepContextOf(schema) {
|
|
131
|
+
const annotation = schema?.["x-telo-step-context"];
|
|
132
|
+
return annotation && typeof annotation === "object" ? annotation : undefined;
|
|
133
|
+
}
|
|
134
|
+
/** A resolved plain reference value (`{kind, name}`, optionally `alias`) — the
|
|
135
|
+
* shape `resolveRefSentinels` leaves at a ref site. NOT a step: a bare boot
|
|
136
|
+
* target written `!ref X` must not mint a step node. */
|
|
137
|
+
function isPlainRefValue(value) {
|
|
138
|
+
if (typeof value.kind !== "string" || typeof value.name !== "string")
|
|
139
|
+
return false;
|
|
140
|
+
return Object.keys(value).every((k) => k === "kind" || k === "name" || k === "alias" || k === "__ref");
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Emit the edges a single step's own ref slots declare.
|
|
144
|
+
*
|
|
145
|
+
* Read from the step ITEM SCHEMA rather than from the reference field map: a
|
|
146
|
+
* step array's items sit behind a local `$ref`, and the field map deliberately
|
|
147
|
+
* does not descend one (descending it there would turn every step's `invoke`
|
|
148
|
+
* into a Phase-5 injection site). The schema is already in hand here, so the
|
|
149
|
+
* graph sees these slots at no cost to the kernel's injection surface.
|
|
150
|
+
*/
|
|
151
|
+
function emitStepEdges(node, ctx) {
|
|
152
|
+
if (!ctx.itemSchema)
|
|
153
|
+
return;
|
|
154
|
+
const schemaDefault = schemaDefaultOf(ctx.itemSchema);
|
|
155
|
+
for (const [key, propSchema] of propertySchemas(ctx.itemSchema)) {
|
|
156
|
+
const slot = readRefSlot(propSchema);
|
|
157
|
+
if (!slot || slot.kinds.length === 0)
|
|
158
|
+
continue;
|
|
159
|
+
const targetName = refTargetName(node.step[key]);
|
|
160
|
+
if (targetName === undefined)
|
|
161
|
+
continue;
|
|
162
|
+
const entry = {
|
|
163
|
+
refs: slot.kinds,
|
|
164
|
+
uses: slot.uses,
|
|
165
|
+
isArray: false,
|
|
166
|
+
...(slot.useCases ? { useCases: slot.useCases } : {}),
|
|
167
|
+
...(slot.inputs !== undefined ? { inputs: slot.inputs } : {}),
|
|
168
|
+
};
|
|
169
|
+
// The step itself is the enclosing object, so a `use` case map and an
|
|
170
|
+
// `inputs` pointer both resolve against the step's own siblings.
|
|
171
|
+
const { use, unresolved, unresolvedReason } = resolveUseAtSite(entry, node.step, key, schemaDefault);
|
|
172
|
+
const edge = {
|
|
173
|
+
from: node.id,
|
|
174
|
+
toName: targetName,
|
|
175
|
+
slot: `${ctx.slotPrefix}.${key}`,
|
|
176
|
+
path: `${node.path}.${key}`,
|
|
177
|
+
use,
|
|
178
|
+
};
|
|
179
|
+
const target = ctx.resolveName(targetName);
|
|
180
|
+
if (target)
|
|
181
|
+
edge.to = target.id;
|
|
182
|
+
if (unresolved)
|
|
183
|
+
edge.unresolved = unresolved;
|
|
184
|
+
if (unresolvedReason)
|
|
185
|
+
edge.unresolvedReason = unresolvedReason;
|
|
186
|
+
if (slot.inputs !== undefined)
|
|
187
|
+
edge.inputs = slot.inputs;
|
|
188
|
+
ctx.edges.push(edge);
|
|
189
|
+
ctx.stepEdgesByPath.set(`${node.owner}\0${edge.path}`, edge);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* The single step-array recursion in the analyzer.
|
|
194
|
+
*
|
|
195
|
+
* A step's position is manifest data, never schema data: no definition declares
|
|
196
|
+
* a next or previous step, and none needs to — order is the written order of the
|
|
197
|
+
* array, and this is a manifest × schema co-traversal, so the array is in hand
|
|
198
|
+
* exactly where step nodes are minted. The schema's whole contribution is to
|
|
199
|
+
* mark an array as a step list and to name the fields that nest further steps
|
|
200
|
+
* (`branch`, `branch-list`, `case-map`).
|
|
201
|
+
*/
|
|
202
|
+
function walkSteps(steps, arrayPath, parent, ctx) {
|
|
203
|
+
const dispatchRole = (data, role, itemsSchema, path, stepId) => {
|
|
204
|
+
if (role === "branch" && Array.isArray(data)) {
|
|
205
|
+
walkSteps(data, path, stepId, ctx);
|
|
206
|
+
}
|
|
207
|
+
else if (role === "case-map" && data && typeof data === "object" && !Array.isArray(data)) {
|
|
208
|
+
for (const [caseKey, arr] of Object.entries(data)) {
|
|
209
|
+
if (Array.isArray(arr))
|
|
210
|
+
walkSteps(arr, `${path}.${caseKey}`, stepId, ctx);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
else if (role === "branch-list" && Array.isArray(data)) {
|
|
214
|
+
const entrySchema = resolveLocalRef(itemsSchema, ctx.rootSchema);
|
|
215
|
+
if (!entrySchema)
|
|
216
|
+
return;
|
|
217
|
+
data.forEach((entry, i) => {
|
|
218
|
+
if (!entry || typeof entry !== "object")
|
|
219
|
+
return;
|
|
220
|
+
for (const [subKey, subSchema] of propertySchemas(entrySchema)) {
|
|
221
|
+
const subRole = subSchema["x-telo-topology-role"];
|
|
222
|
+
if (typeof subRole !== "string")
|
|
223
|
+
continue;
|
|
224
|
+
dispatchRole(entry[subKey], subRole, subSchema.items, `${path}[${i}].${subKey}`, stepId);
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
steps.forEach((step, index) => {
|
|
230
|
+
if (!step || typeof step !== "object" || Array.isArray(step))
|
|
231
|
+
return;
|
|
232
|
+
// A bare reference in a step position (`targets: [!ref X]`, or the resolved
|
|
233
|
+
// `{kind, name}` it becomes) is a target, not a step — the field-map walk
|
|
234
|
+
// owns that edge. Minting a node here would put ref noise in the step model.
|
|
235
|
+
if (isRefSentinel(step))
|
|
236
|
+
return;
|
|
237
|
+
const value = step;
|
|
238
|
+
if (isPlainRefValue(value))
|
|
239
|
+
return;
|
|
240
|
+
const path = `${arrayPath}[${index}]`;
|
|
241
|
+
const id = `${ctx.owner.id}#${path}`;
|
|
242
|
+
const node = {
|
|
243
|
+
type: "step",
|
|
244
|
+
id,
|
|
245
|
+
owner: ctx.owner.id,
|
|
246
|
+
path,
|
|
247
|
+
array: arrayPath,
|
|
248
|
+
index,
|
|
249
|
+
step: value,
|
|
250
|
+
};
|
|
251
|
+
if (typeof value.name === "string")
|
|
252
|
+
node.name = value.name;
|
|
253
|
+
if (parent)
|
|
254
|
+
node.parent = parent;
|
|
255
|
+
ctx.nodes.set(id, node);
|
|
256
|
+
ctx.order.push(node);
|
|
257
|
+
emitStepEdges(node, ctx);
|
|
258
|
+
if (!ctx.itemSchema)
|
|
259
|
+
return;
|
|
260
|
+
for (const [key, propSchema] of propertySchemas(ctx.itemSchema)) {
|
|
261
|
+
const role = propSchema["x-telo-topology-role"];
|
|
262
|
+
if (typeof role !== "string")
|
|
263
|
+
continue;
|
|
264
|
+
dispatchRole(value[key], role, propSchema.items, `${path}.${key}`, id);
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
/** The concrete step path a nested site belongs to, or undefined for a
|
|
269
|
+
* resource-level site. Longest-prefix match, so a site inside `steps[0].do[1]`
|
|
270
|
+
* attaches to that step rather than to `steps[0]`. */
|
|
271
|
+
function ownerStepOf(steps, concretePath) {
|
|
272
|
+
let best;
|
|
273
|
+
for (const step of steps) {
|
|
274
|
+
if (!concretePath.startsWith(`${step.path}.`))
|
|
275
|
+
continue;
|
|
276
|
+
if (!best || step.path.length > best.path.length)
|
|
277
|
+
best = step;
|
|
278
|
+
}
|
|
279
|
+
return best;
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Build the call graph for one manifest set.
|
|
283
|
+
*
|
|
284
|
+
* Reference discovery is three-fold. Field-map sites come from `visitManifest`
|
|
285
|
+
* — the same walk the reference validators use — and are stamped `injected`,
|
|
286
|
+
* because those and only those are Phase-5 injection sites. Step slots are read
|
|
287
|
+
* from the step item schema, because they sit behind local `$ref`s the field
|
|
288
|
+
* map deliberately does not descend. Everything else is caught by the value-
|
|
289
|
+
* tree scan (`discoverNestedRefs`): a `!ref` is an explicit marker, so a ref in
|
|
290
|
+
* a structure no annotation anticipated is still an edge — with no declared
|
|
291
|
+
* `use`, read conservatively — instead of a blind spot. Inline declarations in
|
|
292
|
+
* `x-telo-scope` arrays become scoped nodes with edges of their own.
|
|
293
|
+
*/
|
|
294
|
+
export function buildCallGraph(resources, registry, options = {}) {
|
|
295
|
+
const nodes = new Map();
|
|
296
|
+
const edges = [];
|
|
297
|
+
const byName = new Map();
|
|
298
|
+
const stepsByOwner = new Map();
|
|
299
|
+
const stepEdgesByPath = new Map();
|
|
300
|
+
/**
|
|
301
|
+
* A resource's definition, resolved in the scope of the module that DECLARED
|
|
302
|
+
* it. A manifest carries the kind as AUTHORED (`Run.Sequence`), while the
|
|
303
|
+
* registry is keyed canonically (`run.Sequence`), so a raw lookup misses for
|
|
304
|
+
* every alias-form kind — which is every kind in a real manifest. That miss
|
|
305
|
+
* is silent and costly: step collection would find no step list (so a step's
|
|
306
|
+
* declared `use` never reaches its edge, and the site degrades to an untyped
|
|
307
|
+
* value-tree edge), and a case map's selector would find no schema `default`
|
|
308
|
+
* (so a slot resolved by an omitted field reads as unresolved). Same scope
|
|
309
|
+
* selection as `expandedFieldMapForResource`.
|
|
310
|
+
*/
|
|
311
|
+
const definitionFor = (manifest) => {
|
|
312
|
+
const direct = registry.resolve(manifest.kind);
|
|
313
|
+
if (direct)
|
|
314
|
+
return direct;
|
|
315
|
+
const module = manifest.metadata?.module;
|
|
316
|
+
const scope = (module ? options.aliasesByModule?.get(module) : undefined) ?? options.aliases;
|
|
317
|
+
const canonical = scope?.resolveKind(manifest.kind);
|
|
318
|
+
return canonical ? registry.resolve(canonical) : undefined;
|
|
319
|
+
};
|
|
320
|
+
for (const manifest of resources) {
|
|
321
|
+
const name = manifest.metadata?.name;
|
|
322
|
+
if (!name || !manifest.kind || SYSTEM_KINDS.has(manifest.kind))
|
|
323
|
+
continue;
|
|
324
|
+
const node = {
|
|
325
|
+
type: "resource",
|
|
326
|
+
id: resourceId(manifest.kind, name),
|
|
327
|
+
kind: manifest.kind,
|
|
328
|
+
name: name,
|
|
329
|
+
manifest,
|
|
330
|
+
};
|
|
331
|
+
nodes.set(node.id, node);
|
|
332
|
+
byName.set(node.name, node);
|
|
333
|
+
}
|
|
334
|
+
// --- step nodes ---
|
|
335
|
+
const collectStepsFor = (node, resolveName) => {
|
|
336
|
+
const definition = definitionFor(node.manifest);
|
|
337
|
+
const schema = definition?.schema;
|
|
338
|
+
if (!schema)
|
|
339
|
+
return;
|
|
340
|
+
const collected = [];
|
|
341
|
+
for (const [key, propSchema] of propertySchemas(schema)) {
|
|
342
|
+
const annotation = stepContextOf(propSchema);
|
|
343
|
+
if (!annotation)
|
|
344
|
+
continue;
|
|
345
|
+
const value = node.manifest[key];
|
|
346
|
+
if (!Array.isArray(value))
|
|
347
|
+
continue;
|
|
348
|
+
walkSteps(value, key, undefined, {
|
|
349
|
+
owner: node,
|
|
350
|
+
rootSchema: schema,
|
|
351
|
+
itemSchema: resolveLocalRef(propSchema.items, schema),
|
|
352
|
+
slotPrefix: `${key}[]`,
|
|
353
|
+
nodes,
|
|
354
|
+
order: collected,
|
|
355
|
+
resolveName,
|
|
356
|
+
edges,
|
|
357
|
+
stepEdgesByPath,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
if (collected.length > 0)
|
|
361
|
+
stepsByOwner.set(node.id, collected);
|
|
362
|
+
};
|
|
363
|
+
for (const node of [...nodes.values()]) {
|
|
364
|
+
collectStepsFor(node, (name) => byName.get(name));
|
|
365
|
+
}
|
|
366
|
+
// --- edges ---
|
|
367
|
+
// Scope-local nodes of the CURRENT resource. The visitor fires `onScope`
|
|
368
|
+
// before that resource's ref sites, so both are set before any edge they
|
|
369
|
+
// qualify is added. Scope-local names win over module-level ones — the order
|
|
370
|
+
// `ScopeContext` and `!ref` already agree on.
|
|
371
|
+
let scopedNames = new Set();
|
|
372
|
+
let scopeLocal = new Map();
|
|
373
|
+
const fieldMapFor = (manifest) => {
|
|
374
|
+
if (options.aliases && options.aliasesByModule) {
|
|
375
|
+
return registry.expandedFieldMapForResource(manifest, options.aliases, options.aliasesByModule);
|
|
376
|
+
}
|
|
377
|
+
if (options.aliases)
|
|
378
|
+
return registry.getFieldMapForKind(manifest.kind, options.aliases);
|
|
379
|
+
return registry.getFieldMap(manifest.kind);
|
|
380
|
+
};
|
|
381
|
+
visitManifest(resources, registry, {
|
|
382
|
+
onScope: (event) => {
|
|
383
|
+
scopedNames = event.enclosedNames;
|
|
384
|
+
scopeLocal = new Map();
|
|
385
|
+
const ownerName = event.source.metadata?.name;
|
|
386
|
+
if (!ownerName || !event.source.kind)
|
|
387
|
+
return;
|
|
388
|
+
const ownerId = resourceId(event.source.kind, ownerName);
|
|
389
|
+
// Inline declarations inside `x-telo-scope` arrays become nodes of
|
|
390
|
+
// their own, keyed by their scope site — the declaration-site identity
|
|
391
|
+
// the zones plan correlates on. They are excluded from init ordering
|
|
392
|
+
// (created when the scope opens), but their own references are real
|
|
393
|
+
// edges of the one model.
|
|
394
|
+
for (const [pointer, manifests] of event.manifestsByPointer) {
|
|
395
|
+
for (const manifest of manifests) {
|
|
396
|
+
const name = manifest.metadata?.name;
|
|
397
|
+
if (typeof name !== "string" || !manifest.kind)
|
|
398
|
+
continue;
|
|
399
|
+
const scopedNode = {
|
|
400
|
+
type: "resource",
|
|
401
|
+
id: `${ownerId}#${pointer}#${resourceId(manifest.kind, name)}`,
|
|
402
|
+
kind: manifest.kind,
|
|
403
|
+
name,
|
|
404
|
+
manifest,
|
|
405
|
+
scoped: true,
|
|
406
|
+
scopeOwner: ownerId,
|
|
407
|
+
scopeSite: pointer,
|
|
408
|
+
};
|
|
409
|
+
nodes.set(scopedNode.id, scopedNode);
|
|
410
|
+
scopeLocal.set(name, scopedNode);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
if (scopeLocal.size === 0)
|
|
414
|
+
return;
|
|
415
|
+
const resolveScoped = (name) => scopeLocal.get(name) ?? byName.get(name);
|
|
416
|
+
// The owner's own step edges were emitted before this scope was seen
|
|
417
|
+
// (step collection precedes the visit), so their names resolved
|
|
418
|
+
// module-level. Re-resolve them now that the scope exists: scope-local
|
|
419
|
+
// names WIN — the order `ScopeContext` and `!ref` already agree on — so
|
|
420
|
+
// a step's `invoke: !ref X` with X declared in `with:` reaches the
|
|
421
|
+
// scoped node, never a same-named module-level shadow.
|
|
422
|
+
for (const [key, edge] of stepEdgesByPath) {
|
|
423
|
+
if (!key.startsWith(`${ownerId}\0`))
|
|
424
|
+
continue;
|
|
425
|
+
const local = scopeLocal.get(edge.toName);
|
|
426
|
+
if (!local)
|
|
427
|
+
continue;
|
|
428
|
+
edge.to = local.id;
|
|
429
|
+
edge.scoped = true;
|
|
430
|
+
}
|
|
431
|
+
for (const scopedNode of scopeLocal.values()) {
|
|
432
|
+
// The scoped resource's own ref slots, from its kind's field map.
|
|
433
|
+
const fieldMap = fieldMapFor(scopedNode.manifest);
|
|
434
|
+
if (fieldMap) {
|
|
435
|
+
const definition = definitionFor(scopedNode.manifest);
|
|
436
|
+
const rootSchema = definition?.schema;
|
|
437
|
+
for (const [fieldPath, entry] of fieldMap) {
|
|
438
|
+
if (!isRefEntry(entry))
|
|
439
|
+
continue;
|
|
440
|
+
for (const { value, path } of resolveFieldEntries(scopedNode.manifest, fieldPath)) {
|
|
441
|
+
const targetName = refTargetName(value);
|
|
442
|
+
if (targetName === undefined)
|
|
443
|
+
continue;
|
|
444
|
+
const schemaDefault = rootSchema
|
|
445
|
+
? schemaDefaultOf(enclosingSchemaOf(rootSchema, fieldPath))
|
|
446
|
+
: NO_DEFAULT;
|
|
447
|
+
const { use, unresolved, unresolvedReason } = resolveUseAtSite(entry, scopedNode.manifest, path, schemaDefault);
|
|
448
|
+
const edge = {
|
|
449
|
+
from: scopedNode.id,
|
|
450
|
+
toName: targetName,
|
|
451
|
+
slot: fieldPath,
|
|
452
|
+
path,
|
|
453
|
+
use,
|
|
454
|
+
};
|
|
455
|
+
const target = resolveScoped(targetName);
|
|
456
|
+
if (target)
|
|
457
|
+
edge.to = target.id;
|
|
458
|
+
if (unresolved)
|
|
459
|
+
edge.unresolved = unresolved;
|
|
460
|
+
if (unresolvedReason)
|
|
461
|
+
edge.unresolvedReason = unresolvedReason;
|
|
462
|
+
if (entry.inputs !== undefined)
|
|
463
|
+
edge.inputs = entry.inputs;
|
|
464
|
+
edges.push(edge);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
// Its step arrays too, resolved scope-local first.
|
|
469
|
+
collectStepsFor(scopedNode, resolveScoped);
|
|
470
|
+
}
|
|
471
|
+
},
|
|
472
|
+
onRef: (event) => {
|
|
473
|
+
const sourceName = event.source.metadata?.name;
|
|
474
|
+
if (!sourceName || !event.source.kind)
|
|
475
|
+
return;
|
|
476
|
+
const sourceId = resourceId(event.source.kind, sourceName);
|
|
477
|
+
if (!nodes.has(sourceId))
|
|
478
|
+
return;
|
|
479
|
+
// A site inside a step was already emitted by the step walk, which
|
|
480
|
+
// reads the step item schema directly. When the FIELD MAP also reaches
|
|
481
|
+
// it — `Telo.Application`'s inline `targets[].invoke`, unlike
|
|
482
|
+
// `Run.Sequence`'s `$ref`-hidden `steps[].invoke` — the site is a
|
|
483
|
+
// Phase-5 injection site, and the existing step edge is stamped so the
|
|
484
|
+
// init-order projection keeps it.
|
|
485
|
+
if (ownerStepOf(stepsByOwner.get(sourceId) ?? [], event.concretePath)) {
|
|
486
|
+
if (!event.nested) {
|
|
487
|
+
const stepEdge = stepEdgesByPath.get(`${sourceId}\0${event.concretePath}`);
|
|
488
|
+
if (stepEdge)
|
|
489
|
+
stepEdge.injected = true;
|
|
490
|
+
}
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
const targetName = refTargetName(event.value);
|
|
494
|
+
if (targetName === undefined)
|
|
495
|
+
return;
|
|
496
|
+
const definition = definitionFor(event.source);
|
|
497
|
+
const rootSchema = definition?.schema;
|
|
498
|
+
const schemaDefault = !event.nested && rootSchema
|
|
499
|
+
? schemaDefaultOf(enclosingSchemaOf(rootSchema, event.fieldPath))
|
|
500
|
+
: NO_DEFAULT;
|
|
501
|
+
const { use, unresolved, unresolvedReason } = resolveUseAtSite(event.entry, event.source, event.concretePath, schemaDefault);
|
|
502
|
+
const edge = {
|
|
503
|
+
from: sourceId,
|
|
504
|
+
toName: targetName,
|
|
505
|
+
slot: event.fieldPath,
|
|
506
|
+
path: event.concretePath,
|
|
507
|
+
use,
|
|
508
|
+
};
|
|
509
|
+
const target = scopedNames.has(targetName)
|
|
510
|
+
? (scopeLocal.get(targetName) ?? byName.get(targetName))
|
|
511
|
+
: byName.get(targetName);
|
|
512
|
+
if (target)
|
|
513
|
+
edge.to = target.id;
|
|
514
|
+
if (unresolved)
|
|
515
|
+
edge.unresolved = unresolved;
|
|
516
|
+
if (unresolvedReason)
|
|
517
|
+
edge.unresolvedReason = unresolvedReason;
|
|
518
|
+
if (event.entry.inputs !== undefined)
|
|
519
|
+
edge.inputs = event.entry.inputs;
|
|
520
|
+
if (event.nested)
|
|
521
|
+
edge.nested = true;
|
|
522
|
+
else
|
|
523
|
+
edge.injected = true;
|
|
524
|
+
if (scopedNames.has(targetName))
|
|
525
|
+
edge.scoped = true;
|
|
526
|
+
edges.push(edge);
|
|
527
|
+
},
|
|
528
|
+
}, {
|
|
529
|
+
aliases: options.aliases,
|
|
530
|
+
aliasesByModule: options.aliasesByModule,
|
|
531
|
+
skipKinds: SYSTEM_KINDS,
|
|
532
|
+
expand: true,
|
|
533
|
+
discoverNestedRefs: true,
|
|
534
|
+
});
|
|
535
|
+
const fromIndex = new Map();
|
|
536
|
+
const toIndex = new Map();
|
|
537
|
+
const push = (index, key, edge) => {
|
|
538
|
+
const bucket = index.get(key);
|
|
539
|
+
if (bucket)
|
|
540
|
+
bucket.push(edge);
|
|
541
|
+
else
|
|
542
|
+
index.set(key, [edge]);
|
|
543
|
+
};
|
|
544
|
+
for (const edge of edges) {
|
|
545
|
+
push(fromIndex, edge.from, edge);
|
|
546
|
+
if (edge.to)
|
|
547
|
+
push(toIndex, edge.to, edge);
|
|
548
|
+
}
|
|
549
|
+
return {
|
|
550
|
+
nodes,
|
|
551
|
+
edges,
|
|
552
|
+
edgesFrom: (id) => fromIndex.get(id) ?? [],
|
|
553
|
+
edgesTo: (id) => toIndex.get(id) ?? [],
|
|
554
|
+
resource: (kind, name) => nodes.get(resourceId(kind, name)),
|
|
555
|
+
resourceByName: (name) => byName.get(name),
|
|
556
|
+
steps: (ownerId) => stepsByOwner.get(ownerId) ?? [],
|
|
557
|
+
controlEdges: () => edges.filter(reachesTarget),
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
/** The target resource NAME a ref site's value carries. Both written forms reach
|
|
561
|
+
* here: an unresolved `!ref <name>` sentinel and the `{kind, name}` object
|
|
562
|
+
* `resolveRefSentinels` rewrites it into. */
|
|
563
|
+
function refTargetName(value) {
|
|
564
|
+
if (isRefSentinel(value))
|
|
565
|
+
return value.source;
|
|
566
|
+
if (!value || typeof value !== "object")
|
|
567
|
+
return undefined;
|
|
568
|
+
const name = value.name;
|
|
569
|
+
return typeof name === "string" ? name : undefined;
|
|
570
|
+
}
|
|
571
|
+
/**
|
|
572
|
+
* Unique `(from, to)` pairs, dropping slot identity and `use`. The projection
|
|
573
|
+
* the init-order consumer needs — the only consumer for which the distinction
|
|
574
|
+
* between two parallel edges genuinely does not matter.
|
|
575
|
+
*
|
|
576
|
+
* **Only injection sites order boot, and that is a property of the SITE, never
|
|
577
|
+
* of the node kind.** A site the reference field map reaches is a Phase-5
|
|
578
|
+
* injection site: the kernel puts the live instance into the field before
|
|
579
|
+
* `init()`, so the target must be constructed first — and that is as true for
|
|
580
|
+
* `Telo.Application`'s inline `targets[].invoke` (a step-declared slot the
|
|
581
|
+
* field map reaches) as for a resource-level `connection:`. A step slot behind
|
|
582
|
+
* a local `$ref` and a value-tree-discovered ref resolve at dispatch instead,
|
|
583
|
+
* so their targets need only exist by the time the step runs. An earlier
|
|
584
|
+
* revision keyed this on node kind and silently dropped boot targets' inline
|
|
585
|
+
* invoke edges from init order — the regression this comment exists to prevent.
|
|
586
|
+
*
|
|
587
|
+
* Scoped nodes take no part at all: a `with:`-scoped resource is created when
|
|
588
|
+
* the scope opens, and an edge into a scope is the owner's runtime business.
|
|
589
|
+
*/
|
|
590
|
+
export function projectToPairs(graph, options = {}) {
|
|
591
|
+
const out = new Map();
|
|
592
|
+
for (const [id, node] of graph.nodes) {
|
|
593
|
+
if (node.type === "resource" && !node.scoped)
|
|
594
|
+
out.set(id, new Set());
|
|
595
|
+
}
|
|
596
|
+
for (const edge of graph.edges) {
|
|
597
|
+
if (!edge.to)
|
|
598
|
+
continue;
|
|
599
|
+
if (!edge.injected && !options.includeNonInjected)
|
|
600
|
+
continue;
|
|
601
|
+
if (options.keepUse && !options.keepUse(edge.use))
|
|
602
|
+
continue;
|
|
603
|
+
const from = graph.nodes.get(edge.from);
|
|
604
|
+
const to = graph.nodes.get(edge.to);
|
|
605
|
+
if (to?.type === "resource" && to.scoped)
|
|
606
|
+
continue;
|
|
607
|
+
let ownerId;
|
|
608
|
+
if (from?.type === "step")
|
|
609
|
+
ownerId = from.owner;
|
|
610
|
+
else if (from?.type === "resource" && from.scoped)
|
|
611
|
+
continue;
|
|
612
|
+
else
|
|
613
|
+
ownerId = edge.from;
|
|
614
|
+
out.get(ownerId)?.add(edge.to);
|
|
615
|
+
}
|
|
616
|
+
return out;
|
|
617
|
+
}
|
|
@@ -14,16 +14,26 @@ export interface DependencyGraph {
|
|
|
14
14
|
cycle?: ReadonlyArray<ResourceNode>;
|
|
15
15
|
}
|
|
16
16
|
/**
|
|
17
|
-
* Builds a directed acyclic graph (DAG) of
|
|
17
|
+
* Builds a directed acyclic graph (DAG) of boot-time resource dependencies and
|
|
18
18
|
* returns either a topological initialization order or the cycle path.
|
|
19
19
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* (scoped resources are initialized on demand at runtime, not at boot).
|
|
23
|
-
* - x-telo-scope fields themselves are excluded from the graph.
|
|
20
|
+
* A projection of the typed reference graph, not a second walk of the manifest.
|
|
21
|
+
* What this consumer keeps of the full graph:
|
|
24
22
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
23
|
+
* - **Injection sites only.** A site the reference field map reaches is a
|
|
24
|
+
* Phase-5 injection site, so its target must be constructed first — including
|
|
25
|
+
* `Telo.Application`'s inline `targets[].invoke`, which is step-declared but
|
|
26
|
+
* injected. A step slot behind a local `$ref` and a value-tree-discovered ref
|
|
27
|
+
* resolve at dispatch, so their targets need only exist by the time the step
|
|
28
|
+
* runs. All can be `use: call` — the difference is the site, never the node
|
|
29
|
+
* kind or the use.
|
|
30
|
+
* - **Every use but `schema`.** A `Telo.Type` slot names a shape; no runtime
|
|
31
|
+
* instance is constructed, so there is nothing to order against.
|
|
32
|
+
* - **No edge into the source's own scope.** A scoped resource is created when
|
|
33
|
+
* the scope opens, not at boot.
|
|
34
|
+
* - **Pairs, not parallel edges.** This is the one consumer for which two slots
|
|
35
|
+
* naming the same target genuinely mean the same thing, so it collapses the
|
|
36
|
+
* multigraph itself instead of the graph erasing the distinction for everyone.
|
|
27
37
|
*/
|
|
28
38
|
export declare function buildDependencyGraph(resources: ResourceManifest[], registry: DefinitionRegistry, aliases?: AliasResolver, aliasesByModule?: Map<string, AliasResolver>): DependencyGraph;
|
|
29
39
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dependency-graph.d.ts","sourceRoot":"","sources":["../src/dependency-graph.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"dependency-graph.d.ts","sourceRoot":"","sources":["../src/dependency-graph.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AACrD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAEzD,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;AAEnE,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,eAAe;IAC9B;kDAC8C;IAC9C,KAAK,CAAC,EAAE,aAAa,CAAC,YAAY,CAAC,CAAC;IACpC;oFACgF;IAChF,KAAK,CAAC,EAAE,aAAa,CAAC,YAAY,CAAC,CAAC;CACrC;AAID;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,oBAAoB,CAClC,SAAS,EAAE,gBAAgB,EAAE,EAC7B,QAAQ,EAAE,kBAAkB,EAC5B,OAAO,CAAC,EAAE,aAAa,EACvB,eAAe,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,aAAa,CAAC,GAC3C,eAAe,CAyDjB;AAED;;;;;;;GAOG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,aAAa,CAAC,YAAY,CAAC,GAAG,MAAM,CAOtE"}
|