@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.
Files changed (76) hide show
  1. package/dist/analysis-registry.d.ts +20 -0
  2. package/dist/analysis-registry.d.ts.map +1 -1
  3. package/dist/analysis-registry.js +36 -3
  4. package/dist/analyzer.d.ts +3 -2
  5. package/dist/analyzer.d.ts.map +1 -1
  6. package/dist/analyzer.js +188 -26
  7. package/dist/builtins.d.ts.map +1 -1
  8. package/dist/builtins.js +32 -12
  9. package/dist/call-graph.d.ts +189 -0
  10. package/dist/call-graph.d.ts.map +1 -0
  11. package/dist/call-graph.js +617 -0
  12. package/dist/dependency-graph.d.ts +17 -7
  13. package/dist/dependency-graph.d.ts.map +1 -1
  14. package/dist/dependency-graph.js +36 -65
  15. package/dist/flatten-for-analyzer.d.ts +8 -0
  16. package/dist/flatten-for-analyzer.d.ts.map +1 -1
  17. package/dist/flatten-for-analyzer.js +32 -0
  18. package/dist/index.d.ts +14 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +7 -1
  21. package/dist/manifest-navigation.d.ts +32 -0
  22. package/dist/manifest-navigation.d.ts.map +1 -0
  23. package/dist/manifest-navigation.js +91 -0
  24. package/dist/manifest-visitor.js +1 -1
  25. package/dist/ref-slot.d.ts +125 -0
  26. package/dist/ref-slot.d.ts.map +1 -0
  27. package/dist/ref-slot.js +226 -0
  28. package/dist/reference-field-map.d.ts +15 -1
  29. package/dist/reference-field-map.d.ts.map +1 -1
  30. package/dist/reference-field-map.js +29 -35
  31. package/dist/resolve-schema-ref-kinds.d.ts +4 -0
  32. package/dist/resolve-schema-ref-kinds.d.ts.map +1 -1
  33. package/dist/resolve-schema-ref-kinds.js +31 -8
  34. package/dist/resolve-zone-requirements.d.ts +110 -0
  35. package/dist/resolve-zone-requirements.d.ts.map +1 -0
  36. package/dist/resolve-zone-requirements.js +541 -0
  37. package/dist/types.d.ts +8 -0
  38. package/dist/types.d.ts.map +1 -1
  39. package/dist/validate-observed-state.d.ts +14 -13
  40. package/dist/validate-observed-state.d.ts.map +1 -1
  41. package/dist/validate-observed-state.js +21 -88
  42. package/dist/validate-ref-slots.d.ts +48 -0
  43. package/dist/validate-ref-slots.d.ts.map +1 -0
  44. package/dist/validate-ref-slots.js +219 -0
  45. package/dist/validate-references.d.ts.map +1 -1
  46. package/dist/validate-references.js +8 -1
  47. package/dist/validate-zone-slots.d.ts +39 -0
  48. package/dist/validate-zone-slots.d.ts.map +1 -0
  49. package/dist/validate-zone-slots.js +114 -0
  50. package/dist/zone-module-documents.d.ts +27 -0
  51. package/dist/zone-module-documents.d.ts.map +1 -0
  52. package/dist/zone-module-documents.js +1 -0
  53. package/dist/zone-slot.d.ts +61 -0
  54. package/dist/zone-slot.d.ts.map +1 -0
  55. package/dist/zone-slot.js +91 -0
  56. package/package.json +3 -3
  57. package/src/analysis-registry.ts +36 -2
  58. package/src/analyzer.ts +206 -24
  59. package/src/builtins.ts +32 -12
  60. package/src/call-graph.ts +827 -0
  61. package/src/dependency-graph.ts +34 -68
  62. package/src/flatten-for-analyzer.ts +32 -0
  63. package/src/index.ts +47 -0
  64. package/src/manifest-navigation.ts +91 -0
  65. package/src/manifest-visitor.ts +1 -1
  66. package/src/ref-slot.ts +273 -0
  67. package/src/reference-field-map.ts +39 -36
  68. package/src/resolve-schema-ref-kinds.ts +34 -7
  69. package/src/resolve-zone-requirements.ts +781 -0
  70. package/src/types.ts +8 -0
  71. package/src/validate-observed-state.ts +26 -92
  72. package/src/validate-ref-slots.ts +293 -0
  73. package/src/validate-references.ts +8 -1
  74. package/src/validate-zone-slots.ts +175 -0
  75. package/src/zone-module-documents.ts +27 -0
  76. 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 runtime resource dependencies and
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
- * Edges represent boot-time dependencies only:
21
- * - x-telo-ref fields that fall within a scope visibility path are excluded
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
- * The registry is queried for each resource's field map by kind — callers do
26
- * not pre-compute or pass field maps separately.
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;AAErD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACzD,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;AAInE,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;;;;;;;;;;;GAWG;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,CAmGjB;AAED;;;;;;;GAOG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,aAAa,CAAC,YAAY,CAAC,GAAG,MAAM,CAOtE"}
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"}